| author | |
| committer | |
| log | 2d9ae5806189ab5ae2c042699773045856976c65 |
| tree | 3e0c9f989a52fe904626cb7812a6b6b8e85c80ce |
| parent | 074834705a828432d90e244347ec8f0913adb5e9 |
| signature | Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU |
- One-row toolbar: groups fold into dropdowns by priority so the row never
overflows; on macOS and GNOME the row is the title bar. The search box folds
to an icon and overflowing section tabs scroll with faded edges.
- Popups: every popup animates as one composited layer, clipped to its rounded
outline, with rows fixed while it opens; dialogs and menus share one tilt.
- Page Color menu: colour swatches, background art and rule lines. Rule lines
are read, drawn and written as OneNote stores them; colour and rule changes
undo in one step without reloading the page.
- Insert Space, matching OneNote 2010, including splitting crossed outlines and
moving ink by offset.
- Search results pane and Tags Summary (#28); command palette (#19) with pages
from every section; tag artwork for all 29 default tags.
- macOS trackpad pinch, smart zoom and swipe navigation (#16); Show in Finder in
the sync popup, including notebooks on mounted shares.
- GNOME: libadwaita window edge and rounded bottom corners (#45); GNOME and KDE
styled menus.
- Mac OS X 10.6: Aqua scrollers painted by AppKit, the resize grip, date picker
Return, and a crash fix for the missing move cursor.
Assisted-by: claude-opus-5.5240 files changed, 8596 insertions(+), 1265 deletions(-)
arc/platforms.md+15-5| ... | ... | @@ -29,8 +29,13 @@ keyboard, the toolbar and the macOS menu bar all run commands from it. |
| 29 | 29 | |
| 30 | 30 | ### macOS |
| 31 | 31 | |
| 32 | - The title bar is transparent. The app draws its own around AppKit's traffic | |
| 33 | lights, as part of the same frame as the rest of the chrome. Under the whole | |
| 32 | - The toolbar's row is the title bar. An empty `NSToolbar`, unified compact | |
| 33 | from macOS 11, with the title hidden makes the title bar the row's height, | |
| 34 | and AppKit places the traffic lights in it; a press in the row's gaps drags | |
| 35 | the window. The title is still set, for the Window menu, Mission Control and | |
| 36 | VoiceOver. `--screenshot` paints the lights where the hidden window's AppKit | |
| 37 | put them. The app draws the row as part of the same frame as the rest of the | |
| 38 | chrome. Under the whole | |
| 34 | 39 | window lies AppKit's title bar material, an `NSVisualEffectView`, and the |
| 35 | 40 | chrome is drawn transparent over it, so the title bar, toolbar, tab row and |
| 36 | 41 | sidebar take the system's desktop tint, appearance and focus state exactly. |
| ... | ... | @@ -52,9 +57,14 @@ keyboard, the toolbar and the macOS menu bar all run commands from it. |
| 52 | 57 | ### Linux |
| 53 | 58 | |
| 54 | 59 | - X11 and Wayland. Title bars come from the window manager, or on Wayland from |
| 55 | the compositor where it offers server-side decorations and a client-side | |
| 56 | frame otherwise. The app draws its own window controls only where no frame | |
| 57 | could be made. | |
| 60 | the compositor where it offers server-side decorations, and the toolbar's | |
| 61 | row lies beneath them, as in Dolphin and Kate. Elsewhere on Wayland, as on | |
| 62 | GNOME, the row is the title bar, as a GTK 4 header bar holding tools: winit's | |
| 63 | Adwaita frame keeps its shadow, corners and resize edges but has no header, | |
| 64 | and the row ends in the window's buttons in the order GNOME's `button-layout` | |
| 65 | lists them. Under that frame on GNOME the window erases its bottom | |
| 66 | corners to transparent pixels and the frame draws libadwaita's window edge | |
| 67 | round it: radius, shadows and outline. KWin rounds Breeze's corners itself. | |
| 58 | 68 | - The toolbar and the rest of the chrome continue the title bar's fill, |
| 59 | 69 | focused and not: on KDE the colour scheme's header colours from |
| 60 | 70 | `kdeglobals`, as KWin paints its title bars, and on GNOME winit's Adwaita |
arc/ui.md+20-5| ... | ... | @@ -23,9 +23,10 @@ quickly and never feel like they're waiting on an animation. Animated values |
| 23 | 23 | ease exponentially toward their targets with a short half-life. That is |
| 24 | 24 | frame-rate independent, a retargeted animation continues smoothly from where |
| 25 | 25 | it is, and it settles fast. Popups open and close on short timed curves, |
| 26 | slow enough to follow: a menu grows out of the pointer or its button as it | |
| 26 | slow enough to follow: a menu swings out of the pointer or its button as it | |
| 27 | 27 | fades in, a combo's field widens into its list, and a dialog swings up into |
| 28 | place over a dimmed window, as Windows opens a window. On GNOME and KDE, menus instead | |
| 28 | place over a dimmed window, as Windows opens a window; the command palette swings | |
| 29 | in the same way near the window's top. On GNOME and KDE, menus instead | |
| 29 | 30 | look and move as the desktop's own (see [platforms](platforms.md)). |
| 30 | 31 | |
| 31 | 32 | ## Why not native widgets |
| ... | ... | @@ -62,7 +63,7 @@ frame N |
| 62 | 63 | route input ─── against frame N-1's layout (hover, press, focus, drags, wheel) |
| 63 | 64 | build ───────── app code declares boxes; each reads its Signal (clicked, dragging, events…) |
| 64 | 65 | solve layout ── per axis: pixels · label size · fraction of an ancestor · sum of children |
| 65 | overflow is shared out by each box's strictness | |
| 66 | overflow taken from space, then by folding groups, then by strictness | |
| 66 | 67 | paint ───────── Layers: primitives under a clip, or a Custom box the host paints |
| 67 | 68 | after paint ─── work the frame asked for (page requests, saving) runs, then asks for a frame |
| 68 | 69 | ``` |
| ... | ... | @@ -75,8 +76,17 @@ frame N |
| 75 | 76 | it is being declared. It sounds odd and is invisible in practice. |
| 76 | 77 | - **Layout is solved after building**, per axis. A box can be sized in fixed |
| 77 | 78 | pixels, by its label, as a fraction of an ancestor, or by its children. When |
| 78 | siblings overflow, each gives up space according to its *strictness*. That | |
| 79 | one knob covers most of what flexbox is usually needed for. | |
| 79 | siblings overflow, space sized from an ancestor gives way first. Then a row | |
| 80 | *folds* its groups, boxes with a full and a folded form (`Spec::fold`), by | |
| 81 | priority. Only then do boxes give up room by their *strictness*, the least | |
| 82 | strict first. That one knob covers most of what flexbox is usually needed | |
| 83 | for. | |
| 84 | - **The toolbar is one row that never overflows.** Both forms of every group | |
| 85 | are built each frame and the solver picks, so a group folds in the frame the | |
| 86 | window narrows, with no widths remembered or worked out by the app. A folded | |
| 87 | group is a dropdown of the same command-table rows, and a gallery it lists | |
| 88 | opens under it, since a folded-away box takes its group's rectangle. The | |
| 89 | section tabs scroll sideways past the row's room, fading out where cut. | |
| 80 | 90 | - **Nothing runs while nothing changes.** The kit reports whether it wants |
| 81 | 91 | another frame (queued input, an animation still settling) and when a timed |
| 82 | 92 | change such as a caret blink is due. The app sleeps in between. |
| ... | ... | @@ -113,6 +123,11 @@ rounded or gradient rectangles, pen strokes and raster images. |
| 113 | 123 | ordinary ones. |
| 114 | 124 | - **Batches** merge consecutive primitives that share a texture and clip, |
| 115 | 125 | without reordering paint. |
| 126 | - **A popup in motion is one picture.** Layers sharing a motion draw | |
| 127 | offscreen together, then fade and lean back as a whole, so nothing beneath | |
| 128 | or within shows through a row. A popup's contents clip to its rounded | |
| 129 | outline, and one opening over a combo lays its rows out at their final | |
| 130 | width while only the outline widens. | |
| 116 | 131 | - **Caches are bounded.** The glyph atlas and image cache have fixed budgets. |
| 117 | 132 | Eviction keeps everything the current frame needs and rebuilds after |
| 118 | 133 | pressure. Images are filtered in linear light with premultiplied alpha. |
corpus/page-background/README.md created+13| ... | ... | @@ -0,0 +1,13 @@ |
| 1 | # Page background | |
| 2 | ||
| 3 | `candidate` is `backgrounds_go_on_and_off_pages_that_have_content` in | |
| 4 | `crates/snowbound/src/manage.rs` (`SNOWBOUND_BACKGROUND_EXPORT`), on a copy of | |
| 5 | `rule-lines/native/notebook`: the Page Color menu's ops give the ruled "Standard" page | |
| 6 | Ivy's art (`manage::art_ops`), give "Wide" the art and take it away again, and colour | |
| 7 | "SmallGrid" `0x00c8d8e8`, a COLORREF outside OneNote's menu. | |
| 8 | ||
| 9 | `cold` is its fresh OneNote 2010 read: "Standard" keeps its rule lines under one | |
| 10 | background image (`backgroundImage="true"`), "Wide" keeps its lines and no image, and | |
| 11 | "SmallGrid" reads back `color="#E8D8C8"` and shows it under its grid. `read/page-006.png` | |
| 12 | shows OneNote drawing the art over the rule lines. `tools/test_page_background.py` | |
| 13 | checks this without a VM. |
corpus/page-background/candidate/Open Notebook.onetoc2 created| Binary files /dev/null and b/corpus/page-background/candidate/Open Notebook.onetoc2 differ |
corpus/page-background/candidate/Rules.one created| Binary files /dev/null and b/corpus/page-background/candidate/Rules.one differ |
corpus/page-background/cold/commands.jsonl created+3| ... | ... | @@ -0,0 +1,3 @@ |
| 1 | {"command": "powershell -NoProfile -Command \"Expand-Archive -LiteralPath C:\\one-tests\\transfer.zip -DestinationPath C:\\one-tests\\runs\\capture\\notebook\"", "exit": 0, "stdout": "", "stderr": "", "error": null} | |
| 2 | {"command": "powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File C:\\one-tests\\read-current.ps1 -Root C:\\one-tests\\runs\\capture -CloneHost ONE-M6-5EF70348 -ExpectedPages 11 -KeepOpen", "exit": 0, "stdout": "Read 1 sections and 11 pages.\r\n", "stderr": "", "error": null} | |
| 3 | {"command": "powershell -NoProfile -Command \"Compress-Archive -Force -Path C:\\one-tests\\runs\\capture\\read -DestinationPath C:\\one-tests\\captured.zip\"", "exit": 0, "stdout": "", "stderr": "", "error": null} |
corpus/page-background/cold/machine.json created+1| ... | ... | @@ -0,0 +1 @@ |
| 1 | {"name": "m6-5ef70348", "hostname": "ONE-M6-5EF70348"} |
corpus/page-background/cold/read/environment.json created+7| ... | ... | @@ -0,0 +1,7 @@ |
| 1 | { | |
| 2 | "powershell": "5.1.14409.1005", | |
| 3 | "schema": "xs2010", | |
| 4 | "hostname": "ONE-M6-5EF70348", | |
| 5 | "cold": true, | |
| 6 | "onenote": "14.0.4763.1000" | |
| 7 | } |
corpus/page-background/cold/read/hierarchy.xml created+2| ... | ... | @@ -0,0 +1,2 @@ |
| 1 | <?xml version="1.0"?> | |
| 2 | <one:Notebook xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" name="notebook" nickname="notebook" ID="{4E9398E9-CFD7-43A8-9875-DD25B12F2FD4}{1}{B0}" path="C:\one-tests\runs\capture\notebook" lastModifiedTime="2026-09-29T08:41:31.000Z" color="#EE9597"><one:Section name="Rules" ID="{8582DE68-7D51-02A8-377A-D6E00C2FCD89}{1}{B0}" path="C:\one-tests\runs\capture\notebook\Rules.one" lastModifiedTime="2026-09-29T08:41:31.000Z" color="#B49EDE"><one:Page ID="{31D0327B-94B7-0B5E-3E7D-671F54D55CEC}{1}{B0}" name="None" dateTime="2026-09-29T07:53:40.000Z" lastModifiedTime="2026-09-29T08:41:31.000Z" pageLevel="1"/><one:Page ID="{6A54865E-CC2F-025D-27BF-0929DFE646F9}{1}{B0}" name="Narrow" dateTime="2026-09-29T07:53:42.000Z" lastModifiedTime="2026-09-29T08:04:10.000Z" pageLevel="1"/><one:Page ID="{37C93113-BD98-0DA1-0616-DB06D76B9DE7}{1}{B0}" name="College" dateTime="2026-09-29T07:53:42.000Z" lastModifiedTime="2026-09-29T07:57:16.000Z" pageLevel="1"/><one:Page ID="{859BC3C1-DA73-0094-187D-081E0BF4B451}{1}{B0}" name="Standard" dateTime="2026-09-29T07:53:42.000Z" lastModifiedTime="2026-09-29T07:57:21.000Z" pageLevel="1"/><one:Page ID="{5E86B14D-2EA5-0CEE-01DC-77C26A83BFD9}{1}{B0}" name="Wide" dateTime="2026-09-29T07:53:42.000Z" lastModifiedTime="2026-09-29T07:57:26.000Z" pageLevel="1"/><one:Page ID="{D343E8F3-DEB6-0F1E-1EB1-8B94E7382757}{1}{B0}" name="SmallGrid" dateTime="2026-09-29T07:53:42.000Z" lastModifiedTime="2026-09-29T07:57:31.000Z" pageLevel="1"/><one:Page ID="{5BFB88BD-0BC5-02DF-1E44-A227794FE0A1}{1}{B0}" name="MediumGrid" dateTime="2026-09-29T07:53:42.000Z" lastModifiedTime="2026-09-29T08:04:20.000Z" pageLevel="1"/><one:Page ID="{9E24A66D-E1BF-0540-29D9-FD682782DED2}{1}{B0}" name="LargeGrid" dateTime="2026-09-29T07:53:42.000Z" lastModifiedTime="2026-09-29T07:57:42.000Z" pageLevel="1"/><one:Page ID="{51A21F45-2AB8-0652-01E3-91BE86AD2DC6}{1}{B0}" name="VeryLargeGrid" dateTime="2026-09-29T07:53:43.000Z" lastModifiedTime="2026-09-29T07:57:47.000Z" pageLevel="1"/><one:Page ID="{B41FFCB9-1864-06FB-3F3A-EA4F5FDC10B9}{1}{B0}" name="ColorRed" dateTime="2026-09-29T07:53:43.000Z" lastModifiedTime="2026-09-29T08:04:15.000Z" pageLevel="1"/><one:Page ID="{ED27E4E9-4094-0D2D-34A2-4C38706F92CD}{1}{B0}" name="Hidden" dateTime="2026-09-29T07:53:43.000Z" lastModifiedTime="2026-09-29T08:06:00.000Z" pageLevel="1"/></one:Section></one:Notebook> |
corpus/page-background/cold/read/page-000.navigation.json created+13| ... | ... | @@ -0,0 +1,13 @@ |
| 1 | { | |
| 2 | "exit": 0, | |
| 3 | "stdout": "", | |
| 4 | "stderr": "", | |
| 5 | "w": 800, | |
| 6 | "h": 600, | |
| 7 | "error": null, | |
| 8 | "win": { | |
| 9 | "title": "None - Microsoft OneNote", | |
| 10 | "class": "Framework::CFrame", | |
| 11 | "dialog": false | |
| 12 | } | |
| 13 | } | |
| \ No newline at end of file |
corpus/page-background/cold/read/page-000.png created| Binary files /dev/null and b/corpus/page-background/cold/read/page-000.png differ |
corpus/page-background/cold/read/page-000.xml created+2| ... | ... | @@ -0,0 +1,2 @@ |
| 1 | <?xml version="1.0"?> | |
| 2 | <one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{31D0327B-94B7-0B5E-3E7D-671F54D55CEC}{1}{B0}" name="None" dateTime="2026-09-29T07:53:40.000Z" lastModifiedTime="2026-09-29T08:41:31.000Z" pageLevel="1"><one:QuickStyleDef index="0" name="p" fontColor="automatic" highlightColor="automatic" font="Calibri" fontSize="11.0" spaceBefore="0.0" spaceAfter="0.0"/><one:PageSettings RTL="false" color="automatic"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="false"/></one:PageSettings><one:Title lang="en-US"><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-29T07:53:42.000Z" lastModifiedTime="2026-09-29T07:53:42.000Z" objectID="{AF1767DC-FDC6-0BBC-0FC1-316E92D0839F}{33}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[None]]></one:T></one:OE></one:Title></one:Page> |
corpus/page-background/cold/read/page-001.navigation.json created+13| ... | ... | @@ -0,0 +1,13 @@ |
| 1 | { | |
| 2 | "exit": 0, | |
| 3 | "stdout": "", | |
| 4 | "stderr": "", | |
| 5 | "w": 800, | |
| 6 | "h": 600, | |
| 7 | "error": null, | |
| 8 | "win": { | |
| 9 | "title": "College - Microsoft OneNote", | |
| 10 | "class": "Framework::CFrame", | |
| 11 | "dialog": false | |
| 12 | } | |
| 13 | } | |
| \ No newline at end of file |
corpus/page-background/cold/read/page-001.png created| Binary files /dev/null and b/corpus/page-background/cold/read/page-001.png differ |
corpus/page-background/cold/read/page-001.xml created+2| ... | ... | @@ -0,0 +1,2 @@ |
| 1 | <?xml version="1.0"?> | |
| 2 | <one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{37C93113-BD98-0DA1-0616-DB06D76B9DE7}{1}{B0}" name="College" dateTime="2026-09-29T07:53:42.000Z" lastModifiedTime="2026-09-29T07:57:16.000Z" pageLevel="1"><one:QuickStyleDef index="0" name="p" fontColor="automatic" highlightColor="automatic" font="Calibri" fontSize="11.0" spaceBefore="0.0" spaceAfter="0.0"/><one:PageSettings RTL="false" color="automatic"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="true"><one:Horizontal color="#CAEBFD" spacing="23.76000022888184"/><one:Margin color="#FF5050"/></one:RuleLines></one:PageSettings><one:Title lang="en-US"><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-29T07:53:42.000Z" lastModifiedTime="2026-09-29T07:57:06.000Z" objectID="{FB694B2B-9D3B-0B6E-15D1-EB5B9DE960D8}{33}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[College]]></one:T></one:OE></one:Title></one:Page> |
corpus/page-background/cold/read/page-002.navigation.json created+13| ... | ... | @@ -0,0 +1,13 @@ |
| 1 | { | |
| 2 | "exit": 0, | |
| 3 | "stdout": "", | |
| 4 | "stderr": "", | |
| 5 | "w": 800, | |
| 6 | "h": 600, | |
| 7 | "error": null, | |
| 8 | "win": { | |
| 9 | "title": "VeryLargeGrid - Microsoft OneNote", | |
| 10 | "class": "Framework::CFrame", | |
| 11 | "dialog": false | |
| 12 | } | |
| 13 | } | |
| \ No newline at end of file |
corpus/page-background/cold/read/page-002.png created| Binary files /dev/null and b/corpus/page-background/cold/read/page-002.png differ |
corpus/page-background/cold/read/page-002.xml created+2| ... | ... | @@ -0,0 +1,2 @@ |
| 1 | <?xml version="1.0"?> | |
| 2 | <one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{51A21F45-2AB8-0652-01E3-91BE86AD2DC6}{1}{B0}" name="VeryLargeGrid" dateTime="2026-09-29T07:53:43.000Z" lastModifiedTime="2026-09-29T07:57:47.000Z" pageLevel="1"><one:QuickStyleDef index="0" name="p" fontColor="automatic" highlightColor="automatic" font="Calibri" fontSize="11.0" spaceBefore="0.0" spaceAfter="0.0"/><one:PageSettings RTL="false" color="automatic"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="true"><one:Horizontal color="#CAEBFD" spacing="56.69279861450195"/><one:Vertical color="#CAEBFD" spacing="56.69279861450195"/></one:RuleLines></one:PageSettings><one:Title lang="en-US"><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-29T07:53:43.000Z" lastModifiedTime="2026-09-29T07:53:43.000Z" objectID="{20F7B97B-36FA-0C22-1185-45868B492B11}{33}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[VeryLargeGrid]]></one:T></one:OE></one:Title></one:Page> |
corpus/page-background/cold/read/page-003.navigation.json created+13| ... | ... | @@ -0,0 +1,13 @@ |
| 1 | { | |
| 2 | "exit": 0, | |
| 3 | "stdout": "", | |
| 4 | "stderr": "", | |
| 5 | "w": 800, | |
| 6 | "h": 600, | |
| 7 | "error": null, | |
| 8 | "win": { | |
| 9 | "title": "MediumGrid - Microsoft OneNote", | |
| 10 | "class": "Framework::CFrame", | |
| 11 | "dialog": false | |
| 12 | } | |
| 13 | } | |
| \ No newline at end of file |
corpus/page-background/cold/read/page-003.png created| Binary files /dev/null and b/corpus/page-background/cold/read/page-003.png differ |
corpus/page-background/cold/read/page-003.xml created+2| ... | ... | @@ -0,0 +1,2 @@ |
| 1 | <?xml version="1.0"?> | |
| 2 | <one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{5BFB88BD-0BC5-02DF-1E44-A227794FE0A1}{1}{B0}" name="MediumGrid" dateTime="2026-09-29T07:53:42.000Z" lastModifiedTime="2026-09-29T08:04:20.000Z" pageLevel="1"><one:QuickStyleDef index="0" name="p" fontColor="automatic" highlightColor="automatic" font="Calibri" fontSize="11.0" spaceBefore="0.0" spaceAfter="0.0"/><one:PageSettings RTL="false" color="automatic"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="true"><one:Horizontal color="#E0C9FF" spacing="28.34639930725098"/><one:Vertical color="#E0C9FF" spacing="28.34639930725098"/></one:RuleLines></one:PageSettings><one:Title lang="en-US"><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-29T07:53:42.000Z" lastModifiedTime="2026-09-29T07:53:42.000Z" objectID="{2E8AFDA7-8F2C-01A8-3BE9-691F5CC5678B}{33}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[MediumGrid]]></one:T></one:OE></one:Title></one:Page> |
corpus/page-background/cold/read/page-004.navigation.json created+13| ... | ... | @@ -0,0 +1,13 @@ |
| 1 | { | |
| 2 | "exit": 0, | |
| 3 | "stdout": "", | |
| 4 | "stderr": "", | |
| 5 | "w": 800, | |
| 6 | "h": 600, | |
| 7 | "error": null, | |
| 8 | "win": { | |
| 9 | "title": "Wide - Microsoft OneNote", | |
| 10 | "class": "Framework::CFrame", | |
| 11 | "dialog": false | |
| 12 | } | |
| 13 | } | |
| \ No newline at end of file |
corpus/page-background/cold/read/page-004.png created| Binary files /dev/null and b/corpus/page-background/cold/read/page-004.png differ |
corpus/page-background/cold/read/page-004.xml created+2| ... | ... | @@ -0,0 +1,2 @@ |
| 1 | <?xml version="1.0"?> | |
| 2 | <one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{5E86B14D-2EA5-0CEE-01DC-77C26A83BFD9}{1}{B0}" name="Wide" dateTime="2026-09-29T07:53:42.000Z" lastModifiedTime="2026-09-29T07:57:26.000Z" pageLevel="1"><one:QuickStyleDef index="0" name="p" fontColor="automatic" highlightColor="automatic" font="Calibri" fontSize="11.0" spaceBefore="0.0" spaceAfter="0.0"/><one:PageSettings RTL="false" color="automatic"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="true"><one:Horizontal color="#CAEBFD" spacing="46.79999923706054"/><one:Margin color="#FF5050"/></one:RuleLines></one:PageSettings><one:Title lang="en-US"><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-29T07:53:42.000Z" lastModifiedTime="2026-09-29T07:57:24.000Z" objectID="{CECBC798-72F2-0899-1721-97EFC8C976CA}{33}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[Wide]]></one:T></one:OE></one:Title></one:Page> |
corpus/page-background/cold/read/page-005.navigation.json created+13| ... | ... | @@ -0,0 +1,13 @@ |
| 1 | { | |
| 2 | "exit": 0, | |
| 3 | "stdout": "", | |
| 4 | "stderr": "", | |
| 5 | "w": 800, | |
| 6 | "h": 600, | |
| 7 | "error": null, | |
| 8 | "win": { | |
| 9 | "title": "Narrow - Microsoft OneNote", | |
| 10 | "class": "Framework::CFrame", | |
| 11 | "dialog": false | |
| 12 | } | |
| 13 | } | |
| \ No newline at end of file |
corpus/page-background/cold/read/page-005.png created| Binary files /dev/null and b/corpus/page-background/cold/read/page-005.png differ |
corpus/page-background/cold/read/page-005.xml created+2| ... | ... | @@ -0,0 +1,2 @@ |
| 1 | <?xml version="1.0"?> | |
| 2 | <one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{6A54865E-CC2F-025D-27BF-0929DFE646F9}{1}{B0}" name="Narrow" dateTime="2026-09-29T07:53:42.000Z" lastModifiedTime="2026-09-29T08:04:10.000Z" pageLevel="1"><one:QuickStyleDef index="0" name="p" fontColor="automatic" highlightColor="automatic" font="Calibri" fontSize="11.0" spaceBefore="0.0" spaceAfter="0.0"/><one:PageSettings RTL="false" color="automatic"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="false"/></one:PageSettings><one:Title lang="en-US"><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-29T07:53:42.000Z" lastModifiedTime="2026-09-29T07:54:02.000Z" objectID="{606364C5-0652-06ED-22E6-A58C8D44710B}{33}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[Narrow]]></one:T></one:OE></one:Title></one:Page> |
corpus/page-background/cold/read/page-006.navigation.json created+13| ... | ... | @@ -0,0 +1,13 @@ |
| 1 | { | |
| 2 | "exit": 0, | |
| 3 | "stdout": "", | |
| 4 | "stderr": "", | |
| 5 | "w": 800, | |
| 6 | "h": 600, | |
| 7 | "error": null, | |
| 8 | "win": { | |
| 9 | "title": "Standard - Microsoft OneNote", | |
| 10 | "class": "Framework::CFrame", | |
| 11 | "dialog": false | |
| 12 | } | |
| 13 | } | |
| \ No newline at end of file |
corpus/page-background/cold/read/page-006.png created| Binary files /dev/null and b/corpus/page-background/cold/read/page-006.png differ |
corpus/page-background/cold/read/page-006.xml created+353| ... | ... | @@ -0,0 +1,353 @@ |
| 1 | <?xml version="1.0"?> | |
| 2 | <one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{859BC3C1-DA73-0094-187D-081E0BF4B451}{1}{B0}" name="Standard" dateTime="2026-09-29T07:53:42.000Z" lastModifiedTime="2026-09-29T07:57:21.000Z" pageLevel="1"><one:QuickStyleDef index="0" name="p" fontColor="automatic" highlightColor="automatic" font="Calibri" fontSize="11.0" spaceBefore="0.0" spaceAfter="0.0"/><one:PageSettings RTL="false" color="automatic"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="true"><one:Horizontal color="#CAEBFD" spacing="33.11999893188476"/><one:Margin color="#FF5050"/></one:RuleLines></one:PageSettings><one:Title lang="en-US"><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-29T07:53:42.000Z" lastModifiedTime="2026-09-29T07:57:19.000Z" objectID="{DC8A2434-6095-0034-03CE-413E1946EF0A}{33}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[Standard]]></one:T></one:OE></one:Title><one:Image format="jpg" backgroundImage="true" lastModifiedTime="2026-09-29T08:39:38.000Z" objectID="{48C362B0-6114-0C90-07D0-9F6A4AEBE04B}{1}{B0}"><one:Position x="-27.0" y="-3.599999666213989" z="0"/><one:Size width="174.5" height="640.0" isSetByUser="true"/><one:Data>/9j/4AAQSkZJRgABAgAAAQABAAD//gAYU25vd2JvdW5kIHRlbXBsYXRlIGl2ef/AABEIAxgA2AMB | |
| 3 | EQACEQEDEQH/2wBDAAMCAgMCAgMDAwMEAwMEBQgFBQQEBQoHBwYIDAoMDAsKCwsNDhIQDQ4RDgsL | |
| 4 | EBYQERMUFRUVDA8XGBYUGBIUFRT/2wBDAQMEBAUEBQkFBQkUDQsNFBQUFBQUFBQUFBQUFBQUFBQU | |
| 5 | FBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBT/xAAfAAABBQEBAQEBAQAAAAAAAAAAAQID | |
| 6 | BAUGBwgJCgv/xAC1EAACAQMDAgQDBQUEBAAAAX0BAgMABBEFEiExQQYTUWEHInEUMoGRoQgjQrHB | |
| 7 | FVLR8CQzYnKCCQoWFxgZGiUmJygpKjQ1Njc4OTpDREVGR0hJSlNUVVZXWFlaY2RlZmdoaWpzdHV2 | |
| 8 | d3h5eoOEhYaHiImKkpOUlZaXmJmaoqOkpaanqKmqsrO0tba3uLm6wsPExcbHyMnK0tPU1dbX2Nna | |
| 9 | 4eLj5OXm5+jp6vHy8/T19vf4+fr/xAAfAQADAQEBAQEBAQEBAAAAAAAAAQIDBAUGBwgJCgv/xAC1 | |
| 10 | EQACAQIEBAMEBwUEBAABAncAAQIDEQQFITEGEkFRB2FxEyIygQgUQpGhscEJIzNS8BVictEKFiQ0 | |
| 11 | 4SXxFxgZGiYnKCkqNTY3ODk6Q0RFRkdISUpTVFVWV1hZWmNkZWZnaGlqc3R1dnd4eXqCg4SFhoeI | |
| 12 | iYqSk5SVlpeYmZqio6Slpqeoqaqys7S1tre4ubrCw8TFxsfIycrS09TV1tfY2dri4+Tl5ufo6ery | |
| 13 | 8/T19vf4+fr/2gAMAwEAAhEDEQA/AP1ToAKAOS8f+KbvwbFYamkX2nThL5V3EB8wDfdcH2wRjoc1 | |
| 14 | 5GYYqpg+SqleN7NfkzGtV9jDnauup0el6nbazp8F7aSCW2nUOjjuK9KlVhXgqkHdM1TUldbHK/Cf | |
| 15 | /kW7r/sIXH/odeblf8F/4pfmcGB/hv1Z2leuegFABQAUAFABQAUAFABQAUAFABQAUAFABQAUAFAB | |
| 16 | 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</one:Data></one:Image></one:Page> |
corpus/page-background/cold/read/page-007.navigation.json created+13| ... | ... | @@ -0,0 +1,13 @@ |
| 1 | { | |
| 2 | "exit": 0, | |
| 3 | "stdout": "", | |
| 4 | "stderr": "", | |
| 5 | "w": 800, | |
| 6 | "h": 600, | |
| 7 | "error": null, | |
| 8 | "win": { | |
| 9 | "title": "LargeGrid - Microsoft OneNote", | |
| 10 | "class": "Framework::CFrame", | |
| 11 | "dialog": false | |
| 12 | } | |
| 13 | } | |
| \ No newline at end of file |
corpus/page-background/cold/read/page-007.png created| Binary files /dev/null and b/corpus/page-background/cold/read/page-007.png differ |
corpus/page-background/cold/read/page-007.xml created+2| ... | ... | @@ -0,0 +1,2 @@ |
| 1 | <?xml version="1.0"?> | |
| 2 | <one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{9E24A66D-E1BF-0540-29D9-FD682782DED2}{1}{B0}" name="LargeGrid" dateTime="2026-09-29T07:53:42.000Z" lastModifiedTime="2026-09-29T07:57:42.000Z" pageLevel="1"><one:QuickStyleDef index="0" name="p" fontColor="automatic" highlightColor="automatic" font="Calibri" fontSize="11.0" spaceBefore="0.0" spaceAfter="0.0"/><one:PageSettings RTL="false" color="automatic"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="true"><one:Horizontal color="#CAEBFD" spacing="42.51959991455078"/><one:Vertical color="#CAEBFD" spacing="42.51959991455078"/></one:RuleLines></one:PageSettings><one:Title lang="en-US"><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-29T07:53:43.000Z" lastModifiedTime="2026-09-29T07:53:43.000Z" objectID="{1915C34E-9F05-0F41-0FB3-CA265D7BA96F}{33}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[LargeGrid]]></one:T></one:OE></one:Title></one:Page> |
corpus/page-background/cold/read/page-008.navigation.json created+13| ... | ... | @@ -0,0 +1,13 @@ |
| 1 | { | |
| 2 | "exit": 0, | |
| 3 | "stdout": "", | |
| 4 | "stderr": "", | |
| 5 | "w": 800, | |
| 6 | "h": 600, | |
| 7 | "error": null, | |
| 8 | "win": { | |
| 9 | "title": "ColorRed - Microsoft OneNote", | |
| 10 | "class": "Framework::CFrame", | |
| 11 | "dialog": false | |
| 12 | } | |
| 13 | } | |
| \ No newline at end of file |
corpus/page-background/cold/read/page-008.png created| Binary files /dev/null and b/corpus/page-background/cold/read/page-008.png differ |
corpus/page-background/cold/read/page-008.xml created+2| ... | ... | @@ -0,0 +1,2 @@ |
| 1 | <?xml version="1.0"?> | |
| 2 | <one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{B41FFCB9-1864-06FB-3F3A-EA4F5FDC10B9}{1}{B0}" name="ColorRed" dateTime="2026-09-29T07:53:43.000Z" lastModifiedTime="2026-09-29T08:04:15.000Z" pageLevel="1"><one:QuickStyleDef index="0" name="p" fontColor="automatic" highlightColor="automatic" font="Calibri" fontSize="11.0" spaceBefore="0.0" spaceAfter="0.0"/><one:PageSettings RTL="false" color="automatic"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="true"><one:Horizontal color="#FFD4D6" spacing="33.11999893188476"/><one:Margin color="#FF5050"/></one:RuleLines></one:PageSettings><one:Title lang="en-US"><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-29T07:53:43.000Z" lastModifiedTime="2026-09-29T07:53:43.000Z" objectID="{0834FA7E-D1E4-0A72-216C-F13A34E6B856}{33}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[ColorRed]]></one:T></one:OE></one:Title></one:Page> |
corpus/page-background/cold/read/page-009.navigation.json created+13| ... | ... | @@ -0,0 +1,13 @@ |
| 1 | { | |
| 2 | "exit": 0, | |
| 3 | "stdout": "", | |
| 4 | "stderr": "", | |
| 5 | "w": 800, | |
| 6 | "h": 600, | |
| 7 | "error": null, | |
| 8 | "win": { | |
| 9 | "title": "SmallGrid - Microsoft OneNote", | |
| 10 | "class": "Framework::CFrame", | |
| 11 | "dialog": false | |
| 12 | } | |
| 13 | } | |
| \ No newline at end of file |
corpus/page-background/cold/read/page-009.png created| Binary files /dev/null and b/corpus/page-background/cold/read/page-009.png differ |
corpus/page-background/cold/read/page-009.xml created+2| ... | ... | @@ -0,0 +1,2 @@ |
| 1 | <?xml version="1.0"?> | |
| 2 | <one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{D343E8F3-DEB6-0F1E-1EB1-8B94E7382757}{1}{B0}" name="SmallGrid" dateTime="2026-09-29T07:53:42.000Z" lastModifiedTime="2026-09-29T07:57:31.000Z" pageLevel="1"><one:QuickStyleDef index="0" name="p" fontColor="automatic" highlightColor="automatic" font="Calibri" fontSize="11.0" spaceBefore="0.0" spaceAfter="0.0"/><one:PageSettings RTL="false" color="#E8D8C8"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="true"><one:Horizontal color="#CAEBFD" spacing="12.0"/><one:Vertical color="#CAEBFD" spacing="12.0"/></one:RuleLines></one:PageSettings><one:Title lang="en-US"><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-29T07:53:42.000Z" lastModifiedTime="2026-09-29T07:53:42.000Z" objectID="{4D4C9C73-4470-097D-2E98-6D8AACCDD845}{33}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[SmallGrid]]></one:T></one:OE></one:Title></one:Page> |
corpus/page-background/cold/read/page-010.navigation.json created+13| ... | ... | @@ -0,0 +1,13 @@ |
| 1 | { | |
| 2 | "exit": 0, | |
| 3 | "stdout": "", | |
| 4 | "stderr": "", | |
| 5 | "w": 800, | |
| 6 | "h": 600, | |
| 7 | "error": null, | |
| 8 | "win": { | |
| 9 | "title": "Hidden - Microsoft OneNote", | |
| 10 | "class": "Framework::CFrame", | |
| 11 | "dialog": false | |
| 12 | } | |
| 13 | } | |
| \ No newline at end of file |
corpus/page-background/cold/read/page-010.png created| Binary files /dev/null and b/corpus/page-background/cold/read/page-010.png differ |
corpus/page-background/cold/read/page-010.xml created+2| ... | ... | @@ -0,0 +1,2 @@ |
| 1 | <?xml version="1.0"?> | |
| 2 | <one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{ED27E4E9-4094-0D2D-34A2-4C38706F92CD}{1}{B0}" name="Hidden" dateTime="2026-09-29T07:53:43.000Z" lastModifiedTime="2026-09-29T08:06:00.000Z" pageLevel="1"><one:QuickStyleDef index="0" name="p" fontColor="automatic" highlightColor="automatic" font="Calibri" fontSize="11.0" spaceBefore="0.0" spaceAfter="0.0"/><one:PageSettings RTL="false" color="#D4F9F2"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="true"><one:Horizontal color="#FFFFFF" spacing="33.11999893188476"/><one:Margin color="#FF5050"/></one:RuleLines></one:PageSettings><one:Title lang="en-US"><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-29T07:53:43.000Z" lastModifiedTime="2026-09-29T08:05:45.000Z" objectID="{25B3BEA8-E4BC-026D-1015-2BBF16614A26}{33}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[Hidden]]></one:T></one:OE></one:Title></one:Page> |
corpus/page-background/cold/read/payloads.json created+3| ... | ... | @@ -0,0 +1,3 @@ |
| 1 | [ | |
| 2 | ||
| 3 | ] | |
| \ No newline at end of file |
corpus/page-background/cold/run.json created+18| ... | ... | @@ -0,0 +1,18 @@ |
| 1 | { | |
| 2 | "notebook": "/private/tmp/snowbound-page-background/candidate", | |
| 3 | "expected_pages": 11, | |
| 4 | "author": null, | |
| 5 | "author_timeout_seconds": 600, | |
| 6 | "inspect": false, | |
| 7 | "collect_notebook": false, | |
| 8 | "base": { | |
| 9 | "file": "win7-office-base.qcow2", | |
| 10 | "format": "qcow2", | |
| 11 | "sha256": "a1a4f8fab782ee14885ff801ca2f6347c208fdcfc3513637096f314315c89346", | |
| 12 | "virtual_size": 68719476736 | |
| 13 | }, | |
| 14 | "scripts": { | |
| 15 | "cold.ps1": "c177fc72ae6c2634d186f5671a880de20b09f9716532c37c69cb13aa15c7e331", | |
| 16 | "read.ps1": "04013bfcccee40a17e2a225f9b9e96f40a3a8daad9e350609654f8eda3ccbb41" | |
| 17 | } | |
| 18 | } |
corpus/page-background/cold/scripts/cold.ps1 created+27| ... | ... | @@ -0,0 +1,27 @@ |
| 1 | param([Parameter(Mandatory=$true)][string]$Root, [string]$CloneHost = '') | |
| 2 | Set-StrictMode -Version Latest | |
| 3 | $ErrorActionPreference = 'Stop' | |
| 4 | $root = [IO.Path]::GetFullPath($Root).TrimEnd('\') | |
| 5 | if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') { | |
| 6 | throw 'Choose a run directly below C:\one-tests\runs.' | |
| 7 | } | |
| 8 | if (Get-Process ONENOTE -ErrorAction SilentlyContinue) { throw 'Close OneNote before resetting its test cache.' } | |
| 9 | $key = 'HKCU:\Software\Microsoft\Office\14.0\OneNote' | |
| 10 | if ($CloneHost) { | |
| 11 | if ($CloneHost -notmatch '^ONE-[A-Z0-9-]+$' -or [Environment]::MachineName -ne $CloneHost) { | |
| 12 | throw 'The disposable clone hostname does not match this machine.' | |
| 13 | } | |
| 14 | New-Item "$key\Options\Paths" -Force | Out-Null | |
| 15 | New-ItemProperty "$key\Options\Paths" -Name UnfiledNotesSection -PropertyType ExpandString -Value 'C:\one-tests\Loose.one' -Force | Out-Null | |
| 16 | } elseif ((Get-ItemProperty "$key\Options\Paths").UnfiledNotesSection -ne 'C:\one-tests\Loose.one' -or | |
| 17 | -not (Test-Path 'C:\one-tests\profile-original-cache')) { | |
| 18 | throw 'Park the personal OneNote profile before resetting the test cache.' | |
| 19 | } | |
| 20 | $cache = Join-Path $env:LOCALAPPDATA 'Microsoft\OneNote\14.0' | |
| 21 | $parked = Join-Path 'C:\one-tests\caches' ([IO.Path]::GetFileName($root)) | |
| 22 | if (Test-Path $parked) { throw 'Choose a new run; its parked cache already exists.' } | |
| 23 | New-Item -ItemType Directory -Path 'C:\one-tests\caches' -Force | Out-Null | |
| 24 | if (Test-Path $cache) { Move-Item -LiteralPath $cache -Destination $parked } | |
| 25 | if (Test-Path "$key\OpenNotebooks") { Remove-Item "$key\OpenNotebooks" -Recurse } | |
| 26 | New-Item "$key\OpenNotebooks" | Out-Null | |
| 27 | New-ItemProperty "$key\OpenNotebooks" -Name '1' -PropertyType String -Value "$root\notebook" | Out-Null |
corpus/page-background/cold/scripts/read.ps1 created+142| ... | ... | @@ -0,0 +1,142 @@ |
| 1 | param( | |
| 2 | [Parameter(Mandatory=$true)][string]$Root, | |
| 3 | [int]$ExpectedPages = -1, | |
| 4 | [switch]$UseCurrentCache, | |
| 5 | [switch]$Pdf, | |
| 6 | [switch]$KeepOpen, | |
| 7 | [string]$CloneHost = '' | |
| 8 | ) | |
| 9 | Set-StrictMode -Version Latest | |
| 10 | $ErrorActionPreference = 'Stop' | |
| 11 | $root = [IO.Path]::GetFullPath($Root).TrimEnd('\') | |
| 12 | if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') { | |
| 13 | throw 'Choose a run directly below C:\one-tests\runs.' | |
| 14 | } | |
| 15 | $notebook = Join-Path $root 'notebook' | |
| 16 | $output = Join-Path $root 'read' | |
| 17 | if (Test-Path $output) { throw 'Choose a new read destination.' } | |
| 18 | if ($UseCurrentCache) { | |
| 19 | if ((Get-ItemProperty 'HKCU:\Software\Microsoft\Office\14.0\OneNote\Options\Paths').UnfiledNotesSection -ne 'C:\one-tests\Loose.one') { | |
| 20 | throw 'Park the personal OneNote profile before reading test notebooks.' | |
| 21 | } | |
| 22 | } else { | |
| 23 | & "$PSScriptRoot\cold-current.ps1" -Root $root -CloneHost $CloneHost | |
| 24 | } | |
| 25 | New-Item -ItemType Directory -Path $output | Out-Null | |
| 26 | $app = New-Object -ComObject OneNote.Application | |
| 27 | $notebookId = '' | |
| 28 | $failure = $null | |
| 29 | try { | |
| 30 | $app.OpenHierarchy($notebook, '', [ref]$notebookId, 0) | |
| 31 | $process = Get-Process ONENOTE | |
| 32 | @{ hostname = [Environment]::MachineName; onenote = $process.MainModule.FileVersionInfo.FileVersion; | |
| 33 | powershell = $PSVersionTable.PSVersion.ToString(); schema = 'xs2010'; cold = (-not $UseCurrentCache.IsPresent) } | | |
| 34 | ConvertTo-Json | Set-Content (Join-Path $output 'environment.json') -Encoding UTF8 | |
| 35 | $sections = @() | |
| 36 | foreach ($file in @(Get-ChildItem $notebook -Recurse | Where-Object { $_.Extension -eq '.one' })) { | |
| 37 | $id = '' | |
| 38 | $app.OpenHierarchy($file.FullName, '', [ref]$id, 0) | |
| 39 | $sections += $id | |
| 40 | } | |
| 41 | $deadline = [DateTime]::UtcNow.AddSeconds(300) | |
| 42 | $previous = '' | |
| 43 | $lastChange = '' | |
| 44 | $stableSince = [DateTime]::UtcNow | |
| 45 | $settled = $false | |
| 46 | do { | |
| 47 | $pages = @{} | |
| 48 | foreach ($section in $sections) { | |
| 49 | $hierarchy = '' | |
| 50 | $app.GetHierarchy($section, 4, [ref]$hierarchy, 1) | |
| 51 | [xml]$xml = $hierarchy | |
| 52 | foreach ($node in $xml.SelectNodes('//*[@path]')) { | |
| 53 | if (-not $node.GetAttribute('path').StartsWith("$notebook\", [StringComparison]::OrdinalIgnoreCase)) { | |
| 54 | throw 'OneNote opened a section outside the copied notebook.' | |
| 55 | } | |
| 56 | } | |
| 57 | foreach ($node in $xml.SelectNodes('//*[local-name()="Page"]')) { | |
| 58 | $id = $node.GetAttribute('ID') | |
| 59 | $content = '' | |
| 60 | $app.GetPageContent($id, [ref]$content, 1, 1) | |
| 61 | $pages[$id] = $content | |
| 62 | } | |
| 63 | } | |
| 64 | $signature = [String]::Join('|', @($pages.Keys | Sort-Object | ForEach-Object { $_ + $pages[$_] })) | |
| 65 | if ($signature -ne $previous) { | |
| 66 | $lastChange = $previous | |
| 67 | $previous = $signature | |
| 68 | $stableSince = [DateTime]::UtcNow | |
| 69 | } | |
| 70 | if ((($ExpectedPages -ge 0 -and $pages.Count -eq $ExpectedPages) -or | |
| 71 | ($ExpectedPages -lt 0 -and $pages.Count -gt 0)) -and | |
| 72 | ([DateTime]::UtcNow - $stableSince).TotalSeconds -ge 2) { $settled = $true; break } | |
| 73 | Start-Sleep -Milliseconds 250 | |
| 74 | } while ([DateTime]::UtcNow -lt $deadline) | |
| 75 | if (-not $settled -or ($ExpectedPages -ge 0 -and $pages.Count -ne $ExpectedPages) -or ($ExpectedPages -lt 0 -and $pages.Count -eq 0)) { | |
| 76 | [IO.File]::WriteAllText((Join-Path $output 'previous-signature.txt'), $lastChange, [Text.Encoding]::UTF8) | |
| 77 | $index = 0 | |
| 78 | foreach ($id in @($pages.Keys | Sort-Object)) { | |
| 79 | [IO.File]::WriteAllText((Join-Path $output ('unsettled-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8) | |
| 80 | $index++ | |
| 81 | } | |
| 82 | $hierarchy = '' | |
| 83 | $app.GetHierarchy($notebookId, 4, [ref]$hierarchy, 1) | |
| 84 | [IO.File]::WriteAllText((Join-Path $output 'unsettled-hierarchy.xml'), $hierarchy, [Text.Encoding]::UTF8) | |
| 85 | throw "Expected $ExpectedPages stable pages; OneNote returned $($pages.Count), settled=$settled." | |
| 86 | } | |
| 87 | $index = 0 | |
| 88 | $payloads = @() | |
| 89 | foreach ($id in @($pages.Keys | Sort-Object)) { | |
| 90 | [IO.File]::WriteAllText((Join-Path $output ('page-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8) | |
| 91 | if ($Pdf) { | |
| 92 | $pdfPath = Join-Path $output ('page-{0:d3}.pdf' -f $index) | |
| 93 | $app.NavigateTo($id, '', $false) | |
| 94 | $app.Publish($id, $pdfPath, 3, '') | |
| 95 | if (-not (Test-Path $pdfPath) -or (Get-Item $pdfPath).Length -eq 0) { throw 'OneNote did not publish the page PDF.' } | |
| 96 | } | |
| 97 | [xml]$page = $pages[$id] | |
| 98 | foreach ($file in $page.SelectNodes('//*[local-name()="InsertedFile" or local-name()="MediaFile"]')) { | |
| 99 | $bytes = [IO.File]::ReadAllBytes($file.GetAttribute('pathCache')) | |
| 100 | $hash = [BitConverter]::ToString([Security.Cryptography.SHA256]::Create().ComputeHash($bytes)).Replace('-', '').ToLowerInvariant() | |
| 101 | [IO.File]::WriteAllBytes((Join-Path $output ($hash + '.attachment')), $bytes) | |
| 102 | $payloads += @{ page = $id; object = $file.ParentNode.GetAttribute('objectID'); | |
| 103 | kind = $file.LocalName; name = $file.GetAttribute('preferredName'); | |
| 104 | sha256 = $hash; bytes = $bytes.Length } | |
| 105 | } | |
| 106 | $index++ | |
| 107 | } | |
| 108 | [IO.File]::WriteAllText((Join-Path $output 'payloads.json'), (ConvertTo-Json -InputObject $payloads -Depth 4), [Text.Encoding]::UTF8) | |
| 109 | $all = '' | |
| 110 | $app.GetHierarchy($notebookId, 4, [ref]$all, 1) | |
| 111 | [xml]$finalTree = $all | |
| 112 | $finalIds = @($finalTree.SelectNodes('//*[local-name()="Page"]') | ForEach-Object { $_.GetAttribute('ID') } | Sort-Object -Unique) | |
| 113 | if ($finalIds.Count -ne $pages.Count -or @($finalIds | Where-Object { -not $pages.ContainsKey($_) }).Count -ne 0) { | |
| 114 | throw 'The notebook hierarchy changed while collecting page evidence; repeat the cold read.' | |
| 115 | } | |
| 116 | [IO.File]::WriteAllText((Join-Path $output 'hierarchy.xml'), $all, [Text.Encoding]::UTF8) | |
| 117 | Write-Output "Read $($sections.Count) sections and $($pages.Count) pages." | |
| 118 | } catch { | |
| 119 | $failure = $_ | |
| 120 | [IO.File]::WriteAllText((Join-Path $output 'failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8) | |
| 121 | throw | |
| 122 | } finally { | |
| 123 | try { | |
| 124 | try { | |
| 125 | if ($notebookId -and $CloneHost) { $app.SyncHierarchy($notebookId) } | |
| 126 | if ($notebookId -and -not $KeepOpen -and (-not $UseCurrentCache -or $CloneHost)) { | |
| 127 | $app.CloseNotebook($notebookId, $false) | |
| 128 | } | |
| 129 | } catch { | |
| 130 | if ($null -eq $failure) { throw } | |
| 131 | [IO.File]::WriteAllText((Join-Path $output 'cleanup-failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8) | |
| 132 | } | |
| 133 | } finally { | |
| 134 | [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($app) | |
| 135 | } | |
| 136 | $app = $null | |
| 137 | [GC]::Collect() | |
| 138 | [GC]::WaitForPendingFinalizers() | |
| 139 | if (-not $UseCurrentCache -and -not $CloneHost) { | |
| 140 | Get-Process ONENOTE -ErrorAction SilentlyContinue | Wait-Process -Timeout 10 | |
| 141 | } | |
| 142 | } |
corpus/page-background/cold/source.json created+14| ... | ... | @@ -0,0 +1,14 @@ |
| 1 | [ | |
| 2 | { | |
| 3 | "path": "Open Notebook.onetoc2", | |
| 4 | "bytes": 4808, | |
| 5 | "sha256": "26e13bc41400333df9b3b6ebc182ea592bdc8a4bd0d3c33c0773e832965add13", | |
| 6 | "mtime_ns": 1790669768851191042 | |
| 7 | }, | |
| 8 | { | |
| 9 | "path": "Rules.one", | |
| 10 | "bytes": 132704, | |
| 11 | "sha256": "a513e56274ae794cf558da34dca7189225159d7eb6ab7d2256531fd7a2a55b44", | |
| 12 | "mtime_ns": 1790671178905073494 | |
| 13 | } | |
| 14 | ] |
corpus/page-background/cold/teardown.json created+1| ... | ... | @@ -0,0 +1 @@ |
| 1 | {"absent": true} |
corpus/rule-lines/README.md created+25| ... | ... | @@ -0,0 +1,25 @@ |
| 1 | # Rule lines | |
| 2 | ||
| 3 | `native` is OneNote 2010's View, Rule Lines, set through its menu on the VM on eleven | |
| 4 | pages of a notebook the COM API made, each titled for what it was given: None; Narrow | |
| 5 | (ruled, then set back to None); College, Standard and Wide Ruled; Small, Medium, Large and | |
| 6 | Very Large Grid; ColorRed (Standard, then Rule Line Color, Red); Hidden (Standard, then | |
| 7 | Rule Line Color, `<none>`, on a Teal page). MediumGrid was then given Rule Line Color, | |
| 8 | Purple. `read` is its cold read in a fresh clone. | |
| 9 | ||
| 10 | The page node stores six properties MS-ONE does not document, in this order: the | |
| 11 | horizontal lines' colour (`0x14001cd5`), kind (`0x14001cd3`: 2 ruled, 1 grid) and spacing | |
| 12 | (`0x14001cd4`, half inches), then the vertical lines' kind (`0x14001cd6`: 3 a margin line, | |
| 13 | 1 grid), spacing (`0x14001cd7`, 0 for a margin line) and colour (`0x14001cd8`). Colours | |
| 14 | are 0xAARRGGBB with alpha 0xFF, unlike the page colour's COLORREF; the menu's `<none>` | |
| 15 | colour is white. None removes all six. OneNote draws lines 1/96 inch wide: horizontal ones | |
| 16 | every spacing from the margin origin (36, 14.4) down across the page, grid lines through | |
| 17 | it either way down the whole page, and a margin line 1/96 inch left of it. | |
| 18 | `colors.txt` is each Rule Line Color as the page XML reports it, in the menu's order. | |
| 19 | ||
| 20 | `candidate` is `rule_lines_store_what_onenote_stores` in `crates/onestore/src/op/tests.rs` | |
| 21 | (`ONESTORE_RULE_LINES_EXPORT`): on OneNote's file, the `RuleLines` op gives None Wide | |
| 22 | Ruled, removes College's, turns Standard into a Small Grid and MediumGrid into Narrow | |
| 23 | Ruled in Red, and gives a created page, "Created", a Very Large Grid. `cold` is its fresh | |
| 24 | OneNote 2010 read: every page reads back with the lines written, and `read/page-*.png` | |
| 25 | shows them. `tools/test_rule_lines.py` checks both without a VM. |
corpus/rule-lines/candidate/Open Notebook.onetoc2 created| Binary files /dev/null and b/corpus/rule-lines/candidate/Open Notebook.onetoc2 differ |
corpus/rule-lines/candidate/Rules.one created| Binary files /dev/null and b/corpus/rule-lines/candidate/Rules.one differ |
corpus/rule-lines/cold/commands.jsonl created+3| ... | ... | @@ -0,0 +1,3 @@ |
| 1 | {"command": "powershell -NoProfile -Command \"Expand-Archive -LiteralPath C:\\one-tests\\transfer.zip -DestinationPath C:\\one-tests\\runs\\capture\\notebook\"", "exit": 0, "stdout": "", "stderr": "", "error": null} | |
| 2 | {"command": "powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File C:\\one-tests\\read-current.ps1 -Root C:\\one-tests\\runs\\capture -CloneHost ONE-M6-8F5199A7 -ExpectedPages 12 -KeepOpen", "exit": 0, "stdout": "Read 1 sections and 12 pages.\r\n", "stderr": "", "error": null} | |
| 3 | {"command": "powershell -NoProfile -Command \"Compress-Archive -Force -Path C:\\one-tests\\runs\\capture\\read -DestinationPath C:\\one-tests\\captured.zip\"", "exit": 0, "stdout": "", "stderr": "", "error": null} |
corpus/rule-lines/cold/machine.json created+1| ... | ... | @@ -0,0 +1 @@ |
| 1 | {"name": "m6-8f5199a7", "hostname": "ONE-M6-8F5199A7"} |
corpus/rule-lines/cold/read/environment.json created+7| ... | ... | @@ -0,0 +1,7 @@ |
| 1 | { | |
| 2 | "powershell": "5.1.14409.1005", | |
| 3 | "schema": "xs2010", | |
| 4 | "hostname": "ONE-M6-8F5199A7", | |
| 5 | "cold": true, | |
| 6 | "onenote": "14.0.4763.1000" | |
| 7 | } |
corpus/rule-lines/cold/read/hierarchy.xml created+2| ... | ... | @@ -0,0 +1,2 @@ |
| 1 | <?xml version="1.0"?> | |
| 2 | <one:Notebook xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" name="notebook" nickname="notebook" ID="{B9BA3950-E123-4A51-A6D1-3636EE3AA760}{1}{B0}" path="C:\one-tests\runs\capture\notebook" lastModifiedTime="2026-09-29T08:18:54.000Z" color="#EE9597"><one:Section name="Rules" ID="{54CC1153-2398-04AD-1E16-8310A0EB1289}{1}{B0}" path="C:\one-tests\runs\capture\notebook\Rules.one" lastModifiedTime="2026-09-29T08:18:54.000Z" color="#B49EDE"><one:Page ID="{E09EFD40-CA7E-0D5B-1711-32EFF81183EC}{1}{B0}" name="None" dateTime="2026-09-29T07:53:40.000Z" lastModifiedTime="2026-09-29T08:18:53.000Z" pageLevel="1"/><one:Page ID="{BB1A4965-92E6-0458-0ED3-5CD9732299F9}{1}{B0}" name="Narrow" dateTime="2026-09-29T07:53:42.000Z" lastModifiedTime="2026-09-29T08:04:10.000Z" pageLevel="1"/><one:Page ID="{E687FE28-E351-0BA4-2F7A-8EF67BAF42E7}{1}{B0}" name="College" dateTime="2026-09-29T07:53:42.000Z" lastModifiedTime="2026-09-29T07:57:16.000Z" pageLevel="1"/><one:Page ID="{54D50CFA-84BA-0691-3111-5DEEA7306B51}{1}{B0}" name="Standard" dateTime="2026-09-29T07:53:42.000Z" lastModifiedTime="2026-09-29T07:57:21.000Z" pageLevel="1"/><one:Page ID="{8FC87E76-706C-0AEB-28B0-2232C64760D9}{1}{B0}" name="Wide" dateTime="2026-09-29T07:53:42.000Z" lastModifiedTime="2026-09-29T07:57:26.000Z" pageLevel="1"/><one:Page ID="{020D27C8-807F-091B-37DD-DE644BFCF857}{1}{B0}" name="SmallGrid" dateTime="2026-09-29T07:53:42.000Z" lastModifiedTime="2026-09-29T07:57:31.000Z" pageLevel="1"/><one:Page ID="{8AB54786-550C-04DA-3728-F7D7D58B3FA1}{1}{B0}" name="MediumGrid" dateTime="2026-09-29T07:53:42.000Z" lastModifiedTime="2026-09-29T08:04:20.000Z" pageLevel="1"/><one:Page ID="{4F6A6956-BF76-0345-00B5-A8988B4601D2}{1}{B0}" name="LargeGrid" dateTime="2026-09-29T07:53:42.000Z" lastModifiedTime="2026-09-29T07:57:42.000Z" pageLevel="1"/><one:Page ID="{80ECD07E-7471-0057-288F-C44E2A69F2C6}{1}{B0}" name="VeryLargeGrid" dateTime="2026-09-29T07:53:43.000Z" lastModifiedTime="2026-09-29T07:57:47.000Z" pageLevel="1"/><one:Page ID="{65513382-46AD-00FE-1656-BFBFF318CFB9}{1}{B0}" name="ColorRed" dateTime="2026-09-29T07:53:43.000Z" lastModifiedTime="2026-09-29T08:04:15.000Z" pageLevel="1"/><one:Page ID="{3C692BD2-1E5D-0B28-1DCE-19C8DCAB4DCD}{1}{B0}" name="Hidden" dateTime="2026-09-29T07:53:43.000Z" lastModifiedTime="2026-09-29T08:06:00.000Z" pageLevel="1"/><one:Page ID="{68170600-C309-0CB8-0BC9-88040B8044BE}{1}{B0}" name="Created" dateTime="2026-09-29T08:15:17.000Z" lastModifiedTime="2026-09-29T08:18:54.000Z" pageLevel="1"/></one:Section></one:Notebook> |
corpus/rule-lines/cold/read/page-000.navigation.json created+13| ... | ... | @@ -0,0 +1,13 @@ |
| 1 | { | |
| 2 | "exit": 0, | |
| 3 | "stdout": "", | |
| 4 | "stderr": "", | |
| 5 | "w": 800, | |
| 6 | "h": 600, | |
| 7 | "error": null, | |
| 8 | "win": { | |
| 9 | "title": "SmallGrid - Microsoft OneNote", | |
| 10 | "class": "Framework::CFrame", | |
| 11 | "dialog": false | |
| 12 | } | |
| 13 | } | |
| \ No newline at end of file |
corpus/rule-lines/cold/read/page-000.png created| Binary files /dev/null and b/corpus/rule-lines/cold/read/page-000.png differ |
corpus/rule-lines/cold/read/page-000.xml created+2| ... | ... | @@ -0,0 +1,2 @@ |
| 1 | <?xml version="1.0"?> | |
| 2 | <one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{020D27C8-807F-091B-37DD-DE644BFCF857}{1}{B0}" name="SmallGrid" dateTime="2026-09-29T07:53:42.000Z" lastModifiedTime="2026-09-29T07:57:31.000Z" pageLevel="1"><one:QuickStyleDef index="0" name="p" fontColor="automatic" highlightColor="automatic" font="Calibri" fontSize="11.0" spaceBefore="0.0" spaceAfter="0.0"/><one:PageSettings RTL="false" color="automatic"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="true"><one:Horizontal color="#CAEBFD" spacing="12.0"/><one:Vertical color="#CAEBFD" spacing="12.0"/></one:RuleLines></one:PageSettings><one:Title lang="en-US"><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-29T07:53:42.000Z" lastModifiedTime="2026-09-29T07:53:42.000Z" objectID="{9C025348-1AB9-0F78-07F4-387A00090745}{33}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[SmallGrid]]></one:T></one:OE></one:Title></one:Page> |
corpus/rule-lines/cold/read/page-001.navigation.json created+13| ... | ... | @@ -0,0 +1,13 @@ |
| 1 | { | |
| 2 | "exit": 0, | |
| 3 | "stdout": "", | |
| 4 | "stderr": "", | |
| 5 | "w": 800, | |
| 6 | "h": 600, | |
| 7 | "error": null, | |
| 8 | "win": { | |
| 9 | "title": "Hidden - Microsoft OneNote", | |
| 10 | "class": "Framework::CFrame", | |
| 11 | "dialog": false | |
| 12 | } | |
| 13 | } | |
| \ No newline at end of file |
corpus/rule-lines/cold/read/page-001.png created| Binary files /dev/null and b/corpus/rule-lines/cold/read/page-001.png differ |
corpus/rule-lines/cold/read/page-001.xml created+2| ... | ... | @@ -0,0 +1,2 @@ |
| 1 | <?xml version="1.0"?> | |
| 2 | <one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{3C692BD2-1E5D-0B28-1DCE-19C8DCAB4DCD}{1}{B0}" name="Hidden" dateTime="2026-09-29T07:53:43.000Z" lastModifiedTime="2026-09-29T08:06:00.000Z" pageLevel="1"><one:QuickStyleDef index="0" name="p" fontColor="automatic" highlightColor="automatic" font="Calibri" fontSize="11.0" spaceBefore="0.0" spaceAfter="0.0"/><one:PageSettings RTL="false" color="#D4F9F2"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="true"><one:Horizontal color="#FFFFFF" spacing="33.11999893188476"/><one:Margin color="#FF5050"/></one:RuleLines></one:PageSettings><one:Title lang="en-US"><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-29T07:53:43.000Z" lastModifiedTime="2026-09-29T08:05:45.000Z" objectID="{F4FD7193-BA75-0468-3979-7E4FBAA59526}{33}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[Hidden]]></one:T></one:OE></one:Title></one:Page> |
corpus/rule-lines/cold/read/page-002.navigation.json created+13| ... | ... | @@ -0,0 +1,13 @@ |
| 1 | { | |
| 2 | "exit": 0, | |
| 3 | "stdout": "", | |
| 4 | "stderr": "", | |
| 5 | "w": 800, | |
| 6 | "h": 600, | |
| 7 | "error": null, | |
| 8 | "win": { | |
| 9 | "title": "LargeGrid - Microsoft OneNote", | |
| 10 | "class": "Framework::CFrame", | |
| 11 | "dialog": false | |
| 12 | } | |
| 13 | } | |
| \ No newline at end of file |
corpus/rule-lines/cold/read/page-002.png created| Binary files /dev/null and b/corpus/rule-lines/cold/read/page-002.png differ |
corpus/rule-lines/cold/read/page-002.xml created+2| ... | ... | @@ -0,0 +1,2 @@ |
| 1 | <?xml version="1.0"?> | |
| 2 | <one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{4F6A6956-BF76-0345-00B5-A8988B4601D2}{1}{B0}" name="LargeGrid" dateTime="2026-09-29T07:53:42.000Z" lastModifiedTime="2026-09-29T07:57:42.000Z" pageLevel="1"><one:QuickStyleDef index="0" name="p" fontColor="automatic" highlightColor="automatic" font="Calibri" fontSize="11.0" spaceBefore="0.0" spaceAfter="0.0"/><one:PageSettings RTL="false" color="automatic"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="true"><one:Horizontal color="#CAEBFD" spacing="42.51959991455078"/><one:Vertical color="#CAEBFD" spacing="42.51959991455078"/></one:RuleLines></one:PageSettings><one:Title lang="en-US"><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-29T07:53:43.000Z" lastModifiedTime="2026-09-29T07:53:43.000Z" objectID="{C85B0C75-C1CC-0944-26DF-9FD6F1BF766F}{33}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[LargeGrid]]></one:T></one:OE></one:Title></one:Page> |
corpus/rule-lines/cold/read/page-003.navigation.json created+13| ... | ... | @@ -0,0 +1,13 @@ |
| 1 | { | |
| 2 | "exit": 0, | |
| 3 | "stdout": "", | |
| 4 | "stderr": "", | |
| 5 | "w": 800, | |
| 6 | "h": 600, | |
| 7 | "error": null, | |
| 8 | "win": { | |
| 9 | "title": "Standard - Microsoft OneNote", | |
| 10 | "class": "Framework::CFrame", | |
| 11 | "dialog": false | |
| 12 | } | |
| 13 | } | |
| \ No newline at end of file |
corpus/rule-lines/cold/read/page-003.png created| Binary files /dev/null and b/corpus/rule-lines/cold/read/page-003.png differ |
corpus/rule-lines/cold/read/page-003.xml created+2| ... | ... | @@ -0,0 +1,2 @@ |
| 1 | <?xml version="1.0"?> | |
| 2 | <one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{54D50CFA-84BA-0691-3111-5DEEA7306B51}{1}{B0}" name="Standard" dateTime="2026-09-29T07:53:42.000Z" lastModifiedTime="2026-09-29T07:57:21.000Z" pageLevel="1"><one:QuickStyleDef index="0" name="p" fontColor="automatic" highlightColor="automatic" font="Calibri" fontSize="11.0" spaceBefore="0.0" spaceAfter="0.0"/><one:PageSettings RTL="false" color="automatic"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="true"><one:Horizontal color="#CAEBFD" spacing="12.0"/><one:Vertical color="#CAEBFD" spacing="12.0"/></one:RuleLines></one:PageSettings><one:Title lang="en-US"><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-29T07:53:42.000Z" lastModifiedTime="2026-09-29T07:57:19.000Z" objectID="{0DC4EB0F-3E5C-0631-2AA2-14CEB582300A}{33}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[Standard]]></one:T></one:OE></one:Title></one:Page> |
corpus/rule-lines/cold/read/page-004.navigation.json created+13| ... | ... | @@ -0,0 +1,13 @@ |
| 1 | { | |
| 2 | "exit": 0, | |
| 3 | "stdout": "", | |
| 4 | "stderr": "", | |
| 5 | "w": 800, | |
| 6 | "h": 600, | |
| 7 | "error": null, | |
| 8 | "win": { | |
| 9 | "title": "ColorRed - Microsoft OneNote", | |
| 10 | "class": "Framework::CFrame", | |
| 11 | "dialog": false | |
| 12 | } | |
| 13 | } | |
| \ No newline at end of file |
corpus/rule-lines/cold/read/page-004.png created| Binary files /dev/null and b/corpus/rule-lines/cold/read/page-004.png differ |
corpus/rule-lines/cold/read/page-004.xml created+2| ... | ... | @@ -0,0 +1,2 @@ |
| 1 | <?xml version="1.0"?> | |
| 2 | <one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{65513382-46AD-00FE-1656-BFBFF318CFB9}{1}{B0}" name="ColorRed" dateTime="2026-09-29T07:53:43.000Z" lastModifiedTime="2026-09-29T08:04:15.000Z" pageLevel="1"><one:QuickStyleDef index="0" name="p" fontColor="automatic" highlightColor="automatic" font="Calibri" fontSize="11.0" spaceBefore="0.0" spaceAfter="0.0"/><one:PageSettings RTL="false" color="automatic"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="true"><one:Horizontal color="#FFD4D6" spacing="33.11999893188476"/><one:Margin color="#FF5050"/></one:RuleLines></one:PageSettings><one:Title lang="en-US"><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-29T07:53:43.000Z" lastModifiedTime="2026-09-29T07:53:43.000Z" objectID="{D97A3545-8F2D-0C77-0800-A4CA98226756}{33}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[ColorRed]]></one:T></one:OE></one:Title></one:Page> |
corpus/rule-lines/cold/read/page-005.navigation.json created+13| ... | ... | @@ -0,0 +1,13 @@ |
| 1 | { | |
| 2 | "exit": 0, | |
| 3 | "stdout": "", | |
| 4 | "stderr": "", | |
| 5 | "w": 800, | |
| 6 | "h": 600, | |
| 7 | "error": null, | |
| 8 | "win": { | |
| 9 | "title": "Created - Microsoft OneNote", | |
| 10 | "class": "Framework::CFrame", | |
| 11 | "dialog": false | |
| 12 | } | |
| 13 | } | |
| \ No newline at end of file |
corpus/rule-lines/cold/read/page-005.png created| Binary files /dev/null and b/corpus/rule-lines/cold/read/page-005.png differ |
corpus/rule-lines/cold/read/page-005.xml created+2| ... | ... | @@ -0,0 +1,2 @@ |
| 1 | <?xml version="1.0"?> | |
| 2 | <one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{68170600-C309-0CB8-0BC9-88040B8044BE}{1}{B0}" name="Created" dateTime="2026-09-29T08:15:17.000Z" lastModifiedTime="2026-09-29T08:18:54.000Z" pageLevel="1"><one:QuickStyleDef index="0" name="PageTitle" font="Calibri" fontSize="17.0"/><one:PageSettings RTL="false" color="automatic"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="true"><one:Horizontal color="#CAEBFD" spacing="56.69279861450195"/><one:Vertical color="#CAEBFD" spacing="56.69279861450195"/></one:RuleLines></one:PageSettings><one:Title showDate="false" showTime="false" lang="en-US"><one:OE author="Author" authorInitials="A" lastModifiedBy="Author" lastModifiedByInitials="A" creationTime="2026-09-29T08:15:17.000Z" lastModifiedTime="2026-09-29T08:15:17.000Z" objectID="{68170600-C309-0CB8-0BC9-88040B8044BE}{15}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[Created]]></one:T></one:OE></one:Title></one:Page> |
corpus/rule-lines/cold/read/page-006.navigation.json created+13| ... | ... | @@ -0,0 +1,13 @@ |
| 1 | { | |
| 2 | "exit": 0, | |
| 3 | "stdout": "", | |
| 4 | "stderr": "", | |
| 5 | "w": 800, | |
| 6 | "h": 600, | |
| 7 | "error": null, | |
| 8 | "win": { | |
| 9 | "title": "VeryLargeGrid - Microsoft OneNote", | |
| 10 | "class": "Framework::CFrame", | |
| 11 | "dialog": false | |
| 12 | } | |
| 13 | } | |
| \ No newline at end of file |
corpus/rule-lines/cold/read/page-006.png created| Binary files /dev/null and b/corpus/rule-lines/cold/read/page-006.png differ |
corpus/rule-lines/cold/read/page-006.xml created+2| ... | ... | @@ -0,0 +1,2 @@ |
| 1 | <?xml version="1.0"?> | |
| 2 | <one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{80ECD07E-7471-0057-288F-C44E2A69F2C6}{1}{B0}" name="VeryLargeGrid" dateTime="2026-09-29T07:53:43.000Z" lastModifiedTime="2026-09-29T07:57:47.000Z" pageLevel="1"><one:QuickStyleDef index="0" name="p" fontColor="automatic" highlightColor="automatic" font="Calibri" fontSize="11.0" spaceBefore="0.0" spaceAfter="0.0"/><one:PageSettings RTL="false" color="automatic"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="true"><one:Horizontal color="#CAEBFD" spacing="56.69279861450195"/><one:Vertical color="#CAEBFD" spacing="56.69279861450195"/></one:RuleLines></one:PageSettings><one:Title lang="en-US"><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-29T07:53:43.000Z" lastModifiedTime="2026-09-29T07:53:43.000Z" objectID="{F1B97640-6833-0A27-38E9-1076278DF411}{33}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[VeryLargeGrid]]></one:T></one:OE></one:Title></one:Page> |
corpus/rule-lines/cold/read/page-007.navigation.json created+13| ... | ... | @@ -0,0 +1,13 @@ |
| 1 | { | |
| 2 | "exit": 0, | |
| 3 | "stdout": "", | |
| 4 | "stderr": "", | |
| 5 | "w": 800, | |
| 6 | "h": 600, | |
| 7 | "error": null, | |
| 8 | "win": { | |
| 9 | "title": "MediumGrid - Microsoft OneNote", | |
| 10 | "class": "Framework::CFrame", | |
| 11 | "dialog": false | |
| 12 | } | |
| 13 | } | |
| \ No newline at end of file |
corpus/rule-lines/cold/read/page-007.png created| Binary files /dev/null and b/corpus/rule-lines/cold/read/page-007.png differ |
corpus/rule-lines/cold/read/page-007.xml created+2| ... | ... | @@ -0,0 +1,2 @@ |
| 1 | <?xml version="1.0"?> | |
| 2 | <one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{8AB54786-550C-04DA-3728-F7D7D58B3FA1}{1}{B0}" name="MediumGrid" dateTime="2026-09-29T07:53:42.000Z" lastModifiedTime="2026-09-29T08:04:20.000Z" pageLevel="1"><one:QuickStyleDef index="0" name="p" fontColor="automatic" highlightColor="automatic" font="Calibri" fontSize="11.0" spaceBefore="0.0" spaceAfter="0.0"/><one:PageSettings RTL="false" color="automatic"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="true"><one:Horizontal color="#FFD4D6" spacing="13.42771530151367"/><one:Margin color="#FF5050"/></one:RuleLines></one:PageSettings><one:Title lang="en-US"><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-29T07:53:42.000Z" lastModifiedTime="2026-09-29T07:53:42.000Z" objectID="{FFC4329C-D1E5-07AD-1285-3CEFF001B88B}{33}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[MediumGrid]]></one:T></one:OE></one:Title></one:Page> |
corpus/rule-lines/cold/read/page-008.navigation.json created+13| ... | ... | @@ -0,0 +1,13 @@ |
| 1 | { | |
| 2 | "exit": 0, | |
| 3 | "stdout": "", | |
| 4 | "stderr": "", | |
| 5 | "w": 800, | |
| 6 | "h": 600, | |
| 7 | "error": null, | |
| 8 | "win": { | |
| 9 | "title": "Wide - Microsoft OneNote", | |
| 10 | "class": "Framework::CFrame", | |
| 11 | "dialog": false | |
| 12 | } | |
| 13 | } | |
| \ No newline at end of file |
corpus/rule-lines/cold/read/page-008.png created| Binary files /dev/null and b/corpus/rule-lines/cold/read/page-008.png differ |
corpus/rule-lines/cold/read/page-008.xml created+2| ... | ... | @@ -0,0 +1,2 @@ |
| 1 | <?xml version="1.0"?> | |
| 2 | <one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{8FC87E76-706C-0AEB-28B0-2232C64760D9}{1}{B0}" name="Wide" dateTime="2026-09-29T07:53:42.000Z" lastModifiedTime="2026-09-29T07:57:26.000Z" pageLevel="1"><one:QuickStyleDef index="0" name="p" fontColor="automatic" highlightColor="automatic" font="Calibri" fontSize="11.0" spaceBefore="0.0" spaceAfter="0.0"/><one:PageSettings RTL="false" color="automatic"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="true"><one:Horizontal color="#CAEBFD" spacing="46.79999923706054"/><one:Margin color="#FF5050"/></one:RuleLines></one:PageSettings><one:Title lang="en-US"><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-29T07:53:42.000Z" lastModifiedTime="2026-09-29T07:57:24.000Z" objectID="{1F8508A3-2C3B-0E9C-3E4D-C21F640DA9CA}{33}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[Wide]]></one:T></one:OE></one:Title></one:Page> |
corpus/rule-lines/cold/read/page-009.navigation.json created+13| ... | ... | @@ -0,0 +1,13 @@ |
| 1 | { | |
| 2 | "exit": 0, | |
| 3 | "stdout": "", | |
| 4 | "stderr": "", | |
| 5 | "w": 800, | |
| 6 | "h": 600, | |
| 7 | "error": null, | |
| 8 | "win": { | |
| 9 | "title": "Narrow - Microsoft OneNote", | |
| 10 | "class": "Framework::CFrame", | |
| 11 | "dialog": false | |
| 12 | } | |
| 13 | } | |
| \ No newline at end of file |
corpus/rule-lines/cold/read/page-009.png created| Binary files /dev/null and b/corpus/rule-lines/cold/read/page-009.png differ |
corpus/rule-lines/cold/read/page-009.xml created+2| ... | ... | @@ -0,0 +1,2 @@ |
| 1 | <?xml version="1.0"?> | |
| 2 | <one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{BB1A4965-92E6-0458-0ED3-5CD9732299F9}{1}{B0}" name="Narrow" dateTime="2026-09-29T07:53:42.000Z" lastModifiedTime="2026-09-29T08:04:10.000Z" pageLevel="1"><one:QuickStyleDef index="0" name="p" fontColor="automatic" highlightColor="automatic" font="Calibri" fontSize="11.0" spaceBefore="0.0" spaceAfter="0.0"/><one:PageSettings RTL="false" color="automatic"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="false"/></one:PageSettings><one:Title lang="en-US"><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-29T07:53:42.000Z" lastModifiedTime="2026-09-29T07:54:02.000Z" objectID="{B12DABFE-589B-00E8-0B8A-F07C2180AE0B}{33}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[Narrow]]></one:T></one:OE></one:Title></one:Page> |
corpus/rule-lines/cold/read/page-010.navigation.json created+13| ... | ... | @@ -0,0 +1,13 @@ |
| 1 | { | |
| 2 | "exit": 0, | |
| 3 | "stdout": "", | |
| 4 | "stderr": "", | |
| 5 | "w": 800, | |
| 6 | "h": 600, | |
| 7 | "error": null, | |
| 8 | "win": { | |
| 9 | "title": "None - Microsoft OneNote", | |
| 10 | "class": "Framework::CFrame", | |
| 11 | "dialog": false | |
| 12 | } | |
| 13 | } | |
| \ No newline at end of file |
corpus/rule-lines/cold/read/page-010.png created| Binary files /dev/null and b/corpus/rule-lines/cold/read/page-010.png differ |
corpus/rule-lines/cold/read/page-010.xml created+2| ... | ... | @@ -0,0 +1,2 @@ |
| 1 | <?xml version="1.0"?> | |
| 2 | <one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{E09EFD40-CA7E-0D5B-1711-32EFF81183EC}{1}{B0}" name="None" dateTime="2026-09-29T07:53:40.000Z" lastModifiedTime="2026-09-29T08:18:53.000Z" pageLevel="1"><one:QuickStyleDef index="0" name="p" fontColor="automatic" highlightColor="automatic" font="Calibri" fontSize="11.0" spaceBefore="0.0" spaceAfter="0.0"/><one:PageSettings RTL="false" color="automatic"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="true"><one:Horizontal color="#CAEBFD" spacing="46.79999923706054"/><one:Margin color="#FF5050"/></one:RuleLines></one:PageSettings><one:Title lang="en-US"><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-29T07:53:42.000Z" lastModifiedTime="2026-09-29T07:53:42.000Z" objectID="{7E59A8E7-A30F-0DB9-26AD-649E3E145C9F}{33}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[None]]></one:T></one:OE></one:Title></one:Page> |
corpus/rule-lines/cold/read/page-011.navigation.json created+13| ... | ... | @@ -0,0 +1,13 @@ |
| 1 | { | |
| 2 | "exit": 0, | |
| 3 | "stdout": "", | |
| 4 | "stderr": "", | |
| 5 | "w": 800, | |
| 6 | "h": 600, | |
| 7 | "error": null, | |
| 8 | "win": { | |
| 9 | "title": "College - Microsoft OneNote", | |
| 10 | "class": "Framework::CFrame", | |
| 11 | "dialog": false | |
| 12 | } | |
| 13 | } | |
| \ No newline at end of file |
corpus/rule-lines/cold/read/page-011.png created| Binary files /dev/null and b/corpus/rule-lines/cold/read/page-011.png differ |
corpus/rule-lines/cold/read/page-011.xml created+2| ... | ... | @@ -0,0 +1,2 @@ |
| 1 | <?xml version="1.0"?> | |
| 2 | <one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{E687FE28-E351-0BA4-2F7A-8EF67BAF42E7}{1}{B0}" name="College" dateTime="2026-09-29T07:53:42.000Z" lastModifiedTime="2026-09-29T07:57:16.000Z" pageLevel="1"><one:QuickStyleDef index="0" name="p" fontColor="automatic" highlightColor="automatic" font="Calibri" fontSize="11.0" spaceBefore="0.0" spaceAfter="0.0"/><one:PageSettings RTL="false" color="automatic"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="false"/></one:PageSettings><one:Title lang="en-US"><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-29T07:53:42.000Z" lastModifiedTime="2026-09-29T07:57:06.000Z" objectID="{2A278410-C3F2-0D6B-3CBD-BEAB312DBFD8}{33}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[College]]></one:T></one:OE></one:Title></one:Page> |
corpus/rule-lines/cold/read/payloads.json created+3| ... | ... | @@ -0,0 +1,3 @@ |
| 1 | [ | |
| 2 | ||
| 3 | ] | |
| \ No newline at end of file |
corpus/rule-lines/cold/run.json created+18| ... | ... | @@ -0,0 +1,18 @@ |
| 1 | { | |
| 2 | "notebook": "/private/tmp/snowbound-rule-lines/candidate", | |
| 3 | "expected_pages": 12, | |
| 4 | "author": null, | |
| 5 | "author_timeout_seconds": 600, | |
| 6 | "inspect": false, | |
| 7 | "collect_notebook": false, | |
| 8 | "base": { | |
| 9 | "file": "win7-office-base.qcow2", | |
| 10 | "format": "qcow2", | |
| 11 | "sha256": "a1a4f8fab782ee14885ff801ca2f6347c208fdcfc3513637096f314315c89346", | |
| 12 | "virtual_size": 68719476736 | |
| 13 | }, | |
| 14 | "scripts": { | |
| 15 | "cold.ps1": "c177fc72ae6c2634d186f5671a880de20b09f9716532c37c69cb13aa15c7e331", | |
| 16 | "read.ps1": "04013bfcccee40a17e2a225f9b9e96f40a3a8daad9e350609654f8eda3ccbb41" | |
| 17 | } | |
| 18 | } |
corpus/rule-lines/cold/scripts/cold.ps1 created+27| ... | ... | @@ -0,0 +1,27 @@ |
| 1 | param([Parameter(Mandatory=$true)][string]$Root, [string]$CloneHost = '') | |
| 2 | Set-StrictMode -Version Latest | |
| 3 | $ErrorActionPreference = 'Stop' | |
| 4 | $root = [IO.Path]::GetFullPath($Root).TrimEnd('\') | |
| 5 | if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') { | |
| 6 | throw 'Choose a run directly below C:\one-tests\runs.' | |
| 7 | } | |
| 8 | if (Get-Process ONENOTE -ErrorAction SilentlyContinue) { throw 'Close OneNote before resetting its test cache.' } | |
| 9 | $key = 'HKCU:\Software\Microsoft\Office\14.0\OneNote' | |
| 10 | if ($CloneHost) { | |
| 11 | if ($CloneHost -notmatch '^ONE-[A-Z0-9-]+$' -or [Environment]::MachineName -ne $CloneHost) { | |
| 12 | throw 'The disposable clone hostname does not match this machine.' | |
| 13 | } | |
| 14 | New-Item "$key\Options\Paths" -Force | Out-Null | |
| 15 | New-ItemProperty "$key\Options\Paths" -Name UnfiledNotesSection -PropertyType ExpandString -Value 'C:\one-tests\Loose.one' -Force | Out-Null | |
| 16 | } elseif ((Get-ItemProperty "$key\Options\Paths").UnfiledNotesSection -ne 'C:\one-tests\Loose.one' -or | |
| 17 | -not (Test-Path 'C:\one-tests\profile-original-cache')) { | |
| 18 | throw 'Park the personal OneNote profile before resetting the test cache.' | |
| 19 | } | |
| 20 | $cache = Join-Path $env:LOCALAPPDATA 'Microsoft\OneNote\14.0' | |
| 21 | $parked = Join-Path 'C:\one-tests\caches' ([IO.Path]::GetFileName($root)) | |
| 22 | if (Test-Path $parked) { throw 'Choose a new run; its parked cache already exists.' } | |
| 23 | New-Item -ItemType Directory -Path 'C:\one-tests\caches' -Force | Out-Null | |
| 24 | if (Test-Path $cache) { Move-Item -LiteralPath $cache -Destination $parked } | |
| 25 | if (Test-Path "$key\OpenNotebooks") { Remove-Item "$key\OpenNotebooks" -Recurse } | |
| 26 | New-Item "$key\OpenNotebooks" | Out-Null | |
| 27 | New-ItemProperty "$key\OpenNotebooks" -Name '1' -PropertyType String -Value "$root\notebook" | Out-Null |
corpus/rule-lines/cold/scripts/read.ps1 created+142| ... | ... | @@ -0,0 +1,142 @@ |
| 1 | param( | |
| 2 | [Parameter(Mandatory=$true)][string]$Root, | |
| 3 | [int]$ExpectedPages = -1, | |
| 4 | [switch]$UseCurrentCache, | |
| 5 | [switch]$Pdf, | |
| 6 | [switch]$KeepOpen, | |
| 7 | [string]$CloneHost = '' | |
| 8 | ) | |
| 9 | Set-StrictMode -Version Latest | |
| 10 | $ErrorActionPreference = 'Stop' | |
| 11 | $root = [IO.Path]::GetFullPath($Root).TrimEnd('\') | |
| 12 | if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') { | |
| 13 | throw 'Choose a run directly below C:\one-tests\runs.' | |
| 14 | } | |
| 15 | $notebook = Join-Path $root 'notebook' | |
| 16 | $output = Join-Path $root 'read' | |
| 17 | if (Test-Path $output) { throw 'Choose a new read destination.' } | |
| 18 | if ($UseCurrentCache) { | |
| 19 | if ((Get-ItemProperty 'HKCU:\Software\Microsoft\Office\14.0\OneNote\Options\Paths').UnfiledNotesSection -ne 'C:\one-tests\Loose.one') { | |
| 20 | throw 'Park the personal OneNote profile before reading test notebooks.' | |
| 21 | } | |
| 22 | } else { | |
| 23 | & "$PSScriptRoot\cold-current.ps1" -Root $root -CloneHost $CloneHost | |
| 24 | } | |
| 25 | New-Item -ItemType Directory -Path $output | Out-Null | |
| 26 | $app = New-Object -ComObject OneNote.Application | |
| 27 | $notebookId = '' | |
| 28 | $failure = $null | |
| 29 | try { | |
| 30 | $app.OpenHierarchy($notebook, '', [ref]$notebookId, 0) | |
| 31 | $process = Get-Process ONENOTE | |
| 32 | @{ hostname = [Environment]::MachineName; onenote = $process.MainModule.FileVersionInfo.FileVersion; | |
| 33 | powershell = $PSVersionTable.PSVersion.ToString(); schema = 'xs2010'; cold = (-not $UseCurrentCache.IsPresent) } | | |
| 34 | ConvertTo-Json | Set-Content (Join-Path $output 'environment.json') -Encoding UTF8 | |
| 35 | $sections = @() | |
| 36 | foreach ($file in @(Get-ChildItem $notebook -Recurse | Where-Object { $_.Extension -eq '.one' })) { | |
| 37 | $id = '' | |
| 38 | $app.OpenHierarchy($file.FullName, '', [ref]$id, 0) | |
| 39 | $sections += $id | |
| 40 | } | |
| 41 | $deadline = [DateTime]::UtcNow.AddSeconds(300) | |
| 42 | $previous = '' | |
| 43 | $lastChange = '' | |
| 44 | $stableSince = [DateTime]::UtcNow | |
| 45 | $settled = $false | |
| 46 | do { | |
| 47 | $pages = @{} | |
| 48 | foreach ($section in $sections) { | |
| 49 | $hierarchy = '' | |
| 50 | $app.GetHierarchy($section, 4, [ref]$hierarchy, 1) | |
| 51 | [xml]$xml = $hierarchy | |
| 52 | foreach ($node in $xml.SelectNodes('//*[@path]')) { | |
| 53 | if (-not $node.GetAttribute('path').StartsWith("$notebook\", [StringComparison]::OrdinalIgnoreCase)) { | |
| 54 | throw 'OneNote opened a section outside the copied notebook.' | |
| 55 | } | |
| 56 | } | |
| 57 | foreach ($node in $xml.SelectNodes('//*[local-name()="Page"]')) { | |
| 58 | $id = $node.GetAttribute('ID') | |
| 59 | $content = '' | |
| 60 | $app.GetPageContent($id, [ref]$content, 1, 1) | |
| 61 | $pages[$id] = $content | |
| 62 | } | |
| 63 | } | |
| 64 | $signature = [String]::Join('|', @($pages.Keys | Sort-Object | ForEach-Object { $_ + $pages[$_] })) | |
| 65 | if ($signature -ne $previous) { | |
| 66 | $lastChange = $previous | |
| 67 | $previous = $signature | |
| 68 | $stableSince = [DateTime]::UtcNow | |
| 69 | } | |
| 70 | if ((($ExpectedPages -ge 0 -and $pages.Count -eq $ExpectedPages) -or | |
| 71 | ($ExpectedPages -lt 0 -and $pages.Count -gt 0)) -and | |
| 72 | ([DateTime]::UtcNow - $stableSince).TotalSeconds -ge 2) { $settled = $true; break } | |
| 73 | Start-Sleep -Milliseconds 250 | |
| 74 | } while ([DateTime]::UtcNow -lt $deadline) | |
| 75 | if (-not $settled -or ($ExpectedPages -ge 0 -and $pages.Count -ne $ExpectedPages) -or ($ExpectedPages -lt 0 -and $pages.Count -eq 0)) { | |
| 76 | [IO.File]::WriteAllText((Join-Path $output 'previous-signature.txt'), $lastChange, [Text.Encoding]::UTF8) | |
| 77 | $index = 0 | |
| 78 | foreach ($id in @($pages.Keys | Sort-Object)) { | |
| 79 | [IO.File]::WriteAllText((Join-Path $output ('unsettled-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8) | |
| 80 | $index++ | |
| 81 | } | |
| 82 | $hierarchy = '' | |
| 83 | $app.GetHierarchy($notebookId, 4, [ref]$hierarchy, 1) | |
| 84 | [IO.File]::WriteAllText((Join-Path $output 'unsettled-hierarchy.xml'), $hierarchy, [Text.Encoding]::UTF8) | |
| 85 | throw "Expected $ExpectedPages stable pages; OneNote returned $($pages.Count), settled=$settled." | |
| 86 | } | |
| 87 | $index = 0 | |
| 88 | $payloads = @() | |
| 89 | foreach ($id in @($pages.Keys | Sort-Object)) { | |
| 90 | [IO.File]::WriteAllText((Join-Path $output ('page-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8) | |
| 91 | if ($Pdf) { | |
| 92 | $pdfPath = Join-Path $output ('page-{0:d3}.pdf' -f $index) | |
| 93 | $app.NavigateTo($id, '', $false) | |
| 94 | $app.Publish($id, $pdfPath, 3, '') | |
| 95 | if (-not (Test-Path $pdfPath) -or (Get-Item $pdfPath).Length -eq 0) { throw 'OneNote did not publish the page PDF.' } | |
| 96 | } | |
| 97 | [xml]$page = $pages[$id] | |
| 98 | foreach ($file in $page.SelectNodes('//*[local-name()="InsertedFile" or local-name()="MediaFile"]')) { | |
| 99 | $bytes = [IO.File]::ReadAllBytes($file.GetAttribute('pathCache')) | |
| 100 | $hash = [BitConverter]::ToString([Security.Cryptography.SHA256]::Create().ComputeHash($bytes)).Replace('-', '').ToLowerInvariant() | |
| 101 | [IO.File]::WriteAllBytes((Join-Path $output ($hash + '.attachment')), $bytes) | |
| 102 | $payloads += @{ page = $id; object = $file.ParentNode.GetAttribute('objectID'); | |
| 103 | kind = $file.LocalName; name = $file.GetAttribute('preferredName'); | |
| 104 | sha256 = $hash; bytes = $bytes.Length } | |
| 105 | } | |
| 106 | $index++ | |
| 107 | } | |
| 108 | [IO.File]::WriteAllText((Join-Path $output 'payloads.json'), (ConvertTo-Json -InputObject $payloads -Depth 4), [Text.Encoding]::UTF8) | |
| 109 | $all = '' | |
| 110 | $app.GetHierarchy($notebookId, 4, [ref]$all, 1) | |
| 111 | [xml]$finalTree = $all | |
| 112 | $finalIds = @($finalTree.SelectNodes('//*[local-name()="Page"]') | ForEach-Object { $_.GetAttribute('ID') } | Sort-Object -Unique) | |
| 113 | if ($finalIds.Count -ne $pages.Count -or @($finalIds | Where-Object { -not $pages.ContainsKey($_) }).Count -ne 0) { | |
| 114 | throw 'The notebook hierarchy changed while collecting page evidence; repeat the cold read.' | |
| 115 | } | |
| 116 | [IO.File]::WriteAllText((Join-Path $output 'hierarchy.xml'), $all, [Text.Encoding]::UTF8) | |
| 117 | Write-Output "Read $($sections.Count) sections and $($pages.Count) pages." | |
| 118 | } catch { | |
| 119 | $failure = $_ | |
| 120 | [IO.File]::WriteAllText((Join-Path $output 'failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8) | |
| 121 | throw | |
| 122 | } finally { | |
| 123 | try { | |
| 124 | try { | |
| 125 | if ($notebookId -and $CloneHost) { $app.SyncHierarchy($notebookId) } | |
| 126 | if ($notebookId -and -not $KeepOpen -and (-not $UseCurrentCache -or $CloneHost)) { | |
| 127 | $app.CloseNotebook($notebookId, $false) | |
| 128 | } | |
| 129 | } catch { | |
| 130 | if ($null -eq $failure) { throw } | |
| 131 | [IO.File]::WriteAllText((Join-Path $output 'cleanup-failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8) | |
| 132 | } | |
| 133 | } finally { | |
| 134 | [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($app) | |
| 135 | } | |
| 136 | $app = $null | |
| 137 | [GC]::Collect() | |
| 138 | [GC]::WaitForPendingFinalizers() | |
| 139 | if (-not $UseCurrentCache -and -not $CloneHost) { | |
| 140 | Get-Process ONENOTE -ErrorAction SilentlyContinue | Wait-Process -Timeout 10 | |
| 141 | } | |
| 142 | } |
corpus/rule-lines/cold/source.json created+14| ... | ... | @@ -0,0 +1,14 @@ |
| 1 | [ | |
| 2 | { | |
| 3 | "path": "Open Notebook.onetoc2", | |
| 4 | "bytes": 4808, | |
| 5 | "sha256": "26e13bc41400333df9b3b6ebc182ea592bdc8a4bd0d3c33c0773e832965add13", | |
| 6 | "mtime_ns": 1790669621631553306 | |
| 7 | }, | |
| 8 | { | |
| 9 | "path": "Rules.one", | |
| 10 | "bytes": 93264, | |
| 11 | "sha256": "3ab2a2abcef76f4e008e349560caa63a907e15a61e71d080dd3e764c4aca34c1", | |
| 12 | "mtime_ns": 1790669717172032242 | |
| 13 | } | |
| 14 | ] |
corpus/rule-lines/cold/teardown.json created+1| ... | ... | @@ -0,0 +1 @@ |
| 1 | {"absent": true} |
corpus/rule-lines/native/colors.txt created+20| ... | ... | @@ -0,0 +1,20 @@ |
| 1 | none #FFFFFF | |
| 2 | Light Blue #CAEBFD | |
| 3 | Blue #D1E5FC | |
| 4 | Yellow #FFF2AE | |
| 5 | Green #D9E2C7 | |
| 6 | Red #FFD4D6 | |
| 7 | Purple #E0C9FF | |
| 8 | Cyan #BBE8DB | |
| 9 | Orange #F5DFB4 | |
| 10 | Magenta #F5D3E8 | |
| 11 | Blue Mist #BED3E2 | |
| 12 | Purple Mist #C5BACD | |
| 13 | Tan #F1E3C2 | |
| 14 | Lemon #F9FBA9 | |
| 15 | Apple #CFF1BF | |
| 16 | Teal #94D7DF | |
| 17 | Red Chalk #D6ACAC | |
| 18 | Silver #C8C8D3 | |
| 19 | ||
| 20 | [exited with code 0] |
corpus/rule-lines/native/commands.jsonl created+3| ... | ... | @@ -0,0 +1,3 @@ |
| 1 | {"command": "powershell -NoProfile -Command \"Expand-Archive -LiteralPath C:\\one-tests\\transfer.zip -DestinationPath C:\\one-tests\\runs\\capture\\notebook\"", "exit": 0, "stdout": "", "stderr": "", "error": null} | |
| 2 | {"command": "powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File C:\\one-tests\\read-current.ps1 -Root C:\\one-tests\\runs\\capture -CloneHost ONE-M6-4E581665 -ExpectedPages 11 -KeepOpen", "exit": 0, "stdout": "Read 1 sections and 11 pages.\r\n", "stderr": "", "error": null} | |
| 3 | {"command": "powershell -NoProfile -Command \"Compress-Archive -Force -Path C:\\one-tests\\runs\\capture\\* -DestinationPath C:\\one-tests\\captured.zip\"", "exit": 0, "stdout": "", "stderr": "", "error": null} |
corpus/rule-lines/native/machine.json created+1| ... | ... | @@ -0,0 +1 @@ |
| 1 | {"name": "m6-4e581665", "hostname": "ONE-M6-4E581665"} |
corpus/rule-lines/native/notebook/Open Notebook.onetoc2 created| Binary files /dev/null and b/corpus/rule-lines/native/notebook/Open Notebook.onetoc2 differ |
corpus/rule-lines/native/notebook/Rules.one created| Binary files /dev/null and b/corpus/rule-lines/native/notebook/Rules.one differ |
corpus/rule-lines/native/read/environment.json created+7| ... | ... | @@ -0,0 +1,7 @@ |
| 1 | { | |
| 2 | "powershell": "5.1.14409.1005", | |
| 3 | "schema": "xs2010", | |
| 4 | "hostname": "ONE-M6-4E581665", | |
| 5 | "cold": true, | |
| 6 | "onenote": "14.0.4763.1000" | |
| 7 | } |
corpus/rule-lines/native/read/hierarchy.xml created+2| ... | ... | @@ -0,0 +1,2 @@ |
| 1 | <?xml version="1.0"?> | |
| 2 | <one:Notebook xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" name="notebook" nickname="notebook" ID="{0D6D4723-06DA-4ECA-B86F-EEA2AD120CC3}{1}{B0}" path="C:\one-tests\runs\capture\notebook" lastModifiedTime="2026-09-29T08:14:14.000Z" color="#EE9597"><one:Section name="Rules" ID="{00577DF8-4566-0170-24C0-7E724DA0E4DA}{1}{B0}" path="C:\one-tests\runs\capture\notebook\Rules.one" lastModifiedTime="2026-09-29T08:14:14.000Z" color="#B49EDE"><one:Page ID="{B40591EB-AC80-0886-2DC7-CF8D155A75BF}{1}{B0}" name="None" dateTime="2026-09-29T07:53:40.000Z" lastModifiedTime="2026-09-29T08:14:14.000Z" pageLevel="1"/><one:Page ID="{EF8125CE-F418-0185-3405-A1BB9E696FAA}{1}{B0}" name="Narrow" dateTime="2026-09-29T07:53:42.000Z" lastModifiedTime="2026-09-29T08:04:10.000Z" pageLevel="1"/><one:Page ID="{B21C9283-85AF-0E79-15AC-739496E4B4B4}{1}{B0}" name="College" dateTime="2026-09-29T07:53:42.000Z" lastModifiedTime="2026-09-29T07:57:16.000Z" pageLevel="1"/><one:Page ID="{004E6051-E244-034C-0BC7-A08C4A7B9D02}{1}{B0}" name="Standard" dateTime="2026-09-29T07:53:42.000Z" lastModifiedTime="2026-09-29T07:57:21.000Z" pageLevel="1"/><one:Page ID="{DB5312DD-1692-0F36-1266-DF502B0C968A}{1}{B0}" name="Wide" dateTime="2026-09-29T07:53:42.000Z" lastModifiedTime="2026-09-29T07:57:26.000Z" pageLevel="1"/><one:Page ID="{56964B63-E681-0CC6-0D0B-2306A6B70E04}{1}{B0}" name="SmallGrid" dateTime="2026-09-29T07:53:42.000Z" lastModifiedTime="2026-09-29T07:57:31.000Z" pageLevel="1"/><one:Page ID="{DE2E2B2D-33F2-0107-0DFE-0AB538C0C9F2}{1}{B0}" name="MediumGrid" dateTime="2026-09-29T07:53:42.000Z" lastModifiedTime="2026-09-29T08:04:20.000Z" pageLevel="1"/><one:Page ID="{1BF105FD-D988-0698-3A63-55FA660DF781}{1}{B0}" name="LargeGrid" dateTime="2026-09-29T07:53:42.000Z" lastModifiedTime="2026-09-29T07:57:42.000Z" pageLevel="1"/><one:Page ID="{D477BCD5-128F-058A-1259-392CC7220495}{1}{B0}" name="VeryLargeGrid" dateTime="2026-09-29T07:53:43.000Z" lastModifiedTime="2026-09-29T07:57:47.000Z" pageLevel="1"/><one:Page ID="{31CA5F29-2053-0523-2C80-42DD1E5339EA}{1}{B0}" name="ColorRed" dateTime="2026-09-29T07:53:43.000Z" lastModifiedTime="2026-09-29T08:04:15.000Z" pageLevel="1"/><one:Page ID="{68F24779-78A3-0EF5-2718-E4AA31E0BB9E}{1}{B0}" name="Hidden" dateTime="2026-09-29T07:53:43.000Z" lastModifiedTime="2026-09-29T08:06:00.000Z" pageLevel="1"/></one:Section></one:Notebook> |
corpus/rule-lines/native/read/page-000.navigation.json created+13| ... | ... | @@ -0,0 +1,13 @@ |
| 1 | { | |
| 2 | "exit": 0, | |
| 3 | "stdout": "", | |
| 4 | "stderr": "", | |
| 5 | "w": 800, | |
| 6 | "h": 600, | |
| 7 | "error": null, | |
| 8 | "win": { | |
| 9 | "title": "Standard - Microsoft OneNote", | |
| 10 | "class": "Framework::CFrame", | |
| 11 | "dialog": false | |
| 12 | } | |
| 13 | } | |
| \ No newline at end of file |
corpus/rule-lines/native/read/page-000.png created| Binary files /dev/null and b/corpus/rule-lines/native/read/page-000.png differ |
corpus/rule-lines/native/read/page-000.xml created+2| ... | ... | @@ -0,0 +1,2 @@ |
| 1 | <?xml version="1.0"?> | |
| 2 | <one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{004E6051-E244-034C-0BC7-A08C4A7B9D02}{1}{B0}" name="Standard" dateTime="2026-09-29T07:53:42.000Z" lastModifiedTime="2026-09-29T07:57:21.000Z" pageLevel="1"><one:QuickStyleDef index="0" name="p" fontColor="automatic" highlightColor="automatic" font="Calibri" fontSize="11.0" spaceBefore="0.0" spaceAfter="0.0"/><one:PageSettings RTL="false" color="automatic"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="true"><one:Horizontal color="#CAEBFD" spacing="33.11999893188476"/><one:Margin color="#FF5050"/></one:RuleLines></one:PageSettings><one:Title lang="en-US"><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-29T07:53:42.000Z" lastModifiedTime="2026-09-29T07:57:19.000Z" objectID="{595F87A4-58A2-03EC-1074-E9AC58C9C659}{33}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[Standard]]></one:T></one:OE></one:Title></one:Page> |
corpus/rule-lines/native/read/page-001.navigation.json created+13| ... | ... | @@ -0,0 +1,13 @@ |
| 1 | { | |
| 2 | "exit": 0, | |
| 3 | "stdout": "", | |
| 4 | "stderr": "", | |
| 5 | "w": 800, | |
| 6 | "h": 600, | |
| 7 | "error": null, | |
| 8 | "win": { | |
| 9 | "title": "LargeGrid - Microsoft OneNote", | |
| 10 | "class": "Framework::CFrame", | |
| 11 | "dialog": false | |
| 12 | } | |
| 13 | } | |
| \ No newline at end of file |
corpus/rule-lines/native/read/page-001.png created| Binary files /dev/null and b/corpus/rule-lines/native/read/page-001.png differ |
corpus/rule-lines/native/read/page-001.xml created+2| ... | ... | @@ -0,0 +1,2 @@ |
| 1 | <?xml version="1.0"?> | |
| 2 | <one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{1BF105FD-D988-0698-3A63-55FA660DF781}{1}{B0}" name="LargeGrid" dateTime="2026-09-29T07:53:42.000Z" lastModifiedTime="2026-09-29T07:57:42.000Z" pageLevel="1"><one:QuickStyleDef index="0" name="p" fontColor="automatic" highlightColor="automatic" font="Calibri" fontSize="11.0" spaceBefore="0.0" spaceAfter="0.0"/><one:PageSettings RTL="false" color="automatic"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="true"><one:Horizontal color="#CAEBFD" spacing="42.51959991455078"/><one:Vertical color="#CAEBFD" spacing="42.51959991455078"/></one:RuleLines></one:PageSettings><one:Title lang="en-US"><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-29T07:53:43.000Z" lastModifiedTime="2026-09-29T07:53:43.000Z" objectID="{9CC060DE-A732-0C99-1C09-62B41CF4803C}{33}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[LargeGrid]]></one:T></one:OE></one:Title></one:Page> |
corpus/rule-lines/native/read/page-002.navigation.json created+13| ... | ... | @@ -0,0 +1,13 @@ |
| 1 | { | |
| 2 | "exit": 0, | |
| 3 | "stdout": "", | |
| 4 | "stderr": "", | |
| 5 | "w": 800, | |
| 6 | "h": 600, | |
| 7 | "error": null, | |
| 8 | "win": { | |
| 9 | "title": "ColorRed - Microsoft OneNote", | |
| 10 | "class": "Framework::CFrame", | |
| 11 | "dialog": false | |
| 12 | } | |
| 13 | } | |
| \ No newline at end of file |
corpus/rule-lines/native/read/page-002.png created| Binary files /dev/null and b/corpus/rule-lines/native/read/page-002.png differ |
corpus/rule-lines/native/read/page-002.xml created+2| ... | ... | @@ -0,0 +1,2 @@ |
| 1 | <?xml version="1.0"?> | |
| 2 | <one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{31CA5F29-2053-0523-2C80-42DD1E5339EA}{1}{B0}" name="ColorRed" dateTime="2026-09-29T07:53:43.000Z" lastModifiedTime="2026-09-29T08:04:15.000Z" pageLevel="1"><one:QuickStyleDef index="0" name="p" fontColor="automatic" highlightColor="automatic" font="Calibri" fontSize="11.0" spaceBefore="0.0" spaceAfter="0.0"/><one:PageSettings RTL="false" color="automatic"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="true"><one:Horizontal color="#FFD4D6" spacing="33.11999893188476"/><one:Margin color="#FF5050"/></one:RuleLines></one:PageSettings><one:Title lang="en-US"><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-29T07:53:43.000Z" lastModifiedTime="2026-09-29T07:53:43.000Z" objectID="{8DE159EE-E9D3-09AA-32D6-59A875699105}{33}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[ColorRed]]></one:T></one:OE></one:Title></one:Page> |
corpus/rule-lines/native/read/page-003.navigation.json created+13| ... | ... | @@ -0,0 +1,13 @@ |
| 1 | { | |
| 2 | "exit": 0, | |
| 3 | "stdout": "", | |
| 4 | "stderr": "", | |
| 5 | "w": 800, | |
| 6 | "h": 600, | |
| 7 | "error": null, | |
| 8 | "win": { | |
| 9 | "title": "SmallGrid - Microsoft OneNote", | |
| 10 | "class": "Framework::CFrame", | |
| 11 | "dialog": false | |
| 12 | } | |
| 13 | } | |
| \ No newline at end of file |
corpus/rule-lines/native/read/page-003.png created| Binary files /dev/null and b/corpus/rule-lines/native/read/page-003.png differ |
corpus/rule-lines/native/read/page-003.xml created+2| ... | ... | @@ -0,0 +1,2 @@ |
| 1 | <?xml version="1.0"?> | |
| 2 | <one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{56964B63-E681-0CC6-0D0B-2306A6B70E04}{1}{B0}" name="SmallGrid" dateTime="2026-09-29T07:53:42.000Z" lastModifiedTime="2026-09-29T07:57:31.000Z" pageLevel="1"><one:QuickStyleDef index="0" name="p" fontColor="automatic" highlightColor="automatic" font="Calibri" fontSize="11.0" spaceBefore="0.0" spaceAfter="0.0"/><one:PageSettings RTL="false" color="automatic"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="true"><one:Horizontal color="#CAEBFD" spacing="12.0"/><one:Vertical color="#CAEBFD" spacing="12.0"/></one:RuleLines></one:PageSettings><one:Title lang="en-US"><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-29T07:53:42.000Z" lastModifiedTime="2026-09-29T07:53:42.000Z" objectID="{C8993FE3-7C47-0AA5-3D22-C518ED42F116}{33}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[SmallGrid]]></one:T></one:OE></one:Title></one:Page> |
corpus/rule-lines/native/read/page-004.navigation.json created+13| ... | ... | @@ -0,0 +1,13 @@ |
| 1 | { | |
| 2 | "exit": 0, | |
| 3 | "stdout": "", | |
| 4 | "stderr": "", | |
| 5 | "w": 800, | |
| 6 | "h": 600, | |
| 7 | "error": null, | |
| 8 | "win": { | |
| 9 | "title": "Hidden - Microsoft OneNote", | |
| 10 | "class": "Framework::CFrame", | |
| 11 | "dialog": false | |
| 12 | } | |
| 13 | } | |
| \ No newline at end of file |
corpus/rule-lines/native/read/page-004.png created| Binary files /dev/null and b/corpus/rule-lines/native/read/page-004.png differ |
corpus/rule-lines/native/read/page-004.xml created+2| ... | ... | @@ -0,0 +1,2 @@ |
| 1 | <?xml version="1.0"?> | |
| 2 | <one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{68F24779-78A3-0EF5-2718-E4AA31E0BB9E}{1}{B0}" name="Hidden" dateTime="2026-09-29T07:53:43.000Z" lastModifiedTime="2026-09-29T08:06:00.000Z" pageLevel="1"><one:QuickStyleDef index="0" name="p" fontColor="automatic" highlightColor="automatic" font="Calibri" fontSize="11.0" spaceBefore="0.0" spaceAfter="0.0"/><one:PageSettings RTL="false" color="#D4F9F2"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="true"><one:Horizontal color="#FFFFFF" spacing="33.11999893188476"/><one:Margin color="#FF5050"/></one:RuleLines></one:PageSettings><one:Title lang="en-US"><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-29T07:53:43.000Z" lastModifiedTime="2026-09-29T08:05:45.000Z" objectID="{A0661D38-DC8B-01B5-03AF-832D57EE6375}{33}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[Hidden]]></one:T></one:OE></one:Title></one:Page> |
corpus/rule-lines/native/read/page-005.navigation.json created+13| ... | ... | @@ -0,0 +1,13 @@ |
| 1 | { | |
| 2 | "exit": 0, | |
| 3 | "stdout": "", | |
| 4 | "stderr": "", | |
| 5 | "w": 800, | |
| 6 | "h": 600, | |
| 7 | "error": null, | |
| 8 | "win": { | |
| 9 | "title": "College - Microsoft OneNote", | |
| 10 | "class": "Framework::CFrame", | |
| 11 | "dialog": false | |
| 12 | } | |
| 13 | } | |
| \ No newline at end of file |
corpus/rule-lines/native/read/page-005.png created| Binary files /dev/null and b/corpus/rule-lines/native/read/page-005.png differ |
corpus/rule-lines/native/read/page-005.xml created+2| ... | ... | @@ -0,0 +1,2 @@ |
| 1 | <?xml version="1.0"?> | |
| 2 | <one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{B21C9283-85AF-0E79-15AC-739496E4B4B4}{1}{B0}" name="College" dateTime="2026-09-29T07:53:42.000Z" lastModifiedTime="2026-09-29T07:57:16.000Z" pageLevel="1"><one:QuickStyleDef index="0" name="p" fontColor="automatic" highlightColor="automatic" font="Calibri" fontSize="11.0" spaceBefore="0.0" spaceAfter="0.0"/><one:PageSettings RTL="false" color="automatic"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="true"><one:Horizontal color="#CAEBFD" spacing="23.76000022888184"/><one:Margin color="#FF5050"/></one:RuleLines></one:PageSettings><one:Title lang="en-US"><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-29T07:53:42.000Z" lastModifiedTime="2026-09-29T07:57:06.000Z" objectID="{7EBCE8BB-A50C-08B6-066B-43C9DC66498B}{33}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[College]]></one:T></one:OE></one:Title></one:Page> |
corpus/rule-lines/native/read/page-006.navigation.json created+13| ... | ... | @@ -0,0 +1,13 @@ |
| 1 | { | |
| 2 | "exit": 0, | |
| 3 | "stdout": "", | |
| 4 | "stderr": "", | |
| 5 | "w": 800, | |
| 6 | "h": 600, | |
| 7 | "error": null, | |
| 8 | "win": { | |
| 9 | "title": "None - Microsoft OneNote", | |
| 10 | "class": "Framework::CFrame", | |
| 11 | "dialog": false | |
| 12 | } | |
| 13 | } | |
| \ No newline at end of file |
corpus/rule-lines/native/read/page-006.png created| Binary files /dev/null and b/corpus/rule-lines/native/read/page-006.png differ |
corpus/rule-lines/native/read/page-006.xml created+2| ... | ... | @@ -0,0 +1,2 @@ |
| 1 | <?xml version="1.0"?> | |
| 2 | <one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{B40591EB-AC80-0886-2DC7-CF8D155A75BF}{1}{B0}" name="None" dateTime="2026-09-29T07:53:40.000Z" lastModifiedTime="2026-09-29T08:14:14.000Z" pageLevel="1"><one:QuickStyleDef index="0" name="p" fontColor="automatic" highlightColor="automatic" font="Calibri" fontSize="11.0" spaceBefore="0.0" spaceAfter="0.0"/><one:PageSettings RTL="false" color="automatic"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="false"/></one:PageSettings><one:Title lang="en-US"><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-29T07:53:42.000Z" lastModifiedTime="2026-09-29T07:53:42.000Z" objectID="{2AC2C44C-C5F1-0864-1C7B-99FCD35FAACC}{33}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[None]]></one:T></one:OE></one:Title></one:Page> |
corpus/rule-lines/native/read/page-007.navigation.json created+13| ... | ... | @@ -0,0 +1,13 @@ |
| 1 | { | |
| 2 | "exit": 0, | |
| 3 | "stdout": "", | |
| 4 | "stderr": "", | |
| 5 | "w": 800, | |
| 6 | "h": 600, | |
| 7 | "error": null, | |
| 8 | "win": { | |
| 9 | "title": "VeryLargeGrid - Microsoft OneNote", | |
| 10 | "class": "Framework::CFrame", | |
| 11 | "dialog": false | |
| 12 | } | |
| 13 | } | |
| \ No newline at end of file |
corpus/rule-lines/native/read/page-007.png created| Binary files /dev/null and b/corpus/rule-lines/native/read/page-007.png differ |
corpus/rule-lines/native/read/page-007.xml created+2| ... | ... | @@ -0,0 +1,2 @@ |
| 1 | <?xml version="1.0"?> | |
| 2 | <one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{D477BCD5-128F-058A-1259-392CC7220495}{1}{B0}" name="VeryLargeGrid" dateTime="2026-09-29T07:53:43.000Z" lastModifiedTime="2026-09-29T07:57:47.000Z" pageLevel="1"><one:QuickStyleDef index="0" name="p" fontColor="automatic" highlightColor="automatic" font="Calibri" fontSize="11.0" spaceBefore="0.0" spaceAfter="0.0"/><one:PageSettings RTL="false" color="automatic"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="true"><one:Horizontal color="#CAEBFD" spacing="56.69279861450195"/><one:Vertical color="#CAEBFD" spacing="56.69279861450195"/></one:RuleLines></one:PageSettings><one:Title lang="en-US"><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-29T07:53:43.000Z" lastModifiedTime="2026-09-29T07:53:43.000Z" objectID="{A5221AEB-0ECD-0FFA-023F-ED14CAC60242}{33}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[VeryLargeGrid]]></one:T></one:OE></one:Title></one:Page> |
corpus/rule-lines/native/read/page-008.navigation.json created+13| ... | ... | @@ -0,0 +1,13 @@ |
| 1 | { | |
| 2 | "exit": 0, | |
| 3 | "stdout": "", | |
| 4 | "stderr": "", | |
| 5 | "w": 800, | |
| 6 | "h": 600, | |
| 7 | "error": null, | |
| 8 | "win": { | |
| 9 | "title": "Wide - Microsoft OneNote", | |
| 10 | "class": "Framework::CFrame", | |
| 11 | "dialog": false | |
| 12 | } | |
| 13 | } | |
| \ No newline at end of file |
corpus/rule-lines/native/read/page-008.png created| Binary files /dev/null and b/corpus/rule-lines/native/read/page-008.png differ |
corpus/rule-lines/native/read/page-008.xml created+2| ... | ... | @@ -0,0 +1,2 @@ |
| 1 | <?xml version="1.0"?> | |
| 2 | <one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{DB5312DD-1692-0F36-1266-DF502B0C968A}{1}{B0}" name="Wide" dateTime="2026-09-29T07:53:42.000Z" lastModifiedTime="2026-09-29T07:57:26.000Z" pageLevel="1"><one:QuickStyleDef index="0" name="p" fontColor="automatic" highlightColor="automatic" font="Calibri" fontSize="11.0" spaceBefore="0.0" spaceAfter="0.0"/><one:PageSettings RTL="false" color="automatic"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="true"><one:Horizontal color="#CAEBFD" spacing="46.79999923706054"/><one:Margin color="#FF5050"/></one:RuleLines></one:PageSettings><one:Title lang="en-US"><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-29T07:53:42.000Z" lastModifiedTime="2026-09-29T07:57:24.000Z" objectID="{4B1E6408-4AC5-0B41-049B-3F7D89465F99}{33}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[Wide]]></one:T></one:OE></one:Title></one:Page> |
corpus/rule-lines/native/read/page-009.navigation.json created+13| ... | ... | @@ -0,0 +1,13 @@ |
| 1 | { | |
| 2 | "exit": 0, | |
| 3 | "stdout": "", | |
| 4 | "stderr": "", | |
| 5 | "w": 800, | |
| 6 | "h": 600, | |
| 7 | "error": null, | |
| 8 | "win": { | |
| 9 | "title": "MediumGrid - Microsoft OneNote", | |
| 10 | "class": "Framework::CFrame", | |
| 11 | "dialog": false | |
| 12 | } | |
| 13 | } | |
| \ No newline at end of file |
corpus/rule-lines/native/read/page-009.png created| Binary files /dev/null and b/corpus/rule-lines/native/read/page-009.png differ |
corpus/rule-lines/native/read/page-009.xml created+2| ... | ... | @@ -0,0 +1,2 @@ |
| 1 | <?xml version="1.0"?> | |
| 2 | <one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{DE2E2B2D-33F2-0107-0DFE-0AB538C0C9F2}{1}{B0}" name="MediumGrid" dateTime="2026-09-29T07:53:42.000Z" lastModifiedTime="2026-09-29T08:04:20.000Z" pageLevel="1"><one:QuickStyleDef index="0" name="p" fontColor="automatic" highlightColor="automatic" font="Calibri" fontSize="11.0" spaceBefore="0.0" spaceAfter="0.0"/><one:PageSettings RTL="false" color="automatic"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="true"><one:Horizontal color="#E0C9FF" spacing="28.34639930725098"/><one:Vertical color="#E0C9FF" spacing="28.34639930725098"/></one:RuleLines></one:PageSettings><one:Title lang="en-US"><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-29T07:53:42.000Z" lastModifiedTime="2026-09-29T07:53:42.000Z" objectID="{AB5F5E37-B71B-0270-2853-C18D1D4A4ED8}{33}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[MediumGrid]]></one:T></one:OE></one:Title></one:Page> |
corpus/rule-lines/native/read/page-010.navigation.json created+13| ... | ... | @@ -0,0 +1,13 @@ |
| 1 | { | |
| 2 | "exit": 0, | |
| 3 | "stdout": "", | |
| 4 | "stderr": "", | |
| 5 | "w": 800, | |
| 6 | "h": 600, | |
| 7 | "error": null, | |
| 8 | "win": { | |
| 9 | "title": "Narrow - Microsoft OneNote", | |
| 10 | "class": "Framework::CFrame", | |
| 11 | "dialog": false | |
| 12 | } | |
| 13 | } | |
| \ No newline at end of file |
corpus/rule-lines/native/read/page-010.png created| Binary files /dev/null and b/corpus/rule-lines/native/read/page-010.png differ |
corpus/rule-lines/native/read/page-010.xml created+2| ... | ... | @@ -0,0 +1,2 @@ |
| 1 | <?xml version="1.0"?> | |
| 2 | <one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{EF8125CE-F418-0185-3405-A1BB9E696FAA}{1}{B0}" name="Narrow" dateTime="2026-09-29T07:53:42.000Z" lastModifiedTime="2026-09-29T08:04:10.000Z" pageLevel="1"><one:QuickStyleDef index="0" name="p" fontColor="automatic" highlightColor="automatic" font="Calibri" fontSize="11.0" spaceBefore="0.0" spaceAfter="0.0"/><one:PageSettings RTL="false" color="automatic"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="false"/></one:PageSettings><one:Title lang="en-US"><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-29T07:53:42.000Z" lastModifiedTime="2026-09-29T07:54:02.000Z" objectID="{E5B6C755-3E65-0535-315C-0D1ECCCB5858}{33}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[Narrow]]></one:T></one:OE></one:Title></one:Page> |
corpus/rule-lines/native/read/payloads.json created+3| ... | ... | @@ -0,0 +1,3 @@ |
| 1 | [ | |
| 2 | ||
| 3 | ] | |
| \ No newline at end of file |
corpus/rule-lines/native/run.json created+18| ... | ... | @@ -0,0 +1,18 @@ |
| 1 | { | |
| 2 | "notebook": "/private/tmp/snowbound-rule-lines/lab/native-notebook", | |
| 3 | "expected_pages": 11, | |
| 4 | "author": null, | |
| 5 | "author_timeout_seconds": 600, | |
| 6 | "inspect": false, | |
| 7 | "collect_notebook": true, | |
| 8 | "base": { | |
| 9 | "file": "win7-office-base.qcow2", | |
| 10 | "format": "qcow2", | |
| 11 | "sha256": "a1a4f8fab782ee14885ff801ca2f6347c208fdcfc3513637096f314315c89346", | |
| 12 | "virtual_size": 68719476736 | |
| 13 | }, | |
| 14 | "scripts": { | |
| 15 | "cold.ps1": "c177fc72ae6c2634d186f5671a880de20b09f9716532c37c69cb13aa15c7e331", | |
| 16 | "read.ps1": "04013bfcccee40a17e2a225f9b9e96f40a3a8daad9e350609654f8eda3ccbb41" | |
| 17 | } | |
| 18 | } |
corpus/rule-lines/native/scripts/cold.ps1 created+27| ... | ... | @@ -0,0 +1,27 @@ |
| 1 | param([Parameter(Mandatory=$true)][string]$Root, [string]$CloneHost = '') | |
| 2 | Set-StrictMode -Version Latest | |
| 3 | $ErrorActionPreference = 'Stop' | |
| 4 | $root = [IO.Path]::GetFullPath($Root).TrimEnd('\') | |
| 5 | if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') { | |
| 6 | throw 'Choose a run directly below C:\one-tests\runs.' | |
| 7 | } | |
| 8 | if (Get-Process ONENOTE -ErrorAction SilentlyContinue) { throw 'Close OneNote before resetting its test cache.' } | |
| 9 | $key = 'HKCU:\Software\Microsoft\Office\14.0\OneNote' | |
| 10 | if ($CloneHost) { | |
| 11 | if ($CloneHost -notmatch '^ONE-[A-Z0-9-]+$' -or [Environment]::MachineName -ne $CloneHost) { | |
| 12 | throw 'The disposable clone hostname does not match this machine.' | |
| 13 | } | |
| 14 | New-Item "$key\Options\Paths" -Force | Out-Null | |
| 15 | New-ItemProperty "$key\Options\Paths" -Name UnfiledNotesSection -PropertyType ExpandString -Value 'C:\one-tests\Loose.one' -Force | Out-Null | |
| 16 | } elseif ((Get-ItemProperty "$key\Options\Paths").UnfiledNotesSection -ne 'C:\one-tests\Loose.one' -or | |
| 17 | -not (Test-Path 'C:\one-tests\profile-original-cache')) { | |
| 18 | throw 'Park the personal OneNote profile before resetting the test cache.' | |
| 19 | } | |
| 20 | $cache = Join-Path $env:LOCALAPPDATA 'Microsoft\OneNote\14.0' | |
| 21 | $parked = Join-Path 'C:\one-tests\caches' ([IO.Path]::GetFileName($root)) | |
| 22 | if (Test-Path $parked) { throw 'Choose a new run; its parked cache already exists.' } | |
| 23 | New-Item -ItemType Directory -Path 'C:\one-tests\caches' -Force | Out-Null | |
| 24 | if (Test-Path $cache) { Move-Item -LiteralPath $cache -Destination $parked } | |
| 25 | if (Test-Path "$key\OpenNotebooks") { Remove-Item "$key\OpenNotebooks" -Recurse } | |
| 26 | New-Item "$key\OpenNotebooks" | Out-Null | |
| 27 | New-ItemProperty "$key\OpenNotebooks" -Name '1' -PropertyType String -Value "$root\notebook" | Out-Null |
corpus/rule-lines/native/scripts/read.ps1 created+142| ... | ... | @@ -0,0 +1,142 @@ |
| 1 | param( | |
| 2 | [Parameter(Mandatory=$true)][string]$Root, | |
| 3 | [int]$ExpectedPages = -1, | |
| 4 | [switch]$UseCurrentCache, | |
| 5 | [switch]$Pdf, | |
| 6 | [switch]$KeepOpen, | |
| 7 | [string]$CloneHost = '' | |
| 8 | ) | |
| 9 | Set-StrictMode -Version Latest | |
| 10 | $ErrorActionPreference = 'Stop' | |
| 11 | $root = [IO.Path]::GetFullPath($Root).TrimEnd('\') | |
| 12 | if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') { | |
| 13 | throw 'Choose a run directly below C:\one-tests\runs.' | |
| 14 | } | |
| 15 | $notebook = Join-Path $root 'notebook' | |
| 16 | $output = Join-Path $root 'read' | |
| 17 | if (Test-Path $output) { throw 'Choose a new read destination.' } | |
| 18 | if ($UseCurrentCache) { | |
| 19 | if ((Get-ItemProperty 'HKCU:\Software\Microsoft\Office\14.0\OneNote\Options\Paths').UnfiledNotesSection -ne 'C:\one-tests\Loose.one') { | |
| 20 | throw 'Park the personal OneNote profile before reading test notebooks.' | |
| 21 | } | |
| 22 | } else { | |
| 23 | & "$PSScriptRoot\cold-current.ps1" -Root $root -CloneHost $CloneHost | |
| 24 | } | |
| 25 | New-Item -ItemType Directory -Path $output | Out-Null | |
| 26 | $app = New-Object -ComObject OneNote.Application | |
| 27 | $notebookId = '' | |
| 28 | $failure = $null | |
| 29 | try { | |
| 30 | $app.OpenHierarchy($notebook, '', [ref]$notebookId, 0) | |
| 31 | $process = Get-Process ONENOTE | |
| 32 | @{ hostname = [Environment]::MachineName; onenote = $process.MainModule.FileVersionInfo.FileVersion; | |
| 33 | powershell = $PSVersionTable.PSVersion.ToString(); schema = 'xs2010'; cold = (-not $UseCurrentCache.IsPresent) } | | |
| 34 | ConvertTo-Json | Set-Content (Join-Path $output 'environment.json') -Encoding UTF8 | |
| 35 | $sections = @() | |
| 36 | foreach ($file in @(Get-ChildItem $notebook -Recurse | Where-Object { $_.Extension -eq '.one' })) { | |
| 37 | $id = '' | |
| 38 | $app.OpenHierarchy($file.FullName, '', [ref]$id, 0) | |
| 39 | $sections += $id | |
| 40 | } | |
| 41 | $deadline = [DateTime]::UtcNow.AddSeconds(300) | |
| 42 | $previous = '' | |
| 43 | $lastChange = '' | |
| 44 | $stableSince = [DateTime]::UtcNow | |
| 45 | $settled = $false | |
| 46 | do { | |
| 47 | $pages = @{} | |
| 48 | foreach ($section in $sections) { | |
| 49 | $hierarchy = '' | |
| 50 | $app.GetHierarchy($section, 4, [ref]$hierarchy, 1) | |
| 51 | [xml]$xml = $hierarchy | |
| 52 | foreach ($node in $xml.SelectNodes('//*[@path]')) { | |
| 53 | if (-not $node.GetAttribute('path').StartsWith("$notebook\", [StringComparison]::OrdinalIgnoreCase)) { | |
| 54 | throw 'OneNote opened a section outside the copied notebook.' | |
| 55 | } | |
| 56 | } | |
| 57 | foreach ($node in $xml.SelectNodes('//*[local-name()="Page"]')) { | |
| 58 | $id = $node.GetAttribute('ID') | |
| 59 | $content = '' | |
| 60 | $app.GetPageContent($id, [ref]$content, 1, 1) | |
| 61 | $pages[$id] = $content | |
| 62 | } | |
| 63 | } | |
| 64 | $signature = [String]::Join('|', @($pages.Keys | Sort-Object | ForEach-Object { $_ + $pages[$_] })) | |
| 65 | if ($signature -ne $previous) { | |
| 66 | $lastChange = $previous | |
| 67 | $previous = $signature | |
| 68 | $stableSince = [DateTime]::UtcNow | |
| 69 | } | |
| 70 | if ((($ExpectedPages -ge 0 -and $pages.Count -eq $ExpectedPages) -or | |
| 71 | ($ExpectedPages -lt 0 -and $pages.Count -gt 0)) -and | |
| 72 | ([DateTime]::UtcNow - $stableSince).TotalSeconds -ge 2) { $settled = $true; break } | |
| 73 | Start-Sleep -Milliseconds 250 | |
| 74 | } while ([DateTime]::UtcNow -lt $deadline) | |
| 75 | if (-not $settled -or ($ExpectedPages -ge 0 -and $pages.Count -ne $ExpectedPages) -or ($ExpectedPages -lt 0 -and $pages.Count -eq 0)) { | |
| 76 | [IO.File]::WriteAllText((Join-Path $output 'previous-signature.txt'), $lastChange, [Text.Encoding]::UTF8) | |
| 77 | $index = 0 | |
| 78 | foreach ($id in @($pages.Keys | Sort-Object)) { | |
| 79 | [IO.File]::WriteAllText((Join-Path $output ('unsettled-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8) | |
| 80 | $index++ | |
| 81 | } | |
| 82 | $hierarchy = '' | |
| 83 | $app.GetHierarchy($notebookId, 4, [ref]$hierarchy, 1) | |
| 84 | [IO.File]::WriteAllText((Join-Path $output 'unsettled-hierarchy.xml'), $hierarchy, [Text.Encoding]::UTF8) | |
| 85 | throw "Expected $ExpectedPages stable pages; OneNote returned $($pages.Count), settled=$settled." | |
| 86 | } | |
| 87 | $index = 0 | |
| 88 | $payloads = @() | |
| 89 | foreach ($id in @($pages.Keys | Sort-Object)) { | |
| 90 | [IO.File]::WriteAllText((Join-Path $output ('page-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8) | |
| 91 | if ($Pdf) { | |
| 92 | $pdfPath = Join-Path $output ('page-{0:d3}.pdf' -f $index) | |
| 93 | $app.NavigateTo($id, '', $false) | |
| 94 | $app.Publish($id, $pdfPath, 3, '') | |
| 95 | if (-not (Test-Path $pdfPath) -or (Get-Item $pdfPath).Length -eq 0) { throw 'OneNote did not publish the page PDF.' } | |
| 96 | } | |
| 97 | [xml]$page = $pages[$id] | |
| 98 | foreach ($file in $page.SelectNodes('//*[local-name()="InsertedFile" or local-name()="MediaFile"]')) { | |
| 99 | $bytes = [IO.File]::ReadAllBytes($file.GetAttribute('pathCache')) | |
| 100 | $hash = [BitConverter]::ToString([Security.Cryptography.SHA256]::Create().ComputeHash($bytes)).Replace('-', '').ToLowerInvariant() | |
| 101 | [IO.File]::WriteAllBytes((Join-Path $output ($hash + '.attachment')), $bytes) | |
| 102 | $payloads += @{ page = $id; object = $file.ParentNode.GetAttribute('objectID'); | |
| 103 | kind = $file.LocalName; name = $file.GetAttribute('preferredName'); | |
| 104 | sha256 = $hash; bytes = $bytes.Length } | |
| 105 | } | |
| 106 | $index++ | |
| 107 | } | |
| 108 | [IO.File]::WriteAllText((Join-Path $output 'payloads.json'), (ConvertTo-Json -InputObject $payloads -Depth 4), [Text.Encoding]::UTF8) | |
| 109 | $all = '' | |
| 110 | $app.GetHierarchy($notebookId, 4, [ref]$all, 1) | |
| 111 | [xml]$finalTree = $all | |
| 112 | $finalIds = @($finalTree.SelectNodes('//*[local-name()="Page"]') | ForEach-Object { $_.GetAttribute('ID') } | Sort-Object -Unique) | |
| 113 | if ($finalIds.Count -ne $pages.Count -or @($finalIds | Where-Object { -not $pages.ContainsKey($_) }).Count -ne 0) { | |
| 114 | throw 'The notebook hierarchy changed while collecting page evidence; repeat the cold read.' | |
| 115 | } | |
| 116 | [IO.File]::WriteAllText((Join-Path $output 'hierarchy.xml'), $all, [Text.Encoding]::UTF8) | |
| 117 | Write-Output "Read $($sections.Count) sections and $($pages.Count) pages." | |
| 118 | } catch { | |
| 119 | $failure = $_ | |
| 120 | [IO.File]::WriteAllText((Join-Path $output 'failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8) | |
| 121 | throw | |
| 122 | } finally { | |
| 123 | try { | |
| 124 | try { | |
| 125 | if ($notebookId -and $CloneHost) { $app.SyncHierarchy($notebookId) } | |
| 126 | if ($notebookId -and -not $KeepOpen -and (-not $UseCurrentCache -or $CloneHost)) { | |
| 127 | $app.CloseNotebook($notebookId, $false) | |
| 128 | } | |
| 129 | } catch { | |
| 130 | if ($null -eq $failure) { throw } | |
| 131 | [IO.File]::WriteAllText((Join-Path $output 'cleanup-failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8) | |
| 132 | } | |
| 133 | } finally { | |
| 134 | [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($app) | |
| 135 | } | |
| 136 | $app = $null | |
| 137 | [GC]::Collect() | |
| 138 | [GC]::WaitForPendingFinalizers() | |
| 139 | if (-not $UseCurrentCache -and -not $CloneHost) { | |
| 140 | Get-Process ONENOTE -ErrorAction SilentlyContinue | Wait-Process -Timeout 10 | |
| 141 | } | |
| 142 | } |
corpus/rule-lines/native/source.json created+14| ... | ... | @@ -0,0 +1,14 @@ |
| 1 | [ | |
| 2 | { | |
| 3 | "path": "Open Notebook.onetoc2", | |
| 4 | "bytes": 4808, | |
| 5 | "sha256": "26e13bc41400333df9b3b6ebc182ea592bdc8a4bd0d3c33c0773e832965add13", | |
| 6 | "mtime_ns": 1790669502093308352 | |
| 7 | }, | |
| 8 | { | |
| 9 | "path": "Rules.one", | |
| 10 | "bytes": 88432, | |
| 11 | "sha256": "76da013db8a7015b9cb864dd354cb0bb184c9ca3b5e76b755b1143c830ca39b1", | |
| 12 | "mtime_ns": 1790669501695822834 | |
| 13 | } | |
| 14 | ] |
corpus/rule-lines/native/teardown.json created+1| ... | ... | @@ -0,0 +1 @@ |
| 1 | {"absent": true} |
crates/canvas/assets/tags/blog.svg created+9| ... | ... | @@ -0,0 +1,9 @@ |
| 1 | <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"> | |
| 2 | <defs> | |
| 3 | <linearGradient id="layer0" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#8a9cb4"/><stop offset="1" stop-color="#5d6f8a"/></linearGradient> | |
| 4 | <linearGradient id="layer1" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#ffffff"/><stop offset="1" stop-color="#d3dcea"/></linearGradient> | |
| 5 | </defs> | |
| 6 | <path d="M8 1.5C12 1.5 15 3.8 15 6.75C15 9.7 12 12 8 12C7.4 12 6.8 11.95 6.25 11.85L2.5 14.5L3.4 10.9C1.9 9.9 1 8.4 1 6.75C1 3.8 4 1.5 8 1.5Z" fill="url(#layer0)"/> | |
| 7 | <path d="M8 2.5C11.5 2.5 14 4.4 14 6.75C14 9.1 11.5 11 8 11C7.4 11 6.8 10.9 6 10.75L4.2 12L4.6 10.4C2.9 9.6 2 8.3 2 6.75C2 4.4 4.5 2.5 8 2.5Z" fill="url(#layer1)"/> | |
| 8 | <path d="M4.5 4.75H11.5V5.75H4.5ZM4 6.75H12V7.75H4ZM5 8.75H10.5V9.75H5Z" fill="#6f86a8"/> | |
| 9 | </svg> |
crates/canvas/assets/tags/book.svg created+12| ... | ... | @@ -0,0 +1,12 @@ |
| 1 | <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"> | |
| 2 | <defs> | |
| 3 | <linearGradient id="layer0" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#4a78c8"/><stop offset="1" stop-color="#1e4494"/></linearGradient> | |
| 4 | <linearGradient id="layer1" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#ffffff"/><stop offset="1" stop-color="#d6dde8"/></linearGradient> | |
| 5 | <linearGradient id="layer2" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#6f9ae0"/><stop offset="1" stop-color="#2e5aa8"/></linearGradient> | |
| 6 | </defs> | |
| 7 | <path d="M1 2.5L7.5 4V15L1 13.5Z" fill="url(#layer0)"/> | |
| 8 | <path d="M2 3.8L6.5 4.9V13.6L2 12.6Z" fill="url(#layer2)"/> | |
| 9 | <path d="M7.5 4L14 1.5V12.5L7.5 15Z" fill="url(#layer0)"/> | |
| 10 | <path d="M8.5 4.6L13 2.9V11.8L8.5 13.5Z" fill="url(#layer1)"/> | |
| 11 | <path d="M9.5 5.6L12 4.6V5.4L9.5 6.4ZM9.5 7.4L12 6.4V7.2L9.5 8.2ZM9.5 9.2L12 8.2V9L9.5 10ZM9.5 11L12 10V10.8L9.5 11.8Z" fill="#9aa8bc"/> | |
| 12 | </svg> |
crates/canvas/assets/tags/box-small.svg created+11| ... | ... | @@ -0,0 +1,11 @@ |
| 1 | <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"> | |
| 2 | <defs> | |
| 3 | <linearGradient id="layer0" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#91b6f7"/><stop offset="1" stop-color="#6498ef"/></linearGradient> | |
| 4 | <linearGradient id="layer1" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#7caff6"/><stop offset="1" stop-color="#5587e9"/></linearGradient> | |
| 5 | <linearGradient id="layer2" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#ffffff"/><stop offset="1" stop-color="#d6e6f5"/></linearGradient> | |
| 6 | </defs> | |
| 7 | <path d="M5 5H15V15H5Z" fill="url(#layer0)"/> | |
| 8 | <path d="M5.75 5.75H14.25V14.25H5.75Z" fill="#ffffff"/> | |
| 9 | <path d="M6.5 6.5H13.5V13.5H6.5Z" fill="url(#layer1)"/> | |
| 10 | <path d="M7.25 7.25H12.75V12.75H7.25Z" fill="url(#layer2)"/> | |
| 11 | </svg> |
crates/canvas/assets/tags/check-small.svg created+6| ... | ... | @@ -0,0 +1,6 @@ |
| 1 | <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"> | |
| 2 | <defs> | |
| 3 | <linearGradient id="layer0" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#f48c77"/><stop offset="1" stop-color="#c84425"/></linearGradient> | |
| 4 | </defs> | |
| 5 | <path d="M6.25 10L8.75 11.9L13.1 6.9L13.75 8.1L8.75 13.75L5.6 11.25Z" fill="url(#layer0)"/> | |
| 6 | </svg> |
crates/canvas/assets/tags/email.svg created+11| ... | ... | @@ -0,0 +1,11 @@ |
| 1 | <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"> | |
| 2 | <defs> | |
| 3 | <linearGradient id="layer0" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#7a8ca8"/><stop offset="1" stop-color="#4d5f7c"/></linearGradient> | |
| 4 | <linearGradient id="layer1" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#ffffff"/><stop offset="1" stop-color="#d6e0ee"/></linearGradient> | |
| 5 | <linearGradient id="layer2" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#f25c54"/><stop offset="1" stop-color="#c8342c"/></linearGradient> | |
| 6 | </defs> | |
| 7 | <path d="M0.5 3.5H15.5V12.5H0.5Z" fill="url(#layer0)"/> | |
| 8 | <path d="M1.5 4.5H14.5V11.5H1.5Z" fill="url(#layer1)"/> | |
| 9 | <path d="M3 8.25H9V9H3ZM3 9.75H7.5V10.5H3Z" fill="#8a9cb4"/> | |
| 10 | <path d="M11 5.25H13.75V7.75H11Z" fill="url(#layer2)"/> | |
| 11 | </svg> |
crates/canvas/assets/tags/idea.svg created+11| ... | ... | @@ -0,0 +1,11 @@ |
| 1 | <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"> | |
| 2 | <defs> | |
| 3 | <linearGradient id="layer0" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#c8a049"/><stop offset="1" stop-color="#a57a2c"/></linearGradient> | |
| 4 | <linearGradient id="layer1" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#fffbd8"/><stop offset="1" stop-color="#f5cf4e"/></linearGradient> | |
| 5 | <linearGradient id="layer2" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#b8c0cc"/><stop offset="1" stop-color="#7a8494"/></linearGradient> | |
| 6 | </defs> | |
| 7 | <path d="M8 0.75C11.2 0.75 13.25 3 13.25 5.75C13.25 8 11.75 9.25 10.75 10.5V11.5H5.25V10.5C4.25 9.25 2.75 8 2.75 5.75C2.75 3 4.8 0.75 8 0.75Z" fill="url(#layer0)"/> | |
| 8 | <path d="M8 1.75C10.6 1.75 12.25 3.6 12.25 5.75C12.25 7.6 11 8.6 9.9 10.5H6.1C5 8.6 3.75 7.6 3.75 5.75C3.75 3.6 5.4 1.75 8 1.75Z" fill="url(#layer1)"/> | |
| 9 | <path d="M5.25 11.5H10.75V13.5L9.5 15H6.5L5.25 13.5Z" fill="url(#layer2)"/> | |
| 10 | <path d="M5.25 12.25H10.75V13H5.25Z" fill="#5d6675"/> | |
| 11 | </svg> |
crates/canvas/assets/tags/mark-arrow.svg created+6| ... | ... | @@ -0,0 +1,6 @@ |
| 1 | <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"> | |
| 2 | <defs> | |
| 3 | <linearGradient id="layer0" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#6fb0ff"/><stop offset="1" stop-color="#1e5ab8"/></linearGradient> | |
| 4 | </defs> | |
| 5 | <path d="M0.5 2H4.5V0L9 3.75L4.5 7.5V5.5H3V8.5H0.5Z" fill="url(#layer0)"/> | |
| 6 | </svg> |
crates/canvas/assets/tags/mark-client.svg created+9| ... | ... | @@ -0,0 +1,9 @@ |
| 1 | <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"> | |
| 2 | <defs> | |
| 3 | <linearGradient id="layer0" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#fff1b0"/><stop offset="1" stop-color="#e0a82e"/></linearGradient> | |
| 4 | <linearGradient id="layer1" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#b58a3a"/><stop offset="1" stop-color="#8a6420"/></linearGradient> | |
| 5 | </defs> | |
| 6 | <path d="M3.5 0.25C5.2 0.25 5.8 1.3 5.4 3L4.4 7.25H2.6L1.6 3C1.2 1.3 1.8 0.25 3.5 0.25Z" fill="url(#layer1)"/> | |
| 7 | <path d="M3.5 1C4.6 1 4.9 1.7 4.6 3L3.8 6.5H3.2L2.4 3C2.1 1.7 2.4 1 3.5 1Z" fill="url(#layer0)"/> | |
| 8 | <path d="M3.5 8A1 1 0 1 1 3.5 10A1 1 0 1 1 3.5 8Z" fill="url(#layer1)"/> | |
| 9 | </svg> |
crates/canvas/assets/tags/mark-manager.svg created+9| ... | ... | @@ -0,0 +1,9 @@ |
| 1 | <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"> | |
| 2 | <defs> | |
| 3 | <linearGradient id="layer0" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#9aa2ae"/><stop offset="1" stop-color="#4a525e"/></linearGradient> | |
| 4 | <linearGradient id="layer1" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#f5d45c"/><stop offset="1" stop-color="#b8901e"/></linearGradient> | |
| 5 | </defs> | |
| 6 | <path d="M4 0.5A2.5 2.5 0 1 1 4 5.5A2.5 2.5 0 1 1 4 0.5Z" fill="url(#layer0)"/> | |
| 7 | <path d="M4 5.5C6.4 5.5 7.75 6.5 7.75 8.5V10H0.25V8.5C0.25 6.5 1.6 5.5 4 5.5Z" fill="url(#layer1)"/> | |
| 8 | <path d="M3.4 5.75H4.6L4 7.25Z" fill="#ffffff"/> | |
| 9 | </svg> |
crates/canvas/assets/tags/mark-one.svg created+7| ... | ... | @@ -0,0 +1,7 @@ |
| 1 | <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"> | |
| 2 | <defs> | |
| 3 | <linearGradient id="layer0" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#5a8ad8"/><stop offset="1" stop-color="#1e4494"/></linearGradient> | |
| 4 | </defs> | |
| 5 | <path d="M0.5 0.5H7V8.5H0.5Z" fill="url(#layer0)"/> | |
| 6 | <path d="M3.25 2.1L4.5 1.6V7.25H3.25V3.3L2.3 3.7V2.6Z" fill="#ffffff"/> | |
| 7 | </svg> |
crates/canvas/assets/tags/mark-person.svg created+9| ... | ... | @@ -0,0 +1,9 @@ |
| 1 | <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"> | |
| 2 | <defs> | |
| 3 | <linearGradient id="layer0" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#9aa2ae"/><stop offset="1" stop-color="#4a525e"/></linearGradient> | |
| 4 | <linearGradient id="layer1" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#6f9ae0"/><stop offset="1" stop-color="#1e4494"/></linearGradient> | |
| 5 | </defs> | |
| 6 | <path d="M4 0.5A2.5 2.5 0 1 1 4 5.5A2.5 2.5 0 1 1 4 0.5Z" fill="url(#layer0)"/> | |
| 7 | <path d="M4 5.5C6.4 5.5 7.75 6.5 7.75 8.5V10H0.25V8.5C0.25 6.5 1.6 5.5 4 5.5Z" fill="url(#layer1)"/> | |
| 8 | <path d="M3.4 5.75H4.6L4 7.25Z" fill="#ffffff"/> | |
| 9 | </svg> |
crates/canvas/assets/tags/mark-two.svg created+7| ... | ... | @@ -0,0 +1,7 @@ |
| 1 | <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"> | |
| 2 | <defs> | |
| 3 | <linearGradient id="layer0" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#e8b84a"/><stop offset="1" stop-color="#a8781a"/></linearGradient> | |
| 4 | </defs> | |
| 5 | <path d="M0.5 0.5H7V8.5H0.5Z" fill="url(#layer0)"/> | |
| 6 | <path d="M2 2.9C2.1 1.9 2.8 1.5 3.75 1.5C4.9 1.5 5.5 2.1 5.5 3C5.5 3.9 4.9 4.5 3.6 5.7L3.3 6H5.6V7.25H1.9V6.2L3.3 4.9C4.1 4.1 4.25 3.7 4.25 3.2C4.25 2.8 4 2.6 3.7 2.6C3.35 2.6 3.2 2.85 3.15 3.2Z" fill="#ffffff"/> | |
| 7 | </svg> |
crates/canvas/assets/tags/movie.svg created+10| ... | ... | @@ -0,0 +1,10 @@ |
| 1 | <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"> | |
| 2 | <defs> | |
| 3 | <linearGradient id="layer0" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#4a5260"/><stop offset="1" stop-color="#1e232b"/></linearGradient> | |
| 4 | <linearGradient id="layer1" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#ffffff"/><stop offset="1" stop-color="#c8d8ee"/></linearGradient> | |
| 5 | </defs> | |
| 6 | <path d="M1 1.5H11.5L15 5V14.5H1Z" fill="url(#layer0)"/> | |
| 7 | <path d="M11.5 1.5V5H15Z" fill="#ffffff"/> | |
| 8 | <path d="M2 4H7.25V12H2ZM8.25 4H14V12H8.25Z" fill="url(#layer1)"/> | |
| 9 | <path d="M2 2.4H3V3.2H2ZM4 2.4H5V3.2H4ZM6 2.4H7V3.2H6ZM8 2.4H9V3.2H8ZM2 12.8H3V13.6H2ZM4 12.8H5V13.6H4ZM6 12.8H7V13.6H6ZM8 12.8H9V13.6H8ZM10 12.8H11V13.6H10ZM12 12.8H13V13.6H12Z" fill="#ffffff"/> | |
| 10 | </svg> |
crates/canvas/assets/tags/password.svg created+11| ... | ... | @@ -0,0 +1,11 @@ |
| 1 | <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"> | |
| 2 | <defs> | |
| 3 | <linearGradient id="layer0" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#a0a8b4"/><stop offset="1" stop-color="#5d6675"/></linearGradient> | |
| 4 | <linearGradient id="layer1" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#b58a3a"/><stop offset="1" stop-color="#8a6420"/></linearGradient> | |
| 5 | <linearGradient id="layer2" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#fff1b0"/><stop offset="1" stop-color="#e0b04c"/></linearGradient> | |
| 6 | </defs> | |
| 7 | <path d="M8 1A4 4 0 0 1 12 5V7.5H10.5V5A2.5 2.5 0 0 0 5.5 5V7.5H4V5A4 4 0 0 1 8 1Z" fill="url(#layer0)"/> | |
| 8 | <path d="M2.5 7H13.5V15H2.5Z" fill="url(#layer1)"/> | |
| 9 | <path d="M3.5 8H12.5V14H3.5Z" fill="url(#layer2)"/> | |
| 10 | <path d="M8 9.25A1.1 1.1 0 0 1 8.6 11.3V12.75H7.4V11.3A1.1 1.1 0 0 1 8 9.25Z" fill="#6e4a14"/> | |
| 11 | </svg> |
crates/canvas/assets/tags/web.svg created+14| ... | ... | @@ -0,0 +1,14 @@ |
| 1 | <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"> | |
| 2 | <defs> | |
| 3 | <linearGradient id="layer0" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#3a74c4"/><stop offset="1" stop-color="#1e4a8a"/></linearGradient> | |
| 4 | <linearGradient id="layer1" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#a8dcff"/><stop offset="1" stop-color="#3c8ee0"/></linearGradient> | |
| 5 | <linearGradient id="layer2" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#8ed46a"/><stop offset="1" stop-color="#3e9a2a"/></linearGradient> | |
| 6 | <linearGradient id="layer3" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#e4ecf6"/><stop offset="1" stop-color="#8a9cb4"/></linearGradient> | |
| 7 | </defs> | |
| 8 | <path d="M7 0.5A6 6 0 1 1 7 12.5A6 6 0 1 1 7 0.5Z" fill="url(#layer0)"/> | |
| 9 | <path d="M7 1.5A5 5 0 1 1 7 11.5A5 5 0 1 1 7 1.5Z" fill="url(#layer1)"/> | |
| 10 | <path d="M4.5 2.3C5.8 2.8 6.2 3.8 5.3 4.6C4.4 5.4 5.6 6.4 4.8 7.2C4 8 2.8 7.2 2.2 6.2C2.4 4.5 3.2 3.1 4.5 2.3ZM8.5 6.2C9.8 5.8 11.2 6.6 10.8 7.8C10.4 9 9.6 10.2 8.3 11C7.9 9.8 8.6 9 8.2 8.2C7.8 7.4 7.7 6.6 8.5 6.2ZM8.8 1.8C10.1 2.2 11.3 3.2 11.8 4.6C10.9 4.8 9.6 4.2 9.2 3.4C8.9 2.9 8.8 2.3 8.8 1.8Z" fill="url(#layer2)"/> | |
| 11 | <path d="M3.5 10.5H7.5A2.5 2.5 0 0 1 7.5 15.5H3.5A2.5 2.5 0 0 1 3.5 10.5ZM3.5 11.75A1.25 1.25 0 0 0 3.5 14.25H7.5A1.25 1.25 0 0 0 7.5 11.75ZM9 10.5H13A2.5 2.5 0 0 1 13 15.5H9A2.5 2.5 0 0 1 9 10.5ZM9 11.75A1.25 1.25 0 0 0 9 14.25H13A1.25 1.25 0 0 0 13 11.75Z" fill="#40689e" fill-rule="evenodd"/> | |
| 12 | <path d="M3.5 11.1H7.5A1.9 1.9 0 0 1 7.5 14.9H3.5A1.9 1.9 0 0 1 3.5 11.1ZM3.5 11.75A1.25 1.25 0 0 0 3.5 14.25H7.5A1.25 1.25 0 0 0 7.5 11.75ZM9 11.1H13A1.9 1.9 0 0 1 13 14.9H9A1.9 1.9 0 0 1 9 11.1ZM9 11.75A1.25 1.25 0 0 0 9 14.25H13A1.25 1.25 0 0 0 13 11.75Z" fill="url(#layer3)" fill-rule="evenodd"/> | |
| 13 | <path d="M6 12.4H10.5V13.6H6Z" fill="#40689e"/> | |
| 14 | </svg> |
crates/canvas/src/editor.rs+168-29| ... | ... | @@ -31,6 +31,7 @@ mod format; |
| 31 | 31 | mod link; |
| 32 | 32 | mod ops; |
| 33 | 33 | pub(crate) mod page; |
| 34 | mod space; | |
| 34 | 35 | mod table; |
| 35 | 36 | pub use format::{ |
| 36 | 37 | Alignment, BULLET_LIBRARY, FormatState, Formatting, ListStyle, NUMBER_LIBRARY, NoteTag, Toggle, |
| ... | ... | @@ -126,6 +127,7 @@ struct PageHeader { |
| 126 | 127 | created: Option<u64>, |
| 127 | 128 | margin_origin: [f32; 2], |
| 128 | 129 | color: Option<u32>, |
| 130 | rule_lines: Option<onestore::page::RuleLines>, | |
| 129 | 131 | areas: Vec<page::TitleArea>, |
| 130 | 132 | } |
| 131 | 133 | |
| ... | ... | @@ -754,6 +756,11 @@ enum RestoreFocus { |
| 754 | 756 | |
| 755 | 757 | enum History { |
| 756 | 758 | Date(Box<PageDate>), |
| 759 | /// The page's colour and rule lines before a change to either. | |
| 760 | Paper { | |
| 761 | color: Option<u32>, | |
| 762 | rule_lines: Option<onestore::page::RuleLines>, | |
| 763 | }, | |
| 757 | 764 | Draft { |
| 758 | 765 | outlines: Box<[TextOutline; 2]>, |
| 759 | 766 | index: usize, |
| ... | ... | @@ -765,10 +772,25 @@ enum History { |
| 765 | 772 | outline: onestore::ExGuid, |
| 766 | 773 | change: Box<TextChange>, |
| 767 | 774 | }, |
| 775 | /// A page object's position: an outline, picture or ink drawing. | |
| 768 | 776 | Position { |
| 769 | outline: onestore::ExGuid, | |
| 777 | object: onestore::ExGuid, | |
| 770 | 778 | position: [Option<f32>; 2], |
| 771 | 779 | }, |
| 780 | /// Parts `outline`'s paragraphs from root node `at` into outline `part` at `position`. | |
| 781 | Split { | |
| 782 | outline: ExGuid, | |
| 783 | at: usize, | |
| 784 | part: ExGuid, | |
| 785 | position: [f32; 2], | |
| 786 | }, | |
| 787 | /// Returns `part`'s paragraphs to the end of `outline` and removes it: each root of | |
| 788 | /// `part` under the parent it had, the outline when `None`. | |
| 789 | Join { | |
| 790 | outline: ExGuid, | |
| 791 | part: ExGuid, | |
| 792 | parents: Vec<(ExGuid, Option<ExGuid>)>, | |
| 793 | }, | |
| 772 | 794 | Layout { |
| 773 | 795 | outline: ExGuid, |
| 774 | 796 | layout: onestore::document::Layout, |
| ... | ... | @@ -797,8 +819,12 @@ enum History { |
| 797 | 819 | selection: Selection, |
| 798 | 820 | focus: RestoreFocus, |
| 799 | 821 | }, |
| 800 | /// One undo step of edits to OneNote's page selection, undone last first. | |
| 801 | Group(Vec<History>), | |
| 822 | /// One undo step of several edits, undone last first; `page` when they edited OneNote's | |
| 823 | /// page selection, which undoing selects again. | |
| 824 | Group { | |
| 825 | entries: Vec<History>, | |
| 826 | page: bool, | |
| 827 | }, | |
| 802 | 828 | } |
| 803 | 829 | |
| 804 | 830 | impl CanvasEditor { |
| ... | ... | @@ -876,6 +902,7 @@ impl CanvasEditor { |
| 876 | 902 | created: page.created, |
| 877 | 903 | margin_origin: page.margin_origin, |
| 878 | 904 | color: page.color, |
| 905 | rule_lines: page.rule_lines, | |
| 879 | 906 | areas, |
| 880 | 907 | }; |
| 881 | 908 | let mut ids = BTreeSet::new(); |
| ... | ... | @@ -1019,6 +1046,7 @@ impl CanvasEditor { |
| 1019 | 1046 | let gone = |focus: &RestoreFocus| matches!(focus, RestoreFocus::Outline(id) if !fresh.outlines.iter().any(|o| o.id == *id)); |
| 1020 | 1047 | let reaches_one = |history: &History| match history { |
| 1021 | 1048 | History::Date(_) => date_changed, |
| 1049 | History::Paper { .. } => false, | |
| 1022 | 1050 | History::Image { .. } | History::Picture { .. } => objects_changed, |
| 1023 | 1051 | History::Draft { outlines, .. } => changed.contains(&outlines[0].id), |
| 1024 | 1052 | History::Text { outline, change } => { |
| ... | ... | @@ -1028,16 +1056,18 @@ impl CanvasEditor { |
| 1028 | 1056 | .iter() |
| 1029 | 1057 | .any(|placement| objects_changed || changed.contains(&placement.id)) |
| 1030 | 1058 | } |
| 1031 | History::Position { outline, .. } | History::Layout { outline, .. } => { | |
| 1032 | changed.contains(outline) | |
| 1059 | History::Position { object, .. } => changed.contains(object) || objects_changed, | |
| 1060 | History::Layout { outline, .. } => changed.contains(outline), | |
| 1061 | History::Split { outline, part, .. } | History::Join { outline, part, .. } => { | |
| 1062 | changed.contains(outline) || changed.contains(part) || objects_changed | |
| 1033 | 1063 | } |
| 1034 | 1064 | History::Remove { outline, focus } => changed.contains(outline) || gone(focus), |
| 1035 | 1065 | History::Insert { outline, focus, .. } => changed.contains(&outline.id) || gone(focus), |
| 1036 | 1066 | History::Restore { source, focus, .. } => changed.contains(&source.id) || gone(focus), |
| 1037 | History::Group(_) => unreachable!("groups do not nest"), | |
| 1067 | History::Group { .. } => unreachable!("groups do not nest"), | |
| 1038 | 1068 | }; |
| 1039 | 1069 | let reaches = |history: &History| match history { |
| 1040 | History::Group(entries) => entries.iter().any(reaches_one), | |
| 1070 | History::Group { entries, .. } => entries.iter().any(reaches_one), | |
| 1041 | 1071 | history => reaches_one(history), |
| 1042 | 1072 | }; |
| 1043 | 1073 | fresh.undo = std::mem::take(&mut self.undo) |
| ... | ... | @@ -1143,6 +1173,7 @@ impl CanvasEditor { |
| 1143 | 1173 | .or(self.header.created), |
| 1144 | 1174 | margin_origin: self.header.margin_origin, |
| 1145 | 1175 | color: self.header.color, |
| 1176 | rule_lines: self.header.rule_lines, | |
| 1146 | 1177 | objects, |
| 1147 | 1178 | definitions, |
| 1148 | 1179 | }) |
| ... | ... | @@ -1215,6 +1246,11 @@ impl CanvasEditor { |
| 1215 | 1246 | self.header.color |
| 1216 | 1247 | } |
| 1217 | 1248 | |
| 1249 | /// The page's rule lines (View, Rule Lines). | |
| 1250 | pub fn rule_lines(&self) -> Option<onestore::page::RuleLines> { | |
| 1251 | self.header.rule_lines | |
| 1252 | } | |
| 1253 | ||
| 1218 | 1254 | /// Where the page's first body line starts, in points, under its title. |
| 1219 | 1255 | pub fn body_start(&self) -> Option<[f32; 2]> { |
| 1220 | 1256 | let title = self |
| ... | ... | @@ -1419,6 +1455,44 @@ impl CanvasEditor { |
| 1419 | 1455 | Ok(true) |
| 1420 | 1456 | } |
| 1421 | 1457 | |
| 1458 | /// Gives the page `color` (View, Page Color) and `rule_lines` (View, Rule Lines) as one | |
| 1459 | /// undo step; false when it had them. | |
| 1460 | pub fn set_paper( | |
| 1461 | &mut self, | |
| 1462 | color: Option<u32>, | |
| 1463 | rule_lines: Option<onestore::page::RuleLines>, | |
| 1464 | ) -> bool { | |
| 1465 | if (color, rule_lines) == (self.header.color, self.header.rule_lines) { | |
| 1466 | return false; | |
| 1467 | } | |
| 1468 | self.finish_composition(); | |
| 1469 | let before = self.paper(color, rule_lines); | |
| 1470 | self.undo.push(before); | |
| 1471 | self.redo.clear(); | |
| 1472 | true | |
| 1473 | } | |
| 1474 | ||
| 1475 | /// Shows `color` and `rule_lines`, recording the ops that store them; the history entry | |
| 1476 | /// restoring what the page had. | |
| 1477 | fn paper( | |
| 1478 | &mut self, | |
| 1479 | color: Option<u32>, | |
| 1480 | rule_lines: Option<onestore::page::RuleLines>, | |
| 1481 | ) -> History { | |
| 1482 | let mut ops = Vec::new(); | |
| 1483 | if color != self.header.color { | |
| 1484 | ops.push(PageOp::Color(color)); | |
| 1485 | } | |
| 1486 | if rule_lines != self.header.rule_lines { | |
| 1487 | ops.push(PageOp::RuleLines(rule_lines)); | |
| 1488 | } | |
| 1489 | self.record(Ok(ops)); | |
| 1490 | History::Paper { | |
| 1491 | color: std::mem::replace(&mut self.header.color, color), | |
| 1492 | rule_lines: std::mem::replace(&mut self.header.rule_lines, rule_lines), | |
| 1493 | } | |
| 1494 | } | |
| 1495 | ||
| 1422 | 1496 | /// Stored content; arrow-created paragraphs appear only in `visible_outlines`. |
| 1423 | 1497 | pub fn outlines(&self) -> &[TextOutline] { |
| 1424 | 1498 | &self.outlines |
| ... | ... | @@ -1665,7 +1739,7 @@ impl CanvasEditor { |
| 1665 | 1739 | let layout = &mut self.outlines[index].layout; |
| 1666 | 1740 | let previous = [layout.x, layout.y]; |
| 1667 | 1741 | self.undo.push(History::Position { |
| 1668 | outline: id, | |
| 1742 | object: id, | |
| 1669 | 1743 | position: previous, |
| 1670 | 1744 | }); |
| 1671 | 1745 | self.redo.clear(); |
| ... | ... | @@ -2145,7 +2219,10 @@ impl CanvasEditor { |
| 2145 | 2219 | let result = edit(self); |
| 2146 | 2220 | let entries = self.undo.split_off(depth); |
| 2147 | 2221 | if !entries.is_empty() { |
| 2148 | self.undo.push(History::Group(entries)); | |
| 2222 | self.undo.push(History::Group { | |
| 2223 | entries, | |
| 2224 | page: true, | |
| 2225 | }); | |
| 2149 | 2226 | } |
| 2150 | 2227 | result |
| 2151 | 2228 | } |
| ... | ... | @@ -3212,10 +3289,9 @@ impl CanvasEditor { |
| 3212 | 3289 | }; |
| 3213 | 3290 | match self.apply_history(engine, history) { |
| 3214 | 3291 | Ok(inverse) => { |
| 3215 | let group = matches!(inverse, History::Group(_)); | |
| 3292 | let page = matches!(inverse, History::Group { page: true, .. }); | |
| 3216 | 3293 | self.redo.push(inverse); |
| 3217 | // Undoing an edit to the page selection selects the page again. | |
| 3218 | if group { | |
| 3294 | if page { | |
| 3219 | 3295 | self.select_page()?; |
| 3220 | 3296 | } |
| 3221 | 3297 | Ok(true) |
| ... | ... | @@ -3255,6 +3331,7 @@ impl CanvasEditor { |
| 3255 | 3331 | .date |
| 3256 | 3332 | .as_ref() |
| 3257 | 3333 | .is_some_and(|current| current.source().id == date.source().id), |
| 3334 | History::Paper { .. } => true, | |
| 3258 | 3335 | History::Draft { |
| 3259 | 3336 | outlines, index, .. |
| 3260 | 3337 | } => { |
| ... | ... | @@ -3265,11 +3342,22 @@ impl CanvasEditor { |
| 3265 | 3342 | .position(|item| item.id == outlines[0].id) |
| 3266 | 3343 | .is_none_or(|found| found == *index) |
| 3267 | 3344 | } |
| 3268 | History::Text { outline, .. } | |
| 3269 | | History::Position { outline, .. } | |
| 3270 | | History::Layout { outline, .. } => { | |
| 3345 | History::Text { outline, .. } | History::Layout { outline, .. } => { | |
| 3271 | 3346 | self.outlines.iter().any(|item| item.id == *outline) |
| 3272 | 3347 | } |
| 3348 | History::Position { object, .. } => self.object_layout_mut(*object).is_some(), | |
| 3349 | History::Split { | |
| 3350 | outline, at, part, .. | |
| 3351 | } => { | |
| 3352 | self.outlines | |
| 3353 | .iter() | |
| 3354 | .find(|item| item.id == *outline) | |
| 3355 | .is_some_and(|item| (1..item.document.nodes().len()).contains(at)) | |
| 3356 | && !self.outlines.iter().any(|item| item.id == *part) | |
| 3357 | } | |
| 3358 | History::Join { outline, part, .. } => [outline, part] | |
| 3359 | .iter() | |
| 3360 | .all(|id| self.outlines.iter().any(|item| item.id == **id)), | |
| 3273 | 3361 | History::Image { image, .. } => self.image(*image).is_some(), |
| 3274 | 3362 | History::Picture { |
| 3275 | 3363 | index, |
| ... | ... | @@ -3290,7 +3378,7 @@ impl CanvasEditor { |
| 3290 | 3378 | History::Insert { index, .. } | History::Restore { index, .. } => { |
| 3291 | 3379 | *index <= self.outlines.len() |
| 3292 | 3380 | } |
| 3293 | History::Group(_) => true, | |
| 3381 | History::Group { .. } => true, | |
| 3294 | 3382 | }; |
| 3295 | 3383 | if !valid { |
| 3296 | 3384 | return Err((history, EditError::InvalidRange.into())); |
| ... | ... | @@ -3301,6 +3389,7 @@ impl CanvasEditor { |
| 3301 | 3389 | self.record(Ok(self.date_ops())); |
| 3302 | 3390 | History::Date(Box::new(shown)) |
| 3303 | 3391 | } |
| 3392 | History::Paper { color, rule_lines } => self.paper(color, rule_lines), | |
| 3304 | 3393 | History::Draft { |
| 3305 | 3394 | outlines, |
| 3306 | 3395 | index, |
| ... | ... | @@ -3387,25 +3476,67 @@ impl CanvasEditor { |
| 3387 | 3476 | change: Box::new(inverse), |
| 3388 | 3477 | } |
| 3389 | 3478 | } |
| 3390 | History::Position { outline, position } => { | |
| 3391 | let index = self | |
| 3392 | .outlines | |
| 3393 | .iter() | |
| 3394 | .position(|item| item.id == outline) | |
| 3395 | .unwrap(); | |
| 3396 | let layout = &mut self.outlines[index].layout; | |
| 3479 | History::Position { object, position } => { | |
| 3480 | let layout = self.object_layout_mut(object).unwrap(); | |
| 3397 | 3481 | let previous = [layout.x, layout.y]; |
| 3398 | 3482 | [layout.x, layout.y] = position; |
| 3399 | self.active = Focus::Outline(index); | |
| 3483 | if let Some(index) = self.outlines.iter().position(|item| item.id == object) { | |
| 3484 | self.active = Focus::Outline(index); | |
| 3485 | } | |
| 3400 | 3486 | self.record(self.placement_ops(&Placement { |
| 3401 | id: outline, | |
| 3487 | id: object, | |
| 3402 | 3488 | position: previous, |
| 3403 | 3489 | })); |
| 3404 | 3490 | History::Position { |
| 3405 | outline, | |
| 3491 | object, | |
| 3406 | 3492 | position: previous, |
| 3407 | 3493 | } |
| 3408 | 3494 | } |
| 3495 | History::Split { | |
| 3496 | outline, | |
| 3497 | at, | |
| 3498 | part, | |
| 3499 | position, | |
| 3500 | } => match self.split_outline(engine, outline, at, part, position) { | |
| 3501 | Ok(parents) => History::Join { | |
| 3502 | outline, | |
| 3503 | part, | |
| 3504 | parents, | |
| 3505 | }, | |
| 3506 | Err(error) => { | |
| 3507 | return Err(( | |
| 3508 | History::Split { | |
| 3509 | outline, | |
| 3510 | at, | |
| 3511 | part, | |
| 3512 | position, | |
| 3513 | }, | |
| 3514 | error, | |
| 3515 | )); | |
| 3516 | } | |
| 3517 | }, | |
| 3518 | History::Join { | |
| 3519 | outline, | |
| 3520 | part, | |
| 3521 | parents, | |
| 3522 | } => match self.join_outline(engine, outline, part, &parents) { | |
| 3523 | Ok((at, position)) => History::Split { | |
| 3524 | outline, | |
| 3525 | at, | |
| 3526 | part, | |
| 3527 | position, | |
| 3528 | }, | |
| 3529 | Err(error) => { | |
| 3530 | return Err(( | |
| 3531 | History::Join { | |
| 3532 | outline, | |
| 3533 | part, | |
| 3534 | parents, | |
| 3535 | }, | |
| 3536 | error, | |
| 3537 | )); | |
| 3538 | } | |
| 3539 | }, | |
| 3409 | 3540 | History::Layout { outline, layout } => { |
| 3410 | 3541 | let index = self |
| 3411 | 3542 | .outlines |
| ... | ... | @@ -3554,18 +3685,21 @@ impl CanvasEditor { |
| 3554 | 3685 | self.active = Focus::Outline(index); |
| 3555 | 3686 | History::Remove { outline: id, focus } |
| 3556 | 3687 | } |
| 3557 | History::Group(mut entries) => { | |
| 3688 | History::Group { mut entries, page } => { | |
| 3558 | 3689 | let mut inverses = Vec::with_capacity(entries.len()); |
| 3559 | 3690 | while let Some(entry) = entries.pop() { |
| 3560 | 3691 | match self.apply_history(engine, entry) { |
| 3561 | 3692 | Ok(inverse) => inverses.push(inverse), |
| 3562 | 3693 | Err((entry, error)) => { |
| 3563 | 3694 | entries.push(entry); |
| 3564 | return Err((History::Group(entries), error)); | |
| 3695 | return Err((History::Group { entries, page }, error)); | |
| 3565 | 3696 | } |
| 3566 | 3697 | } |
| 3567 | 3698 | } |
| 3568 | History::Group(inverses) | |
| 3699 | History::Group { | |
| 3700 | entries: inverses, | |
| 3701 | page, | |
| 3702 | } | |
| 3569 | 3703 | } |
| 3570 | 3704 | }; |
| 3571 | 3705 | self.preferred_x = None; |
| ... | ... | @@ -5445,6 +5579,7 @@ mod tests { |
| 5445 | 5579 | created: None, |
| 5446 | 5580 | margin_origin: [36.0, 14.4], |
| 5447 | 5581 | color: None, |
| 5582 | rule_lines: None, | |
| 5448 | 5583 | definitions: BTreeMap::new(), |
| 5449 | 5584 | objects: vec![PageObject::Image(background), PageObject::Image(picture)], |
| 5450 | 5585 | }, |
| ... | ... | @@ -5569,6 +5704,7 @@ mod tests { |
| 5569 | 5704 | created: None, |
| 5570 | 5705 | margin_origin: [36.0, 14.4], |
| 5571 | 5706 | color: None, |
| 5707 | rule_lines: None, | |
| 5572 | 5708 | definitions: BTreeMap::new(), |
| 5573 | 5709 | objects, |
| 5574 | 5710 | }, |
| ... | ... | @@ -5627,6 +5763,7 @@ mod tests { |
| 5627 | 5763 | created: None, |
| 5628 | 5764 | margin_origin: [36.0, 14.4], |
| 5629 | 5765 | color: None, |
| 5766 | rule_lines: None, | |
| 5630 | 5767 | definitions: BTreeMap::new(), |
| 5631 | 5768 | objects: vec![PageObject::Outline(source)], |
| 5632 | 5769 | }, |
| ... | ... | @@ -5767,6 +5904,7 @@ mod tests { |
| 5767 | 5904 | created: None, |
| 5768 | 5905 | margin_origin: [36.0, 14.4], |
| 5769 | 5906 | color: None, |
| 5907 | rule_lines: None, | |
| 5770 | 5908 | definitions: BTreeMap::new(), |
| 5771 | 5909 | objects: vec![ |
| 5772 | 5910 | PageObject::Outline(mixed), |
| ... | ... | @@ -5858,6 +5996,7 @@ mod tests { |
| 5858 | 5996 | created: None, |
| 5859 | 5997 | margin_origin: [36.0, 14.4], |
| 5860 | 5998 | color: None, |
| 5999 | rule_lines: None, | |
| 5861 | 6000 | definitions: BTreeMap::new(), |
| 5862 | 6001 | objects, |
| 5863 | 6002 | }, |
crates/canvas/src/editor/format.rs+12-3| ... | ... | @@ -1895,13 +1895,19 @@ mod tests { |
| 1895 | 1895 | |editor: &CanvasEditor| editor.active_outline().shaped.paragraphs[1].tags[0].icon; |
| 1896 | 1896 | assert_eq!( |
| 1897 | 1897 | icon(&editor), |
| 1898 | crate::outline::TagIcon::CheckBox { checked: false } | |
| 1898 | crate::outline::TagIcon::CheckBox { | |
| 1899 | checked: false, | |
| 1900 | mark: None | |
| 1901 | } | |
| 1899 | 1902 | ); |
| 1900 | 1903 | editor.format(&mut engine, Formatting::Check).unwrap(); |
| 1901 | 1904 | assert_eq!(text_tags(&editor, 1), [("To Do".into(), 1, true)]); |
| 1902 | 1905 | assert_eq!( |
| 1903 | 1906 | icon(&editor), |
| 1904 | crate::outline::TagIcon::CheckBox { checked: true } | |
| 1907 | crate::outline::TagIcon::CheckBox { | |
| 1908 | checked: true, | |
| 1909 | mark: None | |
| 1910 | } | |
| 1905 | 1911 | ); |
| 1906 | 1912 | editor.select([at(1, 0); 2].into()).unwrap(); |
| 1907 | 1913 | editor.format(&mut engine, Formatting::Check).unwrap(); |
| ... | ... | @@ -1976,7 +1982,10 @@ mod tests { |
| 1976 | 1982 | assert_eq!( |
| 1977 | 1983 | icons[..7], |
| 1978 | 1984 | [ |
| 1979 | TagIcon::CheckBox { checked: false }, | |
| 1985 | TagIcon::CheckBox { | |
| 1986 | checked: false, | |
| 1987 | mark: None | |
| 1988 | }, | |
| 1980 | 1989 | TagIcon::Star, |
| 1981 | 1990 | TagIcon::Question, |
| 1982 | 1991 | TagIcon::Highlight, |
crates/canvas/src/editor/ops.rs+30-24| ... | ... | @@ -173,7 +173,7 @@ fn lower( |
| 173 | 173 | |
| 174 | 174 | /// An empty paragraph keeping a container from emptying while its paragraphs are removed |
| 175 | 175 | /// and inserted again; the lowering removes it after. |
| 176 | fn anchor() -> Result<PageParagraph, EditError> { | |
| 176 | pub(super) fn anchor() -> Result<PageParagraph, EditError> { | |
| 177 | 177 | crate::document::node( |
| 178 | 178 | Paragraph::new(String::new(), Format::default()), |
| 179 | 179 | Format::default(), |
| ... | ... | @@ -327,6 +327,9 @@ impl CanvasEditor { |
| 327 | 327 | for object in &self.objects { |
| 328 | 328 | match object { |
| 329 | 329 | page::Content::Image(image) if image.id == placement.id => return picture(image), |
| 330 | page::Content::Ink(ink) if ink.id == placement.id => { | |
| 331 | return layout_ops(ink.id, &old, &moved(&ink.layout)); | |
| 332 | } | |
| 330 | 333 | page::Content::Outline { source, .. } if source.id == placement.id => { |
| 331 | 334 | return layout_ops(source.id, &old, &moved(&source.layout)); |
| 332 | 335 | } |
| ... | ... | @@ -352,29 +355,7 @@ impl CanvasEditor { |
| 352 | 355 | match (old, new) { |
| 353 | 356 | (None, None) => Ok(Vec::new()), |
| 354 | 357 | (Some(_), None) => Ok(vec![PageOp::Delete { object: id }]), |
| 355 | (None, Some(new)) => { | |
| 356 | let outline = new.snapshot(); | |
| 357 | let page = |objects| Page { | |
| 358 | title: String::new(), | |
| 359 | identity: None, | |
| 360 | created: None, | |
| 361 | margin_origin: [0.0; 2], | |
| 362 | color: None, | |
| 363 | objects, | |
| 364 | definitions: named(&self.definitions, &[&outline.paragraphs]), | |
| 365 | }; | |
| 366 | let mut ops = op::lower_page( | |
| 367 | &page(Vec::new()), | |
| 368 | &page(vec![PageObject::Outline(outline.clone())]), | |
| 369 | )?; | |
| 370 | let successor = self.successor(id); | |
| 371 | for op in &mut ops { | |
| 372 | if let PageOp::Add { before, .. } = op { | |
| 373 | *before = successor; | |
| 374 | } | |
| 375 | } | |
| 376 | Ok(ops) | |
| 377 | } | |
| 358 | (None, Some(new)) => self.added(new.snapshot()), | |
| 378 | 359 | (Some(old), Some(new)) => { |
| 379 | 360 | let (a, b) = (old.document.nodes(), new.document.nodes()); |
| 380 | 361 | let prefix = a.iter().zip(b).take_while(|(x, y)| x == y).count(); |
| ... | ... | @@ -397,6 +378,31 @@ impl CanvasEditor { |
| 397 | 378 | } |
| 398 | 379 | } |
| 399 | 380 | |
| 381 | /// Ops adding `outline`, new to the stored page, in its place in paint order. | |
| 382 | pub(super) fn added(&self, outline: Outline) -> Lowered { | |
| 383 | let page = |objects| Page { | |
| 384 | title: String::new(), | |
| 385 | identity: None, | |
| 386 | created: None, | |
| 387 | margin_origin: [0.0; 2], | |
| 388 | color: None, | |
| 389 | rule_lines: None, | |
| 390 | objects, | |
| 391 | definitions: named(&self.definitions, &[&outline.paragraphs]), | |
| 392 | }; | |
| 393 | let mut ops = op::lower_page( | |
| 394 | &page(Vec::new()), | |
| 395 | &page(vec![PageObject::Outline(outline.clone())]), | |
| 396 | )?; | |
| 397 | let successor = self.successor(outline.id); | |
| 398 | for op in &mut ops { | |
| 399 | if let PageOp::Add { before, .. } = op { | |
| 400 | *before = successor; | |
| 401 | } | |
| 402 | } | |
| 403 | Ok(ops) | |
| 404 | } | |
| 405 | ||
| 400 | 406 | /// The page's date as the editor shows it. |
| 401 | 407 | pub(super) fn date_ops(&self) -> Vec<PageOp> { |
| 402 | 408 | let Some(date) = &self.date else { |
crates/canvas/src/editor/page.rs+15-4| ... | ... | @@ -76,9 +76,9 @@ impl Content { |
| 76 | 76 | match self { |
| 77 | 77 | Self::Outline { source, .. } => Some((source.id, &mut source.layout)), |
| 78 | 78 | Self::Image(source) => Some((source.id, &mut source.layout)), |
| 79 | Self::Ink(source) => Some((source.id, &mut source.layout)), | |
| 79 | 80 | Self::ReadOnly(object) => Some((object.source.id(), object.source.layout_mut())), |
| 80 | // Strokes carry their own page coordinates, so a layout move would not move them. | |
| 81 | Self::Date { .. } | Self::Ink(_) | Self::Editable(_) => None, | |
| 81 | Self::Date { .. } | Self::Editable(_) => None, | |
| 82 | 82 | } |
| 83 | 83 | } |
| 84 | 84 | } |
| ... | ... | @@ -357,8 +357,19 @@ pub(crate) fn build( |
| 357 | 357 | }) |
| 358 | 358 | } |
| 359 | 359 | |
| 360 | /// The painted extent of every stroke, pen included, as `[x0, y0, x1, y1]` page points. | |
| 360 | /// The painted extent of a page's ink drawing, pen included, as `[x0, y0, x1, y1]` page | |
| 361 | /// points. | |
| 361 | 362 | pub(crate) fn ink_bounds(ink: &Ink) -> Option<[f32; 4]> { |
| 363 | let [x, y] = ink_offset(ink); | |
| 364 | stroke_bounds(ink).map(|[x0, y0, x1, y1]| [x0 + x, y0 + y, x1 + x, y1 + y]) | |
| 365 | } | |
| 366 | ||
| 367 | pub(crate) fn ink_offset(ink: &Ink) -> [f32; 2] { | |
| 368 | [ink.layout.x.unwrap_or(0.0), ink.layout.y.unwrap_or(0.0)] | |
| 369 | } | |
| 370 | ||
| 371 | /// The painted extent of every stroke before the drawing's offset. | |
| 372 | fn stroke_bounds(ink: &Ink) -> Option<[f32; 4]> { | |
| 362 | 373 | ink.strokes |
| 363 | 374 | .iter() |
| 364 | 375 | .flat_map(|stroke| { |
| ... | ... | @@ -368,7 +379,7 @@ pub(crate) fn ink_bounds(ink: &Ink) -> Option<[f32; 4]> { |
| 368 | 379 | .iter() |
| 369 | 380 | .map(move |[x, y]| [x - r, y - r, x + r, y + r]) |
| 370 | 381 | }) |
| 371 | .chain(ink.groups.iter().filter_map(ink_bounds)) | |
| 382 | .chain(ink.groups.iter().filter_map(stroke_bounds)) | |
| 372 | 383 | .reduce(|a, b| { |
| 373 | 384 | [ |
| 374 | 385 | a[0].min(b[0]), |
crates/canvas/src/editor/space.rs created+336| ... | ... | @@ -0,0 +1,336 @@ |
| 1 | //! OneNote 2010's Insert Space: what lies past a line across the page moves along one axis, | |
| 2 | //! and a body outline the line crosses parts between its paragraphs. | |
| 3 | ||
| 4 | use super::*; | |
| 5 | ||
| 6 | /// Something Insert Space moves. | |
| 7 | enum Piece { | |
| 8 | /// A body outline, picture or ink drawing, placed anew. | |
| 9 | Object(ExGuid), | |
| 10 | /// The root nodes of a body outline from `at` on. | |
| 11 | Tail { outline: ExGuid, at: usize }, | |
| 12 | } | |
| 13 | ||
| 14 | impl CanvasEditor { | |
| 15 | /// What starts at or past `line` along `axis` (0 for x, 1 for y) with its extent as | |
| 16 | /// `[x0, y0, x1, y1]` page points, and the farthest end of what starts before the line. | |
| 17 | fn spaced(&self, axis: usize, line: f32) -> (Vec<(Piece, [f32; 4])>, f32) { | |
| 18 | let mut moved = Vec::new(); | |
| 19 | let mut floor = f32::NEG_INFINITY; | |
| 20 | let mut stays = |rect: [f32; 4]| { | |
| 21 | if rect[axis] < line { | |
| 22 | floor = floor.max(rect[axis + 2]); | |
| 23 | } | |
| 24 | }; | |
| 25 | for outline in &self.outlines { | |
| 26 | let bounds = outline.bounds(); | |
| 27 | let rect = [bounds.x0, bounds.y0, bounds.x1, bounds.y1].map(|value| value as f32); | |
| 28 | if outline.title { | |
| 29 | stays(rect); | |
| 30 | continue; | |
| 31 | } | |
| 32 | if rect[axis] >= line { | |
| 33 | moved.push((Piece::Object(outline.id), rect)); | |
| 34 | continue; | |
| 35 | } | |
| 36 | let top = outline.origin()[1]; | |
| 37 | let spans: Vec<Option<[f32; 2]>> = outline.shaped.spans().collect(); | |
| 38 | let at = (axis == 1) | |
| 39 | .then(|| { | |
| 40 | spans | |
| 41 | .iter() | |
| 42 | .position(|span| span.is_some_and(|[start, _]| top + start >= line)) | |
| 43 | }) | |
| 44 | .flatten(); | |
| 45 | let Some(at) = at else { | |
| 46 | stays(rect); | |
| 47 | continue; | |
| 48 | }; | |
| 49 | let [start, _] = spans[at].unwrap(); | |
| 50 | moved.push(( | |
| 51 | Piece::Tail { | |
| 52 | outline: outline.id, | |
| 53 | at, | |
| 54 | }, | |
| 55 | [rect[0], top + start, rect[2], rect[3]], | |
| 56 | )); | |
| 57 | let end = spans[..at] | |
| 58 | .iter() | |
| 59 | .flatten() | |
| 60 | .map(|[_, end]| *end) | |
| 61 | .fold(0.0, f32::max); | |
| 62 | stays([rect[0], rect[1], rect[2], top + end]); | |
| 63 | } | |
| 64 | for content in &self.objects { | |
| 65 | let (id, rect) = match content { | |
| 66 | page::Content::Outline { | |
| 67 | source, | |
| 68 | layout, | |
| 69 | below_title: None, | |
| 70 | } => { | |
| 71 | let [x, y] = [source.layout.x, source.layout.y].map(|v| v.unwrap_or(0.0)); | |
| 72 | (source.id, [x, y, x + layout.size[0], y + layout.size[1]]) | |
| 73 | } | |
| 74 | page::Content::Image(image) => { | |
| 75 | let (origin, size) = self.image_placement(image.id).unwrap_or_default(); | |
| 76 | let rect = [ | |
| 77 | origin[0], | |
| 78 | origin[1], | |
| 79 | origin[0] + size[0], | |
| 80 | origin[1] + size[1], | |
| 81 | ]; | |
| 82 | (image.id, rect) | |
| 83 | } | |
| 84 | page::Content::Ink(ink) => match page::ink_bounds(ink) { | |
| 85 | Some(rect) => (ink.id, rect), | |
| 86 | None => continue, | |
| 87 | }, | |
| 88 | page::Content::ReadOnly(object) | |
| 89 | if matches!(object.source, PageObject::Outline(_) | PageObject::Image(_)) => | |
| 90 | { | |
| 91 | (object.source.id(), object.rect()) | |
| 92 | } | |
| 93 | page::Content::ReadOnly(object) => { | |
| 94 | stays(object.rect()); | |
| 95 | continue; | |
| 96 | } | |
| 97 | page::Content::Outline { .. } | page::Content::Date { .. } => continue, | |
| 98 | page::Content::Editable(_) => continue, | |
| 99 | }; | |
| 100 | if rect[axis] >= line { | |
| 101 | moved.push((Piece::Object(id), rect)); | |
| 102 | } else { | |
| 103 | stays(rect); | |
| 104 | } | |
| 105 | } | |
| 106 | (moved, floor) | |
| 107 | } | |
| 108 | ||
| 109 | /// How far back from `line` Insert Space can take the page along `axis`: never over what | |
| 110 | /// stays before the line, so at most zero. | |
| 111 | pub fn space_limit(&self, axis: usize, line: f32) -> f32 { | |
| 112 | (self.spaced(axis, line).1 - line).min(0.0) | |
| 113 | } | |
| 114 | ||
| 115 | /// The extents, as `[x0, y0, x1, y1]` page points, of what Insert Space at `line` along | |
| 116 | /// `axis` moves. | |
| 117 | pub fn space_preview(&self, axis: usize, line: f32) -> Vec<[f32; 4]> { | |
| 118 | self.spaced(axis, line) | |
| 119 | .0 | |
| 120 | .into_iter() | |
| 121 | .map(|(_, rect)| rect) | |
| 122 | .collect() | |
| 123 | } | |
| 124 | ||
| 125 | /// Moves what starts at or past `line` along `axis` by `delta`, taking no more space back | |
| 126 | /// than `space_limit` allows, as one undo step. False when nothing moves. | |
| 127 | pub fn insert_space( | |
| 128 | &mut self, | |
| 129 | engine: &mut TextEngine, | |
| 130 | axis: usize, | |
| 131 | line: f32, | |
| 132 | delta: f32, | |
| 133 | ) -> Result<bool, EditorError> { | |
| 134 | if !line.is_finite() || !delta.is_finite() || axis > 1 { | |
| 135 | return Err(EditError::InvalidRange.into()); | |
| 136 | } | |
| 137 | let (moved, floor) = self.spaced(axis, line); | |
| 138 | let delta = delta.max((floor - line).min(0.0)); | |
| 139 | if delta == 0.0 || moved.is_empty() { | |
| 140 | return Ok(false); | |
| 141 | } | |
| 142 | self.finish_composition(); | |
| 143 | self.whole = None; | |
| 144 | if let Focus::Draft { index, .. } = self.active { | |
| 145 | self.active = Focus::Outline(index); | |
| 146 | } | |
| 147 | let shift = |mut position: [f32; 2]| { | |
| 148 | position[axis] += delta; | |
| 149 | position | |
| 150 | }; | |
| 151 | let depth = self.undo.len(); | |
| 152 | for (piece, rect) in moved { | |
| 153 | let result = match piece { | |
| 154 | Piece::Object(id) => { | |
| 155 | let layout = self.object_layout_mut(id).unwrap(); | |
| 156 | // Undo restores an unset coordinate as the zero it stands for, as ops | |
| 157 | // cannot unset one. | |
| 158 | let origin = [layout.x, layout.y].map(|v| v.unwrap_or(0.0)); | |
| 159 | let previous = origin.map(Some); | |
| 160 | [layout.x, layout.y] = shift(origin).map(Some); | |
| 161 | self.undo.push(History::Position { | |
| 162 | object: id, | |
| 163 | position: previous, | |
| 164 | }); | |
| 165 | self.record(self.placement_ops(&Placement { | |
| 166 | id, | |
| 167 | position: previous, | |
| 168 | })); | |
| 169 | Ok(()) | |
| 170 | } | |
| 171 | Piece::Tail { outline, at } => { | |
| 172 | let part = onestore::page::text::new_id()?; | |
| 173 | self.split_outline(engine, outline, at, part, shift([rect[0], rect[1]])) | |
| 174 | .map(|parents| { | |
| 175 | self.undo.push(History::Join { | |
| 176 | outline, | |
| 177 | part, | |
| 178 | parents, | |
| 179 | }) | |
| 180 | }) | |
| 181 | } | |
| 182 | }; | |
| 183 | if let Err(error) = result { | |
| 184 | self.group(depth); | |
| 185 | return Err(error); | |
| 186 | } | |
| 187 | } | |
| 188 | self.group(depth); | |
| 189 | self.redo.clear(); | |
| 190 | self.preferred_x = None; | |
| 191 | Ok(true) | |
| 192 | } | |
| 193 | ||
| 194 | /// Makes the history entries past `depth` one undo step that leaves the selection alone. | |
| 195 | fn group(&mut self, depth: usize) { | |
| 196 | let entries = self.undo.split_off(depth); | |
| 197 | if !entries.is_empty() { | |
| 198 | self.undo.push(History::Group { | |
| 199 | entries, | |
| 200 | page: false, | |
| 201 | }); | |
| 202 | } | |
| 203 | } | |
| 204 | ||
| 205 | /// Parts outline `id`'s root nodes from `at` into new outline `part` at `position`, which | |
| 206 | /// takes the rest of the outline's layout; returns each root `part` then holds with the | |
| 207 | /// parent it had. | |
| 208 | pub(super) fn split_outline( | |
| 209 | &mut self, | |
| 210 | engine: &mut TextEngine, | |
| 211 | id: ExGuid, | |
| 212 | at: usize, | |
| 213 | part: ExGuid, | |
| 214 | position: [f32; 2], | |
| 215 | ) -> Result<Vec<(ExGuid, Option<ExGuid>)>, EditorError> { | |
| 216 | let index = self | |
| 217 | .outlines | |
| 218 | .iter() | |
| 219 | .position(|outline| outline.id == id) | |
| 220 | .ok_or(EditError::InvalidRange)?; | |
| 221 | let mut kept = self.outlines[index].snapshot(); | |
| 222 | let mut tail = kept.paragraphs.split_off(at); | |
| 223 | let head: BTreeSet<ExGuid> = kept.paragraphs.iter().map(|node| node.id).collect(); | |
| 224 | let mut parents = Vec::new(); | |
| 225 | for node in &mut tail { | |
| 226 | if node.parent.is_none_or(|parent| head.contains(&parent)) { | |
| 227 | parents.push((node.id, node.parent.take())); | |
| 228 | } | |
| 229 | } | |
| 230 | let [x, y] = position.map(Some); | |
| 231 | let moved = Outline { | |
| 232 | id: part, | |
| 233 | layout: onestore::document::Layout { | |
| 234 | x, | |
| 235 | y, | |
| 236 | max_height: None, | |
| 237 | ..kept.layout.clone() | |
| 238 | }, | |
| 239 | paragraphs: tail, | |
| 240 | ..kept.clone() | |
| 241 | }; | |
| 242 | let head = TextOutline::from_outline(engine, &kept, &self.definitions)?; | |
| 243 | let tail = TextOutline::from_outline(engine, &moved, &self.definitions)?; | |
| 244 | self.outlines[index] = head; | |
| 245 | self.outlines.insert(index + 1, tail); | |
| 246 | match &mut self.active { | |
| 247 | Focus::Outline(active) | Focus::Caret { index: active, .. } if *active > index => { | |
| 248 | *active += 1 | |
| 249 | } | |
| 250 | _ => {} | |
| 251 | } | |
| 252 | if let Some(slot) = self | |
| 253 | .objects | |
| 254 | .iter() | |
| 255 | .position(|object| matches!(object, page::Content::Editable(slot) if *slot == id)) | |
| 256 | { | |
| 257 | self.objects.insert(slot + 1, page::Content::Editable(part)); | |
| 258 | } | |
| 259 | let anchor = ops::anchor()?; | |
| 260 | let lowered = self | |
| 261 | .added(Outline { | |
| 262 | paragraphs: vec![anchor.clone()], | |
| 263 | ..moved | |
| 264 | }) | |
| 265 | .map(|mut added| { | |
| 266 | added.extend(parents.iter().map(|(root, _)| PageOp::Move { | |
| 267 | object: *root, | |
| 268 | parent: Some(part), | |
| 269 | before: Some(anchor.id), | |
| 270 | })); | |
| 271 | added.push(PageOp::Delete { object: anchor.id }); | |
| 272 | added | |
| 273 | }); | |
| 274 | self.record(lowered); | |
| 275 | Ok(parents) | |
| 276 | } | |
| 277 | ||
| 278 | /// Returns outline `part`'s root nodes to the end of outline `id`, each under its parent | |
| 279 | /// in `parents`, and removes `part`; returns where `split_outline` would part them again. | |
| 280 | pub(super) fn join_outline( | |
| 281 | &mut self, | |
| 282 | engine: &mut TextEngine, | |
| 283 | id: ExGuid, | |
| 284 | part: ExGuid, | |
| 285 | parents: &[(ExGuid, Option<ExGuid>)], | |
| 286 | ) -> Result<(usize, [f32; 2]), EditorError> { | |
| 287 | let find = |id| self.outlines.iter().position(|outline| outline.id == id); | |
| 288 | let (Some(index), Some(from)) = (find(id), find(part)) else { | |
| 289 | return Err(EditError::InvalidRange.into()); | |
| 290 | }; | |
| 291 | let mut joined = self.outlines[index].snapshot(); | |
| 292 | let at = joined.paragraphs.len(); | |
| 293 | let position = self.outlines[from].origin(); | |
| 294 | let parents: BTreeMap<ExGuid, Option<ExGuid>> = parents.iter().copied().collect(); | |
| 295 | joined | |
| 296 | .paragraphs | |
| 297 | .extend(self.outlines[from].document.nodes().iter().map(|node| { | |
| 298 | let mut node = node.clone(); | |
| 299 | if let Some(parent) = parents.get(&node.id) { | |
| 300 | node.parent = *parent; | |
| 301 | } | |
| 302 | node | |
| 303 | })); | |
| 304 | let roots: Vec<ExGuid> = self.outlines[from] | |
| 305 | .document | |
| 306 | .nodes() | |
| 307 | .iter() | |
| 308 | .filter(|node| node.parent.is_none()) | |
| 309 | .map(|node| node.id) | |
| 310 | .collect(); | |
| 311 | self.outlines[index] = TextOutline::from_outline(engine, &joined, &self.definitions)?; | |
| 312 | self.outlines.remove(from); | |
| 313 | match &mut self.active { | |
| 314 | Focus::Outline(active) if *active == from => { | |
| 315 | *active = index - usize::from(index > from) | |
| 316 | } | |
| 317 | Focus::Outline(active) | Focus::Caret { index: active, .. } if *active > from => { | |
| 318 | *active -= 1 | |
| 319 | } | |
| 320 | _ => {} | |
| 321 | } | |
| 322 | self.objects | |
| 323 | .retain(|object| !matches!(object, page::Content::Editable(slot) if *slot == part)); | |
| 324 | // Moving its last paragraph out removes the emptied outline. | |
| 325 | let ops = roots | |
| 326 | .into_iter() | |
| 327 | .map(|root| PageOp::Move { | |
| 328 | object: root, | |
| 329 | parent: Some(parents.get(&root).copied().flatten().unwrap_or(id)), | |
| 330 | before: None, | |
| 331 | }) | |
| 332 | .collect(); | |
| 333 | self.record(Ok(ops)); | |
| 334 | Ok((at, position)) | |
| 335 | } | |
| 336 | } |
crates/canvas/src/gpu/mod.rs+89-6| ... | ... | @@ -4,7 +4,7 @@ mod profile; |
| 4 | 4 | |
| 5 | 5 | use crate::{ |
| 6 | 6 | layout::{TextBrush, TextLayout}, |
| 7 | outline::TagIcon, | |
| 7 | outline::{BoxMark, TagIcon}, | |
| 8 | 8 | }; |
| 9 | 9 | use draw::{GlyphRun, Glyphs, Layer, Primitive, RenderError}; |
| 10 | 10 | use parley::PositionedLayoutItem; |
| ... | ... | @@ -33,6 +33,7 @@ impl Viewport { |
| 33 | 33 | origin: self.origin, |
| 34 | 34 | clip: None, |
| 35 | 35 | backdrop: None, |
| 36 | round: None, | |
| 36 | 37 | motion: None, |
| 37 | 38 | primitives, |
| 38 | 39 | } |
| ... | ... | @@ -70,7 +71,7 @@ impl Paper { |
| 70 | 71 | /// A fill OneNote draws on white paper, moved onto this paper: as far from it in |
| 71 | 72 | /// OKLab lightness as from white, in the same hue and chroma, so text keeps its |
| 72 | 73 | /// contrast on it. |
| 73 | pub(crate) fn tint(&self, light: [f32; 4]) -> [f32; 4] { | |
| 74 | pub fn tint(&self, light: [f32; 4]) -> [f32; 4] { | |
| 74 | 75 | let [lightness, a, b] = draw::oklab(light); |
| 75 | 76 | let [paper, ..] = draw::oklab(self.color); |
| 76 | 77 | let distance = 1.0 - lightness; |
| ... | ... | @@ -153,13 +154,82 @@ const RED_SQUARE: &str = include_str!("../../assets/tags/red-square.svg"); |
| 153 | 154 | const YELLOW_SQUARE: &str = include_str!("../../assets/tags/yellow-square.svg"); |
| 154 | 155 | const BLUE_SQUARE: &str = include_str!("../../assets/tags/blue-square.svg"); |
| 155 | 156 | const FLAG: &str = include_str!("../../assets/tags/flag.svg"); |
| 157 | const IDEA: &str = include_str!("../../assets/tags/idea.svg"); | |
| 158 | const PASSWORD: &str = include_str!("../../assets/tags/password.svg"); | |
| 159 | const MOVIE: &str = include_str!("../../assets/tags/movie.svg"); | |
| 160 | const BOOK: &str = include_str!("../../assets/tags/book.svg"); | |
| 161 | const WEB: &str = include_str!("../../assets/tags/web.svg"); | |
| 162 | const BLOG: &str = include_str!("../../assets/tags/blog.svg"); | |
| 163 | const EMAIL: &str = include_str!("../../assets/tags/email.svg"); | |
| 164 | const BOX_SMALL: &str = include_str!("../../assets/tags/box-small.svg"); | |
| 165 | const CHECK_SMALL: &str = include_str!("../../assets/tags/check-small.svg"); | |
| 166 | const MARK_PERSON: &str = include_str!("../../assets/tags/mark-person.svg"); | |
| 167 | const MARK_MANAGER: &str = include_str!("../../assets/tags/mark-manager.svg"); | |
| 168 | const MARK_ARROW: &str = include_str!("../../assets/tags/mark-arrow.svg"); | |
| 169 | const MARK_ONE: &str = include_str!("../../assets/tags/mark-one.svg"); | |
| 170 | const MARK_TWO: &str = include_str!("../../assets/tags/mark-two.svg"); | |
| 171 | const MARK_CLIENT: &str = include_str!("../../assets/tags/mark-client.svg"); | |
| 156 | 172 | const TAG: &str = include_str!("../../assets/tags/tag.svg"); |
| 157 | 173 | |
| 158 | 174 | /// The tag's artwork, drawn in order. |
| 159 | 175 | pub fn tag_sources(icon: TagIcon) -> &'static [&'static str] { |
| 160 | 176 | match icon { |
| 161 | TagIcon::CheckBox { checked: false } => &[CHECKBOX], | |
| 162 | TagIcon::CheckBox { checked: true } => &[CHECKBOX, CHECKMARK], | |
| 177 | TagIcon::CheckBox { | |
| 178 | checked: false, | |
| 179 | mark: None, | |
| 180 | } => &[CHECKBOX], | |
| 181 | TagIcon::CheckBox { | |
| 182 | checked: true, | |
| 183 | mark: None, | |
| 184 | } => &[CHECKBOX, CHECKMARK], | |
| 185 | TagIcon::CheckBox { | |
| 186 | checked: false, | |
| 187 | mark: Some(BoxMark::Person), | |
| 188 | } => &[BOX_SMALL, MARK_PERSON], | |
| 189 | TagIcon::CheckBox { | |
| 190 | checked: true, | |
| 191 | mark: Some(BoxMark::Person), | |
| 192 | } => &[BOX_SMALL, CHECK_SMALL, MARK_PERSON], | |
| 193 | TagIcon::CheckBox { | |
| 194 | checked: false, | |
| 195 | mark: Some(BoxMark::Manager), | |
| 196 | } => &[BOX_SMALL, MARK_MANAGER], | |
| 197 | TagIcon::CheckBox { | |
| 198 | checked: true, | |
| 199 | mark: Some(BoxMark::Manager), | |
| 200 | } => &[BOX_SMALL, CHECK_SMALL, MARK_MANAGER], | |
| 201 | TagIcon::CheckBox { | |
| 202 | checked: false, | |
| 203 | mark: Some(BoxMark::Arrow), | |
| 204 | } => &[BOX_SMALL, MARK_ARROW], | |
| 205 | TagIcon::CheckBox { | |
| 206 | checked: true, | |
| 207 | mark: Some(BoxMark::Arrow), | |
| 208 | } => &[BOX_SMALL, CHECK_SMALL, MARK_ARROW], | |
| 209 | TagIcon::CheckBox { | |
| 210 | checked: false, | |
| 211 | mark: Some(BoxMark::One), | |
| 212 | } => &[BOX_SMALL, MARK_ONE], | |
| 213 | TagIcon::CheckBox { | |
| 214 | checked: true, | |
| 215 | mark: Some(BoxMark::One), | |
| 216 | } => &[BOX_SMALL, CHECK_SMALL, MARK_ONE], | |
| 217 | TagIcon::CheckBox { | |
| 218 | checked: false, | |
| 219 | mark: Some(BoxMark::Two), | |
| 220 | } => &[BOX_SMALL, MARK_TWO], | |
| 221 | TagIcon::CheckBox { | |
| 222 | checked: true, | |
| 223 | mark: Some(BoxMark::Two), | |
| 224 | } => &[BOX_SMALL, CHECK_SMALL, MARK_TWO], | |
| 225 | TagIcon::CheckBox { | |
| 226 | checked: false, | |
| 227 | mark: Some(BoxMark::Client), | |
| 228 | } => &[BOX_SMALL, MARK_CLIENT], | |
| 229 | TagIcon::CheckBox { | |
| 230 | checked: true, | |
| 231 | mark: Some(BoxMark::Client), | |
| 232 | } => &[BOX_SMALL, CHECK_SMALL, MARK_CLIENT], | |
| 163 | 233 | TagIcon::Star => &[STAR], |
| 164 | 234 | TagIcon::Question => &[QUESTION], |
| 165 | 235 | TagIcon::Highlight => &[HIGHLIGHT], |
| ... | ... | @@ -172,6 +242,13 @@ pub fn tag_sources(icon: TagIcon) -> &'static [&'static str] { |
| 172 | 242 | TagIcon::YellowSquare => &[YELLOW_SQUARE], |
| 173 | 243 | TagIcon::BlueSquare => &[BLUE_SQUARE], |
| 174 | 244 | TagIcon::Flag => &[FLAG], |
| 245 | TagIcon::Idea => &[IDEA], | |
| 246 | TagIcon::Password => &[PASSWORD], | |
| 247 | TagIcon::Movie => &[MOVIE], | |
| 248 | TagIcon::Book => &[BOOK], | |
| 249 | TagIcon::Web => &[WEB], | |
| 250 | TagIcon::Blog => &[BLOG], | |
| 251 | TagIcon::Email => &[EMAIL], | |
| 175 | 252 | TagIcon::Other => &[TAG], |
| 176 | 253 | } |
| 177 | 254 | } |
| ... | ... | @@ -288,8 +365,14 @@ mod tests { |
| 288 | 365 | }); |
| 289 | 366 | let mut renderer = draw::Renderer::new(device, queue, wgpu::TextureFormat::Rgba8UnormSrgb); |
| 290 | 367 | let icons = [ |
| 291 | TagIcon::CheckBox { checked: false }, | |
| 292 | TagIcon::CheckBox { checked: true }, | |
| 368 | TagIcon::CheckBox { | |
| 369 | checked: false, | |
| 370 | mark: None, | |
| 371 | }, | |
| 372 | TagIcon::CheckBox { | |
| 373 | checked: true, | |
| 374 | mark: None, | |
| 375 | }, | |
| 293 | 376 | TagIcon::Star, |
| 294 | 377 | TagIcon::Question, |
| 295 | 378 | TagIcon::Highlight, |
crates/canvas/src/gpu/page.rs+12-1| ... | ... | @@ -733,7 +733,8 @@ impl PageScene { |
| 733 | 733 | continue; |
| 734 | 734 | } |
| 735 | 735 | Content::Ink(ink) => { |
| 736 | append_ink(ink, offset, primitives); | |
| 736 | let [x, y] = crate::editor::page::ink_offset(ink); | |
| 737 | append_ink(ink, [offset[0] + x, offset[1] + y], primitives); | |
| 737 | 738 | continue; |
| 738 | 739 | } |
| 739 | 740 | }; |
| ... | ... | @@ -1086,6 +1087,7 @@ mod tests { |
| 1086 | 1087 | title: "Header".into(), |
| 1087 | 1088 | margin_origin: [36.0, 14.4], |
| 1088 | 1089 | color: None, |
| 1090 | rule_lines: None, | |
| 1089 | 1091 | definitions: BTreeMap::new(), |
| 1090 | 1092 | objects: vec![PageObject::Title(Title { |
| 1091 | 1093 | date: Some(fields[1].id), |
| ... | ... | @@ -1528,6 +1530,7 @@ mod tests { |
| 1528 | 1530 | title: "Header".into(), |
| 1529 | 1531 | margin_origin: [36.0, 14.4], |
| 1530 | 1532 | color: None, |
| 1533 | rule_lines: None, | |
| 1531 | 1534 | definitions: BTreeMap::new(), |
| 1532 | 1535 | objects: vec![PageObject::Title(Title { |
| 1533 | 1536 | date: None, |
| ... | ... | @@ -1603,6 +1606,7 @@ mod tests { |
| 1603 | 1606 | title: String::new(), |
| 1604 | 1607 | margin_origin: [0.0; 2], |
| 1605 | 1608 | color: None, |
| 1609 | rule_lines: None, | |
| 1606 | 1610 | definitions: BTreeMap::new(), |
| 1607 | 1611 | objects, |
| 1608 | 1612 | }; |
| ... | ... | @@ -1686,6 +1690,7 @@ mod tests { |
| 1686 | 1690 | title: String::new(), |
| 1687 | 1691 | margin_origin: [36.0, 14.0], |
| 1688 | 1692 | color: None, |
| 1693 | rule_lines: None, | |
| 1689 | 1694 | objects: vec![unsupported, PageObject::Outline(editable)], |
| 1690 | 1695 | definitions: BTreeMap::from([ |
| 1691 | 1696 | ( |
| ... | ... | @@ -1773,6 +1778,7 @@ mod tests { |
| 1773 | 1778 | title: String::new(), |
| 1774 | 1779 | margin_origin: [0.0; 2], |
| 1775 | 1780 | color: None, |
| 1781 | rule_lines: None, | |
| 1776 | 1782 | definitions: BTreeMap::new(), |
| 1777 | 1783 | objects: vec![ |
| 1778 | 1784 | PageObject::Outline(Outline { |
| ... | ... | @@ -1953,6 +1959,7 @@ mod tests { |
| 1953 | 1959 | title: String::new(), |
| 1954 | 1960 | margin_origin: [0.0; 2], |
| 1955 | 1961 | color: None, |
| 1962 | rule_lines: None, | |
| 1956 | 1963 | definitions: BTreeMap::new(), |
| 1957 | 1964 | objects: vec![ |
| 1958 | 1965 | outline(2.0, "first"), |
| ... | ... | @@ -2127,6 +2134,7 @@ mod tests { |
| 2127 | 2134 | title: String::new(), |
| 2128 | 2135 | margin_origin: [0.0; 2], |
| 2129 | 2136 | color: None, |
| 2137 | rule_lines: None, | |
| 2130 | 2138 | definitions: BTreeMap::new(), |
| 2131 | 2139 | objects: vec![PageObject::Ink(ink.clone())], |
| 2132 | 2140 | }; |
| ... | ... | @@ -2187,6 +2195,7 @@ mod tests { |
| 2187 | 2195 | title: String::new(), |
| 2188 | 2196 | margin_origin: [0.0; 2], |
| 2189 | 2197 | color: None, |
| 2198 | rule_lines: None, | |
| 2190 | 2199 | definitions: BTreeMap::new(), |
| 2191 | 2200 | objects: vec![PageObject::Image(Image { |
| 2192 | 2201 | size: None, |
| ... | ... | @@ -2273,6 +2282,7 @@ mod tests { |
| 2273 | 2282 | title: String::new(), |
| 2274 | 2283 | margin_origin: [0.0; 2], |
| 2275 | 2284 | color: None, |
| 2285 | rule_lines: None, | |
| 2276 | 2286 | definitions: BTreeMap::new(), |
| 2277 | 2287 | objects: vec![PageObject::Image(background), PageObject::Image(picture)], |
| 2278 | 2288 | }; |
| ... | ... | @@ -2341,6 +2351,7 @@ mod tests { |
| 2341 | 2351 | title: String::new(), |
| 2342 | 2352 | margin_origin: [0.0; 2], |
| 2343 | 2353 | color: None, |
| 2354 | rule_lines: None, | |
| 2344 | 2355 | definitions: BTreeMap::new(), |
| 2345 | 2356 | objects: pictures.iter().cloned().map(PageObject::Image).collect(), |
| 2346 | 2357 | }; |
crates/canvas/src/gpu/page/background.rs+1| ... | ... | @@ -910,6 +910,7 @@ mod tests { |
| 910 | 910 | title: String::new(), |
| 911 | 911 | margin_origin: [0.0; 2], |
| 912 | 912 | color: None, |
| 913 | rule_lines: None, | |
| 913 | 914 | definitions: BTreeMap::new(), |
| 914 | 915 | objects: vec![PageObject::Image(background)], |
| 915 | 916 | }; |
crates/canvas/src/interaction/accessibility.rs+11-2| ... | ... | @@ -609,6 +609,13 @@ fn runs_of(shaped: &ParagraphLayout, breaks: bool) -> Result<Vec<(Node, Vec<u32> |
| 609 | 609 | TagIcon::YellowSquare => "Project B", |
| 610 | 610 | TagIcon::BlueSquare => "Project C", |
| 611 | 611 | TagIcon::Flag => "Outlook task", |
| 612 | TagIcon::Idea => "Idea", | |
| 613 | TagIcon::Password => "Password", | |
| 614 | TagIcon::Movie => "Movie to see", | |
| 615 | TagIcon::Book => "Book to read", | |
| 616 | TagIcon::Web => "Web site to visit", | |
| 617 | TagIcon::Blog => "Remember for blog", | |
| 618 | TagIcon::Email => "Send in email", | |
| 612 | 619 | TagIcon::Other => "Tag", |
| 613 | 620 | }; |
| 614 | 621 | let label = if tag.label.is_empty() { |
| ... | ... | @@ -617,8 +624,8 @@ fn runs_of(shaped: &ParagraphLayout, breaks: bool) -> Result<Vec<(Node, Vec<u32> |
| 617 | 624 | &tag.label |
| 618 | 625 | }; |
| 619 | 626 | let state = match tag.icon { |
| 620 | TagIcon::CheckBox { checked: true } => ", completed", | |
| 621 | TagIcon::CheckBox { checked: false } => ", incomplete", | |
| 627 | TagIcon::CheckBox { checked: true, .. } => ", completed", | |
| 628 | TagIcon::CheckBox { checked: false, .. } => ", incomplete", | |
| 622 | 629 | _ => "", |
| 623 | 630 | }; |
| 624 | 631 | format!( |
| ... | ... | @@ -739,6 +746,7 @@ pub(super) mod tests { |
| 739 | 746 | created: None, |
| 740 | 747 | margin_origin: [36.0, 14.4], |
| 741 | 748 | color: None, |
| 749 | rule_lines: None, | |
| 742 | 750 | definitions: Default::default(), |
| 743 | 751 | objects: vec![ |
| 744 | 752 | PageObject::Title(Title { |
| ... | ... | @@ -832,6 +840,7 @@ pub(super) mod tests { |
| 832 | 840 | title: String::new(), |
| 833 | 841 | margin_origin: [0.0; 2], |
| 834 | 842 | color: None, |
| 843 | rule_lines: None, | |
| 835 | 844 | definitions: Default::default(), |
| 836 | 845 | objects: vec![PageObject::Unsupported(Unsupported { |
| 837 | 846 | id: Default::default(), |
crates/canvas/src/interaction/mod.rs+175-6| ... | ... | @@ -6,6 +6,7 @@ pub mod accessibility; |
| 6 | 6 | #[cfg(test)] |
| 7 | 7 | mod profile; |
| 8 | 8 | mod scroll; |
| 9 | mod space; | |
| 9 | 10 | |
| 10 | 11 | pub use scroll::Scroll; |
| 11 | 12 | #[cfg(test)] |
| ... | ... | @@ -42,6 +43,10 @@ pub enum Cursor { |
| 42 | 43 | NsResize, |
| 43 | 44 | NwseResize, |
| 44 | 45 | NeswResize, |
| 46 | /// Insert Space's line across the page. | |
| 47 | RowResize, | |
| 48 | /// Insert Space's line down the page. | |
| 49 | ColResize, | |
| 45 | 50 | } |
| 46 | 51 | |
| 47 | 52 | /// Work only the host can do, asked for by an event. |
| ... | ... | @@ -151,6 +156,8 @@ enum Drag { |
| 151 | 156 | press: [f32; 2], |
| 152 | 157 | pending_press: Option<[f32; 2]>, |
| 153 | 158 | }, |
| 159 | /// Insert Space from a press on `line` along `axis`, 0 for x. | |
| 160 | Space { axis: usize, line: f32 }, | |
| 154 | 161 | } |
| 155 | 162 | |
| 156 | 163 | #[derive(Clone, Copy)] |
| ... | ... | @@ -205,6 +212,8 @@ pub struct PageView { |
| 205 | 212 | pointer: [f32; 2], |
| 206 | 213 | pointer_inside: bool, |
| 207 | 214 | drag: Option<Drag>, |
| 215 | /// Insert Space waits for its press: its line follows the pointer. | |
| 216 | space: bool, | |
| 208 | 217 | object_focus: Option<ObjectFocus>, |
| 209 | 218 | modifiers: Modifiers, |
| 210 | 219 | focused: bool, |
| ... | ... | @@ -269,6 +278,7 @@ impl PageView { |
| 269 | 278 | pointer: [0.0; 2], |
| 270 | 279 | pointer_inside: false, |
| 271 | 280 | drag: None, |
| 281 | space: false, | |
| 272 | 282 | object_focus: None, |
| 273 | 283 | modifiers: Modifiers::default(), |
| 274 | 284 | focused: true, |
| ... | ... | @@ -287,6 +297,7 @@ impl PageView { |
| 287 | 297 | self.editor = editor; |
| 288 | 298 | self.scene = scene; |
| 289 | 299 | self.drag = None; |
| 300 | self.space = false; | |
| 290 | 301 | self.object_focus = None; |
| 291 | 302 | self.found.clear(); |
| 292 | 303 | } |
| ... | ... | @@ -349,6 +360,32 @@ impl PageView { |
| 349 | 360 | self.object_focus |
| 350 | 361 | } |
| 351 | 362 | |
| 363 | /// Starts OneNote's Insert Space: a line follows the pointer, across the page or, within | |
| 364 | /// half an inch of the view's left or right edge, down it; dragging from it moves what | |
| 365 | /// lies past it, and Escape cancels. | |
| 366 | pub fn insert_space(&mut self) -> Response { | |
| 367 | self.space = true; | |
| 368 | self.drag = None; | |
| 369 | Response::redraw() | |
| 370 | } | |
| 371 | ||
| 372 | /// Whether Insert Space waits for or follows a drag. | |
| 373 | pub fn inserting_space(&self) -> bool { | |
| 374 | self.space || matches!(self.drag, Some(Drag::Space { .. })) | |
| 375 | } | |
| 376 | ||
| 377 | /// The axis Insert Space's line moves the page along, 0 for x, while it shows. | |
| 378 | fn space_axis(&self) -> Option<usize> { | |
| 379 | if let Some(Drag::Space { axis, .. }) = self.drag { | |
| 380 | return Some(axis); | |
| 381 | } | |
| 382 | let reach = 48.0 * self.display_scale; | |
| 383 | let x = self.pointer[0]; | |
| 384 | self.space.then_some(usize::from( | |
| 385 | x >= reach && x <= self.viewport.size[0] as f32 - reach, | |
| 386 | )) | |
| 387 | } | |
| 388 | ||
| 352 | 389 | /// Text input goes to the editor unless an object holds focus. |
| 353 | 390 | pub fn accepts_text(&self) -> bool { |
| 354 | 391 | self.object_focus.is_none() |
| ... | ... | @@ -622,6 +659,12 @@ impl PageView { |
| 622 | 659 | self.moved() |
| 623 | 660 | } |
| 624 | 661 | |
| 662 | /// Scales the view by `factor` about the pointer, as a trackpad pinch does. | |
| 663 | pub fn pinch(&mut self, factor: f32) -> Result<Response> { | |
| 664 | self.zoom_about(factor, self.pointer); | |
| 665 | self.moved() | |
| 666 | } | |
| 667 | ||
| 625 | 668 | /// Scales the view by `factor`, keeping the document point under `anchor` in place. |
| 626 | 669 | fn zoom_about(&mut self, factor: f32, anchor: [f32; 2]) { |
| 627 | 670 | let point = self.viewport.document_point(anchor); |
| ... | ... | @@ -721,7 +764,7 @@ impl PageView { |
| 721 | 764 | .map(|(id, origin, size)| PointerFeedback::Image(id, origin, size)) |
| 722 | 765 | }) |
| 723 | 766 | .or_else(|| { |
| 724 | if !self.pointer_inside { | |
| 767 | if !self.pointer_inside || self.space { | |
| 725 | 768 | return None; |
| 726 | 769 | } |
| 727 | 770 | match page_hit_test( |
| ... | ... | @@ -737,7 +780,18 @@ impl PageView { |
| 737 | 780 | } |
| 738 | 781 | }), |
| 739 | 782 | }; |
| 740 | page_primitives( | |
| 783 | let [left, top] = self.viewport.document_point([0.0; 2]); | |
| 784 | let [right, bottom] = self | |
| 785 | .viewport | |
| 786 | .document_point(self.viewport.size.map(|side| side as f32)); | |
| 787 | let mut primitives = rule_primitives( | |
| 788 | self.editor.rule_lines(), | |
| 789 | self.editor.margin_origin(), | |
| 790 | [left, top, right, bottom], | |
| 791 | self.pixel(), | |
| 792 | colors.paper, | |
| 793 | ); | |
| 794 | primitives.extend(page_primitives( | |
| 741 | 795 | &self.editor, |
| 742 | 796 | self.scene.as_ref(), |
| 743 | 797 | preview, |
| ... | ... | @@ -746,7 +800,12 @@ impl PageView { |
| 746 | 800 | caret: if self.focused |
| 747 | 801 | && !matches!( |
| 748 | 802 | self.drag, |
| 749 | Some(Drag::Outline { .. } | Drag::Resize { .. } | Drag::Image { .. }) | |
| 803 | Some( | |
| 804 | Drag::Outline { .. } | |
| 805 | | Drag::Resize { .. } | |
| 806 | | Drag::Image { .. } | |
| 807 | | Drag::Space { .. } | |
| 808 | ) | |
| 750 | 809 | ) { |
| 751 | 810 | self.caret |
| 752 | 811 | } else { |
| ... | ... | @@ -760,11 +819,36 @@ impl PageView { |
| 760 | 819 | chrome: !self.touch || self.focused, |
| 761 | 820 | found: &self.found, |
| 762 | 821 | }, |
| 763 | ) | |
| 822 | )?); | |
| 823 | if let Some(axis) = self | |
| 824 | .space_axis() | |
| 825 | .filter(|_| self.pointer_inside || self.drag.is_some()) | |
| 826 | { | |
| 827 | let point = self.viewport.document_point(self.pointer)[axis]; | |
| 828 | let (line, to, moved) = match self.drag { | |
| 829 | Some(Drag::Space { line, .. }) => { | |
| 830 | let delta = (point - line).max(self.editor.space_limit(axis, line)); | |
| 831 | (line, line + delta, self.editor.space_preview(axis, line)) | |
| 832 | } | |
| 833 | _ => (point, point, Vec::new()), | |
| 834 | }; | |
| 835 | space::append( | |
| 836 | axis, | |
| 837 | [line, to], | |
| 838 | &moved, | |
| 839 | [left, top, right, bottom], | |
| 840 | self.pixel(), | |
| 841 | &mut primitives, | |
| 842 | ); | |
| 843 | } | |
| 844 | Ok(primitives) | |
| 764 | 845 | } |
| 765 | 846 | |
| 766 | 847 | /// The pointer shape at the pointer's position. |
| 767 | 848 | pub fn cursor(&self) -> Cursor { |
| 849 | if let Some(axis) = self.space_axis() { | |
| 850 | return [Cursor::ColResize, Cursor::RowResize][axis]; | |
| 851 | } | |
| 768 | 852 | let hit = self.hit_test(self.viewport.document_point(self.pointer)); |
| 769 | 853 | match (&self.drag, hit) { |
| 770 | 854 | (Some(Drag::Image { handle, .. }), _) => handle_cursor(*handle), |
| ... | ... | @@ -816,6 +900,7 @@ impl PageView { |
| 816 | 900 | self.focused = focused; |
| 817 | 901 | if !focused { |
| 818 | 902 | self.drag = None; |
| 903 | self.space = false; | |
| 819 | 904 | } |
| 820 | 905 | self.changed() |
| 821 | 906 | } |
| ... | ... | @@ -864,6 +949,7 @@ impl PageView { |
| 864 | 949 | } |
| 865 | 950 | self.changed() |
| 866 | 951 | } |
| 952 | Some(Drag::Space { .. }) => Ok(Response::redraw()), | |
| 867 | 953 | Some(Drag::Resize { outline, grab }) => { |
| 868 | 954 | let point = self.viewport.document_point(self.pointer); |
| 869 | 955 | let width = (point[0] - self.editor.active_outline().origin()[0] - *grab).max(36.0); |
| ... | ... | @@ -886,6 +972,15 @@ impl PageView { |
| 886 | 972 | /// A primary-button press at the pointer's position, at `now` for click counting. |
| 887 | 973 | pub fn pointer_pressed(&mut self, now: Instant) -> Result<Response> { |
| 888 | 974 | let point = self.viewport.document_point(self.pointer); |
| 975 | if let Some(axis) = self.space_axis().filter(|_| self.space) { | |
| 976 | self.space = false; | |
| 977 | self.set_object_focus(None); | |
| 978 | self.drag = Some(Drag::Space { | |
| 979 | axis, | |
| 980 | line: point[axis], | |
| 981 | }); | |
| 982 | return Ok(Response::redraw()); | |
| 983 | } | |
| 889 | 984 | let unit = self |
| 890 | 985 | .clicks |
| 891 | 986 | .press(now, self.pointer, 4.0 * self.display_scale); |
| ... | ... | @@ -987,6 +1082,13 @@ impl PageView { |
| 987 | 1082 | } |
| 988 | 1083 | |
| 989 | 1084 | pub fn pointer_released(&mut self) -> Result<Response> { |
| 1085 | if let Some(Drag::Space { axis, line }) = self.drag { | |
| 1086 | self.drag = None; | |
| 1087 | let delta = self.viewport.document_point(self.pointer)[axis] - line; | |
| 1088 | self.editor | |
| 1089 | .insert_space(&mut self.engine, axis, line, delta)?; | |
| 1090 | return self.changed(); | |
| 1091 | } | |
| 990 | 1092 | // As in OneNote, an outline or picture dropped outside the view stays where it was. |
| 991 | 1093 | let inside = |
| 992 | 1094 | (0..2).all(|axis| (0.0..self.viewport.size[axis] as f32).contains(&self.pointer[axis])); |
| ... | ... | @@ -1302,6 +1404,18 @@ impl PageView { |
| 1302 | 1404 | self.changed() |
| 1303 | 1405 | } |
| 1304 | 1406 | |
| 1407 | /// Gives the page a colour (COLORREF) and rule lines, as one undo step. | |
| 1408 | pub fn set_paper( | |
| 1409 | &mut self, | |
| 1410 | color: Option<u32>, | |
| 1411 | rule_lines: Option<onestore::page::RuleLines>, | |
| 1412 | ) -> Result<Response> { | |
| 1413 | if !self.editor.set_paper(color, rule_lines) { | |
| 1414 | return Ok(Response::default()); | |
| 1415 | } | |
| 1416 | self.changed() | |
| 1417 | } | |
| 1418 | ||
| 1305 | 1419 | /// Gives keyboard focus to a read-only object, as an assistive technology asks. |
| 1306 | 1420 | pub fn focus_read_only(&mut self, index: usize) -> Result<Response> { |
| 1307 | 1421 | self.set_object_focus(Some(ObjectFocus::ReadOnly(index))); |
| ... | ... | @@ -1355,9 +1469,16 @@ impl PageView { |
| 1355 | 1469 | } |
| 1356 | 1470 | if matches!( |
| 1357 | 1471 | self.drag, |
| 1358 | Some(Drag::Outline { .. } | Drag::Resize { .. } | Drag::Image { .. }) | |
| 1359 | ) { | |
| 1472 | Some( | |
| 1473 | Drag::Outline { .. } | |
| 1474 | | Drag::Resize { .. } | |
| 1475 | | Drag::Image { .. } | |
| 1476 | | Drag::Space { .. } | |
| 1477 | ) | |
| 1478 | ) || self.space | |
| 1479 | { | |
| 1360 | 1480 | self.drag = None; |
| 1481 | self.space = false; | |
| 1361 | 1482 | if key == &Key::Named(NamedKey::Escape) { |
| 1362 | 1483 | return self.changed(); |
| 1363 | 1484 | } |
| ... | ... | @@ -1712,6 +1833,54 @@ fn page_hit( |
| 1712 | 1833 | .find_map(pass) |
| 1713 | 1834 | } |
| 1714 | 1835 | |
| 1836 | /// The page's rule lines over `visible`, a document rectangle, as OneNote 2010 draws them: | |
| 1837 | /// 1/96 inch wide, or a device pixel where that is wider, horizontal lines from the margin | |
| 1838 | /// origin down, and a grid's vertical lines through it or a margin line 1/96 inch left of it. | |
| 1839 | fn rule_primitives( | |
| 1840 | lines: Option<onestore::page::RuleLines>, | |
| 1841 | [x, y]: [f32; 2], | |
| 1842 | [left, top, right, bottom]: [f32; 4], | |
| 1843 | pixel: f32, | |
| 1844 | paper: Paper, | |
| 1845 | ) -> Vec<Primitive<'static>> { | |
| 1846 | use onestore::page::VerticalRule; | |
| 1847 | let Some(lines) = lines else { | |
| 1848 | return Vec::new(); | |
| 1849 | }; | |
| 1850 | let width = pixel.max(0.75); | |
| 1851 | // Stored spacings are in half inches; nothing draws closer than a device pixel apart. | |
| 1852 | let step = |spacing: f32| (spacing * 36.0).max(pixel); | |
| 1853 | let color = |color| paper.tint(crate::gpu::colorref(color)); | |
| 1854 | let horizontal = step(lines.spacing); | |
| 1855 | let first = ((top - y) / horizontal).ceil().max(0.0); | |
| 1856 | let mut primitives: Vec<_> = (first as u32..) | |
| 1857 | .map(|index| y + index as f32 * horizontal) | |
| 1858 | .take_while(|at| *at <= bottom + width) | |
| 1859 | .map(|at| Primitive::Rect { | |
| 1860 | rect: [left, at - width / 2.0, right, at + width / 2.0], | |
| 1861 | color: color(lines.color), | |
| 1862 | }) | |
| 1863 | .collect(); | |
| 1864 | let vertical = |at: f32, rule_color| Primitive::Rect { | |
| 1865 | rect: [at - width / 2.0, top, at + width / 2.0, bottom], | |
| 1866 | color: color(rule_color), | |
| 1867 | }; | |
| 1868 | match lines.vertical { | |
| 1869 | VerticalRule::Margin(rule_color) => primitives.push(vertical(x - 0.75, rule_color)), | |
| 1870 | VerticalRule::Grid { spacing, color } => { | |
| 1871 | let spacing = step(spacing); | |
| 1872 | let first = ((left - x) / spacing).floor(); | |
| 1873 | primitives.extend( | |
| 1874 | (0..) | |
| 1875 | .map(|index| x + (first + index as f32) * spacing) | |
| 1876 | .take_while(|at| *at <= right + width) | |
| 1877 | .map(|at| vertical(at, color)), | |
| 1878 | ); | |
| 1879 | } | |
| 1880 | } | |
| 1881 | primitives | |
| 1882 | } | |
| 1883 | ||
| 1715 | 1884 | fn page_primitives<'a>( |
| 1716 | 1885 | editor: &'a CanvasEditor, |
| 1717 | 1886 | scene: Option<&'a (PageScene, [f32; 2])>, |
crates/canvas/src/interaction/space.rs created+94| ... | ... | @@ -0,0 +1,94 @@ |
| 1 | //! Insert Space's feedback, as OneNote 2010 draws it: lines across the view where the drag | |
| 2 | //! began and where it is, an arrow between them in the middle of the view, and a tint where | |
| 3 | //! what moves lands. | |
| 4 | ||
| 5 | use crate::gpu::colorref; | |
| 6 | use draw::Primitive; | |
| 7 | ||
| 8 | /// Appends the feedback for lines at `from` and `to` along `axis` (0 for x), with `moved` the | |
| 9 | /// extents of what moves, over `view`, the page's visible rectangle; `pixel` is page units | |
| 10 | /// per pixel. | |
| 11 | pub(super) fn append( | |
| 12 | axis: usize, | |
| 13 | [from, to]: [f32; 2], | |
| 14 | moved: &[[f32; 4]], | |
| 15 | view: [f32; 4], | |
| 16 | pixel: f32, | |
| 17 | primitives: &mut Vec<Primitive<'_>>, | |
| 18 | ) { | |
| 19 | let across = 1 - axis; | |
| 20 | let rect = |along: [f32; 2], side: [f32; 2]| { | |
| 21 | let [a0, a1] = [along[0].min(along[1]), along[0].max(along[1])]; | |
| 22 | let [s0, s1] = side; | |
| 23 | if axis == 1 { | |
| 24 | [s0, a0, s1, a1] | |
| 25 | } else { | |
| 26 | [a0, s0, a1, s1] | |
| 27 | } | |
| 28 | }; | |
| 29 | let point = |along: f32, side: f32| { | |
| 30 | let mut point = [side; 2]; | |
| 31 | point[axis] = along; | |
| 32 | point | |
| 33 | }; | |
| 34 | let delta = to - from; | |
| 35 | let mut tint = colorref(0x00f7e4cc); | |
| 36 | tint[3] = 0.6; | |
| 37 | for extent in moved { | |
| 38 | let mut extent = *extent; | |
| 39 | extent[axis] += delta; | |
| 40 | extent[axis + 2] += delta; | |
| 41 | primitives.push(Primitive::Rect { | |
| 42 | rect: extent, | |
| 43 | color: tint, | |
| 44 | }); | |
| 45 | } | |
| 46 | if delta.abs() >= pixel { | |
| 47 | let (fill, edge) = (colorref(0x00fff2e5), colorref(0x00e8a460)); | |
| 48 | let middle = (view[across] + view[across + 2]) / 2.0; | |
| 49 | let [shaft, head] = [36.5 * pixel, 73.0 * pixel]; | |
| 50 | let length = (31.0 * pixel).min(delta.abs()); | |
| 51 | let base = to - length * delta.signum(); | |
| 52 | primitives.push(Primitive::Rect { | |
| 53 | rect: rect([from, base], [middle - shaft, middle + shaft]), | |
| 54 | color: fill, | |
| 55 | }); | |
| 56 | let rows = (length / pixel).ceil().max(1.0); | |
| 57 | for row in 0..rows as u32 { | |
| 58 | let row = row as f32; | |
| 59 | let half = head * (1.0 - (row + 0.5) / rows); | |
| 60 | let near = base + delta.signum() * row * pixel; | |
| 61 | let far = base + delta.signum() * ((row + 1.0) * pixel).min(length); | |
| 62 | primitives.push(Primitive::Rect { | |
| 63 | rect: rect([near, far], [middle - half, middle + half]), | |
| 64 | color: fill, | |
| 65 | }); | |
| 66 | } | |
| 67 | let segments = [ | |
| 68 | (point(from, middle - shaft), point(base, middle - shaft)), | |
| 69 | (point(from, middle + shaft), point(base, middle + shaft)), | |
| 70 | (point(base, middle - shaft), point(base, middle - head)), | |
| 71 | (point(base, middle + shaft), point(base, middle + head)), | |
| 72 | (point(base, middle - head), point(to, middle)), | |
| 73 | (point(base, middle + head), point(to, middle)), | |
| 74 | ]; | |
| 75 | for (from, to) in segments { | |
| 76 | primitives.push(Primitive::Segment { | |
| 77 | from, | |
| 78 | to, | |
| 79 | width: pixel, | |
| 80 | round: false, | |
| 81 | color: edge, | |
| 82 | }); | |
| 83 | } | |
| 84 | } | |
| 85 | for at in [from, to] { | |
| 86 | primitives.push(Primitive::Rect { | |
| 87 | rect: rect( | |
| 88 | [at - pixel / 2.0, at + pixel / 2.0], | |
| 89 | [view[across], view[across + 2]], | |
| 90 | ), | |
| 91 | color: colorref(0x00df862d), | |
| 92 | }); | |
| 93 | } | |
| 94 | } |
crates/canvas/src/interaction/tests.rs+200| ... | ... | @@ -52,6 +52,7 @@ fn date_buttons_keep_accessibility_identity_and_match_mouse_hits_after_reflow() |
| 52 | 52 | created: Some(1), |
| 53 | 53 | margin_origin: [36.0, 14.4], |
| 54 | 54 | color: None, |
| 55 | rule_lines: None, | |
| 55 | 56 | definitions: Default::default(), |
| 56 | 57 | objects: vec![onestore::page::PageObject::Title(onestore::page::Title { |
| 57 | 58 | id: onestore::ExGuid::default(), |
| ... | ... | @@ -584,6 +585,7 @@ fn extension_hits_yield_to_objects_and_keep_their_source_coordinate_frame() { |
| 584 | 585 | created: None, |
| 585 | 586 | margin_origin: [0.0; 2], |
| 586 | 587 | color: None, |
| 588 | rule_lines: None, | |
| 587 | 589 | definitions: Default::default(), |
| 588 | 590 | objects: vec![onestore::page::PageObject::Outline(source)], |
| 589 | 591 | }; |
| ... | ... | @@ -707,6 +709,7 @@ fn a_picture_in_an_outline_takes_the_click_over_its_text() { |
| 707 | 709 | created: None, |
| 708 | 710 | margin_origin: [36.0, 14.4], |
| 709 | 711 | color: None, |
| 712 | rule_lines: None, | |
| 710 | 713 | definitions: Default::default(), |
| 711 | 714 | objects: vec![PageObject::Outline(source)], |
| 712 | 715 | }, |
| ... | ... | @@ -1219,6 +1222,7 @@ fn overlapping_objects_follow_paint_order_through_creation_movement_and_undo() { |
| 1219 | 1222 | title: String::new(), |
| 1220 | 1223 | margin_origin: [0.0; 2], |
| 1221 | 1224 | color: None, |
| 1225 | rule_lines: None, | |
| 1222 | 1226 | definitions: Default::default(), |
| 1223 | 1227 | objects, |
| 1224 | 1228 | }; |
| ... | ... | @@ -1416,6 +1420,7 @@ fn read_only_focus_retires_text_overlays_and_draws_a_scaled_focus_border() { |
| 1416 | 1420 | title: String::new(), |
| 1417 | 1421 | margin_origin: [0.0; 2], |
| 1418 | 1422 | color: None, |
| 1423 | rule_lines: None, | |
| 1419 | 1424 | definitions: Default::default(), |
| 1420 | 1425 | objects: vec![PageObject::Unsupported(Unsupported { |
| 1421 | 1426 | id: Default::default(), |
| ... | ... | @@ -1531,6 +1536,7 @@ fn picture_view() -> (PageView, onestore::ExGuid) { |
| 1531 | 1536 | created: None, |
| 1532 | 1537 | margin_origin: [36.0, 14.4], |
| 1533 | 1538 | color: None, |
| 1539 | rule_lines: None, | |
| 1534 | 1540 | definitions: Default::default(), |
| 1535 | 1541 | objects: vec![PageObject::Outline(source)], |
| 1536 | 1542 | }, |
| ... | ... | @@ -1568,6 +1574,7 @@ fn a_page_outline_emptied_by_backspace_still_draws() { |
| 1568 | 1574 | created: None, |
| 1569 | 1575 | margin_origin: [36.0, 14.4], |
| 1570 | 1576 | color: None, |
| 1577 | rule_lines: None, | |
| 1571 | 1578 | definitions: Default::default(), |
| 1572 | 1579 | objects, |
| 1573 | 1580 | }; |
| ... | ... | @@ -2018,6 +2025,7 @@ fn page_keys_scroll_or_carry_the_caret_as_the_platform_does() { |
| 2018 | 2025 | created: None, |
| 2019 | 2026 | margin_origin: [36.0, 14.4], |
| 2020 | 2027 | color: None, |
| 2028 | rule_lines: None, | |
| 2021 | 2029 | definitions: Default::default(), |
| 2022 | 2030 | objects: vec![onestore::page::PageObject::Outline(outline.snapshot())], |
| 2023 | 2031 | }, |
| ... | ... | @@ -2109,6 +2117,7 @@ fn page_selection_view() -> (PageView, [onestore::ExGuid; 4]) { |
| 2109 | 2117 | created: None, |
| 2110 | 2118 | margin_origin: [36.0, 14.4], |
| 2111 | 2119 | color: None, |
| 2120 | rule_lines: None, | |
| 2112 | 2121 | definitions: Default::default(), |
| 2113 | 2122 | objects: std::iter::once(onestore::page::PageObject::Title(onestore::page::Title { |
| 2114 | 2123 | id: onestore::page::text::new_id().unwrap(), |
| ... | ... | @@ -2425,3 +2434,194 @@ fn the_page_selection_does_not_return_with_its_text_selection() { |
| 2425 | 2434 | view.editor.select(whole).unwrap(); |
| 2426 | 2435 | assert_eq!(view.editor.whole(), None); |
| 2427 | 2436 | } |
| 2437 | ||
| 2438 | #[test] | |
| 2439 | fn pinch_zooms_about_the_pointer_within_range() { | |
| 2440 | let mut engine = TextEngine::default(); | |
| 2441 | let editor = CanvasEditor::new( | |
| 2442 | &mut engine, | |
| 2443 | TextDocument::new(vec![Paragraph::new("text".into(), Format::default())]).unwrap(), | |
| 2444 | 240.0, | |
| 2445 | ) | |
| 2446 | .unwrap(); | |
| 2447 | let mut view = PageView::new( | |
| 2448 | editor, | |
| 2449 | engine, | |
| 2450 | None, | |
| 2451 | [800, 600], | |
| 2452 | 1.0, | |
| 2453 | Duration::from_millis(500), | |
| 2454 | ); | |
| 2455 | // The host's own scrolling leaves the anchor unclamped, as iOS's does. | |
| 2456 | view.host_viewport = true; | |
| 2457 | let pointer = [300.0, 200.0]; | |
| 2458 | let _ = view.pointer_moved(pointer).unwrap(); | |
| 2459 | let under = view.viewport.document_point(pointer); | |
| 2460 | let zoom = view.zoom(); | |
| 2461 | assert!(view.pinch(1.5).unwrap().moved); | |
| 2462 | assert!((view.zoom() / zoom - 1.5).abs() < 1e-5); | |
| 2463 | let after = view.viewport.document_point(pointer); | |
| 2464 | assert!((after[0] - under[0]).abs() < 1e-3 && (after[1] - under[1]).abs() < 1e-3); | |
| 2465 | let _ = view.pinch(100.0).unwrap(); | |
| 2466 | assert!((view.zoom() - 4.0).abs() < 1e-5); | |
| 2467 | } | |
| 2468 | ||
| 2469 | fn texts(view: &PageView) -> Vec<(String, [f32; 2])> { | |
| 2470 | view.editor | |
| 2471 | .outlines() | |
| 2472 | .iter() | |
| 2473 | .filter(|outline| !outline.title) | |
| 2474 | .map(|outline| (outline.shown_text(), outline.origin())) | |
| 2475 | .collect() | |
| 2476 | } | |
| 2477 | ||
| 2478 | /// Drags Insert Space from `from` to `to`, view points at 1:1. | |
| 2479 | fn insert_space(view: &mut PageView, from: [f32; 2], to: [f32; 2]) { | |
| 2480 | let _ = view.insert_space(); | |
| 2481 | let _ = view.pointer_moved(from).unwrap(); | |
| 2482 | let _ = view.pointer_pressed(Instant::now()).unwrap(); | |
| 2483 | let _ = view.pointer_moved(to).unwrap(); | |
| 2484 | let _ = view.pointer_released().unwrap(); | |
| 2485 | } | |
| 2486 | ||
| 2487 | /// As OneNote 2010's Insert Space (lab, 2026-09-29): what starts at or below the line moves | |
| 2488 | /// by the drag, an outline it crosses parts between paragraphs into a new outline below, the | |
| 2489 | /// title stays, and one undo step puts everything back. | |
| 2490 | #[test] | |
| 2491 | fn insert_space_moves_what_lies_below_and_parts_a_crossed_outline() { | |
| 2492 | let (mut view, [alpha, ..]) = page_selection_view(); | |
| 2493 | let stored = texts(&view); | |
| 2494 | let title = view.editor.outlines()[0].origin(); | |
| 2495 | let alpha_outline = view | |
| 2496 | .editor | |
| 2497 | .outlines() | |
| 2498 | .iter() | |
| 2499 | .find(|o| o.id == alpha) | |
| 2500 | .unwrap(); | |
| 2501 | let second = alpha_outline.origin()[1] + alpha_outline.shaped().paragraphs[1].origin[1]; | |
| 2502 | let _ = view.insert_space(); | |
| 2503 | let _ = view.pointer_moved([400.0, second]).unwrap(); | |
| 2504 | assert!(view.inserting_space()); | |
| 2505 | assert_eq!(view.cursor(), Cursor::RowResize); | |
| 2506 | let _ = view.pointer_pressed(Instant::now()).unwrap(); | |
| 2507 | let _ = view.pointer_moved([400.0, second + 50.0]).unwrap(); | |
| 2508 | // Nothing moves until the drag ends; its lines, arrow and landing tints show. | |
| 2509 | assert_eq!(texts(&view), stored); | |
| 2510 | assert!(view.primitives(COLORS).is_ok()); | |
| 2511 | let _ = view.pointer_released().unwrap(); | |
| 2512 | assert!(!view.inserting_space()); | |
| 2513 | let moved = texts(&view); | |
| 2514 | let expected: Vec<(String, [f32; 2])> = vec![ | |
| 2515 | ("Alpha one".into(), stored[0].1), | |
| 2516 | ("Alpha two".into(), [stored[0].1[0], second + 50.0]), | |
| 2517 | ("Beta".into(), [300.0, 230.0]), | |
| 2518 | ("Gamma long line of text".into(), [90.0, 350.0]), | |
| 2519 | ("Delta".into(), [380.0, 72.0]), | |
| 2520 | ]; | |
| 2521 | assert_eq!(moved, expected); | |
| 2522 | assert_eq!(view.editor.outlines()[0].origin(), title); | |
| 2523 | assert!(!view.editor.take_ops().unwrap().is_empty()); | |
| 2524 | let _ = view.undo(false).unwrap(); | |
| 2525 | assert_eq!(texts(&view), stored); | |
| 2526 | assert_eq!(view.editor.whole(), None); | |
| 2527 | let _ = view.undo(true).unwrap(); | |
| 2528 | assert_eq!(texts(&view), expected); | |
| 2529 | } | |
| 2530 | ||
| 2531 | /// Within half an inch of the view's side the line runs down the page and moves what starts | |
| 2532 | /// right of it; taking space back stops at the end of what stays before the line. | |
| 2533 | #[test] | |
| 2534 | fn insert_space_runs_down_the_page_near_its_side_and_takes_back_only_space() { | |
| 2535 | let (mut view, _) = page_selection_view(); | |
| 2536 | let stored = texts(&view); | |
| 2537 | let _ = view.insert_space(); | |
| 2538 | let _ = view.pointer_moved([20.0, 200.0]).unwrap(); | |
| 2539 | assert_eq!(view.cursor(), Cursor::ColResize); | |
| 2540 | let _ = view.pointer_moved([790.0, 200.0]).unwrap(); | |
| 2541 | assert_eq!(view.cursor(), Cursor::ColResize); | |
| 2542 | insert_space(&mut view, [20.0, 200.0], [60.0, 200.0]); | |
| 2543 | let right: Vec<_> = stored | |
| 2544 | .iter() | |
| 2545 | .map(|(text, [x, y])| (text.clone(), [x + 40.0, *y])) | |
| 2546 | .collect(); | |
| 2547 | assert_eq!(texts(&view), right); | |
| 2548 | let _ = view.undo(false).unwrap(); | |
| 2549 | // Undo scrolls to what it restores; the drags below are in page points. | |
| 2550 | view.viewport.origin = [0.0; 2]; | |
| 2551 | ||
| 2552 | // Beta ends at its line's bottom; Gamma, below the line at 250, comes up to it and no | |
| 2553 | // further. | |
| 2554 | let beta = view.editor.outlines()[2].bounds().y1 as f32; | |
| 2555 | insert_space(&mut view, [400.0, 250.0], [400.0, 20.0]); | |
| 2556 | let gamma = texts(&view)[2].1[1]; | |
| 2557 | assert!((gamma - (300.0 - (250.0 - beta))).abs() < 1e-3, "{gamma}"); | |
| 2558 | // A line with something stationary across it cannot take space back. | |
| 2559 | let _ = view.undo(false).unwrap(); | |
| 2560 | view.viewport.origin = [0.0; 2]; | |
| 2561 | insert_space(&mut view, [400.0, 185.0], [400.0, 20.0]); | |
| 2562 | assert_eq!(texts(&view), stored); | |
| 2563 | } | |
| 2564 | ||
| 2565 | #[test] | |
| 2566 | fn escape_cancels_insert_space() { | |
| 2567 | let (mut view, _) = page_selection_view(); | |
| 2568 | let stored = texts(&view); | |
| 2569 | let _ = view.editor.take_ops().unwrap(); | |
| 2570 | let _ = view.insert_space(); | |
| 2571 | let _ = view.pointer_moved([400.0, 150.0]).unwrap(); | |
| 2572 | let _ = view.key(&Key::Named(NamedKey::Escape), None).unwrap(); | |
| 2573 | assert!(!view.inserting_space()); | |
| 2574 | let _ = view.insert_space(); | |
| 2575 | let _ = view.pointer_pressed(Instant::now()).unwrap(); | |
| 2576 | let _ = view.pointer_moved([400.0, 250.0]).unwrap(); | |
| 2577 | let _ = view.key(&Key::Named(NamedKey::Escape), None).unwrap(); | |
| 2578 | let _ = view.pointer_released().unwrap(); | |
| 2579 | assert!(!view.inserting_space()); | |
| 2580 | assert_eq!(texts(&view), stored); | |
| 2581 | assert!(view.editor.take_ops().unwrap().is_empty()); | |
| 2582 | } | |
| 2583 | ||
| 2584 | /// Rule lines draw as OneNote 2010 draws them: horizontal lines from the margin origin | |
| 2585 | /// down, a margin line 1/96 inch left of it, and grid lines through it either way. | |
| 2586 | #[test] | |
| 2587 | fn rule_lines_start_at_the_margin_origin() { | |
| 2588 | // Each line's axis and the centre across it, to a hundredth of a point. | |
| 2589 | let lines = |lines| { | |
| 2590 | super::rule_primitives( | |
| 2591 | Some(lines), | |
| 2592 | [36.0, 14.4], | |
| 2593 | [0.0, 0.0, 100.0, 100.0], | |
| 2594 | 0.5, | |
| 2595 | Paper::WHITE, | |
| 2596 | ) | |
| 2597 | .into_iter() | |
| 2598 | .map(|primitive| match primitive { | |
| 2599 | Primitive::Rect { | |
| 2600 | rect: [x0, y0, x1, y1], | |
| 2601 | .. | |
| 2602 | } => { | |
| 2603 | let vertical = y1 - y0 > x1 - x0; | |
| 2604 | let center = if vertical { x0 + x1 } else { y0 + y1 } / 2.0; | |
| 2605 | (vertical, (center * 100.0).round() / 100.0) | |
| 2606 | } | |
| 2607 | _ => panic!("rule lines are rectangles"), | |
| 2608 | }) | |
| 2609 | .collect::<Vec<_>>() | |
| 2610 | }; | |
| 2611 | let [(_, standard), (_, small)] = [ | |
| 2612 | crate::template::RULE_LINES[2], | |
| 2613 | crate::template::RULE_LINES[4], | |
| 2614 | ]; | |
| 2615 | assert_eq!( | |
| 2616 | lines(standard), | |
| 2617 | [(false, 14.4), (false, 47.52), (false, 80.64), (true, 35.25)] | |
| 2618 | ); | |
| 2619 | let vertical: Vec<f32> = lines(small) | |
| 2620 | .into_iter() | |
| 2621 | .filter_map(|(vertical, at)| vertical.then_some(at)) | |
| 2622 | .collect(); | |
| 2623 | assert_eq!( | |
| 2624 | vertical, | |
| 2625 | [0.0, 12.0, 24.0, 36.0, 48.0, 60.0, 72.0, 84.0, 96.0] | |
| 2626 | ); | |
| 2627 | } |
crates/canvas/src/outline.rs+62-2| ... | ... | @@ -213,6 +213,8 @@ pub struct ParagraphLayout { |
| 213 | 213 | pub enum TagIcon { |
| 214 | 214 | CheckBox { |
| 215 | 215 | checked: bool, |
| 216 | /// The badge on the small box of OneNote's check box variants. | |
| 217 | mark: Option<BoxMark>, | |
| 216 | 218 | }, |
| 217 | 219 | Star, |
| 218 | 220 | Question, |
| ... | ... | @@ -227,10 +229,28 @@ pub enum TagIcon { |
| 227 | 229 | BlueSquare, |
| 228 | 230 | /// An Outlook task flag. |
| 229 | 231 | Flag, |
| 232 | Idea, | |
| 233 | Password, | |
| 234 | Movie, | |
| 235 | Book, | |
| 236 | Web, | |
| 237 | Blog, | |
| 238 | Email, | |
| 230 | 239 | /// A tag symbol without artwork of its own. |
| 231 | 240 | Other, |
| 232 | 241 | } |
| 233 | 242 | |
| 243 | /// What OneNote draws on a check box variant's small box. | |
| 244 | #[derive(Clone, Copy, Debug, Hash, PartialEq, Eq)] | |
| 245 | pub enum BoxMark { | |
| 246 | Person, | |
| 247 | Manager, | |
| 248 | Arrow, | |
| 249 | One, | |
| 250 | Two, | |
| 251 | Client, | |
| 252 | } | |
| 253 | ||
| 234 | 254 | impl TagIcon { |
| 235 | 255 | /// How the page draws tag symbol `shape`, `checked` where it is a check box; none for |
| 236 | 256 | /// shape 0, which marks the text instead. |
| ... | ... | @@ -238,7 +258,21 @@ impl TagIcon { |
| 238 | 258 | Some(match shape { |
| 239 | 259 | 0 => return None, |
| 240 | 260 | // OneNote's check boxes, plain and with a person, arrow, number or bulb. |
| 241 | 3 | 8 | 12 | 28 | 71 | 94 | 95 => Self::CheckBox { checked }, | |
| 261 | 3 => Self::CheckBox { | |
| 262 | checked, | |
| 263 | mark: None, | |
| 264 | }, | |
| 265 | 8 | 12 | 28 | 71 | 94 | 95 => Self::CheckBox { | |
| 266 | checked, | |
| 267 | mark: Some(match shape { | |
| 268 | 8 => BoxMark::Client, | |
| 269 | 12 => BoxMark::Arrow, | |
| 270 | 28 => BoxMark::One, | |
| 271 | 71 => BoxMark::Two, | |
| 272 | 94 => BoxMark::Person, | |
| 273 | _ => BoxMark::Manager, | |
| 274 | }), | |
| 275 | }, | |
| 242 | 276 | 13 => Self::Star, |
| 243 | 277 | 15 => Self::Question, |
| 244 | 278 | 17 => Self::Exclamation, |
| ... | ... | @@ -250,6 +284,13 @@ impl TagIcon { |
| 250 | 284 | 101 => Self::YellowSquare, |
| 251 | 285 | 102 => Self::BlueSquare, |
| 252 | 286 | 121 => Self::Music, |
| 287 | 21 => Self::Idea, | |
| 288 | 131 => Self::Password, | |
| 289 | 122 => Self::Movie, | |
| 290 | 132 => Self::Book, | |
| 291 | 125 => Self::Web, | |
| 292 | 24 => Self::Blog, | |
| 293 | 106 => Self::Email, | |
| 253 | 294 | _ => Self::Other, |
| 254 | 295 | }) |
| 255 | 296 | } |
| ... | ... | @@ -1214,6 +1255,19 @@ impl OutlineLayout { |
| 1214 | 1255 | .flatten() |
| 1215 | 1256 | } |
| 1216 | 1257 | |
| 1258 | /// Where each root node's flow starts and ends in the outline, or `None` while a collapsed | |
| 1259 | /// parent hides it. | |
| 1260 | pub(crate) fn spans(&self) -> impl Iterator<Item = Option<[f32; 2]>> + '_ { | |
| 1261 | let mut state = (0.0, None); | |
| 1262 | self.blocks.iter().map(move |block| { | |
| 1263 | let span = block | |
| 1264 | .metrics | |
| 1265 | .map(|[before, after, _]| [top(&mut state, [before, after]), block.state.0 as f32]); | |
| 1266 | state = block.state; | |
| 1267 | span | |
| 1268 | }) | |
| 1269 | } | |
| 1270 | ||
| 1217 | 1271 | /// The paragraphs, tables and objects of root nodes `range`. |
| 1218 | 1272 | pub(crate) fn pieces(&self, range: Range<usize>) -> [Range<usize>; 3] { |
| 1219 | 1273 | let first = |root: usize| { |
| ... | ... | @@ -2039,7 +2093,13 @@ mod tests { |
| 2039 | 2093 | extra_set: 0, |
| 2040 | 2094 | }); |
| 2041 | 2095 | for (shape, expected) in [ |
| 2042 | (3, TagIcon::CheckBox { checked: true }), | |
| 2096 | ( | |
| 2097 | 3, | |
| 2098 | TagIcon::CheckBox { | |
| 2099 | checked: true, | |
| 2100 | mark: None, | |
| 2101 | }, | |
| 2102 | ), | |
| 2043 | 2103 | (13, TagIcon::Star), |
| 2044 | 2104 | (15, TagIcon::Question), |
| 2045 | 2105 | (17, TagIcon::Exclamation), |
crates/canvas/src/search.rs+116-13| ... | ... | @@ -6,6 +6,7 @@ use crate::document::TextPosition; |
| 6 | 6 | use crate::editor::{CanvasEditor, Selection}; |
| 7 | 7 | use icu_normalizer::DecomposingNormalizerBorrowed; |
| 8 | 8 | use onestore::ExGuid; |
| 9 | use onestore::document::Kind; | |
| 9 | 10 | use onestore::page::{Page, PageObject, PageParagraph, ParagraphContent, text::Paragraph}; |
| 10 | 11 | use std::ops::Range; |
| 11 | 12 | |
| ... | ... | @@ -176,32 +177,96 @@ pub fn shown(paragraph: &Paragraph) -> String { |
| 176 | 177 | .collect() |
| 177 | 178 | } |
| 178 | 179 | |
| 179 | /// A page's text outside its title, a paragraph to a line: outlines in page order, tables | |
| 180 | /// Calls `visit` with each text paragraph of the page's outlines, in page order, tables | |
| 180 | 181 | /// cell by cell. |
| 181 | pub fn page_text(page: &Page) -> String { | |
| 182 | fn walk(paragraphs: &[PageParagraph], out: &mut Vec<String>) { | |
| 182 | fn text_paragraphs<'a>(page: &'a Page, mut visit: impl FnMut(&'a PageParagraph, &'a Paragraph)) { | |
| 183 | fn walk<'a>( | |
| 184 | paragraphs: &'a [PageParagraph], | |
| 185 | visit: &mut impl FnMut(&'a PageParagraph, &'a Paragraph), | |
| 186 | ) { | |
| 183 | 187 | for paragraph in paragraphs { |
| 184 | 188 | match &paragraph.content { |
| 185 | ParagraphContent::Text(text) => out.push(shown(&text.text)), | |
| 189 | ParagraphContent::Text(text) => visit(paragraph, &text.text), | |
| 186 | 190 | ParagraphContent::Table(table) => { |
| 187 | 191 | for cell in table.rows.iter().flat_map(|row| &row.cells) { |
| 188 | walk(&cell.paragraphs, out); | |
| 192 | walk(&cell.paragraphs, visit); | |
| 189 | 193 | } |
| 190 | 194 | } |
| 191 | 195 | _ => {} |
| 192 | 196 | } |
| 193 | 197 | } |
| 194 | 198 | } |
| 195 | let mut out = Vec::new(); | |
| 196 | 199 | for object in &page.objects { |
| 197 | 200 | if let PageObject::Outline(outline) = object { |
| 198 | walk(&outline.paragraphs, &mut out); | |
| 201 | walk(&outline.paragraphs, &mut visit); | |
| 199 | 202 | } |
| 200 | 203 | } |
| 204 | } | |
| 205 | ||
| 206 | /// A page's text outside its title, a paragraph to a line. | |
| 207 | pub fn page_text(page: &Page) -> String { | |
| 208 | let mut out = Vec::new(); | |
| 209 | text_paragraphs(page, |_, text| out.push(shown(text))); | |
| 201 | 210 | out.retain(|line| !line.trim().is_empty()); |
| 202 | 211 | out.join("\n") |
| 203 | 212 | } |
| 204 | 213 | |
| 214 | /// A tagged paragraph, as OneNote's Tags Summary lists it: once for each of its tags. | |
| 215 | #[derive(Clone, Debug, PartialEq)] | |
| 216 | pub struct Tagged { | |
| 217 | pub section: String, | |
| 218 | pub space: ExGuid, | |
| 219 | /// The page's title. | |
| 220 | pub title: String, | |
| 221 | pub paragraph: ExGuid, | |
| 222 | /// The tag's name, as its definition stores it. | |
| 223 | pub name: String, | |
| 224 | /// The tag's symbol, as its definition stores it. | |
| 225 | pub shape: u16, | |
| 226 | /// A check box tag is checked. | |
| 227 | pub checked: bool, | |
| 228 | pub text: String, | |
| 229 | /// When the tag was applied, in Time32, or the page's modification time where unknown. | |
| 230 | pub created: u64, | |
| 231 | } | |
| 232 | ||
| 233 | /// The page's tagged paragraphs, oldest tag first on each, as OneNote lists them; a tag | |
| 234 | /// without a definition, as an Outlook task's, is left out. | |
| 235 | fn tagged(section: &str, space: ExGuid, page: &Page, modified: u64) -> Vec<Tagged> { | |
| 236 | let mut out = Vec::new(); | |
| 237 | text_paragraphs(page, |paragraph, text| { | |
| 238 | let ParagraphContent::Text(object) = &paragraph.content else { | |
| 239 | return; | |
| 240 | }; | |
| 241 | // Stored newest first. | |
| 242 | for tag in paragraph.tags.iter().chain(&object.tags).rev() { | |
| 243 | let Some(Kind::TagDefinition { | |
| 244 | label: Some(name), | |
| 245 | shape, | |
| 246 | .. | |
| 247 | }) = tag | |
| 248 | .definition | |
| 249 | .and_then(|id| page.definitions.get(&id)) | |
| 250 | .map(|definition| &definition.kind) | |
| 251 | else { | |
| 252 | continue; | |
| 253 | }; | |
| 254 | out.push(Tagged { | |
| 255 | section: section.to_owned(), | |
| 256 | space, | |
| 257 | title: page.title.clone(), | |
| 258 | paragraph: paragraph.id, | |
| 259 | name: name.to_string(), | |
| 260 | shape: shape.unwrap_or(0), | |
| 261 | checked: tag.status & 1 != 0, | |
| 262 | text: shown(text), | |
| 263 | created: tag.created.map_or(modified, u64::from), | |
| 264 | }); | |
| 265 | } | |
| 266 | }); | |
| 267 | out | |
| 268 | } | |
| 269 | ||
| 205 | 270 | /// A page as search knows it. |
| 206 | 271 | pub struct Entry { |
| 207 | 272 | /// The section's key, which the host chooses. |
| ... | ... | @@ -213,6 +278,7 @@ pub struct Entry { |
| 213 | 278 | text: String, |
| 214 | 279 | folded_title: String, |
| 215 | 280 | folded_text: String, |
| 281 | tagged: Vec<Tagged>, | |
| 216 | 282 | } |
| 217 | 283 | |
| 218 | 284 | impl Entry { |
| ... | ... | @@ -227,6 +293,7 @@ impl Entry { |
| 227 | 293 | title: page.title.clone(), |
| 228 | 294 | modified, |
| 229 | 295 | text, |
| 296 | tagged: tagged(section, space, page, modified), | |
| 230 | 297 | } |
| 231 | 298 | } |
| 232 | 299 | } |
| ... | ... | @@ -237,6 +304,9 @@ pub struct Found { |
| 237 | 304 | pub section: String, |
| 238 | 305 | pub space: ExGuid, |
| 239 | 306 | pub title: String, |
| 307 | pub modified: u64, | |
| 308 | /// The page's place in the index, which keeps each section's pages in order. | |
| 309 | pub order: usize, | |
| 240 | 310 | /// Every word is in the title, as OneNote's "Title contains" lists it. |
| 241 | 311 | pub in_title: bool, |
| 242 | 312 | /// Byte ranges of the title the words match. |
| ... | ... | @@ -261,6 +331,11 @@ impl Index { |
| 261 | 331 | self.entries.is_empty() |
| 262 | 332 | } |
| 263 | 333 | |
| 334 | /// Every page, each section's in order. | |
| 335 | pub fn entries(&self) -> &[Entry] { | |
| 336 | &self.entries | |
| 337 | } | |
| 338 | ||
| 264 | 339 | /// Adds `entry`, replacing the page it names. |
| 265 | 340 | pub fn set(&mut self, entry: Entry) { |
| 266 | 341 | match self |
| ... | ... | @@ -291,29 +366,32 @@ impl Index { |
| 291 | 366 | if query.is_empty() { |
| 292 | 367 | return Vec::new(); |
| 293 | 368 | } |
| 294 | let mut found: Vec<(&Entry, bool)> = self | |
| 369 | let mut found: Vec<(usize, &Entry, bool)> = self | |
| 295 | 370 | .entries |
| 296 | 371 | .iter() |
| 297 | .filter(|entry| scope(&entry.section)) | |
| 298 | .filter_map(|entry| { | |
| 372 | .enumerate() | |
| 373 | .filter(|(_, entry)| scope(&entry.section)) | |
| 374 | .filter_map(|(order, entry)| { | |
| 299 | 375 | let in_title = query.all_in(&entry.folded_title); |
| 300 | 376 | let matches = in_title |
| 301 | 377 | || query.terms.iter().all(|term| { |
| 302 | 378 | starts_word(term, &entry.folded_title) |
| 303 | 379 | || starts_word(term, &entry.folded_text) |
| 304 | 380 | }); |
| 305 | matches.then_some((entry, in_title)) | |
| 381 | matches.then_some((order, entry, in_title)) | |
| 306 | 382 | }) |
| 307 | 383 | .collect(); |
| 308 | found.sort_by_key(|(entry, in_title)| (!in_title, std::cmp::Reverse(entry.modified))); | |
| 384 | found.sort_by_key(|(_, entry, in_title)| (!in_title, std::cmp::Reverse(entry.modified))); | |
| 309 | 385 | found |
| 310 | 386 | .into_iter() |
| 311 | .map(|(entry, in_title)| { | |
| 387 | .map(|(order, entry, in_title)| { | |
| 312 | 388 | let (snippet, snippet_hits) = snippet(entry, query); |
| 313 | 389 | Found { |
| 314 | 390 | section: entry.section.clone(), |
| 315 | 391 | space: entry.space, |
| 316 | 392 | title: entry.title.clone(), |
| 393 | modified: entry.modified, | |
| 394 | order, | |
| 317 | 395 | in_title, |
| 318 | 396 | title_hits: query.find(&entry.title), |
| 319 | 397 | snippet, |
| ... | ... | @@ -322,6 +400,16 @@ impl Index { |
| 322 | 400 | }) |
| 323 | 401 | .collect() |
| 324 | 402 | } |
| 403 | ||
| 404 | /// The tagged paragraphs of the pages whose section and page `scope` accepts, in page | |
| 405 | /// order. | |
| 406 | pub fn tagged(&self, scope: impl Fn(&Entry) -> bool) -> Vec<Tagged> { | |
| 407 | self.entries | |
| 408 | .iter() | |
| 409 | .filter(|entry| scope(entry)) | |
| 410 | .flat_map(|entry| entry.tagged.iter().cloned()) | |
| 411 | .collect() | |
| 412 | } | |
| 325 | 413 | } |
| 326 | 414 | |
| 327 | 415 | /// Part of the line holding the first match in the entry's text, the matches within it; |
| ... | ... | @@ -428,3 +516,18 @@ pub fn page_matches(editor: &CanvasEditor, query: &Query) -> Vec<PageMatch> { |
| 428 | 516 | } |
| 429 | 517 | matches |
| 430 | 518 | } |
| 519 | ||
| 520 | /// The whole of paragraph `id` on the page `editor` shows, as a tag summary selects it. | |
| 521 | pub fn paragraph_match(editor: &CanvasEditor, id: ExGuid) -> Option<PageMatch> { | |
| 522 | editor.outlines().iter().find_map(|outline| { | |
| 523 | let document = outline.document(); | |
| 524 | let index = document.text_nodes().position(|node| node.id == id)?; | |
| 525 | let paragraph = document.paragraph(index)?; | |
| 526 | let end = paragraph.utf16_offset(paragraph.text().len()).ok()?; | |
| 527 | let at = |offset| TextPosition { | |
| 528 | paragraph: index, | |
| 529 | offset, | |
| 530 | }; | |
| 531 | Some((outline.id, [at(0), at(end)].into())) | |
| 532 | }) | |
| 533 | } |
crates/canvas/src/template.rs+40-2| ... | ... | @@ -1,6 +1,8 @@ |
| 1 | 1 | //! The page templates Snowbound offers: OneNote 2010's background art, recreated |
| 2 | //! (`gpu::page::template_picture`), placed where OneNote's templates place it, and plain | |
| 3 | //! page colours. | |
| 2 | //! (`gpu::page::template_picture`), placed where OneNote's templates place it, plain | |
| 3 | //! page colours and rule lines. | |
| 4 | ||
| 5 | use onestore::page::{RuleLines, VerticalRule}; | |
| 4 | 6 | |
| 5 | 7 | /// One background picture of a template: the recreated art's name, and the picture's |
| 6 | 8 | /// position and size in points from the page origin as OneNote's template stores them. |
| ... | ... | @@ -623,6 +625,42 @@ pub const PAGE_COLORS: &[(&str, u32)] = &[ |
| 623 | 625 | ("Teal", 0xf2f9d4), |
| 624 | 626 | ]; |
| 625 | 627 | |
| 628 | /// OneNote 2010's View, Rule Lines presets in its menu order, in its default light blue | |
| 629 | /// with a red margin line | |
| 630 | /// (`corpus/rule-lines/native`). | |
| 631 | pub const RULE_LINES: &[(&str, RuleLines)] = &[ | |
| 632 | ("Narrow Ruled", ruled(0x3ebe_f8d2)), | |
| 633 | ("College Ruled", ruled(0x3f28_f5c3)), | |
| 634 | ("Standard Ruled", ruled(0x3f6b_851f)), | |
| 635 | ("Wide Ruled", ruled(0x3fa6_6666)), | |
| 636 | ("Small Grid", grid(0x3eaa_aaab)), | |
| 637 | ("Medium Grid", grid(0x3f49_930c)), | |
| 638 | ("Large Grid", grid(0x3f97_2e49)), | |
| 639 | ("Very Large Grid", grid(0x3fc9_930c)), | |
| 640 | ]; | |
| 641 | ||
| 642 | const RULE_COLOR: u32 = 0xfdebca; | |
| 643 | ||
| 644 | /// Lines `spacing` apart, the bits of the half inches OneNote stores. | |
| 645 | const fn ruled(spacing: u32) -> RuleLines { | |
| 646 | RuleLines { | |
| 647 | spacing: f32::from_bits(spacing), | |
| 648 | color: RULE_COLOR, | |
| 649 | vertical: VerticalRule::Margin(0x5050ff), | |
| 650 | } | |
| 651 | } | |
| 652 | ||
| 653 | const fn grid(spacing: u32) -> RuleLines { | |
| 654 | RuleLines { | |
| 655 | spacing: f32::from_bits(spacing), | |
| 656 | color: RULE_COLOR, | |
| 657 | vertical: VerticalRule::Grid { | |
| 658 | spacing: f32::from_bits(spacing), | |
| 659 | color: RULE_COLOR, | |
| 660 | }, | |
| 661 | } | |
| 662 | } | |
| 663 | ||
| 626 | 664 | /// The template named `name`. |
| 627 | 665 | pub fn find(name: &str) -> Option<&'static Template> { |
| 628 | 666 | TEMPLATES.iter().find(|template| template.name == name) |
crates/canvas/tests/ops_differential.rs+33-1| ... | ... | @@ -95,11 +95,14 @@ enum Kind { |
| 95 | 95 | PageType, |
| 96 | 96 | PageMove, |
| 97 | 97 | PageBold, |
| 98 | InsertSpace, | |
| 99 | RemoveSpace, | |
| 100 | InsertSpaceRight, | |
| 98 | 101 | Undo, |
| 99 | 102 | Redo, |
| 100 | 103 | } |
| 101 | 104 | |
| 102 | const KINDS: [Kind; 52] = [ | |
| 105 | const KINDS: [Kind; 55] = [ | |
| 103 | 106 | Kind::Type, |
| 104 | 107 | Kind::TypeEnd, |
| 105 | 108 | Kind::Compose, |
| ... | ... | @@ -150,6 +153,9 @@ const KINDS: [Kind; 52] = [ |
| 150 | 153 | Kind::PageType, |
| 151 | 154 | Kind::PageMove, |
| 152 | 155 | Kind::PageBold, |
| 156 | Kind::InsertSpace, | |
| 157 | Kind::RemoveSpace, | |
| 158 | Kind::InsertSpaceRight, | |
| 153 | 159 | Kind::Undo, |
| 154 | 160 | Kind::Redo, |
| 155 | 161 | ]; |
| ... | ... | @@ -438,6 +444,24 @@ fn perform( |
| 438 | 444 | .format(engine, Formatting::Toggle(Toggle::Bold)) |
| 439 | 445 | .is_ok() |
| 440 | 446 | } |
| 447 | Kind::InsertSpace | Kind::RemoveSpace => { | |
| 448 | // A line at a paragraph's top parts the outline there, or moves all of it. | |
| 449 | let outline = editor.active_outline(); | |
| 450 | let paragraphs = &outline.shaped().paragraphs; | |
| 451 | let line = outline.origin()[1] | |
| 452 | + paragraphs[random.below(paragraphs.len())].origin[1] | |
| 453 | + [0.0, 0.5][random.below(2)]; | |
| 454 | let delta = if kind == Kind::InsertSpace { | |
| 455 | 36.0 | |
| 456 | } else { | |
| 457 | -200.0 | |
| 458 | }; | |
| 459 | editor.insert_space(engine, 1, line, delta).unwrap_or(false) | |
| 460 | } | |
| 461 | Kind::InsertSpaceRight => { | |
| 462 | let line = editor.active_outline().origin()[0] - random.below(40) as f32; | |
| 463 | editor.insert_space(engine, 0, line, 45.0).unwrap_or(false) | |
| 464 | } | |
| 441 | 465 | Kind::Undo => editor.undo(engine).unwrap_or(false), |
| 442 | 466 | Kind::Redo => editor.redo(engine).unwrap_or(false), |
| 443 | 467 | } |
| ... | ... | @@ -812,4 +836,12 @@ fn editor_ops_store_the_editor_s_page() { |
| 812 | 836 | println!("{report}"); |
| 813 | 837 | let differs = totals[4]; |
| 814 | 838 | assert_eq!(differs, 0, "{report}"); |
| 839 | // Undoing stores what the edit it undoes stored, so storage never refuses it. | |
| 840 | for kind in [Kind::Undo, Kind::Redo] { | |
| 841 | assert_eq!( | |
| 842 | per_kind.get(&kind).map_or(0, |counts| counts[1]), | |
| 843 | 0, | |
| 844 | "{report}" | |
| 845 | ); | |
| 846 | } | |
| 815 | 847 | } |
crates/canvas/tests/search.rs+45-1| ... | ... | @@ -5,7 +5,7 @@ use canvas::{ |
| 5 | 5 | document::TextDocument, |
| 6 | 6 | editor::CanvasEditor, |
| 7 | 7 | layout::TextEngine, |
| 8 | search::{Entry, Index, Query, fold, page_matches}, | |
| 8 | search::{Entry, Index, Query, fold, page_matches, paragraph_match}, | |
| 9 | 9 | }; |
| 10 | 10 | use onestore::{ |
| 11 | 11 | ExGuid, RevisionIndex, Store, |
| ... | ... | @@ -214,3 +214,47 @@ fn words_match_word_starts_and_pages_rank_by_title_then_recency() { |
| 214 | 214 | assert_eq!(order(&index, "cafe"), ["Café list"]); |
| 215 | 215 | assert_eq!(index.len(), 2); |
| 216 | 216 | } |
| 217 | ||
| 218 | /// OneNote's tag gallery (`corpus/structural-probe`): a paragraph under each default tag, | |
| 219 | /// listed once for each tag with its stored name, and selected whole from the summary. | |
| 220 | #[test] | |
| 221 | fn tags_summary_lists_each_tagged_paragraph() { | |
| 222 | let file = | |
| 223 | Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus/structural-probe/tag-gallery.one"); | |
| 224 | let bytes = std::fs::read(file).unwrap(); | |
| 225 | let store = Store::parse(&bytes).unwrap(); | |
| 226 | let index = RevisionIndex::parse(&store).unwrap(); | |
| 227 | let document = Document::parse(&index).unwrap(); | |
| 228 | let (space, _) = document.pages().unwrap()[0]; | |
| 229 | let page = Page::from_space(&document, space).unwrap(); | |
| 230 | let mut index = Index::default(); | |
| 231 | index.set(Entry::new("gallery", space, &page, 7)); | |
| 232 | let tagged = index.tagged(|_| true); | |
| 233 | let mut names: Vec<&str> = tagged.iter().map(|tagged| tagged.name.as_str()).collect(); | |
| 234 | names.sort_unstable(); | |
| 235 | // The probe's T24 and T25 also carry Call back, one entry under each tag. | |
| 236 | assert_eq!(tagged.len(), names.len()); | |
| 237 | names.dedup(); | |
| 238 | let mut labels = canvas::editor::NoteTag::ALL.map(|tag| tag.label()); | |
| 239 | labels.sort_unstable(); | |
| 240 | assert_eq!(names, labels); | |
| 241 | assert!(tagged.iter().all(|tagged| tagged.space == space)); | |
| 242 | assert!(index.tagged(|entry| entry.section != "gallery").is_empty()); | |
| 243 | let mut engine = TextEngine::default(); | |
| 244 | let editor = CanvasEditor::from_page(page, &mut engine).unwrap(); | |
| 245 | let (outline, selection) = paragraph_match(&editor, tagged[0].paragraph).unwrap(); | |
| 246 | let outline = editor | |
| 247 | .outlines() | |
| 248 | .iter() | |
| 249 | .find(|candidate| candidate.id == outline) | |
| 250 | .unwrap(); | |
| 251 | let [start, end] = selection.positions; | |
| 252 | let text = outline | |
| 253 | .document() | |
| 254 | .paragraphs() | |
| 255 | .nth(start.paragraph) | |
| 256 | .unwrap(); | |
| 257 | assert_eq!(start.offset, 0); | |
| 258 | assert_eq!(end.offset as usize, text.text().encode_utf16().count()); | |
| 259 | assert_eq!(canvas::search::shown(text), tagged[0].text); | |
| 260 | } |
crates/draw/src/draw.glsl+11| ... | ... | @@ -8,12 +8,16 @@ attribute vec4 color; |
| 8 | 8 | attribute vec2 local; |
| 9 | 9 | attribute vec4 shape; |
| 10 | 10 | attribute float stroke; |
| 11 | attribute vec2 clip_local; | |
| 12 | attribute vec3 clip; | |
| 11 | 13 | #endif |
| 12 | 14 | varying vec2 v_uv; |
| 13 | 15 | varying vec4 v_color; |
| 14 | 16 | varying vec2 v_local; |
| 15 | 17 | varying vec4 v_shape; |
| 16 | 18 | varying float v_stroke; |
| 19 | varying vec2 v_clip_local; | |
| 20 | varying vec3 v_clip; | |
| 17 | 21 | |
| 18 | 22 | #ifdef VERTEX |
| 19 | 23 | void main() { |
| ... | ... | @@ -22,6 +26,8 @@ void main() { |
| 22 | 26 | v_local = local; |
| 23 | 27 | v_shape = shape; |
| 24 | 28 | v_stroke = stroke; |
| 29 | v_clip_local = clip_local; | |
| 30 | v_clip = clip; | |
| 25 | 31 | gl_Position = vec4(position, 0.0, 1.0); |
| 26 | 32 | } |
| 27 | 33 | #else |
| ... | ... | @@ -97,6 +103,11 @@ void main() { |
| 97 | 103 | } |
| 98 | 104 | color *= coverage; |
| 99 | 105 | } |
| 106 | if (v_clip.x > 0.0) { | |
| 107 | vec2 q = abs(v_clip_local) - v_clip.xy + v_clip.z; | |
| 108 | float distance = length(max(q, vec2(0.0))) + min(max(q.x, q.y), 0.0) - v_clip.z; | |
| 109 | color *= clamp(0.5 - distance, 0.0, 1.0); | |
| 110 | } | |
| 100 | 111 | gl_FragColor = color; |
| 101 | 112 | } |
| 102 | 113 | #endif |
crates/draw/src/draw.wgsl+10-2| ... | ... | @@ -5,6 +5,8 @@ struct Vertex { |
| 5 | 5 | @location(2) local: vec2<f32>, |
| 6 | 6 | @location(3) @interpolate(flat) shape: vec4<f32>, |
| 7 | 7 | @location(4) @interpolate(flat) stroke: f32, |
| 8 | @location(5) clip_local: vec2<f32>, | |
| 9 | @location(6) @interpolate(flat) clip: vec3<f32>, | |
| 8 | 10 | } |
| 9 | 11 | |
| 10 | 12 | @group(0) @binding(0) var atlas: texture_2d<f32>; |
| ... | ... | @@ -13,8 +15,9 @@ struct Vertex { |
| 13 | 15 | @vertex |
| 14 | 16 | fn vertex(@location(0) position: vec2<f32>, @location(1) uv: vec2<f32>, |
| 15 | 17 | @location(2) color: vec4<f32>, @location(3) local: vec2<f32>, |
| 16 | @location(4) shape: vec4<f32>, @location(5) stroke: f32) -> Vertex { | |
| 17 | return Vertex(vec4<f32>(position, 0.0, 1.0), uv, color, local, shape, stroke); | |
| 18 | @location(4) shape: vec4<f32>, @location(5) stroke: f32, | |
| 19 | @location(6) clip_local: vec2<f32>, @location(7) clip: vec3<f32>) -> Vertex { | |
| 20 | return Vertex(vec4<f32>(position, 0.0, 1.0), uv, color, local, shape, stroke, clip_local, clip); | |
| 18 | 21 | } |
| 19 | 22 | |
| 20 | 23 | fn ellipse_arc(angle: f32, radius: vec2<f32>) -> f32 { |
| ... | ... | @@ -83,5 +86,10 @@ fn fragment(input: Vertex) -> @location(0) vec4<f32> { |
| 83 | 86 | } |
| 84 | 87 | color *= coverage; |
| 85 | 88 | } |
| 89 | if input.clip.x > 0.0 { | |
| 90 | let q = abs(input.clip_local) - input.clip.xy + input.clip.z; | |
| 91 | let distance = length(max(q, vec2<f32>(0.0))) + min(max(q.x, q.y), 0.0) - input.clip.z; | |
| 92 | color *= clamp(0.5 - distance, 0.0, 1.0); | |
| 93 | } | |
| 86 | 94 | return color; |
| 87 | 95 | } |
crates/draw/src/render.rs+179-23| ... | ... | @@ -170,6 +170,12 @@ struct Batch { |
| 170 | 170 | scissor: [u32; 4], |
| 171 | 171 | } |
| 172 | 172 | |
| 173 | /// Batches that paint offscreen as one, then onto the target by `composite`'s vertices. | |
| 174 | struct Group { | |
| 175 | batches: Range<usize>, | |
| 176 | composite: Range<u32>, | |
| 177 | } | |
| 178 | ||
| 173 | 179 | /// How a batch meets what lies beneath it. |
| 174 | 180 | #[derive(Clone, Copy, PartialEq)] |
| 175 | 181 | enum Blend { |
| ... | ... | @@ -189,6 +195,10 @@ struct Vertex { |
| 189 | 195 | local: [f32; 2], |
| 190 | 196 | shape: [f32; 4], |
| 191 | 197 | stroke: f32, |
| 198 | /// The position from the middle of the rounded rectangle the vertex paints within, and | |
| 199 | /// that rectangle's half size and corner radius; a zero half size paints everywhere. | |
| 200 | clip_local: [f32; 2], | |
| 201 | clip: [f32; 3], | |
| 192 | 202 | } |
| 193 | 203 | |
| 194 | 204 | #[derive(Hash, PartialEq, Eq)] |
| ... | ... | @@ -244,6 +254,10 @@ pub struct Layer<'a> { |
| 244 | 254 | /// The colour behind the layer: on a dark one, text in dark colours of its own is |
| 245 | 255 | /// lifted to stay legible against what lies behind it, as OneNote's dark page does. |
| 246 | 256 | pub backdrop: Option<[f32; 4]>, |
| 257 | /// Device bounds and corner radius of a rounded rectangle the layer paints only inside. | |
| 258 | pub round: Option<([f32; 4], f32)>, | |
| 259 | /// Consecutive layers with the same motion appear as one: painted together offscreen, | |
| 260 | /// then faded and leaned back as a whole, so none shows through another. | |
| 247 | 261 | pub motion: Option<Motion>, |
| 248 | 262 | pub primitives: &'a [Primitive<'a>], |
| 249 | 263 | } |
| ... | ... | @@ -260,28 +274,16 @@ pub struct Motion { |
| 260 | 274 | } |
| 261 | 275 | |
| 262 | 276 | impl Motion { |
| 263 | fn apply(&self, vertex: &mut Vertex, size: [u32; 2], blend: Blend) { | |
| 264 | match blend { | |
| 265 | Blend::Image(_) => vertex.color = vertex.color.map(|v| v * self.opacity), | |
| 266 | Blend::Over | Blend::Erase => vertex.color[3] *= self.opacity, | |
| 267 | } | |
| 268 | if self.tilt == 0.0 { | |
| 269 | return; | |
| 270 | } | |
| 271 | let [width, height] = size.map(|v| v as f32); | |
| 277 | /// Where device point `[x, y]` on a target `height` tall shows, turned. | |
| 278 | fn project(&self, [x, y]: [f32; 2], height: f32) -> [f32; 2] { | |
| 272 | 279 | // Seen from twice the target's height away, so a small turn reads as depth. |
| 273 | 280 | let distance = 2.0 * height; |
| 274 | let [x, y] = [ | |
| 275 | (vertex.position[0] + 1.0) * width / 2.0, | |
| 276 | (1.0 - vertex.position[1]) * height / 2.0, | |
| 277 | ]; | |
| 278 | 281 | let below = y - self.pivot[1]; |
| 279 | 282 | let near = distance / (distance + below * self.tilt.sin()); |
| 280 | let [x, y] = [ | |
| 283 | [ | |
| 281 | 284 | self.pivot[0] + (x - self.pivot[0]) * near, |
| 282 | 285 | self.pivot[1] + below * self.tilt.cos() * near, |
| 283 | ]; | |
| 284 | vertex.position = [x * 2.0 / width - 1.0, 1.0 - y * 2.0 / height]; | |
| 286 | ] | |
| 285 | 287 | } |
| 286 | 288 | } |
| 287 | 289 | |
| ... | ... | @@ -292,7 +294,7 @@ struct Space { |
| 292 | 294 | scale: f32, |
| 293 | 295 | origin: [f32; 2], |
| 294 | 296 | backdrop: Option<[f32; 4]>, |
| 295 | motion: Option<Motion>, | |
| 297 | round: Option<([f32; 4], f32)>, | |
| 296 | 298 | } |
| 297 | 299 | |
| 298 | 300 | impl Space { |
| ... | ... | @@ -458,6 +460,9 @@ pub struct Renderer { |
| 458 | 460 | glyphs: HashMap<AtlasKey, Option<AtlasGlyph>>, |
| 459 | 461 | images: HashMap<u64, CachedImage>, |
| 460 | 462 | batches: Vec<Batch>, |
| 463 | groups: Vec<Group>, | |
| 464 | /// Batches before this one take no more primitives. | |
| 465 | barrier: usize, | |
| 461 | 466 | scaler: ScaleContext, |
| 462 | 467 | pen: [u32; 2], |
| 463 | 468 | row_height: u32, |
| ... | ... | @@ -479,6 +484,8 @@ impl Renderer { |
| 479 | 484 | glyphs: HashMap::new(), |
| 480 | 485 | images: HashMap::new(), |
| 481 | 486 | batches: Vec::new(), |
| 487 | groups: Vec::new(), | |
| 488 | barrier: 0, | |
| 482 | 489 | scaler: ScaleContext::with_max_entries(32), |
| 483 | 490 | pen: [1, 0], |
| 484 | 491 | row_height: 1, |
| ... | ... | @@ -557,7 +564,7 @@ impl Renderer { |
| 557 | 564 | scale: layer.scale, |
| 558 | 565 | origin: layer.origin, |
| 559 | 566 | backdrop: layer.backdrop, |
| 560 | motion: layer.motion, | |
| 567 | round: layer.round, | |
| 561 | 568 | }; |
| 562 | 569 | for primitive in layer.primitives { |
| 563 | 570 | if let Primitive::Image { image, rect } = primitive |
| ... | ... | @@ -574,6 +581,8 @@ impl Renderer { |
| 574 | 581 | for attempt in 0..2 { |
| 575 | 582 | self.vertices.clear(); |
| 576 | 583 | self.batches.clear(); |
| 584 | self.groups.clear(); | |
| 585 | self.barrier = 0; | |
| 577 | 586 | match self.prepare(size, layers, &active_images) { |
| 578 | 587 | Err(RenderError::AtlasFull) if attempt == 0 => self.clear_glyph_cache(), |
| 579 | 588 | result => { |
| ... | ... | @@ -582,7 +591,7 @@ impl Renderer { |
| 582 | 591 | } |
| 583 | 592 | } |
| 584 | 593 | } |
| 585 | self.submit(target, clear); | |
| 594 | self.submit(target, size, clear); | |
| 586 | 595 | Ok(()) |
| 587 | 596 | } |
| 588 | 597 | |
| ... | ... | @@ -592,13 +601,26 @@ impl Renderer { |
| 592 | 601 | layers: &[Layer<'_>], |
| 593 | 602 | active_images: &HashSet<u64>, |
| 594 | 603 | ) -> Result<(), RenderError> { |
| 604 | // The motion the layers painted since `start` share, where they paint as a group. | |
| 605 | let mut group: Option<(Motion, usize)> = None; | |
| 595 | 606 | for layer in layers { |
| 607 | let motion = layer | |
| 608 | .motion | |
| 609 | .filter(|motion| motion.opacity < 1.0 || motion.tilt != 0.0); | |
| 610 | if motion != group.map(|(motion, _)| motion) { | |
| 611 | if let Some((motion, start)) = group { | |
| 612 | self.group(motion, start, size)?; | |
| 613 | } | |
| 614 | // A group's batches never merge with those outside it. | |
| 615 | self.barrier = self.batches.len(); | |
| 616 | group = motion.map(|motion| (motion, self.batches.len())); | |
| 617 | } | |
| 596 | 618 | let space = Space { |
| 597 | 619 | size, |
| 598 | 620 | scale: layer.scale, |
| 599 | 621 | origin: layer.origin, |
| 600 | 622 | backdrop: layer.backdrop, |
| 601 | motion: layer.motion, | |
| 623 | round: layer.round, | |
| 602 | 624 | }; |
| 603 | 625 | let bounds = match layer.clip { |
| 604 | 626 | Some(clip) => space.scissor(clip), |
| ... | ... | @@ -611,6 +633,67 @@ impl Renderer { |
| 611 | 633 | self.primitive(space, bounds, primitive, active_images)?; |
| 612 | 634 | } |
| 613 | 635 | } |
| 636 | if let Some((motion, start)) = group { | |
| 637 | self.group(motion, start, size)?; | |
| 638 | } | |
| 639 | Ok(()) | |
| 640 | } | |
| 641 | ||
| 642 | /// Makes the batches from `start` a group appearing by `motion`: the vertices that | |
| 643 | /// paint its offscreen picture over the target, in strips so the turn stays in | |
| 644 | /// perspective across the picture. | |
| 645 | fn group(&mut self, motion: Motion, start: usize, size: [u32; 2]) -> Result<(), RenderError> { | |
| 646 | let batches = start..self.batches.len(); | |
| 647 | if batches.is_empty() { | |
| 648 | return Ok(()); | |
| 649 | } | |
| 650 | let [width, height] = size.map(|side| side as f32); | |
| 651 | let [mut left, mut top, mut right, mut bottom] = [width, height, 0.0, 0.0]; | |
| 652 | let first = self.batches[start].vertices.start as usize; | |
| 653 | for vertex in &self.vertices[first..] { | |
| 654 | let [x, y] = [ | |
| 655 | (vertex.position[0] + 1.0) * width / 2.0, | |
| 656 | (1.0 - vertex.position[1]) * height / 2.0, | |
| 657 | ]; | |
| 658 | [left, top] = [left.min(x), top.min(y)]; | |
| 659 | [right, bottom] = [right.max(x), bottom.max(y)]; | |
| 660 | } | |
| 661 | let [left, top] = [left.max(0.0).floor(), top.max(0.0).floor()]; | |
| 662 | let [right, bottom] = [right.min(width).ceil(), bottom.min(height).ceil()]; | |
| 663 | let strips = if motion.tilt == 0.0 { 1 } else { 32 }; | |
| 664 | if self.vertices.len() + 6 * strips > MAX_VERTICES { | |
| 665 | return Err(RenderError::FrameTooLarge); | |
| 666 | } | |
| 667 | let from = self.vertices.len() as u32; | |
| 668 | let corner = |x: f32, y: f32| { | |
| 669 | let [shown_x, shown_y] = motion.project([x, y], height); | |
| 670 | let v = y / height; | |
| 671 | Vertex { | |
| 672 | position: [shown_x * 2.0 / width - 1.0, 1.0 - shown_y * 2.0 / height], | |
| 673 | uv: [x / width, if backend::FLIPPED { 1.0 - v } else { v }], | |
| 674 | color: [motion.opacity; 4], | |
| 675 | local: [0.0; 2], | |
| 676 | shape: [0.0; 4], | |
| 677 | stroke: 0.0, | |
| 678 | clip_local: [0.0; 2], | |
| 679 | clip: [0.0; 3], | |
| 680 | } | |
| 681 | }; | |
| 682 | for strip in 0..strips { | |
| 683 | let [y0, y1] = | |
| 684 | [strip, strip + 1].map(|edge| top + (bottom - top) * edge as f32 / strips as f32); | |
| 685 | let [a, b, c, d] = [ | |
| 686 | corner(left, y0), | |
| 687 | corner(left, y1), | |
| 688 | corner(right, y1), | |
| 689 | corner(right, y0), | |
| 690 | ]; | |
| 691 | self.vertices.extend([a, b, c, a, c, d]); | |
| 692 | } | |
| 693 | self.groups.push(Group { | |
| 694 | batches, | |
| 695 | composite: from..self.vertices.len() as u32, | |
| 696 | }); | |
| 614 | 697 | Ok(()) |
| 615 | 698 | } |
| 616 | 699 | |
| ... | ... | @@ -714,13 +797,21 @@ impl Renderer { |
| 714 | 797 | } |
| 715 | 798 | } |
| 716 | 799 | let end = self.vertices.len() as u32; |
| 717 | if let Some(motion) = space.motion { | |
| 800 | if let Some((rect, radius)) = space.round { | |
| 801 | let [width, height] = space.size.map(|side| side as f32); | |
| 802 | let half = [(rect[2] - rect[0]) / 2.0, (rect[3] - rect[1]) / 2.0]; | |
| 803 | let middle = [rect[0] + half[0], rect[1] + half[1]]; | |
| 718 | 804 | for vertex in &mut self.vertices[start as usize..] { |
| 719 | motion.apply(vertex, space.size, blend); | |
| 805 | vertex.clip_local = [ | |
| 806 | (vertex.position[0] + 1.0) * width / 2.0 - middle[0], | |
| 807 | (1.0 - vertex.position[1]) * height / 2.0 - middle[1], | |
| 808 | ]; | |
| 809 | vertex.clip = [half[0], half[1], radius.min(half[0]).min(half[1])]; | |
| 720 | 810 | } |
| 721 | 811 | } |
| 722 | 812 | if start != end { |
| 723 | if let Some(last) = self.batches.last_mut() | |
| 813 | if self.batches.len() > self.barrier | |
| 814 | && let Some(last) = self.batches.last_mut() | |
| 724 | 815 | && last.blend == blend |
| 725 | 816 | && last.scissor == scissor |
| 726 | 817 | { |
| ... | ... | @@ -1232,6 +1323,8 @@ impl Renderer { |
| 1232 | 1323 | local, |
| 1233 | 1324 | shape: [half[0], half[1], corner, corner], |
| 1234 | 1325 | stroke: 0.0, |
| 1326 | clip_local: [0.0; 2], | |
| 1327 | clip: [0.0; 3], | |
| 1235 | 1328 | }); |
| 1236 | 1329 | } |
| 1237 | 1330 | Ok(()) |
| ... | ... | @@ -1283,6 +1376,8 @@ impl Renderer { |
| 1283 | 1376 | local, |
| 1284 | 1377 | shape: [0.0; 4], |
| 1285 | 1378 | stroke: 0.0, |
| 1379 | clip_local: [0.0; 2], | |
| 1380 | clip: [0.0; 3], | |
| 1286 | 1381 | }); |
| 1287 | 1382 | } |
| 1288 | 1383 | Ok(()) |
| ... | ... | @@ -1715,11 +1810,66 @@ mod tests { |
| 1715 | 1810 | origin: [24.0; 2], |
| 1716 | 1811 | clip: None, |
| 1717 | 1812 | backdrop: None, |
| 1813 | round: None, | |
| 1718 | 1814 | motion: None, |
| 1719 | 1815 | primitives, |
| 1720 | 1816 | }] |
| 1721 | 1817 | } |
| 1722 | 1818 | |
| 1819 | #[test] | |
| 1820 | #[ignore = "requires a native GPU adapter"] | |
| 1821 | fn layers_sharing_a_motion_fade_as_one_inside_their_rounded_clip() { | |
| 1822 | let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle()); | |
| 1823 | let adapter = pollster::block_on(instance.request_adapter(&Default::default())).unwrap(); | |
| 1824 | let (device, queue) = | |
| 1825 | pollster::block_on(adapter.request_device(&Default::default())).unwrap(); | |
| 1826 | let target = Target::new(&device); | |
| 1827 | let mut renderer = Renderer::new(device, queue, wgpu::TextureFormat::Rgba8UnormSrgb); | |
| 1828 | let rect = |rect, color| [Primitive::Rect { rect, color }]; | |
| 1829 | let [blue, red, black] = [ | |
| 1830 | rect([0.0, 0.0, 100.0, 100.0], [0.0, 0.0, 1.0, 1.0]), | |
| 1831 | rect([50.0, 0.0, 150.0, 100.0], [1.0, 0.0, 0.0, 1.0]), | |
| 1832 | rect([200.0, 0.0, 300.0, 100.0], [0.0, 0.0, 0.0, 1.0]), | |
| 1833 | ]; | |
| 1834 | let half = Motion { | |
| 1835 | opacity: 0.5, | |
| 1836 | tilt: 0.0, | |
| 1837 | pivot: [0.0; 2], | |
| 1838 | }; | |
| 1839 | let layer = |primitives, motion, round| Layer { | |
| 1840 | scale: 1.0, | |
| 1841 | origin: [0.0; 2], | |
| 1842 | clip: None, | |
| 1843 | backdrop: None, | |
| 1844 | round, | |
| 1845 | motion, | |
| 1846 | primitives, | |
| 1847 | }; | |
| 1848 | target | |
| 1849 | .draw( | |
| 1850 | &mut renderer, | |
| 1851 | &[ | |
| 1852 | layer(&blue, Some(half), None), | |
| 1853 | layer(&red, Some(half), None), | |
| 1854 | layer(&black, None, Some(([200.0, 0.0, 300.0, 100.0], 30.0))), | |
| 1855 | ], | |
| 1856 | ) | |
| 1857 | .unwrap(); | |
| 1858 | let capture = target.capture(&renderer); | |
| 1859 | let pixel = |x: usize, y: usize| &capture[(y * 512 + x) * 4..(y * 512 + x) * 4 + 4]; | |
| 1860 | let [overlap, alone] = [pixel(75, 50), pixel(25, 50)]; | |
| 1861 | assert!( | |
| 1862 | overlap[0] == 255 && overlap[1] == overlap[2] && overlap[1] < 255, | |
| 1863 | "the red covers the blue before both fade: {overlap:?}" | |
| 1864 | ); | |
| 1865 | assert!( | |
| 1866 | alone[2] == 255 && alone[0] == alone[1] && alone[0] < 255, | |
| 1867 | "{alone:?}" | |
| 1868 | ); | |
| 1869 | assert_eq!(pixel(202, 2), [255; 4], "outside the rounded corner"); | |
| 1870 | assert_eq!(pixel(250, 50), [0, 0, 0, 255]); | |
| 1871 | } | |
| 1872 | ||
| 1723 | 1873 | #[test] |
| 1724 | 1874 | #[ignore = "requires a native GPU adapter"] |
| 1725 | 1875 | fn eviction_and_repaint_reproduce_pixels() { |
| ... | ... | @@ -2119,6 +2269,7 @@ mod tests { |
| 2119 | 2269 | origin: [0.0; 2], |
| 2120 | 2270 | clip: None, |
| 2121 | 2271 | backdrop: None, |
| 2272 | round: None, | |
| 2122 | 2273 | motion: None, |
| 2123 | 2274 | primitives: &primitives, |
| 2124 | 2275 | }], |
| ... | ... | @@ -2191,6 +2342,7 @@ mod tests { |
| 2191 | 2342 | origin: [0.0; 2], |
| 2192 | 2343 | clip: None, |
| 2193 | 2344 | backdrop: None, |
| 2345 | round: None, | |
| 2194 | 2346 | motion: None, |
| 2195 | 2347 | primitives: &primitives, |
| 2196 | 2348 | }], |
| ... | ... | @@ -2236,6 +2388,7 @@ mod tests { |
| 2236 | 2388 | origin: [0.0; 2], |
| 2237 | 2389 | clip: None, |
| 2238 | 2390 | backdrop: None, |
| 2391 | round: None, | |
| 2239 | 2392 | motion: None, |
| 2240 | 2393 | primitives: &primitives, |
| 2241 | 2394 | }], |
| ... | ... | @@ -2288,6 +2441,7 @@ mod tests { |
| 2288 | 2441 | origin: [0.0; 2], |
| 2289 | 2442 | clip: Some([100.0, 50.0, 200.5, 80.0]), |
| 2290 | 2443 | backdrop: None, |
| 2444 | round: None, | |
| 2291 | 2445 | motion: None, |
| 2292 | 2446 | primitives: &fill, |
| 2293 | 2447 | }, |
| ... | ... | @@ -2296,6 +2450,7 @@ mod tests { |
| 2296 | 2450 | origin: [300.0, 10.0], |
| 2297 | 2451 | clip: None, |
| 2298 | 2452 | backdrop: None, |
| 2453 | round: None, | |
| 2299 | 2454 | motion: None, |
| 2300 | 2455 | primitives: &chrome, |
| 2301 | 2456 | }, |
| ... | ... | @@ -2324,6 +2479,7 @@ mod tests { |
| 2324 | 2479 | origin: [0.0; 2], |
| 2325 | 2480 | clip: None, |
| 2326 | 2481 | backdrop: None, |
| 2482 | round: None, | |
| 2327 | 2483 | motion: None, |
| 2328 | 2484 | primitives: &fill, |
| 2329 | 2485 | }] |
crates/draw/src/render/gl.rs+164-55| ... | ... | @@ -38,6 +38,7 @@ const CLAMP_TO_EDGE: GLint = 0x812F; |
| 38 | 38 | const TEXTURE0: GLenum = 0x84C0; |
| 39 | 39 | const ARRAY_BUFFER: GLenum = 0x8892; |
| 40 | 40 | const STREAM_DRAW: GLenum = 0x88E0; |
| 41 | const STATIC_DRAW: GLenum = 0x88E4; | |
| 41 | 42 | const FRAGMENT_SHADER: GLenum = 0x8B30; |
| 42 | 43 | const VERTEX_SHADER: GLenum = 0x8B31; |
| 43 | 44 | const COMPILE_STATUS: GLenum = 0x8B81; |
| ... | ... | @@ -103,6 +104,7 @@ unsafe extern "C" { |
| 103 | 104 | fn glBindBuffer(target: GLenum, buffer: GLuint); |
| 104 | 105 | fn glBufferData(target: GLenum, size: isize, data: *const c_void, usage: GLenum); |
| 105 | 106 | fn glEnableVertexAttribArray(index: GLuint); |
| 107 | fn glDisableVertexAttribArray(index: GLuint); | |
| 106 | 108 | fn glVertexAttribPointer( |
| 107 | 109 | index: GLuint, |
| 108 | 110 | size: GLint, |
| ... | ... | @@ -132,6 +134,7 @@ unsafe extern "C" { |
| 132 | 134 | fn glUseProgram(program: GLuint); |
| 133 | 135 | fn glGetUniformLocation(program: GLuint, name: *const c_char) -> GLint; |
| 134 | 136 | fn glUniform1i(location: GLint, value: GLint); |
| 137 | fn glUniform2f(location: GLint, x: f32, y: f32); | |
| 135 | 138 | |
| 136 | 139 | fn glGenFramebuffersEXT(n: GLsizei, framebuffers: *mut GLuint); |
| 137 | 140 | fn glDeleteFramebuffersEXT(n: GLsizei, framebuffers: *const GLuint); |
| ... | ... | @@ -158,6 +161,9 @@ unsafe extern "C" { |
| 158 | 161 | ); |
| 159 | 162 | } |
| 160 | 163 | |
| 164 | /// Blending a premultiplied picture over what lies beneath. | |
| 165 | const PREMULTIPLIED: [GLenum; 4] = [ONE, ONE_MINUS_SRC_ALPHA, ONE, ONE_MINUS_SRC_ALPHA]; | |
| 166 | ||
| 161 | 167 | /// An sRGB RGBA texture, deleted with its value. |
| 162 | 168 | pub(super) struct Image { |
| 163 | 169 | name: GLuint, |
| ... | ... | @@ -283,65 +289,96 @@ impl Drop for Target { |
| 283 | 289 | } |
| 284 | 290 | } |
| 285 | 291 | |
| 292 | /// Whether an offscreen picture's rows run bottom first. | |
| 293 | pub(super) const FLIPPED: bool = true; | |
| 294 | ||
| 286 | 295 | pub(super) struct Gpu { |
| 287 | 296 | program: GLuint, |
| 288 | 297 | buffer: GLuint, |
| 298 | /// `translucent.glsl`, and the triangle it covers a window with. | |
| 299 | translucent: GLuint, | |
| 300 | triangle: GLuint, | |
| 289 | 301 | atlas: Image, |
| 290 | 302 | max_texture: u32, |
| 303 | /// Offscreen pictures for groups, the target's size. | |
| 304 | groups: Vec<Target>, | |
| 291 | 305 | } |
| 292 | 306 | |
| 293 | 307 | /// Each attribute's name, component count and offset, bound to its index in the shader. |
| 294 | const ATTRIBUTES: [(&CStr, GLint, usize); 6] = [ | |
| 308 | const ATTRIBUTES: [(&CStr, GLint, usize); 8] = [ | |
| 295 | 309 | (c"position", 2, offset_of!(Vertex, position)), |
| 296 | 310 | (c"uv", 2, offset_of!(Vertex, uv)), |
| 297 | 311 | (c"color", 4, offset_of!(Vertex, color)), |
| 298 | 312 | (c"local", 2, offset_of!(Vertex, local)), |
| 299 | 313 | (c"shape", 4, offset_of!(Vertex, shape)), |
| 300 | 314 | (c"stroke", 1, offset_of!(Vertex, stroke)), |
| 315 | (c"clip_local", 2, offset_of!(Vertex, clip_local)), | |
| 316 | (c"clip", 3, offset_of!(Vertex, clip)), | |
| 301 | 317 | ]; |
| 302 | 318 | |
| 303 | 319 | impl Renderer { |
| 304 | 320 | /// Draws with the OpenGL context current on this thread, which must stay current |
| 305 | 321 | /// whenever the renderer or a `Target` is used. |
| 306 | 322 | pub fn new() -> Result<Self, String> { |
| 307 | let source = include_str!("../draw.glsl"); | |
| 308 | let program = unsafe { | |
| 309 | let vertex = compile( | |
| 310 | VERTEX_SHADER, | |
| 311 | &format!("#version 120\n#define VERTEX\n{source}"), | |
| 312 | )?; | |
| 313 | let fragment = compile(FRAGMENT_SHADER, &format!("#version 120\n{source}"))?; | |
| 314 | let program = glCreateProgram(); | |
| 315 | glAttachShader(program, vertex); | |
| 316 | glAttachShader(program, fragment); | |
| 317 | for (index, (name, _, _)) in ATTRIBUTES.iter().enumerate() { | |
| 318 | glBindAttribLocation(program, index as GLuint, name.as_ptr()); | |
| 319 | } | |
| 320 | glLinkProgram(program); | |
| 321 | let mut linked = 0; | |
| 322 | glGetProgramiv(program, LINK_STATUS, &mut linked); | |
| 323 | if linked == 0 { | |
| 324 | return Err(format!( | |
| 325 | "Linking the draw shader failed: {}", | |
| 326 | log(program, glGetProgramiv, glGetProgramInfoLog) | |
| 327 | )); | |
| 328 | } | |
| 329 | program | |
| 330 | }; | |
| 331 | let mut buffer = 0; | |
| 323 | let names: Vec<_> = ATTRIBUTES.iter().map(|(name, _, _)| *name).collect(); | |
| 324 | let draw = unsafe { program(include_str!("../draw.glsl"), &names)? }; | |
| 325 | let translucent = unsafe { program(include_str!("translucent.glsl"), &[c"position"])? }; | |
| 326 | let [mut buffer, mut triangle] = [0; 2]; | |
| 332 | 327 | let mut max_texture = 0; |
| 333 | 328 | unsafe { |
| 334 | 329 | glGenBuffers(1, &mut buffer); |
| 330 | glGenBuffers(1, &mut triangle); | |
| 331 | glBindBuffer(ARRAY_BUFFER, triangle); | |
| 332 | let corners: [f32; 6] = [-1.0, -1.0, 3.0, -1.0, -1.0, 3.0]; | |
| 333 | glBufferData( | |
| 334 | ARRAY_BUFFER, | |
| 335 | size_of_val(&corners) as isize, | |
| 336 | corners.as_ptr().cast(), | |
| 337 | STATIC_DRAW, | |
| 338 | ); | |
| 335 | 339 | glGetIntegerv(MAX_TEXTURE_SIZE, &mut max_texture); |
| 336 | 340 | } |
| 337 | 341 | Ok(Self::with_gpu(Gpu { |
| 338 | program, | |
| 342 | program: draw, | |
| 339 | 343 | buffer, |
| 344 | translucent, | |
| 345 | triangle, | |
| 340 | 346 | atlas: Image::new([ATLAS_SIZE; 2], NEAREST, None), |
| 341 | 347 | max_texture: max_texture as u32, |
| 348 | groups: Vec::new(), | |
| 342 | 349 | })) |
| 343 | 350 | } |
| 344 | 351 | |
| 352 | /// Copies `target` to the context's window, the same size, premultiplying each pixel | |
| 353 | /// again in sRGB, as a window server compositing a transparent surface needs. | |
| 354 | pub fn present_translucent(&self, target: &Target) { | |
| 355 | let [width, height] = target.size().map(|side| side as f32); | |
| 356 | let program = self.gpu.translucent; | |
| 357 | unsafe { | |
| 358 | glBindFramebufferEXT(FRAMEBUFFER, 0); | |
| 359 | glDisable(FRAMEBUFFER_SRGB); | |
| 360 | glDisable(BLEND); | |
| 361 | glDisable(SCISSOR_TEST); | |
| 362 | glViewport(0, 0, width as GLsizei, height as GLsizei); | |
| 363 | glUseProgram(program); | |
| 364 | glUniform1i(glGetUniformLocation(program, c"frame".as_ptr()), 0); | |
| 365 | glUniform2f( | |
| 366 | glGetUniformLocation(program, c"size".as_ptr()), | |
| 367 | width, | |
| 368 | height, | |
| 369 | ); | |
| 370 | glActiveTexture(TEXTURE0); | |
| 371 | glBindTexture(TEXTURE_2D, target.texture.name); | |
| 372 | glBindBuffer(ARRAY_BUFFER, self.gpu.triangle); | |
| 373 | for index in 1..ATTRIBUTES.len() { | |
| 374 | glDisableVertexAttribArray(index as GLuint); | |
| 375 | } | |
| 376 | glEnableVertexAttribArray(0); | |
| 377 | glVertexAttribPointer(0, 2, FLOAT, 0, 0, ptr::null()); | |
| 378 | glDrawArrays(TRIANGLES, 0, 3); | |
| 379 | } | |
| 380 | } | |
| 381 | ||
| 345 | 382 | /// The widest and tallest texture the driver takes, in pixels. |
| 346 | 383 | pub fn max_texture_dimension(&self) -> u32 { |
| 347 | 384 | self.gpu.max_texture |
| ... | ... | @@ -368,17 +405,68 @@ impl Renderer { |
| 368 | 405 | Image::new(image.size, LINEAR, Some(image.pixels())) |
| 369 | 406 | } |
| 370 | 407 | |
| 371 | /// Clears `target` to linear `clear` and draws the prepared batches. | |
| 372 | pub(super) fn submit(&mut self, target: &Target, clear: [f32; 4]) { | |
| 373 | let [width, height] = target.size().map(|side| side as GLsizei); | |
| 408 | /// Clears `target`, `size` device pixels, to linear `clear` and draws the prepared | |
| 409 | /// batches, each group's offscreen first. | |
| 410 | pub(super) fn submit(&mut self, target: &Target, size: [u32; 2], clear: [f32; 4]) { | |
| 411 | if self | |
| 412 | .gpu | |
| 413 | .groups | |
| 414 | .first() | |
| 415 | .is_some_and(|picture| picture.size() != size) | |
| 416 | { | |
| 417 | self.gpu.groups.clear(); | |
| 418 | } | |
| 419 | while self.gpu.groups.len() < self.groups.len() { | |
| 420 | match Target::new(size) { | |
| 421 | Ok(picture) => unsafe { | |
| 422 | // Filtered, so a picture leaning back stays smooth. | |
| 423 | glBindTexture(TEXTURE_2D, picture.texture.name); | |
| 424 | glTexParameteri(TEXTURE_2D, TEXTURE_MIN_FILTER, LINEAR); | |
| 425 | glTexParameteri(TEXTURE_2D, TEXTURE_MAG_FILTER, LINEAR); | |
| 426 | self.gpu.groups.push(picture); | |
| 427 | }, | |
| 428 | Err(error) => { | |
| 429 | eprintln!("{error}"); | |
| 430 | return; | |
| 431 | } | |
| 432 | } | |
| 433 | } | |
| 434 | let [width, height] = size.map(|side| side as GLsizei); | |
| 374 | 435 | let gpu = &self.gpu; |
| 375 | unsafe { | |
| 436 | let begin = |target: &Target, clear: [f32; 4]| unsafe { | |
| 376 | 437 | glBindFramebufferEXT(FRAMEBUFFER, target.framebuffer); |
| 377 | glEnable(FRAMEBUFFER_SRGB); | |
| 378 | glViewport(0, 0, width, height); | |
| 379 | 438 | glDisable(SCISSOR_TEST); |
| 380 | 439 | glClearColor(clear[0], clear[1], clear[2], clear[3]); |
| 381 | 440 | glClear(COLOR_BUFFER_BIT); |
| 441 | glEnable(SCISSOR_TEST); | |
| 442 | }; | |
| 443 | let draw = |batches: &[Batch]| unsafe { | |
| 444 | for batch in batches { | |
| 445 | let [x, y, w, h] = batch.scissor.map(|value| value as GLint); | |
| 446 | glScissor(x, height - y - h, w, h); | |
| 447 | let (texture, [src_rgb, dst_rgb, src_alpha, dst_alpha]) = match batch.blend { | |
| 448 | Blend::Over => ( | |
| 449 | gpu.atlas.name, | |
| 450 | [SRC_ALPHA, ONE_MINUS_SRC_ALPHA, ONE, ONE_MINUS_SRC_ALPHA], | |
| 451 | ), | |
| 452 | Blend::Image(id) => (self.images[&id].texture.name, PREMULTIPLIED), | |
| 453 | Blend::Erase => ( | |
| 454 | gpu.atlas.name, | |
| 455 | [ZERO, ONE_MINUS_SRC_ALPHA, ZERO, ONE_MINUS_SRC_ALPHA], | |
| 456 | ), | |
| 457 | }; | |
| 458 | glBlendFuncSeparate(src_rgb, dst_rgb, src_alpha, dst_alpha); | |
| 459 | glBindTexture(TEXTURE_2D, texture); | |
| 460 | glDrawArrays( | |
| 461 | TRIANGLES, | |
| 462 | batch.vertices.start as GLint, | |
| 463 | batch.vertices.len() as GLsizei, | |
| 464 | ); | |
| 465 | } | |
| 466 | }; | |
| 467 | unsafe { | |
| 468 | glEnable(FRAMEBUFFER_SRGB); | |
| 469 | glViewport(0, 0, width, height); | |
| 382 | 470 | glUseProgram(gpu.program); |
| 383 | 471 | glUniform1i(glGetUniformLocation(gpu.program, c"atlas".as_ptr()), 0); |
| 384 | 472 | glActiveTexture(TEXTURE0); |
| ... | ... | @@ -401,32 +489,26 @@ impl Renderer { |
| 401 | 489 | ); |
| 402 | 490 | } |
| 403 | 491 | glEnable(BLEND); |
| 404 | glEnable(SCISSOR_TEST); | |
| 405 | for batch in &self.batches { | |
| 406 | let [x, y, w, h] = batch.scissor.map(|value| value as GLint); | |
| 407 | glScissor(x, height - y - h, w, h); | |
| 408 | let (texture, [src_rgb, dst_rgb, src_alpha, dst_alpha]) = match batch.blend { | |
| 409 | Blend::Over => ( | |
| 410 | &gpu.atlas, | |
| 411 | [SRC_ALPHA, ONE_MINUS_SRC_ALPHA, ONE, ONE_MINUS_SRC_ALPHA], | |
| 412 | ), | |
| 413 | Blend::Image(id) => ( | |
| 414 | &self.images[&id].texture, | |
| 415 | [ONE, ONE_MINUS_SRC_ALPHA, ONE, ONE_MINUS_SRC_ALPHA], | |
| 416 | ), | |
| 417 | Blend::Erase => ( | |
| 418 | &gpu.atlas, | |
| 419 | [ZERO, ONE_MINUS_SRC_ALPHA, ZERO, ONE_MINUS_SRC_ALPHA], | |
| 420 | ), | |
| 421 | }; | |
| 492 | for (group, picture) in self.groups.iter().zip(&gpu.groups) { | |
| 493 | begin(picture, [0.0; 4]); | |
| 494 | draw(&self.batches[group.batches.clone()]); | |
| 495 | } | |
| 496 | begin(target, clear); | |
| 497 | let mut next = 0; | |
| 498 | for (group, picture) in self.groups.iter().zip(&gpu.groups) { | |
| 499 | draw(&self.batches[next..group.batches.start]); | |
| 500 | glScissor(0, 0, width, height); | |
| 501 | let [src_rgb, dst_rgb, src_alpha, dst_alpha] = PREMULTIPLIED; | |
| 422 | 502 | glBlendFuncSeparate(src_rgb, dst_rgb, src_alpha, dst_alpha); |
| 423 | glBindTexture(TEXTURE_2D, texture.name); | |
| 503 | glBindTexture(TEXTURE_2D, picture.texture.name); | |
| 424 | 504 | glDrawArrays( |
| 425 | 505 | TRIANGLES, |
| 426 | batch.vertices.start as GLint, | |
| 427 | batch.vertices.len() as GLsizei, | |
| 506 | group.composite.start as GLint, | |
| 507 | group.composite.len() as GLsizei, | |
| 428 | 508 | ); |
| 509 | next = group.batches.end; | |
| 429 | 510 | } |
| 511 | draw(&self.batches[next..]); | |
| 430 | 512 | glDisable(SCISSOR_TEST); |
| 431 | 513 | glDisable(BLEND); |
| 432 | 514 | glBindFramebufferEXT(FRAMEBUFFER, 0); |
| ... | ... | @@ -434,6 +516,33 @@ impl Renderer { |
| 434 | 516 | } |
| 435 | 517 | } |
| 436 | 518 | |
| 519 | /// A program from `source`'s two stages, its attributes bound in order. | |
| 520 | unsafe fn program(source: &str, attributes: &[&CStr]) -> Result<GLuint, String> { | |
| 521 | unsafe { | |
| 522 | let vertex = compile( | |
| 523 | VERTEX_SHADER, | |
| 524 | &format!("#version 120\n#define VERTEX\n{source}"), | |
| 525 | )?; | |
| 526 | let fragment = compile(FRAGMENT_SHADER, &format!("#version 120\n{source}"))?; | |
| 527 | let program = glCreateProgram(); | |
| 528 | glAttachShader(program, vertex); | |
| 529 | glAttachShader(program, fragment); | |
| 530 | for (index, name) in attributes.iter().enumerate() { | |
| 531 | glBindAttribLocation(program, index as GLuint, name.as_ptr()); | |
| 532 | } | |
| 533 | glLinkProgram(program); | |
| 534 | let mut linked = 0; | |
| 535 | glGetProgramiv(program, LINK_STATUS, &mut linked); | |
| 536 | if linked == 0 { | |
| 537 | return Err(format!( | |
| 538 | "Linking a shader failed: {}", | |
| 539 | log(program, glGetProgramiv, glGetProgramInfoLog) | |
| 540 | )); | |
| 541 | } | |
| 542 | Ok(program) | |
| 543 | } | |
| 544 | } | |
| 545 | ||
| 437 | 546 | unsafe fn compile(kind: GLenum, source: &str) -> Result<GLuint, String> { |
| 438 | 547 | unsafe { |
| 439 | 548 | let shader = glCreateShader(kind); |
| ... | ... | @@ -444,7 +553,7 @@ unsafe fn compile(kind: GLenum, source: &str) -> Result<GLuint, String> { |
| 444 | 553 | glGetShaderiv(shader, COMPILE_STATUS, &mut compiled); |
| 445 | 554 | if compiled == 0 { |
| 446 | 555 | return Err(format!( |
| 447 | "Compiling the draw shader failed: {}", | |
| 556 | "Compiling a shader failed: {}", | |
| 448 | 557 | log(shader, glGetShaderiv, glGetShaderInfoLog) |
| 449 | 558 | )); |
| 450 | 559 | } |
crates/draw/src/render/translucent.glsl created+22| ... | ... | @@ -0,0 +1,22 @@ |
| 1 | // GLSL 1.20 twin of translucent.wgsl: the frame, blended in linear light, premultiplied | |
| 2 | // again in sRGB for a window server that composites that way. | |
| 3 | #ifdef VERTEX | |
| 4 | attribute vec2 position; | |
| 5 | ||
| 6 | void main() { | |
| 7 | gl_Position = vec4(position, 0.0, 1.0); | |
| 8 | } | |
| 9 | #else | |
| 10 | uniform sampler2D frame; | |
| 11 | uniform vec2 size; | |
| 12 | ||
| 13 | vec3 encode(vec3 linear) { | |
| 14 | return mix(1.055 * pow(linear, vec3(1.0 / 2.4)) - 0.055, linear * 12.92, | |
| 15 | vec3(lessThanEqual(linear, vec3(0.0031308)))); | |
| 16 | } | |
| 17 | ||
| 18 | void main() { | |
| 19 | vec4 pixel = texture2D(frame, gl_FragCoord.xy / size); | |
| 20 | gl_FragColor = pixel.a == 0.0 ? vec4(0.0) : vec4(encode(pixel.rgb / pixel.a) * pixel.a, pixel.a); | |
| 21 | } | |
| 22 | #endif |
crates/draw/src/render/webgpu.rs+90-17| ... | ... | @@ -6,6 +6,9 @@ pub type Target = wgpu::TextureView; |
| 6 | 6 | |
| 7 | 7 | pub(super) type Image = wgpu::BindGroup; |
| 8 | 8 | |
| 9 | /// Whether an offscreen picture's rows run bottom first. | |
| 10 | pub(super) const FLIPPED: bool = false; | |
| 11 | ||
| 9 | 12 | pub(super) struct Gpu { |
| 10 | 13 | pipeline: wgpu::RenderPipeline, |
| 11 | 14 | image_pipeline: wgpu::RenderPipeline, |
| ... | ... | @@ -14,6 +17,10 @@ pub(super) struct Gpu { |
| 14 | 17 | bind_group: wgpu::BindGroup, |
| 15 | 18 | image_sampler: wgpu::Sampler, |
| 16 | 19 | vertex_buffer: wgpu::Buffer, |
| 20 | format: wgpu::TextureFormat, | |
| 21 | /// Offscreen pictures for groups, the target's size, and how each is sampled. | |
| 22 | groups: Vec<(wgpu::TextureView, wgpu::BindGroup)>, | |
| 23 | groups_size: [u32; 2], | |
| 17 | 24 | } |
| 18 | 25 | |
| 19 | 26 | impl Renderer { |
| ... | ... | @@ -56,7 +63,7 @@ impl Renderer { |
| 56 | 63 | buffers: &[Some(wgpu::VertexBufferLayout { |
| 57 | 64 | array_stride: size_of::<Vertex>() as u64, |
| 58 | 65 | step_mode: wgpu::VertexStepMode::Vertex, |
| 59 | attributes: &wgpu::vertex_attr_array![0 => Float32x2, 1 => Float32x2, 2 => Float32x4, 3 => Float32x2, 4 => Float32x4, 5 => Float32], | |
| 66 | attributes: &wgpu::vertex_attr_array![0 => Float32x2, 1 => Float32x2, 2 => Float32x4, 3 => Float32x2, 4 => Float32x4, 5 => Float32, 6 => Float32x2, 7 => Float32x3], | |
| 60 | 67 | })], |
| 61 | 68 | }, |
| 62 | 69 | fragment: Some(wgpu::FragmentState { |
| ... | ... | @@ -148,6 +155,9 @@ impl Renderer { |
| 148 | 155 | bind_group, |
| 149 | 156 | image_sampler, |
| 150 | 157 | vertex_buffer, |
| 158 | format, | |
| 159 | groups: Vec::new(), | |
| 160 | groups_size: [0; 2], | |
| 151 | 161 | }, |
| 152 | 162 | ) |
| 153 | 163 | } |
| ... | ... | @@ -225,18 +235,76 @@ impl Renderer { |
| 225 | 235 | }) |
| 226 | 236 | } |
| 227 | 237 | |
| 228 | /// Clears `target` to linear `clear` and draws the prepared batches. | |
| 229 | pub(super) fn submit(&mut self, target: &Target, clear: [f32; 4]) { | |
| 238 | /// An offscreen picture `size` large for each group, kept for later frames. | |
| 239 | fn group_pictures(&mut self, size: [u32; 2]) { | |
| 240 | let gpu = &mut self.gpu; | |
| 241 | if gpu.groups_size != size { | |
| 242 | gpu.groups.clear(); | |
| 243 | gpu.groups_size = size; | |
| 244 | } | |
| 245 | while gpu.groups.len() < self.groups.len() { | |
| 246 | let texture = self.device.create_texture(&wgpu::TextureDescriptor { | |
| 247 | label: Some("Draw group"), | |
| 248 | size: wgpu::Extent3d { | |
| 249 | width: size[0], | |
| 250 | height: size[1], | |
| 251 | depth_or_array_layers: 1, | |
| 252 | }, | |
| 253 | mip_level_count: 1, | |
| 254 | sample_count: 1, | |
| 255 | dimension: wgpu::TextureDimension::D2, | |
| 256 | format: gpu.format, | |
| 257 | usage: wgpu::TextureUsages::RENDER_ATTACHMENT | |
| 258 | | wgpu::TextureUsages::TEXTURE_BINDING, | |
| 259 | view_formats: &[], | |
| 260 | }); | |
| 261 | let view = texture.create_view(&Default::default()); | |
| 262 | let binding = self.device.create_bind_group(&wgpu::BindGroupDescriptor { | |
| 263 | label: Some("Draw group"), | |
| 264 | layout: &gpu.pipeline.get_bind_group_layout(0), | |
| 265 | entries: &[ | |
| 266 | wgpu::BindGroupEntry { | |
| 267 | binding: 0, | |
| 268 | resource: wgpu::BindingResource::TextureView(&view), | |
| 269 | }, | |
| 270 | wgpu::BindGroupEntry { | |
| 271 | binding: 1, | |
| 272 | resource: wgpu::BindingResource::Sampler(&gpu.image_sampler), | |
| 273 | }, | |
| 274 | ], | |
| 275 | }); | |
| 276 | gpu.groups.push((view, binding)); | |
| 277 | } | |
| 278 | } | |
| 279 | ||
| 280 | /// Clears `target`, `size` device pixels, to linear `clear` and draws the prepared | |
| 281 | /// batches, each group's offscreen first. | |
| 282 | pub(super) fn submit(&mut self, target: &Target, size: [u32; 2], clear: [f32; 4]) { | |
| 283 | self.group_pictures(size); | |
| 230 | 284 | let gpu = &self.gpu; |
| 231 | 285 | self.queue |
| 232 | 286 | .write_buffer(&gpu.vertex_buffer, 0, bytemuck::cast_slice(&self.vertices)); |
| 233 | 287 | let mut encoder = self.device.create_command_encoder(&Default::default()); |
| 234 | { | |
| 288 | let draw = |pass: &mut wgpu::RenderPass, batches: &[Batch]| { | |
| 289 | for batch in batches { | |
| 290 | let [x, y, width, height] = batch.scissor; | |
| 291 | pass.set_scissor_rect(x, y, width, height); | |
| 292 | let (pipeline, binding) = match batch.blend { | |
| 293 | Blend::Over => (&gpu.pipeline, &gpu.bind_group), | |
| 294 | Blend::Image(id) => (&gpu.image_pipeline, &self.images[&id].texture), | |
| 295 | Blend::Erase => (&gpu.erase_pipeline, &gpu.bind_group), | |
| 296 | }; | |
| 297 | pass.set_pipeline(pipeline); | |
| 298 | pass.set_bind_group(0, binding, &[]); | |
| 299 | pass.draw(batch.vertices.clone(), 0..1); | |
| 300 | } | |
| 301 | }; | |
| 302 | let begin = |encoder: &mut wgpu::CommandEncoder, view, clear: [f32; 4]| { | |
| 235 | 303 | let [r, g, b, a] = clear.map(f64::from); |
| 236 | 304 | let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { |
| 237 | 305 | label: Some("Draw"), |
| 238 | 306 | color_attachments: &[Some(wgpu::RenderPassColorAttachment { |
| 239 | view: target, | |
| 307 | view, | |
| 240 | 308 | depth_slice: None, |
| 241 | 309 | resolve_target: None, |
| 242 | 310 | ops: wgpu::Operations { |
| ... | ... | @@ -247,19 +315,24 @@ impl Renderer { |
| 247 | 315 | ..Default::default() |
| 248 | 316 | }); |
| 249 | 317 | pass.set_vertex_buffer(0, gpu.vertex_buffer.slice(..)); |
| 250 | for batch in &self.batches { | |
| 251 | let [x, y, width, height] = batch.scissor; | |
| 252 | pass.set_scissor_rect(x, y, width, height); | |
| 253 | let (pipeline, binding) = match batch.blend { | |
| 254 | Blend::Over => (&gpu.pipeline, &gpu.bind_group), | |
| 255 | Blend::Image(id) => (&gpu.image_pipeline, &self.images[&id].texture), | |
| 256 | Blend::Erase => (&gpu.erase_pipeline, &gpu.bind_group), | |
| 257 | }; | |
| 258 | pass.set_pipeline(pipeline); | |
| 259 | pass.set_bind_group(0, binding, &[]); | |
| 260 | pass.draw(batch.vertices.clone(), 0..1); | |
| 261 | } | |
| 318 | pass.forget_lifetime() | |
| 319 | }; | |
| 320 | for (group, (view, _)) in self.groups.iter().zip(&gpu.groups) { | |
| 321 | let mut pass = begin(&mut encoder, view, [0.0; 4]); | |
| 322 | draw(&mut pass, &self.batches[group.batches.clone()]); | |
| 323 | } | |
| 324 | let mut pass = begin(&mut encoder, target, clear); | |
| 325 | let mut next = 0; | |
| 326 | for (group, (_, binding)) in self.groups.iter().zip(&gpu.groups) { | |
| 327 | draw(&mut pass, &self.batches[next..group.batches.start]); | |
| 328 | pass.set_scissor_rect(0, 0, size[0], size[1]); | |
| 329 | pass.set_pipeline(&gpu.image_pipeline); | |
| 330 | pass.set_bind_group(0, binding, &[]); | |
| 331 | pass.draw(group.composite.clone(), 0..1); | |
| 332 | next = group.batches.end; | |
| 262 | 333 | } |
| 334 | draw(&mut pass, &self.batches[next..]); | |
| 335 | drop(pass); | |
| 263 | 336 | self.queue.submit([encoder.finish()]); |
| 264 | 337 | } |
| 265 | 338 | } |
crates/notebook/examples/keystroke_probe.rs+1| ... | ... | @@ -136,6 +136,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> { |
| 136 | 136 | created: None, |
| 137 | 137 | margin_origin: [0.0; 2], |
| 138 | 138 | color: None, |
| 139 | rule_lines: None, | |
| 139 | 140 | objects: vec![PageObject::Outline(Outline { |
| 140 | 141 | id: new_id()?, |
| 141 | 142 | title: false, |
crates/notebook/examples/open_probe.rs+1| ... | ... | @@ -45,6 +45,7 @@ fn generated(count: usize) -> Result<Vec<u8>, Box<dyn std::error::Error>> { |
| 45 | 45 | created: None, |
| 46 | 46 | margin_origin: [0.0; 2], |
| 47 | 47 | color: None, |
| 48 | rule_lines: None, | |
| 48 | 49 | objects: vec![PageObject::Outline(Outline { |
| 49 | 50 | id: new_id()?, |
| 50 | 51 | title: false, |
crates/notebook/examples/save_probe.rs+1| ... | ... | @@ -51,6 +51,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> { |
| 51 | 51 | created: None, |
| 52 | 52 | margin_origin: [0.0; 2], |
| 53 | 53 | color: None, |
| 54 | rule_lines: None, | |
| 54 | 55 | objects: vec![PageObject::Outline(Outline { |
| 55 | 56 | id: new_id()?, |
| 56 | 57 | title: false, |
crates/notebook/src/merge.rs+10-6| ... | ... | @@ -153,7 +153,7 @@ fn named(op: &PageOp) -> Vec<ExGuid> { |
| 153 | 153 | | PageOp::Equation { text, .. } |
| 154 | 154 | | PageOp::Split { text, .. } => vec![*text], |
| 155 | 155 | PageOp::Date { fields, .. } => fields.iter().map(|(text, _)| *text).collect(), |
| 156 | PageOp::Color(_) => Vec::new(), | |
| 156 | PageOp::Color(_) | PageOp::RuleLines(_) => Vec::new(), | |
| 157 | 157 | PageOp::Join { left, right } => vec![*left, *right], |
| 158 | 158 | PageOp::Insert { container, .. } => vec![*container], |
| 159 | 159 | PageOp::Move { object, .. } |
| ... | ... | @@ -655,8 +655,11 @@ impl Diff { |
| 655 | 655 | self.region(text)?; |
| 656 | 656 | } |
| 657 | 657 | } |
| 658 | // A page's colour is the latest one set, whichever side set it. | |
| 659 | PageOp::Insert { .. } | PageOp::Add { .. } | PageOp::Color(_) => {} | |
| 658 | // A page's colour and rule lines are the latest set, whichever side set them. | |
| 659 | PageOp::Insert { .. } | |
| 660 | | PageOp::Add { .. } | |
| 661 | | PageOp::Color(_) | |
| 662 | | PageOp::RuleLines(_) => {} | |
| 660 | 663 | PageOp::Move { |
| 661 | 664 | object, |
| 662 | 665 | parent, |
| ... | ... | @@ -692,9 +695,10 @@ impl Diff { |
| 692 | 695 | // What the edit sets, as each side has it. |
| 693 | 696 | let part = |index: &Index| { |
| 694 | 697 | index.nodes.get(object).map(|node| match (&node.own, edit) { |
| 695 | (Own::Outline { layout, .. }, OutlineEdit::Position { .. }) => { | |
| 696 | (layout.x, layout.y, None, None) | |
| 697 | } | |
| 698 | ( | |
| 699 | Own::Outline { layout, .. } | Own::Ink(Ink { layout, .. }), | |
| 700 | OutlineEdit::Position { .. }, | |
| 701 | ) => (layout.x, layout.y, None, None), | |
| 698 | 702 | (Own::Outline { layout, .. }, OutlineEdit::Width { .. }) => ( |
| 699 | 703 | layout.max_width, |
| 700 | 704 | layout.reserved_width, |
crates/onestore/README.md+3-2| ... | ... | @@ -46,7 +46,7 @@ harness also accepts `--client-profile release`. |
| 46 | 46 | | `Page::copy` | The page's content under fresh identities for copying into another section, definitions and payloads included; indent levels only an outline group carries refuse | |
| 47 | 47 | | `page::link::internal_link`, `page::link::parse_internal_link` | Build and read the `onenote:#…` URLs OneNote stores for links to sections, pages and paragraphs, by identity | |
| 48 | 48 | | `page::Recording`, `page::MediaIndex` | A recording attachment's identity and type, and a paragraph's link to a moment in recordings; read from native pages and kept through edits, never authored | |
| 49 | | `page::Page`, `page::Paragraph`, `page::Ink`, `page::Math` | Build an editable page model (title, outlines, paragraphs with coalesced text spans, tables, images, attachments, ink drawings and handwriting decoded to stroke polylines in page points) with stored identities; equations parse from their linear text and run data into a tree that renders the MathML OneNote exports; content outside the model is retained as `Unsupported` | | |
| 49 | | `page::Page`, `page::Paragraph`, `page::Ink`, `page::Math` | Build an editable page model (title, outlines, paragraphs with coalesced text spans, tables, images, attachments, ink drawings and handwriting decoded to stroke polylines in page points, a moved drawing offset by its position) with stored identities; equations parse from their linear text and run data into a tree that renders the MathML OneNote exports; content outside the model is retained as `Unsupported` | | |
| 50 | 50 | | `protected::UnlockedSection` (optional feature) | Own decoded buffers for explicit known-password inspection; clear those buffers on drop; derived document strings/exports remain caller-owned | |
| 51 | 51 | | `create_section` | Create one page containing one plain-text paragraph and an author, including Unicode | |
| 52 | 52 | | `PageCreation`, `SectionOp::Create` | Add an empty top-level page and its section entry atomically, under identities the intent retains across retries | |
| ... | ... | @@ -91,7 +91,8 @@ text, as OneNote titles a new page; `keeping(identity, created)` keeps another p |
| 91 | 91 | identity and creation time, as a page moved to the recycle bin keeps them. The page has |
| 92 | 92 | no body outlines or applied template; `create_empty_section` makes a section for such |
| 93 | 93 | pages, and `PageOp::Color` sets or clears a page's colour (`0x14001d2a` on the page node, |
| 94 | absent for "No color"). | |
| 94 | absent for "No color"), and `PageOp::RuleLines` its rule lines (six properties on the page | |
| 95 | node, absent for None; [rule-lines](../../corpus/rule-lines/README.md)). | |
| 95 | 96 | Retain the intent to preserve its page, title and space identities; existing |
| 96 | 97 | identities require reconciliation before retry. Body insertion and title edits |
| 97 | 98 | use those identities through page ops. [Native page-creation fixtures](../../corpus/page-lifecycle/creation/README.md) |
crates/onestore/src/op/apply.rs+20| ... | ... | @@ -638,6 +638,26 @@ impl<'a> Writer<'_, 'a> { |
| 638 | 638 | } |
| 639 | 639 | Ok(BTreeMap::from([(node, object)])) |
| 640 | 640 | }), |
| 641 | PageOp::RuleLines(lines) => self.write(|page| { | |
| 642 | let [node] = crate::active::manifest_pages(&page.view)[..] else { | |
| 643 | return Err(invalid("The page has no page node to rule")); | |
| 644 | }; | |
| 645 | let mut object = | |
| 646 | crate::write::PropertyObject::from_object(&page.live.revision.objects[&node])?; | |
| 647 | // OneNote's "None" removes the properties. | |
| 648 | match lines { | |
| 649 | Some(lines) => { | |
| 650 | let properties = lines.properties(); | |
| 651 | let values: Vec<_> = properties | |
| 652 | .iter() | |
| 653 | .map(|(id, value)| (*id, &value[..])) | |
| 654 | .collect(); | |
| 655 | object.set(&values)? | |
| 656 | } | |
| 657 | None => object.remove(&crate::page::RULE_LINES)?, | |
| 658 | } | |
| 659 | Ok(BTreeMap::from([(node, object)])) | |
| 660 | }), | |
| 641 | 661 | PageOp::Insert { |
| 642 | 662 | container, |
| 643 | 663 | before, |
crates/onestore/src/op/lower.rs+23-2| ... | ... | @@ -45,6 +45,7 @@ pub fn lower( |
| 45 | 45 | created: None, |
| 46 | 46 | margin_origin: [0.0; 2], |
| 47 | 47 | color: None, |
| 48 | rule_lines: None, | |
| 48 | 49 | objects: vec![PageObject::Outline(Outline { |
| 49 | 50 | id: container, |
| 50 | 51 | title: false, |
| ... | ... | @@ -1424,8 +1425,25 @@ impl Lowering { |
| 1424 | 1425 | } |
| 1425 | 1426 | |
| 1426 | 1427 | fn strokes(&mut self, stored: &crate::page::Ink, ink: &crate::page::Ink) -> Result<(), Error> { |
| 1427 | if stored.layout != ink.layout || stored.groups != ink.groups { | |
| 1428 | return Err(invalid("Ink position and groups stay as stored")); | |
| 1428 | let placed = |layout: &crate::document::Layout| crate::document::Layout { | |
| 1429 | x: None, | |
| 1430 | y: None, | |
| 1431 | ..layout.clone() | |
| 1432 | }; | |
| 1433 | if placed(&stored.layout) != placed(&ink.layout) || stored.groups != ink.groups { | |
| 1434 | return Err(invalid("Ink size and groups stay as stored")); | |
| 1435 | } | |
| 1436 | if (stored.layout.x, stored.layout.y) != (ink.layout.x, ink.layout.y) { | |
| 1437 | let (Some(x), Some(y)) = (ink.layout.x, ink.layout.y) else { | |
| 1438 | return Err(invalid("An ink position needs both coordinates")); | |
| 1439 | }; | |
| 1440 | self.emit(PageOp::Outline { | |
| 1441 | object: ink.id, | |
| 1442 | edit: crate::OutlineEdit::Position { x, y }, | |
| 1443 | })?; | |
| 1444 | if stored.strokes == ink.strokes { | |
| 1445 | return Ok(()); | |
| 1446 | } | |
| 1429 | 1447 | } |
| 1430 | 1448 | let mut add = Vec::new(); |
| 1431 | 1449 | for stroke in &ink.strokes { |
| ... | ... | @@ -1569,6 +1587,9 @@ impl Lowering { |
| 1569 | 1587 | if before.color != after.color { |
| 1570 | 1588 | self.emit(PageOp::Color(after.color))?; |
| 1571 | 1589 | } |
| 1590 | if before.rule_lines != after.rule_lines { | |
| 1591 | self.emit(PageOp::RuleLines(after.rule_lines))?; | |
| 1592 | } | |
| 1572 | 1593 | if before.created == after.created && shown { |
| 1573 | 1594 | return Ok(()); |
| 1574 | 1595 | } |
crates/onestore/src/op/mod.rs+2| ... | ... | @@ -88,6 +88,8 @@ pub enum PageOp { |
| 88 | 88 | }, |
| 89 | 89 | /// The page's colour (View, Page Color), COLORREF; `None` is OneNote's "No color". |
| 90 | 90 | Color(Option<u32>), |
| 91 | /// The page's rule lines (View, Rule Lines); `None` is OneNote's "None". | |
| 92 | RuleLines(Option<crate::page::RuleLines>), | |
| 91 | 93 | /// Inserts paragraphs before a direct child of `container`, or last. Children follow |
| 92 | 94 | /// their parents in `paragraphs`; levels are absolute. Text keeps its spans' formats; |
| 93 | 95 | /// lists, tags, styles and collapse state are set by their own ops. |
crates/onestore/src/op/model.rs+7-3| ... | ... | @@ -627,6 +627,7 @@ fn interpret(page: &mut Page, op: &PageOp) -> Result<(), Error> { |
| 627 | 627 | page.created = Some(*created); |
| 628 | 628 | } |
| 629 | 629 | PageOp::Color(color) => page.color = *color, |
| 630 | PageOp::RuleLines(lines) => page.rule_lines = *lines, | |
| 630 | 631 | PageOp::Text { text, range, with } => { |
| 631 | 632 | let object = text_mut(page, *text)?; |
| 632 | 633 | object.text = replace_text(&object.text, range.clone(), with)?; |
| ... | ... | @@ -923,9 +924,12 @@ fn interpret(page: &mut Page, op: &PageOp) -> Result<(), Error> { |
| 923 | 924 | PageOp::Outline { object, edit } => match edit { |
| 924 | 925 | OutlineEdit::Collapsed(value) => paragraph_mut(page, *object)?.collapsed = *value, |
| 925 | 926 | OutlineEdit::Position { x, y } => { |
| 926 | let outline = outline_mut(page, *object)?; | |
| 927 | outline.layout.x = Some(stored(*x)); | |
| 928 | outline.layout.y = Some(stored(*y)); | |
| 927 | let layout = match page.objects.iter_mut().find(|o| o.id() == *object) { | |
| 928 | Some(PageObject::Ink(ink)) => &mut ink.layout, | |
| 929 | _ => &mut outline_mut(page, *object)?.layout, | |
| 930 | }; | |
| 931 | layout.x = Some(stored(*x)); | |
| 932 | layout.y = Some(stored(*y)); | |
| 929 | 933 | retitle(page, None); |
| 930 | 934 | } |
| 931 | 935 | OutlineEdit::Width { points, user_set } => { |
crates/onestore/src/op/tests.rs+190-2| ... | ... | @@ -8,7 +8,8 @@ use crate::{ |
| 8 | 8 | document::{Document, Format, Kind, Layout}, |
| 9 | 9 | page::{ |
| 10 | 10 | Attachment, Definition, Image, Ink, InkStroke, Outline, PageObject, PageParagraph, |
| 11 | ParagraphContent, Table, TableCell, TableColumn, TableRow, TextObject, text::new_id, | |
| 11 | ParagraphContent, Table, TableCell, TableColumn, TableRow, TextObject, VerticalRule, | |
| 12 | text::new_id, | |
| 12 | 13 | }, |
| 13 | 14 | write::GUIDS, |
| 14 | 15 | }; |
| ... | ... | @@ -352,11 +353,12 @@ pub(super) enum Family { |
| 352 | 353 | AttachmentRename, |
| 353 | 354 | NewInk, |
| 354 | 355 | InkStrokes, |
| 356 | InkPosition, | |
| 355 | 357 | Link, |
| 356 | 358 | Equation, |
| 357 | 359 | } |
| 358 | 360 | |
| 359 | pub(super) const FAMILIES: [Family; 33] = [ | |
| 361 | pub(super) const FAMILIES: [Family; 34] = [ | |
| 360 | 362 | Family::Text, |
| 361 | 363 | Family::Format, |
| 362 | 364 | Family::Insert, |
| ... | ... | @@ -388,6 +390,7 @@ pub(super) const FAMILIES: [Family; 33] = [ |
| 388 | 390 | Family::AttachmentRename, |
| 389 | 391 | Family::NewInk, |
| 390 | 392 | Family::InkStrokes, |
| 393 | Family::InkPosition, | |
| 391 | 394 | Family::Link, |
| 392 | 395 | Family::Equation, |
| 393 | 396 | ]; |
| ... | ... | @@ -1223,6 +1226,17 @@ pub(super) fn mutate(page: &mut Page, rng: &mut Rng, family: Family) -> bool { |
| 1223 | 1226 | } |
| 1224 | 1227 | true |
| 1225 | 1228 | } |
| 1229 | Family::InkPosition => { | |
| 1230 | let Some(ink) = page.objects.iter_mut().find_map(|o| match o { | |
| 1231 | PageObject::Ink(ink) => Some(ink), | |
| 1232 | _ => None, | |
| 1233 | }) else { | |
| 1234 | return false; | |
| 1235 | }; | |
| 1236 | ink.layout.x = Some((rng.next() % 40) as f32 - 20.0); | |
| 1237 | ink.layout.y = Some((rng.next() % 90) as f32); | |
| 1238 | true | |
| 1239 | } | |
| 1226 | 1240 | } |
| 1227 | 1241 | } |
| 1228 | 1242 | |
| ... | ... | @@ -2769,3 +2783,177 @@ fn a_page_date_stores_what_onenote_stores() { |
| 2769 | 2783 | .unwrap(); |
| 2770 | 2784 | } |
| 2771 | 2785 | } |
| 2786 | ||
| 2787 | /// OneNote 2010's View, Rule Lines (`corpus/rule-lines/native`): each page is titled for | |
| 2788 | /// what its menu gave it, and "Narrow" was ruled, then set back to None. | |
| 2789 | const RULED: &[u8] = include_bytes!("../../../../corpus/rule-lines/native/notebook/Rules.one"); | |
| 2790 | ||
| 2791 | /// The rule-line properties on `space`'s page node, in stored order. | |
| 2792 | fn rule_properties(image: &[u8], space: ExGuid) -> Vec<(u32, Vec<u8>)> { | |
| 2793 | let store = Store::parse(image).unwrap(); | |
| 2794 | let index = RevisionIndex::parse(&store).unwrap(); | |
| 2795 | let revision = index.resolve(space, index.active(space).unwrap()).unwrap(); | |
| 2796 | let document = Document::parse(&index).unwrap(); | |
| 2797 | let [node] = crate::active::manifest_pages(document.active(space).unwrap())[..] else { | |
| 2798 | panic!("one page node") | |
| 2799 | }; | |
| 2800 | let crate::ObjectData::Properties(bytes) = revision.objects[&node].data else { | |
| 2801 | panic!("a page node's properties") | |
| 2802 | }; | |
| 2803 | crate::PropertySets::parse(bytes).unwrap().sets[0] | |
| 2804 | .iter() | |
| 2805 | .filter(|property| crate::page::RULE_LINES.contains(&property.id)) | |
| 2806 | .map(|property| match property.value { | |
| 2807 | crate::Value::Bytes(bytes) => (property.id, bytes.to_vec()), | |
| 2808 | _ => panic!("rule lines are scalars"), | |
| 2809 | }) | |
| 2810 | .collect() | |
| 2811 | } | |
| 2812 | ||
| 2813 | /// Rule lines read as OneNote's page XML reports them, and the `RuleLines` op stores the | |
| 2814 | /// properties OneNote stores, in its order, or removes them for None. | |
| 2815 | #[test] | |
| 2816 | fn rule_lines_store_what_onenote_stores() { | |
| 2817 | let apply = |section: &mut Section, op| { | |
| 2818 | section.apply( | |
| 2819 | "Author", | |
| 2820 | &Edit { | |
| 2821 | at: AT, | |
| 2822 | ops: vec![op], | |
| 2823 | }, | |
| 2824 | ) | |
| 2825 | }; | |
| 2826 | let titled = |image: &[u8], title: &str| { | |
| 2827 | *pages(image) | |
| 2828 | .iter() | |
| 2829 | .find(|space| read(image, **space).title == title) | |
| 2830 | .unwrap() | |
| 2831 | }; | |
| 2832 | let lines = |title| read(RULED, titled(RULED, title)).rule_lines; | |
| 2833 | let blue = 0xfdebca; | |
| 2834 | assert_eq!(lines("None"), None); | |
| 2835 | assert_eq!(lines("Narrow"), None); | |
| 2836 | // The page XML reports these spacings in points. | |
| 2837 | for (title, spacing, vertical) in [ | |
| 2838 | ("College", 23.76, None), | |
| 2839 | ("Standard", 33.12, None), | |
| 2840 | ("Wide", 46.8, None), | |
| 2841 | ("SmallGrid", 12.0, Some(blue)), | |
| 2842 | ("MediumGrid", 28.3464, Some(0xffc9e0)), | |
| 2843 | ("LargeGrid", 42.5196, Some(blue)), | |
| 2844 | ("VeryLargeGrid", 56.6928, Some(blue)), | |
| 2845 | ] { | |
| 2846 | let lines = lines(title).unwrap(); | |
| 2847 | assert!((lines.spacing * 36.0 - spacing).abs() < 1e-3, "{title}"); | |
| 2848 | assert_eq!(lines.color, vertical.unwrap_or(blue), "{title}"); | |
| 2849 | assert_eq!( | |
| 2850 | lines.vertical, | |
| 2851 | match vertical { | |
| 2852 | Some(color) => VerticalRule::Grid { | |
| 2853 | spacing: lines.spacing, | |
| 2854 | color, | |
| 2855 | }, | |
| 2856 | None => VerticalRule::Margin(0x5050ff), | |
| 2857 | }, | |
| 2858 | "{title}" | |
| 2859 | ); | |
| 2860 | } | |
| 2861 | assert_eq!(lines("ColorRed").unwrap().color, 0xd6d4ff); | |
| 2862 | // "Hidden" is Standard in the menu's "<none>" colour, white, on a Teal page. | |
| 2863 | assert_eq!(lines("Hidden").unwrap().color, 0xffffff); | |
| 2864 | ||
| 2865 | // Each page's lines given to the unruled page store what OneNote stored for them. | |
| 2866 | let arena = Arena::default(); | |
| 2867 | let bare = titled(RULED, "None"); | |
| 2868 | for title in [ | |
| 2869 | "College", | |
| 2870 | "Standard", | |
| 2871 | "Wide", | |
| 2872 | "SmallGrid", | |
| 2873 | "MediumGrid", | |
| 2874 | "LargeGrid", | |
| 2875 | "VeryLargeGrid", | |
| 2876 | "ColorRed", | |
| 2877 | "Hidden", | |
| 2878 | ] { | |
| 2879 | let mut section = Section::open(&arena, RULED.to_vec()).unwrap(); | |
| 2880 | let op = PageOp::RuleLines(lines(title)); | |
| 2881 | let mut predicted = section.page(bare).unwrap(); | |
| 2882 | model::apply(&mut predicted, &op).unwrap(); | |
| 2883 | apply(&mut section, Op::Page { space: bare, op }).unwrap(); | |
| 2884 | section.seal().unwrap(); | |
| 2885 | let image = section.image(); | |
| 2886 | assert_eq!(read(&image, bare), predicted, "{title}"); | |
| 2887 | assert_eq!( | |
| 2888 | rule_properties(&image, bare), | |
| 2889 | rule_properties(RULED, titled(RULED, title)), | |
| 2890 | "{title}" | |
| 2891 | ); | |
| 2892 | } | |
| 2893 | ||
| 2894 | // The candidate: rules added, changed between ruled and grid, and removed on OneNote's | |
| 2895 | // pages, and a created page ruled. | |
| 2896 | let mut section = Section::open(&arena, RULED.to_vec()).unwrap(); | |
| 2897 | let creation = crate::PageCreation::new(None, Some("Created"), "Author").unwrap(); | |
| 2898 | apply( | |
| 2899 | &mut section, | |
| 2900 | Op::Section(SectionOp::Create(creation.clone())), | |
| 2901 | ) | |
| 2902 | .unwrap(); | |
| 2903 | let wide = lines("Wide"); | |
| 2904 | let red_narrow = crate::page::RuleLines { | |
| 2905 | spacing: f32::from_bits(0x3ebe_f8d2), | |
| 2906 | color: 0xd6d4ff, | |
| 2907 | vertical: VerticalRule::Margin(0x5050ff), | |
| 2908 | }; | |
| 2909 | let edits = [ | |
| 2910 | (bare, wide), | |
| 2911 | (titled(RULED, "College"), None), | |
| 2912 | (titled(RULED, "Standard"), lines("SmallGrid")), | |
| 2913 | (titled(RULED, "MediumGrid"), Some(red_narrow)), | |
| 2914 | (creation.space(), lines("VeryLargeGrid")), | |
| 2915 | ]; | |
| 2916 | for (space, lines) in edits { | |
| 2917 | apply( | |
| 2918 | &mut section, | |
| 2919 | Op::Page { | |
| 2920 | space, | |
| 2921 | op: PageOp::RuleLines(lines), | |
| 2922 | }, | |
| 2923 | ) | |
| 2924 | .unwrap(); | |
| 2925 | section.seal().unwrap(); | |
| 2926 | } | |
| 2927 | let image = section.image(); | |
| 2928 | RevisionIndex::parse(&Store::parse(&image).unwrap()) | |
| 2929 | .unwrap() | |
| 2930 | .validate_current() | |
| 2931 | .unwrap(); | |
| 2932 | for (space, lines) in edits { | |
| 2933 | assert_eq!(read(&image, space).rule_lines, lines); | |
| 2934 | } | |
| 2935 | assert!(rule_properties(&image, titled(&image, "College")).is_empty()); | |
| 2936 | ||
| 2937 | // The whole-page lowering reaches the same pages through the same op. | |
| 2938 | let before = read(RULED, bare); | |
| 2939 | let mut after = before.clone(); | |
| 2940 | after.rule_lines = wide; | |
| 2941 | assert_eq!( | |
| 2942 | lower_page(&before, &after).unwrap(), | |
| 2943 | [PageOp::RuleLines(wide)] | |
| 2944 | ); | |
| 2945 | ||
| 2946 | if let Some(directory) = std::env::var_os("ONESTORE_RULE_LINES_EXPORT") { | |
| 2947 | let directory = std::path::Path::new(&directory); | |
| 2948 | std::fs::create_dir_all(directory).unwrap(); | |
| 2949 | std::fs::write(directory.join("Rules.one"), &image).unwrap(); | |
| 2950 | std::fs::copy( | |
| 2951 | concat!( | |
| 2952 | env!("CARGO_MANIFEST_DIR"), | |
| 2953 | "/../../corpus/rule-lines/native/notebook/Open Notebook.onetoc2" | |
| 2954 | ), | |
| 2955 | directory.join("Open Notebook.onetoc2"), | |
| 2956 | ) | |
| 2957 | .unwrap(); | |
| 2958 | } | |
| 2959 | } |
crates/onestore/src/outline.rs+13-2| ... | ... | @@ -13,7 +13,8 @@ use std::collections::{BTreeMap, BTreeSet}; |
| 13 | 13 | #[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] |
| 14 | 14 | #[serde(deny_unknown_fields)] |
| 15 | 15 | pub enum OutlineEdit { |
| 16 | /// Moves an ordinary page outline to coordinates measured in points. | |
| 16 | /// Moves an ordinary page outline to coordinates measured in points, or offsets a page's | |
| 17 | /// ink drawing from its strokes' coordinates by that much, as OneNote 2010 moves one. | |
| 17 | 18 | /// Refreshes automatic page titles when the leading outline changes. |
| 18 | 19 | Position { x: f32, y: f32 }, |
| 19 | 20 | /// Changes an ordinary outline's maximum width in points, at least 36. |
| ... | ... | @@ -63,7 +64,17 @@ impl OutlineEdit { |
| 63 | 64 | return Err(invalid("Select a paragraph for its saved expansion state")); |
| 64 | 65 | } |
| 65 | 66 | } |
| 66 | _ => { | |
| 67 | Self::Position { .. } => { | |
| 68 | if !matches!(node.kind, Kind::Outline { .. } | Kind::Ink { .. }) | |
| 69 | || parents.get(&object).map(Vec::as_slice) != Some(pages.as_slice()) | |
| 70 | || !view.nodes[page].children.contains(&object) | |
| 71 | { | |
| 72 | return Err(invalid( | |
| 73 | "Select an ordinary outline or ink drawing directly on the page", | |
| 74 | )); | |
| 75 | } | |
| 76 | } | |
| 77 | Self::Width { .. } => { | |
| 67 | 78 | if !matches!(node.kind, Kind::Outline { .. }) |
| 68 | 79 | || parents.get(&object).map(Vec::as_slice) != Some(pages.as_slice()) |
| 69 | 80 | || !view.nodes[page].children.contains(&object) |
crates/onestore/src/page/ink.rs+2-1| ... | ... | @@ -27,6 +27,7 @@ const DIMENSION_Y: [u8; 16] = [ |
| 27 | 27 | #[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] |
| 28 | 28 | pub struct Ink { |
| 29 | 29 | pub id: ExGuid, |
| 30 | /// A drawing's `x` and `y` offset every stroke point, as OneNote moves ink. | |
| 30 | 31 | pub layout: Layout, |
| 31 | 32 | pub strokes: Vec<InkStroke>, |
| 32 | 33 | /// Nested ink containers, as newer OneNote versions group handwriting. |
| ... | ... | @@ -36,7 +37,7 @@ pub struct Ink { |
| 36 | 37 | #[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] |
| 37 | 38 | pub struct InkStroke { |
| 38 | 39 | pub id: ExGuid, |
| 39 | /// Absolute page coordinates in points. | |
| 40 | /// Page coordinates in points, before the drawing's offset. | |
| 40 | 41 | pub points: Vec<[f32; 2]>, |
| 41 | 42 | /// Pen width and height in points. |
| 42 | 43 | pub width: f32, |
crates/onestore/src/page/mod.rs+94| ... | ... | @@ -56,6 +56,94 @@ pub use text::Paragraph; |
| 56 | 56 | /// MS-ONE, observed in OneNote 2010's pages (`corpus/notebook-management/native/page-color`). |
| 57 | 57 | pub(crate) const PAGE_COLOR: u32 = 0x14001d2a; |
| 58 | 58 | |
| 59 | /// The page node's rule lines (View, Rule Lines), as OneNote 2010 orders them; not in MS-ONE, | |
| 60 | /// observed in its pages (`corpus/rule-lines/native`). Each direction stores a kind (1 grid, | |
| 61 | /// 2 ruled, 3 margin line), a spacing in half inches and a colour as 0xAARRGGBB. | |
| 62 | pub(crate) const RULE_LINES: [u32; 6] = [ | |
| 63 | HORIZONTAL_COLOR, | |
| 64 | HORIZONTAL_KIND, | |
| 65 | HORIZONTAL_SPACING, | |
| 66 | VERTICAL_KIND, | |
| 67 | VERTICAL_SPACING, | |
| 68 | VERTICAL_COLOR, | |
| 69 | ]; | |
| 70 | const HORIZONTAL_KIND: u32 = 0x14001cd3; | |
| 71 | const HORIZONTAL_SPACING: u32 = 0x14001cd4; | |
| 72 | const HORIZONTAL_COLOR: u32 = 0x14001cd5; | |
| 73 | const VERTICAL_KIND: u32 = 0x14001cd6; | |
| 74 | const VERTICAL_SPACING: u32 = 0x14001cd7; | |
| 75 | const VERTICAL_COLOR: u32 = 0x14001cd8; | |
| 76 | ||
| 77 | /// A page's rule lines (View, Rule Lines): horizontal lines every `spacing` from the margin | |
| 78 | /// origin down, crossed by a margin line or a grid's vertical lines. Spacings are in half | |
| 79 | /// inches, as stored, so OneNote's presets survive exactly. | |
| 80 | #[derive(Clone, Copy, Debug, PartialEq, serde::Serialize, serde::Deserialize)] | |
| 81 | pub struct RuleLines { | |
| 82 | pub spacing: f32, | |
| 83 | /// COLORREF. | |
| 84 | pub color: u32, | |
| 85 | pub vertical: VerticalRule, | |
| 86 | } | |
| 87 | ||
| 88 | #[derive(Clone, Copy, Debug, PartialEq, serde::Serialize, serde::Deserialize)] | |
| 89 | pub enum VerticalRule { | |
| 90 | /// One line just left of the margin origin, COLORREF. | |
| 91 | Margin(u32), | |
| 92 | /// Lines every `spacing` either side of the margin origin. | |
| 93 | Grid { spacing: f32, color: u32 }, | |
| 94 | } | |
| 95 | ||
| 96 | impl RuleLines { | |
| 97 | /// The page node's properties for these lines, in `RULE_LINES` order. | |
| 98 | pub(crate) fn properties(&self) -> [(u32, [u8; 4]); 6] { | |
| 99 | let argb = |color: u32| { | |
| 100 | let [red, green, blue, _] = color.to_le_bytes(); | |
| 101 | [blue, green, red, 0xff] | |
| 102 | }; | |
| 103 | let (kinds, spacing, color) = match self.vertical { | |
| 104 | VerticalRule::Margin(color) => ([2, 3], 0.0, color), | |
| 105 | VerticalRule::Grid { spacing, color } => ([1, 1], spacing, color), | |
| 106 | }; | |
| 107 | [ | |
| 108 | (HORIZONTAL_COLOR, argb(self.color)), | |
| 109 | (HORIZONTAL_KIND, u32::to_le_bytes(kinds[0])), | |
| 110 | (HORIZONTAL_SPACING, self.spacing.to_le_bytes()), | |
| 111 | (VERTICAL_KIND, u32::to_le_bytes(kinds[1])), | |
| 112 | (VERTICAL_SPACING, spacing.to_le_bytes()), | |
| 113 | (VERTICAL_COLOR, argb(color)), | |
| 114 | ] | |
| 115 | } | |
| 116 | ||
| 117 | /// The lines a page node's `fields` store, if any are whole enough to draw. | |
| 118 | fn read(fields: &[crate::document::Field<'_>]) -> Option<Self> { | |
| 119 | let value = |id: u32| { | |
| 120 | fields | |
| 121 | .iter() | |
| 122 | .find(|field| field.id == id) | |
| 123 | .and_then(|field| match field.value { | |
| 124 | FieldValue::Bytes(&[a, b, c, d]) => Some([a, b, c, d]), | |
| 125 | _ => None, | |
| 126 | }) | |
| 127 | }; | |
| 128 | let spacing = |id| value(id).map(f32::from_le_bytes); | |
| 129 | let color = | |
| 130 | |id| value(id).map(|[blue, green, red, _]| u32::from_le_bytes([red, green, blue, 0])); | |
| 131 | value(HORIZONTAL_KIND)?; | |
| 132 | let vertical = match value(VERTICAL_KIND).map(u32::from_le_bytes) { | |
| 133 | Some(1) => VerticalRule::Grid { | |
| 134 | spacing: spacing(VERTICAL_SPACING)?, | |
| 135 | color: color(VERTICAL_COLOR)?, | |
| 136 | }, | |
| 137 | _ => VerticalRule::Margin(color(VERTICAL_COLOR)?), | |
| 138 | }; | |
| 139 | Some(Self { | |
| 140 | spacing: spacing(HORIZONTAL_SPACING).filter(|spacing| *spacing > 0.0)?, | |
| 141 | color: color(HORIZONTAL_COLOR)?, | |
| 142 | vertical, | |
| 143 | }) | |
| 144 | } | |
| 145 | } | |
| 146 | ||
| 59 | 147 | /// The role of a title-outline paragraph that displays the page's creation date or time. |
| 60 | 148 | #[derive(Clone, Copy, Debug, PartialEq, serde::Serialize, serde::Deserialize)] |
| 61 | 149 | pub enum DateField { |
| ... | ... | @@ -74,6 +162,8 @@ pub struct Page { |
| 74 | 162 | /// The page's colour (View, Page Color), COLORREF; `None` is OneNote's "No color". |
| 75 | 163 | #[serde(default)] |
| 76 | 164 | pub color: Option<u32>, |
| 165 | #[serde(default)] | |
| 166 | pub rule_lines: Option<RuleLines>, | |
| 77 | 167 | pub objects: Vec<PageObject>, |
| 78 | 168 | pub definitions: BTreeMap<ExGuid, Definition>, |
| 79 | 169 | } |
| ... | ... | @@ -617,6 +707,10 @@ impl Page { |
| 617 | 707 | _ => Err(invalid("The page color is damaged")), |
| 618 | 708 | }) |
| 619 | 709 | .transpose()?, |
| 710 | rule_lines: root | |
| 711 | .extra | |
| 712 | .first() | |
| 713 | .and_then(|fields| RuleLines::read(fields)), | |
| 620 | 714 | objects: Vec::new(), |
| 621 | 715 | definitions: BTreeMap::new(), |
| 622 | 716 | }; |
crates/snowbound/Cargo.toml+1-1| ... | ... | @@ -29,7 +29,7 @@ accesskit_winit = { version = "0.34.0", default-features = false, features = ["r |
| 29 | 29 | [target.'cfg(target_os = "macos")'.dependencies] |
| 30 | 30 | libc = "0.2" |
| 31 | 31 | objc2 = "0.5.2" |
| 32 | objc2-app-kit = { version = "0.2.2", default-features = false, features = ["NSAlert", "NSButton", "NSCell", "NSControl", "NSView", "NSResponder", "NSWindow", "NSApplication", "NSEvent", "NSMenu", "NSMenuItem", "NSColor", "NSColorSpace", "NSDatePicker", "NSDatePickerCell", "NSOpenPanel", "NSPanel", "NSSavePanel"] } | |
| 32 | objc2-app-kit = { version = "0.2.2", default-features = false, features = ["NSAlert", "NSButton", "NSCell", "NSControl", "NSView", "NSResponder", "NSWindow", "NSApplication", "NSEvent", "NSMenu", "NSMenuItem", "NSColor", "NSColorSpace", "NSDatePicker", "NSDatePickerCell", "NSOpenPanel", "NSPanel", "NSSavePanel", "NSWorkspace"] } | |
| 33 | 33 | objc2-foundation = { version = "0.2.2", default-features = false, features = ["NSString", "NSThread", "NSAttributedString", "NSDate", "NSCalendar", "NSDateFormatter", "NSGeometry", "NSLocale", "NSPathUtilities", "NSTimeZone", "NSURL", "NSArray"] } |
| 34 | 34 | |
| 35 | 35 | [target.'cfg(target_os = "linux")'.dependencies] |
crates/snowbound/assets/icons/full-page-view.svg deleted-12| ... | ... | @@ -1,12 +0,0 @@ |
| 1 | <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"> | |
| 2 | <defs> | |
| 3 | <linearGradient id="g0" x2="0" y2="1"><stop offset="0" stop-color="#ffffff"/><stop offset="1" stop-color="#e3e9f0"/></linearGradient> | |
| 4 | <linearGradient id="g1" x2="0" y2="1"><stop offset="0" stop-color="#f1f4f8"/><stop offset="1" stop-color="#c9d3df"/></linearGradient> | |
| 5 | </defs> | |
| 6 | <path d="M4.5 4.25L9.5 4.25L11.5 6.25L11.5 13.25L4.5 13.25Z" fill="#000000" fill-opacity="0.16"/> | |
| 7 | <path d="M4.5 3.5L9.5 3.5L11.5 5.5L11.5 12.5L4.5 12.5Z" fill="url(#g0)" stroke="#98a4b3" stroke-width="0.8" stroke-linecap="round" stroke-linejoin="round"/> | |
| 8 | <path d="M9.5 4.25L9.5 6.25L11.5 6.25Z" fill="#000000" fill-opacity="0.16"/> | |
| 9 | <path d="M9.5 3.5L9.5 5.5L11.5 5.5Z" fill="url(#g1)" stroke="#98a4b3" stroke-width="0.8" stroke-linecap="round" stroke-linejoin="round"/> | |
| 10 | <path class="accent" d="M6 7H9M6 9H9" fill="none" stroke="#8cc0f5" stroke-width="1" stroke-linecap="round" stroke-linejoin="round"/> | |
| 11 | <path class="accent" d="M1.5 5V1.5H5M11 1.5H14.5V5M14.5 11V14.5H11M5 14.5H1.5V11" fill="none" stroke="#1f6fe0" stroke-width="1" stroke-linecap="round" stroke-linejoin="round"/> | |
| 12 | </svg> |
crates/snowbound/assets/icons/rule-lines.svg deleted-9| ... | ... | @@ -1,9 +0,0 @@ |
| 1 | <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"> | |
| 2 | <defs> | |
| 3 | <linearGradient id="g0" x2="0" y2="1"><stop offset="0" stop-color="#ffffff"/><stop offset="1" stop-color="#e3e9f0"/></linearGradient> | |
| 4 | </defs> | |
| 5 | <path d="M3 2.25H13A0.5 0.5 0 0 1 13.5 2.75V14.75A0.5 0.5 0 0 1 13 15.25H3A0.5 0.5 0 0 1 2.5 14.75V2.75A0.5 0.5 0 0 1 3 2.25Z" fill="#000000" fill-opacity="0.16"/> | |
| 6 | <path d="M3 1.5H13A0.5 0.5 0 0 1 13.5 2V14A0.5 0.5 0 0 1 13 14.5H3A0.5 0.5 0 0 1 2.5 14V2A0.5 0.5 0 0 1 3 1.5Z" fill="url(#g0)" stroke="#98a4b3" stroke-width="0.8" stroke-linecap="round" stroke-linejoin="round"/> | |
| 7 | <path class="accent" d="M3 4H13V5H3ZM3 7H13V8H3ZM3 10H13V11H3ZM3 13H13V14H3Z" fill="#8cc0f5"/> | |
| 8 | <path d="M5 2H6V14H5Z" fill="#f03b30"/> | |
| 9 | </svg> |
crates/snowbound/assets/icons/sync-busy.svg created+4| ... | ... | @@ -0,0 +1,4 @@ |
| 1 | <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"> | |
| 2 | <path d="M12.6 6.75A4.75 4.75 0 0 0 4.1 5.2M3.4 9.25A4.75 4.75 0 0 0 11.9 10.8" fill="none" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/> | |
| 3 | <path d="M3.75 2.75V5.5H6.5M12.25 13.25V10.5H9.5" fill="none" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/> | |
| 4 | </svg> |
crates/snowbound/assets/icons/sync-done.svg created+4| ... | ... | @@ -0,0 +1,4 @@ |
| 1 | <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"> | |
| 2 | <path d="M4.25 12.25H11.5A2.75 2.75 0 0 0 11.9 6.78A4 4 0 0 0 4.3 5.9A3.2 3.2 0 0 0 4.25 12.25Z" fill="none" stroke="currentColor" stroke-width="1.25" stroke-linejoin="round"/> | |
| 3 | <path d="M5.9 8.9L7.4 10.4L10.1 7.4" fill="none" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/> | |
| 4 | </svg> |
crates/snowbound/assets/icons/sync-error.svg created+4| ... | ... | @@ -0,0 +1,4 @@ |
| 1 | <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"> | |
| 2 | <path d="M8 2.25L14 12.75H2Z" fill="none" stroke="currentColor" stroke-width="1.25" stroke-linejoin="round"/> | |
| 3 | <path d="M8 6.5V9.25M8 11.25V11.3" fill="none" stroke="currentColor" stroke-width="1.35" stroke-linecap="round"/> | |
| 4 | </svg> |
crates/snowbound/assets/icons/sync-offline.svg created+4| ... | ... | @@ -0,0 +1,4 @@ |
| 1 | <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"> | |
| 2 | <path d="M4.25 12.25H11.5A2.75 2.75 0 0 0 11.9 6.78A4 4 0 0 0 4.3 5.9A3.2 3.2 0 0 0 4.25 12.25Z" fill="none" stroke="currentColor" stroke-width="1.25" stroke-linejoin="round"/> | |
| 3 | <path d="M2.75 2.75L13.25 13.25" fill="none" stroke="currentColor" stroke-width="1.25" stroke-linecap="round"/> | |
| 4 | </svg> |
crates/snowbound/src/aqua.rs created+324| ... | ... | @@ -0,0 +1,324 @@ |
| 1 | //! Mac OS X 10.6's window, scrollers and resize grip. The window is textured, its title's | |
| 2 | //! gradient running on through the toolbar's row. Its scrollers sit beside the content | |
| 3 | //! rather than over it, so AppKit paints them here, into bitmaps the interface draws; it | |
| 4 | //! resizes only from the grip, which the OpenGL surface covers, so the app draws that. | |
| 5 | ||
| 6 | use objc2::{ | |
| 7 | ClassType, | |
| 8 | declare::ClassBuilder, | |
| 9 | msg_send, msg_send_id, | |
| 10 | rc::{Allocated, Retained}, | |
| 11 | runtime::{AnyClass, AnyObject, Bool, Sel}, | |
| 12 | sel, | |
| 13 | }; | |
| 14 | use objc2_foundation::{MainThreadMarker, NSPoint, NSRect, NSSize}; | |
| 15 | use std::{cell::Cell, collections::HashMap}; | |
| 16 | use ui::{Axis, PaintedScroller, Scroller, ScrollerPart}; | |
| 17 | use winit::{ | |
| 18 | raw_window_handle::{HasWindowHandle, RawWindowHandle}, | |
| 19 | window::{CursorIcon, Window}, | |
| 20 | }; | |
| 21 | ||
| 22 | /// Whether the system predates 10.7, which moved scrollers over the content, gave | |
| 23 | /// NSScroller its styles and let windows resize from any edge. | |
| 24 | pub(crate) fn before_lion() -> bool { | |
| 25 | let scroller = AnyClass::get("NSScroller").expect("AppKit is linked"); | |
| 26 | let styled: bool = | |
| 27 | unsafe { msg_send![scroller, respondsToSelector: sel!(preferredScrollerStyle)] }; | |
| 28 | !styled | |
| 29 | } | |
| 30 | ||
| 31 | /// Makes the window textured, AppKit's gradient running from the title through `row` points | |
| 32 | /// of content below it, as 10.6's unified toolbars do. Where frames are transparent it | |
| 33 | /// shows through. Returns whether the system draws windows so, which only 10.6 does here. | |
| 34 | pub fn textured(window: &Window, row: f32) -> bool { | |
| 35 | if !before_lion() { | |
| 36 | return false; | |
| 37 | } | |
| 38 | let RawWindowHandle::AppKit(handle) = | |
| 39 | window.window_handle().expect("Live AppKit window").as_raw() | |
| 40 | else { | |
| 41 | unreachable!() | |
| 42 | }; | |
| 43 | unsafe { | |
| 44 | let view = &*handle.ns_view.as_ptr().cast::<AnyObject>(); | |
| 45 | let window: Retained<AnyObject> = msg_send_id![view, window]; | |
| 46 | let title: Retained<AnyObject> = msg_send_id![&window, title]; | |
| 47 | let mask: usize = msg_send![&window, styleMask]; | |
| 48 | // NSTexturedBackgroundWindowMask. | |
| 49 | let _: () = msg_send![&window, setStyleMask: mask | 1 << 8]; | |
| 50 | // The style change leaves 10.6's title nil, which winit's getter can't return. | |
| 51 | let _: () = msg_send![&window, setTitle: &*title]; | |
| 52 | // NSMaxYEdge. | |
| 53 | let _: () = | |
| 54 | msg_send![&window, setAutorecalculatesContentBorderThickness: false, forEdge: 3usize]; | |
| 55 | let _: () = msg_send![&window, setContentBorderThickness: f64::from(row), forEdge: 3usize]; | |
| 56 | } | |
| 57 | true | |
| 58 | } | |
| 59 | ||
| 60 | thread_local! { | |
| 61 | static DRAGS: Cell<bool> = const { Cell::new(false) }; | |
| 62 | } | |
| 63 | ||
| 64 | /// Whether a press where the pointer is drags the window, which a textured window asks | |
| 65 | /// its content of: presses on empty toolbar space do, and nowhere else. | |
| 66 | pub fn set_window_drags(drags: bool) { | |
| 67 | DRAGS.set(drags); | |
| 68 | } | |
| 69 | ||
| 70 | /// `-mouseDownCanMoveWindow` for the content view of a textured window. | |
| 71 | pub extern "C" fn window_drags(_: &AnyObject, _: Sel) -> Bool { | |
| 72 | Bool::new(DRAGS.get()) | |
| 73 | } | |
| 74 | ||
| 75 | /// The cursor for moving something: winit loads its move cursor from a folder of cursors | |
| 76 | /// 10.6 doesn't have, so there it is the open hand. | |
| 77 | pub fn move_cursor() -> CursorIcon { | |
| 78 | if before_lion() { | |
| 79 | CursorIcon::Grab | |
| 80 | } else { | |
| 81 | CursorIcon::Move | |
| 82 | } | |
| 83 | } | |
| 84 | ||
| 85 | /// NSScrollerKnob, NSScrollerKnobSlot, NSScrollerDecrementLine, NSScrollerIncrementLine. | |
| 86 | const KNOB: usize = 2; | |
| 87 | const SLOT: usize = 6; | |
| 88 | const DECREMENT_LINE: usize = 4; | |
| 89 | const INCREMENT_LINE: usize = 5; | |
| 90 | ||
| 91 | extern "C" fn yes(_: &AnyObject, _: Sel) -> Bool { | |
| 92 | Bool::YES | |
| 93 | } | |
| 94 | ||
| 95 | /// AppKit's scrollers, on systems whose scrollers sit beside the content. | |
| 96 | pub fn scrollers() -> Option<ui::Scrollers> { | |
| 97 | if !before_lion() { | |
| 98 | return None; | |
| 99 | } | |
| 100 | MainThreadMarker::new().expect("Views belong to the main thread"); | |
| 101 | let window_class = objc2_app_kit::NSWindow::class(); | |
| 102 | // A scroller colours its knob by whether its window is key; these windows never show. | |
| 103 | let mut key = ClassBuilder::new("SnowboundKeyWindow", window_class).expect("Unique class"); | |
| 104 | unsafe { | |
| 105 | key.add_method(sel!(isKeyWindow), yes as extern "C" fn(_, _) -> _); | |
| 106 | key.add_method(sel!(isMainWindow), yes as extern "C" fn(_, _) -> _); | |
| 107 | } | |
| 108 | let key = key.register(); | |
| 109 | let windows = [window_class, key].map(|class| unsafe { | |
| 110 | let window: Allocated<AnyObject> = msg_send_id![class, alloc]; | |
| 111 | let window: Retained<AnyObject> = msg_send_id![ | |
| 112 | window, | |
| 113 | initWithContentRect: NSRect::new(NSPoint::new(-10000.0, -10000.0), NSSize::new(16.0, 16.0)), | |
| 114 | styleMask: 0usize, | |
| 115 | backing: 2usize, | |
| 116 | defer: true | |
| 117 | ]; | |
| 118 | let _: () = msg_send![&window, setReleasedWhenClosed: false]; | |
| 119 | window | |
| 120 | }); | |
| 121 | let thickness: f64 = unsafe { msg_send![AnyClass::get("NSScroller").unwrap(), scrollerWidth] }; | |
| 122 | let mut views: HashMap<(bool, bool), Retained<AnyObject>> = HashMap::new(); | |
| 123 | let mut painted: HashMap<[u32; 6], PaintedScroller> = HashMap::new(); | |
| 124 | Some(ui::Scrollers { | |
| 125 | thickness: thickness as f32, | |
| 126 | paint: Box::new(move |request: &Scroller| { | |
| 127 | let key = [ | |
| 128 | u32::from(request.axis == Axis::Y), | |
| 129 | request.length.to_bits(), | |
| 130 | request.value.to_bits(), | |
| 131 | request.proportion.to_bits(), | |
| 132 | request.held.map_or(0, |part| part as u32 + 1), | |
| 133 | u32::from(request.active), | |
| 134 | ]; | |
| 135 | if let Some(cached) = painted.get(&key) { | |
| 136 | return cached.clone(); | |
| 137 | } | |
| 138 | let vertical = request.axis == Axis::Y; | |
| 139 | let window = &windows[usize::from(request.active)]; | |
| 140 | let view = views | |
| 141 | .entry((request.active, vertical)) | |
| 142 | .or_insert_with(|| unsafe { | |
| 143 | let view: Allocated<AnyObject> = | |
| 144 | msg_send_id![AnyClass::get("NSScroller").unwrap(), alloc]; | |
| 145 | let size = if vertical { | |
| 146 | NSSize::new(thickness, 100.0) | |
| 147 | } else { | |
| 148 | NSSize::new(100.0, thickness) | |
| 149 | }; | |
| 150 | let view: Retained<AnyObject> = | |
| 151 | msg_send_id![view, initWithFrame: NSRect::new(NSPoint::new(0.0, 0.0), size)]; | |
| 152 | let content: Retained<AnyObject> = msg_send_id![window, contentView]; | |
| 153 | let _: () = msg_send![&content, addSubview: &*view]; | |
| 154 | view | |
| 155 | }); | |
| 156 | let result = unsafe { paint(window, view, request, thickness) }; | |
| 157 | if painted.len() > 32 { | |
| 158 | painted.clear(); | |
| 159 | } | |
| 160 | painted.insert(key, result.clone()); | |
| 161 | result | |
| 162 | }), | |
| 163 | }) | |
| 164 | } | |
| 165 | ||
| 166 | unsafe fn paint( | |
| 167 | window: &AnyObject, | |
| 168 | view: &AnyObject, | |
| 169 | request: &Scroller, | |
| 170 | thickness: f64, | |
| 171 | ) -> PaintedScroller { | |
| 172 | let vertical = request.axis == Axis::Y; | |
| 173 | let length = f64::from(request.length); | |
| 174 | let size = if vertical { | |
| 175 | NSSize::new(thickness, length) | |
| 176 | } else { | |
| 177 | NSSize::new(length, thickness) | |
| 178 | }; | |
| 179 | unsafe { | |
| 180 | let _: () = msg_send![window, setContentSize: size]; | |
| 181 | let _: () = msg_send![view, setFrame: NSRect::new(NSPoint::new(0.0, 0.0), size)]; | |
| 182 | let _: () = msg_send![view, setEnabled: true]; | |
| 183 | let _: () = msg_send![view, setKnobProportion: f64::from(request.proportion)]; | |
| 184 | let _: () = msg_send![view, setDoubleValue: f64::from(request.value)]; | |
| 185 | let bounds: NSRect = msg_send![view, bounds]; | |
| 186 | let rep: Retained<AnyObject> = | |
| 187 | msg_send_id![view, bitmapImageRepForCachingDisplayInRect: bounds]; | |
| 188 | let _: () = msg_send![view, cacheDisplayInRect: bounds, toBitmapImageRep: &*rep]; | |
| 189 | // NSScrollerIncrementArrow, NSScrollerDecrementArrow. | |
| 190 | let arrow = match request.held { | |
| 191 | Some(ScrollerPart::Increment) => Some(0usize), | |
| 192 | Some(ScrollerPart::Decrement) => Some(1usize), | |
| 193 | _ => None, | |
| 194 | }; | |
| 195 | if let Some(arrow) = arrow { | |
| 196 | let class = AnyClass::get("NSGraphicsContext").unwrap(); | |
| 197 | let context: Retained<AnyObject> = | |
| 198 | msg_send_id![class, graphicsContextWithBitmapImageRep: &*rep]; | |
| 199 | let _: () = msg_send![class, saveGraphicsState]; | |
| 200 | let _: () = msg_send![class, setCurrentContext: &*context]; | |
| 201 | let flipped: bool = msg_send![view, isFlipped]; | |
| 202 | if flipped { | |
| 203 | let transform: Retained<AnyObject> = | |
| 204 | msg_send_id![AnyClass::get("NSAffineTransform").unwrap(), transform]; | |
| 205 | let _: () = msg_send![&transform, translateXBy: 0.0f64, yBy: size.height]; | |
| 206 | let _: () = msg_send![&transform, scaleXBy: 1.0f64, yBy: -1.0f64]; | |
| 207 | let _: () = msg_send![&transform, concat]; | |
| 208 | } | |
| 209 | let _: () = msg_send![view, drawArrow: arrow, highlight: true]; | |
| 210 | let _: () = msg_send![class, restoreGraphicsState]; | |
| 211 | } | |
| 212 | let along = |part: usize| { | |
| 213 | let rect: NSRect = msg_send![view, rectForPart: part]; | |
| 214 | let (start, extent) = if vertical { | |
| 215 | (rect.origin.y, rect.size.height) | |
| 216 | } else { | |
| 217 | (rect.origin.x, rect.size.width) | |
| 218 | }; | |
| 219 | [start as f32, (start + extent) as f32] | |
| 220 | }; | |
| 221 | PaintedScroller { | |
| 222 | image: pixels(&rep), | |
| 223 | knob: along(KNOB), | |
| 224 | slot: along(SLOT), | |
| 225 | decrement: along(DECREMENT_LINE), | |
| 226 | increment: along(INCREMENT_LINE), | |
| 227 | } | |
| 228 | } | |
| 229 | } | |
| 230 | ||
| 231 | /// An NSBitmapImageRep's pixels as premultiplied RGBA rows. | |
| 232 | unsafe fn pixels(rep: &AnyObject) -> draw::RasterImage { | |
| 233 | unsafe { | |
| 234 | let width: isize = msg_send![rep, pixelsWide]; | |
| 235 | let height: isize = msg_send![rep, pixelsHigh]; | |
| 236 | let row: isize = msg_send![rep, bytesPerRow]; | |
| 237 | let samples: isize = msg_send![rep, samplesPerPixel]; | |
| 238 | // NSAlphaFirstBitmapFormat, NSAlphaNonpremultipliedBitmapFormat. | |
| 239 | let format: usize = msg_send![rep, bitmapFormat]; | |
| 240 | let data: *const u8 = msg_send![rep, bitmapData]; | |
| 241 | let [width, height, row, samples] = [width, height, row, samples].map(|v| v as usize); | |
| 242 | let bytes = std::slice::from_raw_parts(data, row * height); | |
| 243 | let mut rgba = Vec::with_capacity(width * height * 4); | |
| 244 | for line in bytes.chunks(row) { | |
| 245 | for pixel in line[..width * samples].chunks(samples) { | |
| 246 | let [r, g, b, a] = match (samples, format & 1 != 0) { | |
| 247 | (4, true) => [pixel[1], pixel[2], pixel[3], pixel[0]], | |
| 248 | (4, false) => [pixel[0], pixel[1], pixel[2], pixel[3]], | |
| 249 | _ => [pixel[0], pixel[1], pixel[2], 255], | |
| 250 | }; | |
| 251 | let premultiply = |value: u8| (u16::from(value) * u16::from(a) / 255) as u8; | |
| 252 | rgba.extend(if format & 2 != 0 { | |
| 253 | [premultiply(r), premultiply(g), premultiply(b), a] | |
| 254 | } else { | |
| 255 | [r, g, b, a] | |
| 256 | }); | |
| 257 | } | |
| 258 | } | |
| 259 | draw::RasterImage::new([width as u32, height as u32], rgba) | |
| 260 | .expect("A scroller's bitmap is a valid image") | |
| 261 | } | |
| 262 | } | |
| 263 | ||
| 264 | /// Where each pixel of the grip lies from the window's bottom-right corner, it takes a | |
| 265 | /// diagonal's black at this opacity: a line, then a shade either side, as 10.6 draws it. | |
| 266 | fn grip_alpha(diagonal: u32) -> u8 { | |
| 267 | match diagonal % 4 { | |
| 268 | 3 => 102, | |
| 269 | 2 => 31, | |
| 270 | 0 => 18, | |
| 271 | _ => 0, | |
| 272 | } | |
| 273 | } | |
| 274 | ||
| 275 | /// 10.6's resize grip at the window's bottom-right corner, over everything the app draws, | |
| 276 | /// where the OpenGL surface hides AppKit's own. AppKit still takes the drag. | |
| 277 | pub fn resize_grip(ui: &mut ui::Ui, window: &Window, size: [f32; 2]) { | |
| 278 | if !before_lion() { | |
| 279 | return; | |
| 280 | } | |
| 281 | static HIDDEN: std::sync::Once = std::sync::Once::new(); | |
| 282 | HIDDEN.call_once(|| { | |
| 283 | let RawWindowHandle::AppKit(handle) = | |
| 284 | window.window_handle().expect("Live AppKit window").as_raw() | |
| 285 | else { | |
| 286 | unreachable!() | |
| 287 | }; | |
| 288 | unsafe { | |
| 289 | let view = &*handle.ns_view.as_ptr().cast::<AnyObject>(); | |
| 290 | let window: Retained<AnyObject> = msg_send_id![view, window]; | |
| 291 | let _: () = msg_send![&window, setShowsResizeIndicator: false]; | |
| 292 | } | |
| 293 | }); | |
| 294 | thread_local! { | |
| 295 | static GRIP: draw::RasterImage = { | |
| 296 | // Three diagonals within 15 pixels of the corner, the last pixel row and column | |
| 297 | // left clear. | |
| 298 | let pixels = (0..15u32) | |
| 299 | .flat_map(|y| (0..15u32).map(move |x| (x, y))) | |
| 300 | .flat_map(|(x, y)| { | |
| 301 | let alpha = if x < 14 && y < 14 && x + y >= 16 { | |
| 302 | grip_alpha(x + y + 2) | |
| 303 | } else { | |
| 304 | 0 | |
| 305 | }; | |
| 306 | [0, 0, 0, alpha] | |
| 307 | }) | |
| 308 | .collect(); | |
| 309 | draw::RasterImage::new([15, 15], pixels).expect("The grip is a valid image") | |
| 310 | }; | |
| 311 | } | |
| 312 | GRIP.with(|grip| { | |
| 313 | ui.leaf( | |
| 314 | "resize grip", | |
| 315 | ui::Spec { | |
| 316 | flags: ui::Flags::FLOAT, | |
| 317 | size: [ui::px(15.0), ui::px(15.0)], | |
| 318 | position: [size[0] - 15.0, size[1] - 15.0], | |
| 319 | image: Some(grip), | |
| 320 | ..ui::Spec::default() | |
| 321 | }, | |
| 322 | ); | |
| 323 | }); | |
| 324 | } |
crates/snowbound/src/art.rs+4-2| ... | ... | @@ -53,16 +53,18 @@ pub const DATE_TIME: &[&str] = art!("icons/date-time"); |
| 53 | 53 | pub const FIND_TAGS: &[&str] = art!("icons/find-tags"); |
| 54 | 54 | pub const FORMAT_PAINTER: &[&str] = art!("icons/format-painter"); |
| 55 | 55 | pub const FORWARD: &[&str] = art!("icons/forward"); |
| 56 | pub const FULL_PAGE_VIEW: &[&str] = art!("icons/full-page-view"); | |
| 57 | 56 | pub const INSERT_SPACE: &[&str] = art!("icons/insert-space"); |
| 58 | 57 | pub const PAGE_COLOR: &[&str] = art!("icons/page-color"); |
| 59 | 58 | pub const PASTE: &[&str] = art!("icons/paste"); |
| 60 | 59 | pub const RECORD_AUDIO: &[&str] = art!("icons/record-audio"); |
| 61 | 60 | pub const RECORD_VIDEO: &[&str] = art!("icons/record-video"); |
| 62 | pub const RULE_LINES: &[&str] = art!("icons/rule-lines"); | |
| 63 | 61 | pub const SCREEN_CLIPPING: &[&str] = art!("icons/screen-clipping"); |
| 64 | 62 | pub const SPELLING: &[&str] = art!("icons/spelling"); |
| 65 | 63 | pub const SYMBOL: &[&str] = art!("icons/symbol"); |
| 64 | pub const SYNC_BUSY: &[&str] = art!("icons/sync-busy"); | |
| 65 | pub const SYNC_DONE: &[&str] = art!("icons/sync-done"); | |
| 66 | pub const SYNC_ERROR: &[&str] = art!("icons/sync-error"); | |
| 67 | pub const SYNC_OFFLINE: &[&str] = art!("icons/sync-offline"); | |
| 66 | 68 | |
| 67 | 69 | pub const TAG_REMEMBER: &[&str] = art!("tags/remember"); |
| 68 | 70 | pub const TAG_DEFINITION: &[&str] = art!("tags/definition"); |
crates/snowbound/src/background.rs created+200| ... | ... | @@ -0,0 +1,200 @@ |
| 1 | //! The Page Color menu: the page's colour, template art to lie behind it, and its rule | |
| 2 | //! lines. | |
| 3 | ||
| 4 | use crate::{ | |
| 5 | commands::Choice, | |
| 6 | templates::{TILE, Thumbnails}, | |
| 7 | }; | |
| 8 | use canvas::{ | |
| 9 | gpu::{Paper, colorref}, | |
| 10 | template::{PAGE_COLORS, RULE_LINES}, | |
| 11 | }; | |
| 12 | use onestore::page::{RuleLines, VerticalRule}; | |
| 13 | use ui::{Anchor, Flags, Id, Spec, Ui, fill, px}; | |
| 14 | ||
| 15 | const COLUMNS: usize = 9; | |
| 16 | const SWATCH: [f32; 2] = [40.0, 32.0]; | |
| 17 | const RULES: f32 = 40.0; | |
| 18 | /// Points of page per point of a rule-line thumbnail. | |
| 19 | const SHRINK: f32 = 4.0; | |
| 20 | /// The art offered after None. | |
| 21 | const ART: [&str; 5] = ["Ivy", "Purple Clouds", "Notebook", "Bamboo", "Blue Clouds"]; | |
| 22 | ||
| 23 | /// Builds popup `id` beside `anchor` while it is open, for a page on `paper` (its own colour | |
| 24 | /// aside) coloured `color` and ruled with `rule_lines`. Returns the choice made. | |
| 25 | #[allow(clippy::too_many_arguments)] | |
| 26 | pub fn menu( | |
| 27 | ui: &mut Ui, | |
| 28 | id: Id, | |
| 29 | anchor: Anchor, | |
| 30 | thumbnails: &mut Thumbnails, | |
| 31 | paper: Paper, | |
| 32 | color: Option<u32>, | |
| 33 | rule_lines: Option<RuleLines>, | |
| 34 | ) -> Option<Choice> { | |
| 35 | let cells: Vec<Choice> = std::iter::once(Choice::PageColor(None)) | |
| 36 | .chain((0..PAGE_COLORS.len()).map(|index| Choice::PageColor(Some(index)))) | |
| 37 | .chain(std::iter::once(Choice::Art(None))) | |
| 38 | .chain(ART.map(|name| Choice::Art(Some(name)))) | |
| 39 | .chain(std::iter::once(Choice::RuleLines(None))) | |
| 40 | .chain((0..RULE_LINES.len()).map(|index| Choice::RuleLines(Some(index)))) | |
| 41 | .collect(); | |
| 42 | let groups = [ | |
| 43 | ui::popup::Group { | |
| 44 | heading: "Page Color", | |
| 45 | cells: 1 + PAGE_COLORS.len(), | |
| 46 | columns: COLUMNS, | |
| 47 | size: SWATCH, | |
| 48 | }, | |
| 49 | ui::popup::Group { | |
| 50 | heading: "Background", | |
| 51 | cells: 1 + ART.len(), | |
| 52 | columns: 3, | |
| 53 | size: [TILE[0] + 8.0, TILE[1] + 8.0], | |
| 54 | }, | |
| 55 | ui::popup::Group { | |
| 56 | heading: "Rule Lines", | |
| 57 | cells: 1 + RULE_LINES.len(), | |
| 58 | columns: COLUMNS, | |
| 59 | size: [RULES; 2], | |
| 60 | }, | |
| 61 | ]; | |
| 62 | let shown = [ | |
| 63 | Choice::PageColor(color.map(|color| { | |
| 64 | PAGE_COLORS | |
| 65 | .iter() | |
| 66 | .position(|(_, listed)| *listed == color) | |
| 67 | .unwrap_or(usize::MAX) | |
| 68 | })), | |
| 69 | Choice::RuleLines(rule_lines.map(|lines| { | |
| 70 | RULE_LINES | |
| 71 | .iter() | |
| 72 | .position(|(_, listed)| { | |
| 73 | listed.spacing == lines.spacing | |
| 74 | && matches!(listed.vertical, VerticalRule::Grid { .. }) | |
| 75 | == matches!(lines.vertical, VerticalRule::Grid { .. }) | |
| 76 | }) | |
| 77 | .unwrap_or(usize::MAX) | |
| 78 | })), | |
| 79 | ]; | |
| 80 | let current: Vec<usize> = shown | |
| 81 | .iter() | |
| 82 | .filter_map(|choice| cells.iter().position(|cell| cell == choice)) | |
| 83 | .collect(); | |
| 84 | let theme = ui.theme.clone(); | |
| 85 | let scale = ui.scale(); | |
| 86 | let dark = paper.ink[0] > paper.color[0]; | |
| 87 | let chosen = ui::popup::gallery(ui, id, anchor, &groups, &current, |ui, index| { | |
| 88 | match cells[index] { | |
| 89 | Choice::PageColor(None) | Choice::Art(None) | Choice::RuleLines(None) => { | |
| 90 | ui.leaf( | |
| 91 | "none", | |
| 92 | Spec { | |
| 93 | size: [fill(), fill()], | |
| 94 | text: Some("None"), | |
| 95 | font_size: Some(theme.font_size - 2.0), | |
| 96 | fill: Some(paper.color), | |
| 97 | border: Some(theme.chip), | |
| 98 | radius: 2.0, | |
| 99 | center: true, | |
| 100 | ..Spec::default() | |
| 101 | }, | |
| 102 | ); | |
| 103 | } | |
| 104 | Choice::PageColor(Some(index)) => { | |
| 105 | let (name, color) = PAGE_COLORS[index]; | |
| 106 | ui.leaf( | |
| 107 | name, | |
| 108 | Spec { | |
| 109 | size: [fill(), fill()], | |
| 110 | fill: Some(paper.colored(Some(color)).color), | |
| 111 | border: Some(theme.chip), | |
| 112 | radius: 2.0, | |
| 113 | ..Spec::default() | |
| 114 | }, | |
| 115 | ); | |
| 116 | } | |
| 117 | Choice::Art(Some(name)) => { | |
| 118 | ui.open( | |
| 119 | name, | |
| 120 | Spec { | |
| 121 | flags: Flags::CLIP, | |
| 122 | size: [px(TILE[0]), px(TILE[1])], | |
| 123 | fill: Some(paper.color), | |
| 124 | border: Some(theme.chip), | |
| 125 | radius: 2.0, | |
| 126 | ..Spec::default() | |
| 127 | }, | |
| 128 | ); | |
| 129 | let art = canvas::template::find(name).map_or(&[][..], |template| { | |
| 130 | thumbnails.art(template, paper, dark, scale) | |
| 131 | }); | |
| 132 | for (index, (image, [x, y, width, height])) in art.iter().enumerate() { | |
| 133 | ui.leaf( | |
| 134 | index, | |
| 135 | Spec { | |
| 136 | flags: Flags::FLOAT, | |
| 137 | size: [px(*width), px(*height)], | |
| 138 | position: [*x, *y], | |
| 139 | image: Some(image), | |
| 140 | ..Spec::default() | |
| 141 | }, | |
| 142 | ); | |
| 143 | } | |
| 144 | ui.close(); | |
| 145 | } | |
| 146 | Choice::RuleLines(Some(index)) => rule_thumbnail(ui, paper, RULE_LINES[index]), | |
| 147 | _ => unreachable!("The menu offers colours, art and rule lines"), | |
| 148 | } | |
| 149 | })?; | |
| 150 | Some(cells[chosen].clone()) | |
| 151 | } | |
| 152 | ||
| 153 | /// A corner of a page ruled with `lines`, drawn a quarter size. | |
| 154 | fn rule_thumbnail(ui: &mut Ui, paper: Paper, (name, lines): (&str, RuleLines)) { | |
| 155 | let side = RULES - 8.0; | |
| 156 | ui.open( | |
| 157 | name, | |
| 158 | Spec { | |
| 159 | flags: Flags::CLIP, | |
| 160 | size: [fill(), fill()], | |
| 161 | fill: Some(paper.color), | |
| 162 | border: Some(ui.theme.chip), | |
| 163 | radius: 2.0, | |
| 164 | ..Spec::default() | |
| 165 | }, | |
| 166 | ); | |
| 167 | let line = |ui: &mut Ui, index: usize, rect: [f32; 4], color: u32| { | |
| 168 | ui.leaf( | |
| 169 | ("line", index), | |
| 170 | Spec { | |
| 171 | flags: Flags::FLOAT, | |
| 172 | position: [rect[0], rect[1]], | |
| 173 | size: [px(rect[2]), px(rect[3])], | |
| 174 | fill: Some(paper.tint(colorref(color))), | |
| 175 | ..Spec::default() | |
| 176 | }, | |
| 177 | ); | |
| 178 | }; | |
| 179 | let [x, y] = [36.0 / SHRINK, 14.4 / SHRINK]; | |
| 180 | let step = |spacing: f32| spacing * 36.0 / SHRINK; | |
| 181 | let mut index = 0; | |
| 182 | let mut at = y; | |
| 183 | while at < side { | |
| 184 | line(ui, index, [0.0, at, side, 1.0], lines.color); | |
| 185 | index += 1; | |
| 186 | at += step(lines.spacing); | |
| 187 | } | |
| 188 | match lines.vertical { | |
| 189 | VerticalRule::Margin(color) => line(ui, index, [x - 1.0, 0.0, 1.0, side], color), | |
| 190 | VerticalRule::Grid { spacing, color } => { | |
| 191 | let mut at = x % step(spacing); | |
| 192 | while at < side { | |
| 193 | line(ui, index, [at, 0.0, 1.0, side], color); | |
| 194 | index += 1; | |
| 195 | at += step(spacing); | |
| 196 | } | |
| 197 | } | |
| 198 | } | |
| 199 | ui.close(); | |
| 200 | } |
crates/snowbound/src/commands.rs+70-10| ... | ... | @@ -29,6 +29,8 @@ pub enum Id { |
| 29 | 29 | SelectAll, |
| 30 | 30 | Find, |
| 31 | 31 | Search, |
| 32 | SearchResults, | |
| 33 | CommandPalette, | |
| 32 | 34 | Back, |
| 33 | 35 | Forward, |
| 34 | 36 | ZoomIn, |
| ... | ... | @@ -63,6 +65,7 @@ pub enum Id { |
| 63 | 65 | ClearFormatting, |
| 64 | 66 | Tag(NoteTag), |
| 65 | 67 | RemoveTags, |
| 68 | FindTags, | |
| 66 | 69 | Help, |
| 67 | 70 | } |
| 68 | 71 | |
| ... | ... | @@ -81,6 +84,10 @@ pub enum Choice { |
| 81 | 84 | Table([usize; 2]), |
| 82 | 85 | /// An index into `PAGE_COLORS`, or `None` for no colour. |
| 83 | 86 | PageColor(Option<usize>), |
| 87 | /// An index into `RULE_LINES`, or `None` for none. | |
| 88 | RuleLines(Option<usize>), | |
| 89 | /// A template whose art becomes the page's background, or `None` for none. | |
| 90 | Art(Option<&'static str>), | |
| 84 | 91 | } |
| 85 | 92 | |
| 86 | 93 | #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] |
| ... | ... | @@ -225,6 +232,19 @@ pub const COMMANDS: &[Command] = &[ |
| 225 | 232 | row!(Id::SelectAll, "Select All", &[cmd('a')], &[cmd('a')]), |
| 226 | 233 | row!(Id::Find, "Find on This Page", &[cmd('f')], &[cmd('f')]), |
| 227 | 234 | row!(Id::Search, "Search Notebooks", &[cmd('e')], &[cmd('e')]), |
| 235 | // OneNote for Mac searches all notebooks with Option-Command-F. | |
| 236 | row!( | |
| 237 | Id::SearchResults, | |
| 238 | "Search Results Pane", | |
| 239 | &[cmd('f').option()], | |
| 240 | &[key('o').option()], | |
| 241 | ), | |
| 242 | row!( | |
| 243 | Id::CommandPalette, | |
| 244 | "Command Palette…", | |
| 245 | &[cmd('p').shift()], | |
| 246 | &[cmd('p').shift()] | |
| 247 | ), | |
| 228 | 248 | // Option-Command-arrows move the caret; Xcode goes back with Control-Command. |
| 229 | 249 | row!( |
| 230 | 250 | Id::Back, |
| ... | ... | @@ -252,7 +272,7 @@ pub const COMMANDS: &[Command] = &[ |
| 252 | 272 | &[cmd('0')], |
| 253 | 273 | &[cmd('0').option()] |
| 254 | 274 | ), |
| 255 | row!(Id::Sidebar, "Sidebar", &[cmd('s').control()], NONE), | |
| 275 | row!(Id::Sidebar, "Notebook List", &[cmd('s').control()], NONE), | |
| 256 | 276 | row!(Id::PageList, "Page List", NONE, NONE), |
| 257 | 277 | row!(Id::DarkPages, "Dark Pages", NONE, NONE), |
| 258 | 278 | // AppKit's Enter Full Screen takes Control-Command-F. |
| ... | ... | @@ -393,6 +413,7 @@ pub const COMMANDS: &[Command] = &[ |
| 393 | 413 | &[cmd('0').control()], |
| 394 | 414 | &[cmd('0')] |
| 395 | 415 | ), |
| 416 | row!(Id::FindTags, "Find Tags", NONE, NONE), | |
| 396 | 417 | row!(Id::Help, "Snowbound Help", NONE, NONE), |
| 397 | 418 | ]; |
| 398 | 419 | |
| ... | ... | @@ -442,7 +463,12 @@ pub fn title(choice: Choice) -> String { |
| 442 | 463 | .iter() |
| 443 | 464 | .find(|(listed, _)| *listed == color) |
| 444 | 465 | .map_or_else(String::new, |(_, name)| (*name).to_owned()), |
| 445 | Choice::Color(_) | Choice::List(_) | Choice::Table(_) | Choice::PageColor(_) => { | |
| 466 | Choice::Color(_) | |
| 467 | | Choice::List(_) | |
| 468 | | Choice::Table(_) | |
| 469 | | Choice::PageColor(_) | |
| 470 | | Choice::RuleLines(_) | |
| 471 | | Choice::Art(_) => { | |
| 446 | 472 | unreachable!("Only the toolbar offers these") |
| 447 | 473 | } |
| 448 | 474 | } |
| ... | ... | @@ -626,10 +652,12 @@ impl State { |
| 626 | 652 | Choice::Size(size) => return checked(format.font_size == Some(*size)), |
| 627 | 653 | Choice::Highlight(_) | Choice::Color(_) | Choice::List(_) => return enabled(text), |
| 628 | 654 | Choice::Table(_) => Id::Table, |
| 629 | Choice::PageColor(_) => Id::PageColor, | |
| 655 | Choice::PageColor(_) | Choice::RuleLines(_) | Choice::Art(_) => Id::PageColor, | |
| 630 | 656 | }; |
| 631 | 657 | match id { |
| 632 | Id::Settings | Id::NewNotebook | Id::OpenNotebook | Id::Help => enabled(!modal), | |
| 658 | Id::Settings | Id::NewNotebook | Id::OpenNotebook | Id::CommandPalette | Id::Help => { | |
| 659 | enabled(!modal) | |
| 660 | } | |
| 633 | 661 | Id::CloseNotebook | Id::ShowNotebook => enabled(!modal && self.notebook().is_some()), |
| 634 | 662 | Id::NewSection | Id::NewSectionGroup => enabled( |
| 635 | 663 | !modal |
| ... | ... | @@ -669,6 +697,14 @@ impl State { |
| 669 | 697 | enabled: !modal && session.is_some(), |
| 670 | 698 | checked: self.pages_open, |
| 671 | 699 | }, |
| 700 | Id::SearchResults => Status { | |
| 701 | enabled: !modal && session.is_some(), | |
| 702 | checked: matches!(self.search.pane, Some(crate::pane::Pane::Search { .. })), | |
| 703 | }, | |
| 704 | Id::FindTags => Status { | |
| 705 | enabled: !modal && session.is_some(), | |
| 706 | checked: matches!(self.search.pane, Some(crate::pane::Pane::Tags { .. })), | |
| 707 | }, | |
| 672 | 708 | Id::DarkPages => Status { |
| 673 | 709 | enabled: !modal, |
| 674 | 710 | checked: !self.light_pages, |
| ... | ... | @@ -679,8 +715,12 @@ impl State { |
| 679 | 715 | }, |
| 680 | 716 | Id::PageColor => enabled(writable && session.is_some()), |
| 681 | 717 | Id::ScreenClipping => enabled(text && cfg!(target_os = "macos")), |
| 682 | // Attach File awaits #23, Record Audio and Video #36, and Insert Space a drag mode. | |
| 683 | Id::Attachment | Id::RecordAudio | Id::RecordVideo | Id::InsertSpace => enabled(false), | |
| 718 | // Attach File awaits #23, Record Audio and Video #36. | |
| 719 | Id::Attachment | Id::RecordAudio | Id::RecordVideo => enabled(false), | |
| 720 | Id::InsertSpace => Status { | |
| 721 | enabled: writable, | |
| 722 | checked: self.view.inserting_space(), | |
| 723 | }, | |
| 684 | 724 | Id::Table |
| 685 | 725 | | Id::Picture |
| 686 | 726 | | Id::Symbol |
| ... | ... | @@ -776,7 +816,14 @@ impl State { |
| 776 | 816 | } |
| 777 | 817 | Choice::PageColor(color) => { |
| 778 | 818 | let color = color.map(|index| canvas::template::PAGE_COLORS[index].1); |
| 779 | return self.color_page(color); | |
| 819 | return self.paper_page(color, self.view.editor.rule_lines()); | |
| 820 | } | |
| 821 | Choice::Art(name) => { | |
| 822 | return self.art_page(name.and_then(canvas::template::find)); | |
| 823 | } | |
| 824 | Choice::RuleLines(lines) => { | |
| 825 | let lines = lines.map(|index| canvas::template::RULE_LINES[index].1); | |
| 826 | return self.paper_page(self.view.editor.page_color(), lines); | |
| 780 | 827 | } |
| 781 | 828 | }; |
| 782 | 829 | let field = self.ui.focused().filter(|focus| *focus != page()); |
| ... | ... | @@ -860,6 +907,14 @@ impl State { |
| 860 | 907 | self.start_search(); |
| 861 | 908 | return Ok(()); |
| 862 | 909 | } |
| 910 | Id::SearchResults | Id::FindTags => { | |
| 911 | self.toggle_pane(id == Id::FindTags); | |
| 912 | return Ok(()); | |
| 913 | } | |
| 914 | Id::CommandPalette => { | |
| 915 | self.ui.open_popup(crate::palette::id()); | |
| 916 | return Ok(()); | |
| 917 | } | |
| 863 | 918 | Id::Back | Id::Forward => { |
| 864 | 919 | self.travel(id == Id::Forward); |
| 865 | 920 | return Ok(()); |
| ... | ... | @@ -917,7 +972,12 @@ impl State { |
| 917 | 972 | self.save_settings(); |
| 918 | 973 | return Ok(()); |
| 919 | 974 | } |
| 920 | Id::Attachment | Id::RecordAudio | Id::RecordVideo | Id::InsertSpace => { | |
| 975 | Id::Attachment | Id::RecordAudio | Id::RecordVideo => { | |
| 976 | return Ok(()); | |
| 977 | } | |
| 978 | Id::InsertSpace => { | |
| 979 | let response = self.view.insert_space(); | |
| 980 | self.respond(response); | |
| 921 | 981 | return Ok(()); |
| 922 | 982 | } |
| 923 | 983 | Id::Link => { |
| ... | ... | @@ -1125,8 +1185,8 @@ mod tests { |
| 1125 | 1185 | fn toolbar_buttons_run_commands() { |
| 1126 | 1186 | let source = include_str!("main.rs"); |
| 1127 | 1187 | let start = source |
| 1128 | .find(" fn toolbar(") | |
| 1129 | .expect("The toolbar is built in main.rs"); | |
| 1188 | .find(" fn tools(") | |
| 1189 | .expect("The toolbar's groups are built in main.rs"); | |
| 1130 | 1190 | let body = &source[start..]; |
| 1131 | 1191 | let body = &body[..body[1..] |
| 1132 | 1192 | .find("\n fn ") |
crates/snowbound/src/conflict_render.rs+1| ... | ... | @@ -133,6 +133,7 @@ fn paint( |
| 133 | 133 | ], |
| 134 | 134 | clip: Some(rect.map(|value| value * SCALE)), |
| 135 | 135 | backdrop: Some(ui.theme.paper), |
| 136 | round: None, | |
| 136 | 137 | motion: None, |
| 137 | 138 | primitives: &primitives, |
| 138 | 139 | }, |
crates/snowbound/src/library.rs+48| ... | ... | @@ -288,6 +288,17 @@ impl Library { |
| 288 | 288 | Ok(section) |
| 289 | 289 | } |
| 290 | 290 | |
| 291 | /// Where `file`, a section file of this notebook, sits on this computer: under the | |
| 292 | /// mount it was opened from when the embedded client holds it share-relative. | |
| 293 | pub fn local(&self, file: &Path) -> Option<PathBuf> { | |
| 294 | if file.is_absolute() { | |
| 295 | return Some(file.to_owned()); | |
| 296 | } | |
| 297 | let server = self.server.as_ref()?; | |
| 298 | let file = file.strip_prefix(&server.mount.root).ok()?; | |
| 299 | Some(Path::new(&self.location).join(file)) | |
| 300 | } | |
| 301 | ||
| 291 | 302 | /// How the notebook's files are reached. |
| 292 | 303 | pub fn transport(&self) -> String { |
| 293 | 304 | match (&self.server, &self.notice) { |
| ... | ... | @@ -584,6 +595,43 @@ mod tests { |
| 584 | 595 | let _ = std::fs::remove_dir_all(&cache); |
| 585 | 596 | } |
| 586 | 597 | |
| 598 | #[test] | |
| 599 | fn share_relative_sections_show_under_their_mount() { | |
| 600 | let library = |root: &str| Library { | |
| 601 | location: "/Volumes/agent/lab".into(), | |
| 602 | name: "lab".into(), | |
| 603 | notebook: Ok(None), | |
| 604 | server: Some(Arc::new(Server { | |
| 605 | mount: Mount { | |
| 606 | server: "nas".into(), | |
| 607 | share: "agent".into(), | |
| 608 | user: None, | |
| 609 | domain: String::new(), | |
| 610 | root: root.into(), | |
| 611 | }, | |
| 612 | login: Login { | |
| 613 | user: String::new(), | |
| 614 | password: String::new(), | |
| 615 | domain: String::new(), | |
| 616 | }, | |
| 617 | })), | |
| 618 | notice: None, | |
| 619 | cache: PathBuf::new(), | |
| 620 | }; | |
| 621 | let shown = |root, file| library(root).local(Path::new(file)); | |
| 622 | let under = Some(PathBuf::from("/Volumes/agent/lab/Group/New Section 1.one")); | |
| 623 | assert_eq!( | |
| 624 | shown("notes/lab", "notes/lab/Group/New Section 1.one"), | |
| 625 | under | |
| 626 | ); | |
| 627 | assert_eq!(shown("", "Group/New Section 1.one"), under); | |
| 628 | assert_eq!(shown("notes/lab", "elsewhere/New Section 1.one"), None); | |
| 629 | assert_eq!( | |
| 630 | shown("notes/lab", "/tmp/Section.one"), | |
| 631 | Some(PathBuf::from("/tmp/Section.one")) | |
| 632 | ); | |
| 633 | } | |
| 634 | ||
| 587 | 635 | #[test] |
| 588 | 636 | fn mounts_name_their_server_share_account_and_folder() { |
| 589 | 637 | assert_eq!( |
crates/snowbound/src/linux.rs+145-52| ... | ... | @@ -17,7 +17,7 @@ use ui::{Flags, Spec, Ui, px}; |
| 17 | 17 | use winit::{ |
| 18 | 18 | error::EventLoopError, |
| 19 | 19 | event_loop::{EventLoop, EventLoopProxy}, |
| 20 | platform::wayland::WindowAttributesExtWayland, | |
| 20 | platform::wayland::{EventLoopExtWayland, WindowAttributesExtWayland}, | |
| 21 | 21 | window::{Icon, ResizeDirection, Theme, Window, WindowAttributes}, |
| 22 | 22 | }; |
| 23 | 23 | |
| ... | ... | @@ -48,25 +48,34 @@ pub fn event_loop(_headless: bool) -> Result<EventLoop<crate::UserEvent>, EventL |
| 48 | 48 | if desktop() == Desktop::Kde { |
| 49 | 49 | follow_breeze_radius(event_loop.create_proxy()); |
| 50 | 50 | } |
| 51 | let libadwaita = desktop() == Desktop::Gnome && event_loop.is_wayland(); | |
| 52 | sctk_adwaita::LIBADWAITA_EDGE.store(libadwaita, Ordering::Relaxed); | |
| 51 | 53 | Ok(event_loop) |
| 52 | 54 | } |
| 53 | 55 | |
| 54 | /// How far the window manager rounds the window's corners: KWin rounds the bottom corners | |
| 55 | /// of Breeze's borderless windows unless they are maximized or full screen. | |
| 56 | /// How far the window's corners round unless it is maximized or full screen: KWin rounds | |
| 57 | /// the bottom corners of Breeze's borderless windows, and on GNOME's Wayland the window cuts | |
| 58 | /// its own to libadwaita's radius, which winit's Adwaita frame edges. | |
| 56 | 59 | pub fn corner_radius(window: &Window) -> f32 { |
| 57 | let units = f32::from_bits(BREEZE_RADIUS.load(Ordering::Relaxed)); | |
| 58 | if units == 0.0 | |
| 59 | || !window.is_decorated() | |
| 60 | || window.is_maximized() | |
| 61 | || window.fullscreen().is_some() | |
| 62 | { | |
| 60 | if !window.is_decorated() || window.is_maximized() || window.fullscreen().is_some() { | |
| 63 | 61 | return 0.0; |
| 64 | 62 | } |
| 63 | if cuts_corners() { | |
| 64 | return sctk_adwaita::WINDOW_RADIUS; | |
| 65 | } | |
| 65 | 66 | // Breeze snaps its radius to the device pixel grid. |
| 67 | let units = f32::from_bits(BREEZE_RADIUS.load(Ordering::Relaxed)); | |
| 66 | 68 | let scale = window.scale_factor() as f32; |
| 67 | 69 | (units * scale).round() / scale |
| 68 | 70 | } |
| 69 | 71 | |
| 72 | /// Whether the window erases its own bottom corners to transparent pixels: under winit's | |
| 73 | /// Adwaita frame on GNOME's Wayland, which draws libadwaita's window edge. Mutter's X11 | |
| 74 | /// frames stay square at the bottom, as libadwaita's own `ssd-frame` style has them. | |
| 75 | pub fn cuts_corners() -> bool { | |
| 76 | sctk_adwaita::LIBADWAITA_EDGE.load(Ordering::Relaxed) | |
| 77 | } | |
| 78 | ||
| 70 | 79 | /// Reads Breeze's corner radius now and again whenever KWin is told to reload its |
| 71 | 80 | /// configuration, as Breeze's and the border size's settings pages do, redrawing then. |
| 72 | 81 | fn follow_breeze_radius(proxy: EventLoopProxy<crate::UserEvent>) { |
| ... | ... | @@ -141,8 +150,12 @@ pub fn window_attributes() -> WindowAttributes { |
| 141 | 150 | Window::default_attributes() |
| 142 | 151 | .with_name(APP_ID, APP_ID) |
| 143 | 152 | .with_window_icon(icon) |
| 153 | .with_transparent(cuts_corners()) | |
| 144 | 154 | } |
| 145 | 155 | |
| 156 | /// The window manager or the frame draws the title bar. | |
| 157 | pub fn install_title_bar(_: &Window) {} | |
| 158 | ||
| 146 | 159 | /// Wayland's clipboard through the window's own connection, as not every compositor offers |
| 147 | 160 | /// a clipboard to clients without a window; X11's otherwise. |
| 148 | 161 | pub enum Clipboard { |
| ... | ... | @@ -180,10 +193,11 @@ impl Clipboard { |
| 180 | 193 | } |
| 181 | 194 | } |
| 182 | 195 | |
| 183 | /// Whether the system draws the title bar; false only where Wayland's client-side frame | |
| 184 | /// could not be made, leaving the app's own. | |
| 196 | /// Whether the system draws the title bar: the window manager, or on Wayland the compositor. | |
| 197 | /// Where winit's Adwaita frame decorates the window instead, as on GNOME, the frame has no | |
| 198 | /// header and the toolbar's row is the title bar, as it is where no frame could be made. | |
| 185 | 199 | pub fn system_titlebar(window: &Window) -> bool { |
| 186 | window.is_decorated() | |
| 200 | window.is_decorated() && !sctk_adwaita::FRAMED.load(Ordering::Relaxed) | |
| 187 | 201 | } |
| 188 | 202 | |
| 189 | 203 | /// Window managers show no document of a window's. |
| ... | ... | @@ -463,41 +477,63 @@ fn adwaita_titlebar(appearance: Theme) -> [[u8; 3]; 2] { |
| 463 | 477 | }) |
| 464 | 478 | } |
| 465 | 479 | |
| 466 | /// Minimize, maximize and close at the title bar's trailing end. | |
| 480 | /// The window's buttons at the title bar's trailing end, in the order GNOME's | |
| 481 | /// `button-layout` lists them, which is close alone by default; elsewhere minimize, | |
| 482 | /// maximize and close. | |
| 467 | 483 | pub fn window_controls(ui: &mut Ui, window: &Window) { |
| 468 | let theme = ui.theme.clone(); | |
| 469 | let mut button = |part: &str, icon| { | |
| 470 | ui.leaf( | |
| 471 | part, | |
| 472 | Spec { | |
| 473 | flags: Flags::CLICKABLE, | |
| 474 | size: [px(crate::TITLE + 12.0), px(crate::TITLE)], | |
| 475 | icon: Some(icon), | |
| 476 | color: Some(theme.text), | |
| 477 | hover_fill: Some(theme.hover()), | |
| 478 | center: true, | |
| 479 | ..Spec::default() | |
| 480 | }, | |
| 481 | ) | |
| 482 | .clicked | |
| 483 | }; | |
| 484 | if button("minimize", MINIMIZE) { | |
| 485 | window.set_minimized(true); | |
| 486 | } | |
| 487 | if button( | |
| 488 | "maximize", | |
| 489 | if window.is_maximized() { | |
| 490 | RESTORE | |
| 491 | } else { | |
| 492 | MAXIMIZE | |
| 493 | }, | |
| 494 | ) { | |
| 495 | zoom(window); | |
| 496 | } | |
| 497 | if button("close", crate::art::CLOSE) | |
| 498 | && let Some(proxy) = QUIT.get() | |
| 499 | { | |
| 500 | let _ = proxy.send_event(crate::UserEvent::Quit); | |
| 484 | static CONTROLS: OnceLock<Vec<&'static str>> = OnceLock::new(); | |
| 485 | let controls = CONTROLS.get_or_init(|| { | |
| 486 | let layout = portal_setting("org.gnome.desktop.wm.preferences", "button-layout") | |
| 487 | .and_then(|value| String::try_from(value).ok()); | |
| 488 | match layout { | |
| 489 | Some(layout) => layout | |
| 490 | .split([':', ',']) | |
| 491 | .filter_map(|name| { | |
| 492 | ["minimize", "maximize", "close"] | |
| 493 | .into_iter() | |
| 494 | .find(|known| *known == name) | |
| 495 | }) | |
| 496 | .collect(), | |
| 497 | None if desktop() == Desktop::Gnome => vec!["close"], | |
| 498 | None => vec!["minimize", "maximize", "close"], | |
| 499 | } | |
| 500 | }); | |
| 501 | let text = ui.theme.text; | |
| 502 | let disc = |alpha| [text[0], text[1], text[2], alpha]; | |
| 503 | for control in controls { | |
| 504 | let icon = match *control { | |
| 505 | "minimize" => MINIMIZE, | |
| 506 | "maximize" if window.is_maximized() => RESTORE, | |
| 507 | "maximize" => MAXIMIZE, | |
| 508 | _ => crate::art::CLOSE, | |
| 509 | }; | |
| 510 | // Round, on a faint disc, as libadwaita's are. | |
| 511 | let clicked = ui | |
| 512 | .leaf( | |
| 513 | control, | |
| 514 | Spec { | |
| 515 | flags: Flags::CLICKABLE, | |
| 516 | size: [px(ui::shell::TOOL); 2], | |
| 517 | icon: Some(icon), | |
| 518 | color: Some(text), | |
| 519 | fill: Some(disc(0.1)), | |
| 520 | hover_fill: Some(disc(0.18)), | |
| 521 | radius: ui::shell::TOOL / 2.0, | |
| 522 | center: true, | |
| 523 | ..Spec::default() | |
| 524 | }, | |
| 525 | ) | |
| 526 | .clicked; | |
| 527 | match *control { | |
| 528 | _ if !clicked => {} | |
| 529 | "minimize" => window.set_minimized(true), | |
| 530 | "maximize" => zoom(window), | |
| 531 | _ => { | |
| 532 | if let Some(proxy) = QUIT.get() { | |
| 533 | let _ = proxy.send_event(crate::UserEvent::Quit); | |
| 534 | } | |
| 535 | } | |
| 536 | } | |
| 501 | 537 | } |
| 502 | 538 | } |
| 503 | 539 | |
| ... | ... | @@ -539,6 +575,21 @@ pub fn zoom(window: &Window) { |
| 539 | 575 | /// Winit delivers typed text with its key events on Linux. |
| 540 | 576 | pub fn install_text_input(_: &Window) {} |
| 541 | 577 | |
| 578 | pub fn move_cursor() -> winit::window::CursorIcon { | |
| 579 | winit::window::CursorIcon::Move | |
| 580 | } | |
| 581 | ||
| 582 | /// Window drags are the app's own. | |
| 583 | pub fn set_window_drags(_: bool) {} | |
| 584 | ||
| 585 | /// Scrollbars overlay the content. | |
| 586 | pub fn scrollers() -> Option<ui::Scrollers> { | |
| 587 | None | |
| 588 | } | |
| 589 | ||
| 590 | /// Resizing takes the window's edges. | |
| 591 | pub fn resize_grip(_: &mut Ui, _: &Window, _: [f32; 2]) {} | |
| 592 | ||
| 542 | 593 | /// Options opens from the sidebar's footer and Ctrl+Comma, as Linux apps have no shared menu. |
| 543 | 594 | pub fn install_menu() {} |
| 544 | 595 | |
| ... | ... | @@ -639,6 +690,45 @@ fn portal_setting(namespace: &str, key: &str) -> Option<zbus::zvariant::OwnedVal |
| 639 | 690 | value.try_into_owned().ok() |
| 640 | 691 | } |
| 641 | 692 | |
| 693 | pub const SHOW_FILE: &str = "Show in Files"; | |
| 694 | ||
| 695 | /// Selects `file` in the desktop's file manager, or opens its folder where none answers | |
| 696 | /// `org.freedesktop.FileManager1`. | |
| 697 | pub fn show_file(file: &std::path::Path) { | |
| 698 | let shown = zbus::blocking::connection::Builder::session() | |
| 699 | .and_then(|builder| builder.method_timeout(Duration::from_secs(2)).build()) | |
| 700 | .and_then(|connection| { | |
| 701 | connection.call_method( | |
| 702 | Some("org.freedesktop.FileManager1"), | |
| 703 | "/org/freedesktop/FileManager1", | |
| 704 | Some("org.freedesktop.FileManager1"), | |
| 705 | "ShowItems", | |
| 706 | &(vec![file_uri(file)], ""), | |
| 707 | ) | |
| 708 | }); | |
| 709 | if shown.is_err() | |
| 710 | && let Some(folder) = file.parent() | |
| 711 | { | |
| 712 | let _ = Command::new("xdg-open").arg(folder).spawn(); | |
| 713 | } | |
| 714 | } | |
| 715 | ||
| 716 | /// The `file://` URI of absolute `path`, its bytes outside RFC 3986's unreserved set and `/` | |
| 717 | /// percent-encoded. | |
| 718 | fn file_uri(path: &std::path::Path) -> String { | |
| 719 | use std::os::unix::ffi::OsStrExt; | |
| 720 | let mut uri = "file://".to_owned(); | |
| 721 | for &byte in path.as_os_str().as_bytes() { | |
| 722 | match byte { | |
| 723 | b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' | b'/' => { | |
| 724 | uri.push(byte as char) | |
| 725 | } | |
| 726 | _ => uri.push_str(&format!("%{byte:02X}")), | |
| 727 | } | |
| 728 | } | |
| 729 | uri | |
| 730 | } | |
| 731 | ||
| 642 | 732 | pub fn cache_dir() -> Option<PathBuf> { |
| 643 | 733 | xdg_dir("XDG_CACHE_HOME", ".cache") |
| 644 | 734 | } |
| ... | ... | @@ -727,12 +817,6 @@ fn xdg_base(variable: &str, fallback: &str) -> Option<PathBuf> { |
| 727 | 817 | .or_else(|| Some(PathBuf::from(std::env::var_os("HOME")?).join(fallback))) |
| 728 | 818 | } |
| 729 | 819 | |
| 730 | /// The application's icon from the icon theme, the smallest at least `pixels` square. | |
| 731 | pub fn app_icon(pixels: u32) -> Option<draw::RasterImage> { | |
| 732 | let (side, rgba) = icon_pixels(pixels)?; | |
| 733 | draw::RasterImage::new([side; 2], rgba).ok() | |
| 734 | } | |
| 735 | ||
| 736 | 820 | /// Looks beside the executable first, as the release archive installs it, then in the |
| 737 | 821 | /// XDG data directories. |
| 738 | 822 | fn icon_pixels(pixels: u32) -> Option<(u32, Vec<u8>)> { |
| ... | ... | @@ -1225,6 +1309,15 @@ mod tests { |
| 1225 | 1309 | assert_eq!(dark.motion, ui::PopupMotion::Cut); |
| 1226 | 1310 | } |
| 1227 | 1311 | |
| 1312 | #[test] | |
| 1313 | fn file_uris_escape_all_but_unreserved_bytes() { | |
| 1314 | let path = std::path::Path::new("/home/me/Notes & Plans/Café.one"); | |
| 1315 | assert_eq!( | |
| 1316 | file_uri(path), | |
| 1317 | "file:///home/me/Notes%20%26%20Plans/Caf%C3%A9.one" | |
| 1318 | ); | |
| 1319 | } | |
| 1320 | ||
| 1228 | 1321 | #[test] |
| 1229 | 1322 | fn kconfig_colours_read_as_channels_or_hex() { |
| 1230 | 1323 | assert_eq!(kde_color("222,224,226"), Some([222, 224, 226])); |
crates/snowbound/src/macos.rs+106-16| ... | ... | @@ -29,6 +29,8 @@ use winit::{ |
| 29 | 29 | window::{Window, WindowAttributes}, |
| 30 | 30 | }; |
| 31 | 31 | |
| 32 | pub use crate::aqua::{move_cursor, resize_grip, scrollers, set_window_drags}; | |
| 33 | ||
| 32 | 34 | static QUIT: OnceLock<EventLoopProxy<crate::UserEvent>> = OnceLock::new(); |
| 33 | 35 | static INPUT_CLASS: OnceLock<&'static AnyClass> = OnceLock::new(); |
| 34 | 36 | static BACKDROP_CLASS: OnceLock<&'static AnyClass> = OnceLock::new(); |
| ... | ... | @@ -86,8 +88,20 @@ pub const LEADING: f32 = 78.0; |
| 86 | 88 | /// How far AppKit rounds a window's corners. |
| 87 | 89 | pub const CORNER_RADIUS: f32 = 10.0; |
| 88 | 90 | |
| 91 | /// How far the window's corners round: 10.6 rounds a textured window's bottom corners | |
| 92 | /// by about 3.5 pixels. | |
| 89 | 93 | pub fn corner_radius(_: &Window) -> f32 { |
| 90 | CORNER_RADIUS | |
| 94 | if crate::aqua::before_lion() { | |
| 95 | 3.5 | |
| 96 | } else { | |
| 97 | CORNER_RADIUS | |
| 98 | } | |
| 99 | } | |
| 100 | ||
| 101 | /// AppKit clips the window's corners itself, except where the OpenGL surface covers | |
| 102 | /// 10.6's textured window, whose rounded bottom corners the app leaves transparent. | |
| 103 | pub fn cuts_corners() -> bool { | |
| 104 | crate::aqua::before_lion() | |
| 91 | 105 | } |
| 92 | 106 | |
| 93 | 107 | /// A transparent title bar over the content, where the window draws its own. |
| ... | ... | @@ -97,6 +111,57 @@ pub fn window_attributes() -> WindowAttributes { |
| 97 | 111 | .with_fullsize_content_view(true) |
| 98 | 112 | } |
| 99 | 113 | |
| 114 | /// Makes the title bar a unified compact toolbar's, as tall as the toolbar's row, which the | |
| 115 | /// app draws: an empty `NSToolbar` and a hidden title. AppKit places the traffic lights in | |
| 116 | /// it. The title is still set, for the Window menu, Mission Control and VoiceOver. | |
| 117 | pub fn install_title_bar(window: &Window) { | |
| 118 | MainThreadMarker::new().expect("Windows belong to the main thread"); | |
| 119 | let window = ns_window(window); | |
| 120 | let responds = | |
| 121 | |selector: Sel| -> bool { unsafe { msg_send![&window, respondsToSelector: selector] } }; | |
| 122 | // Before 10.10 the title bar keeps its own line above the row. | |
| 123 | if !responds(sel!(setTitleVisibility:)) { | |
| 124 | return; | |
| 125 | } | |
| 126 | unsafe { | |
| 127 | let class = AnyClass::get("NSToolbar").expect("AppKit is linked"); | |
| 128 | let toolbar: Allocated<AnyObject> = msg_send_id![class, alloc]; | |
| 129 | let toolbar: Retained<AnyObject> = | |
| 130 | msg_send_id![toolbar, initWithIdentifier: &*NSString::from_str("Snowbound")]; | |
| 131 | let _: () = msg_send![&toolbar, setShowsBaselineSeparator: false]; | |
| 132 | let _: () = msg_send![&window, setToolbar: &*toolbar]; | |
| 133 | // NSWindowTitleHidden. | |
| 134 | let _: () = msg_send![&window, setTitleVisibility: 1isize]; | |
| 135 | if responds(sel!(setToolbarStyle:)) { | |
| 136 | // NSWindowToolbarStyleUnifiedCompact and NSTitlebarSeparatorStyleNone. | |
| 137 | let _: () = msg_send![&window, setToolbarStyle: 4isize]; | |
| 138 | let _: () = msg_send![&window, setTitlebarSeparatorStyle: 1isize]; | |
| 139 | } | |
| 140 | } | |
| 141 | } | |
| 142 | ||
| 143 | /// The centres of the close, minimize and zoom buttons where AppKit placed them, in points | |
| 144 | /// from the content's top left corner. | |
| 145 | pub fn traffic_lights(window: &Window) -> [[f32; 2]; 3] { | |
| 146 | let window = ns_window(window); | |
| 147 | unsafe { | |
| 148 | let content: Retained<AnyObject> = msg_send_id![&window, contentView]; | |
| 149 | let content: NSRect = msg_send![&content, frame]; | |
| 150 | // NSWindowCloseButton, NSWindowMiniaturizeButton and NSWindowZoomButton, in the | |
| 151 | // window's coordinates, which rise from its bottom left corner. | |
| 152 | [0usize, 1, 2].map(|kind| { | |
| 153 | let button: Retained<AnyObject> = msg_send_id![&window, standardWindowButton: kind]; | |
| 154 | let bounds: NSRect = msg_send![&button, bounds]; | |
| 155 | let frame: NSRect = | |
| 156 | msg_send![&button, convertRect: bounds, toView: std::ptr::null::<AnyObject>()]; | |
| 157 | [ | |
| 158 | (frame.origin.x + frame.size.width / 2.0) as f32, | |
| 159 | (content.size.height - frame.origin.y - frame.size.height / 2.0) as f32, | |
| 160 | ] | |
| 161 | }) | |
| 162 | } | |
| 163 | } | |
| 164 | ||
| 100 | 165 | pub struct Clipboard(arboard::Clipboard); |
| 101 | 166 | |
| 102 | 167 | impl Clipboard { |
| ... | ... | @@ -189,9 +254,9 @@ unsafe extern "C" fn hit_nothing(_: &AnyObject, _: Sel, _: NSPoint) -> *mut AnyO |
| 189 | 254 | /// through it to the view it lies in. |
| 190 | 255 | pub fn install_backdrop(window: &Window) -> bool { |
| 191 | 256 | MainThreadMarker::new().expect("Views belong to the main thread"); |
| 192 | // Before 10.10 AppKit has no materials. | |
| 257 | // Before 10.10 AppKit has no materials; 10.6's textured window stands in. | |
| 193 | 258 | let Some(effect) = AnyClass::get("NSVisualEffectView") else { |
| 194 | return false; | |
| 259 | return crate::aqua::textured(window, crate::TITLE); | |
| 195 | 260 | }; |
| 196 | 261 | let RawWindowHandle::AppKit(handle) = |
| 197 | 262 | window.window_handle().expect("Live AppKit window").as_raw() |
| ... | ... | @@ -230,6 +295,18 @@ pub fn resize_direction(_: &Window, _: [f32; 2]) -> Option<winit::window::Resize |
| 230 | 295 | None |
| 231 | 296 | } |
| 232 | 297 | |
| 298 | pub const SHOW_FILE: &str = "Show in Finder"; | |
| 299 | ||
| 300 | /// Opens a Finder window with `file` selected. | |
| 301 | pub fn show_file(file: &std::path::Path) { | |
| 302 | unsafe { | |
| 303 | let url = | |
| 304 | objc2_foundation::NSURL::fileURLWithPath(&NSString::from_str(&file.to_string_lossy())); | |
| 305 | objc2_app_kit::NSWorkspace::sharedWorkspace() | |
| 306 | .activateFileViewerSelectingURLs(&objc2_foundation::NSArray::from_vec(vec![url])); | |
| 307 | } | |
| 308 | } | |
| 309 | ||
| 233 | 310 | pub fn cache_dir() -> Option<std::path::PathBuf> { |
| 234 | 311 | Some(std::path::PathBuf::from(std::env::var_os("HOME")?).join("Library/Caches/snowbound")) |
| 235 | 312 | } |
| ... | ... | @@ -453,6 +530,12 @@ pub fn install_text_input(window: &Window) { |
| 453 | 530 | sel!(insertText:replacementRange:), |
| 454 | 531 | insert_text as unsafe extern "C" fn(_, _, _, _), |
| 455 | 532 | ); |
| 533 | if crate::aqua::before_lion() { | |
| 534 | class.add_method( | |
| 535 | sel!(mouseDownCanMoveWindow), | |
| 536 | crate::aqua::window_drags as extern "C" fn(_, _) -> _, | |
| 537 | ); | |
| 538 | } | |
| 456 | 539 | class.register() |
| 457 | 540 | }); |
| 458 | 541 | assert_eq!(class.superclass(), Some(view.class())); |
| ... | ... | @@ -676,11 +759,6 @@ pub fn commit_presentation(window: &Window) { |
| 676 | 759 | } |
| 677 | 760 | } |
| 678 | 761 | |
| 679 | /// None: AppKit's title shows the document's icon in its place. | |
| 680 | pub fn app_icon(_: u32) -> Option<draw::RasterImage> { | |
| 681 | None | |
| 682 | } | |
| 683 | ||
| 684 | 762 | /// The insertion point's colour and selected text's fill with and without keyboard focus, |
| 685 | 763 | /// linear RGBA, in `window`'s appearance. |
| 686 | 764 | pub fn text_colors(window: &Window) -> [[f32; 4]; 3] { |
| ... | ... | @@ -770,17 +848,29 @@ declare_class!( |
| 770 | 848 | objc2::runtime::Bool::new(status.enabled) |
| 771 | 849 | } |
| 772 | 850 | |
| 773 | // NSAlert consumes Escape before it reaches cancelOperation:. | |
| 851 | // NSAlert consumes Escape before it reaches cancelOperation:, and 10.6's date | |
| 852 | // picker consumes Return before it reaches the default button. | |
| 774 | 853 | #[method(sendEvent:)] |
| 775 | 854 | fn send_event(&self, event: &NSEvent) { |
| 776 | 855 | unsafe { |
| 777 | if event.r#type() == NSEventType::KeyDown | |
| 778 | && self.modalWindow().is_some() | |
| 779 | && event.charactersIgnoringModifiers().is_some_and(|text| text.to_string() == "\u{1b}") | |
| 780 | { | |
| 781 | self.stopModal(); | |
| 782 | } else { | |
| 783 | let _: () = msg_send![super(self), sendEvent: event]; | |
| 856 | let key = (event.r#type() == NSEventType::KeyDown) | |
| 857 | .then(|| event.charactersIgnoringModifiers()) | |
| 858 | .flatten() | |
| 859 | .map(|text| text.to_string()); | |
| 860 | let modal = self.modalWindow(); | |
| 861 | match (key.as_deref(), &modal) { | |
| 862 | (Some("\u{1b}"), Some(_)) => self.stopModal(), | |
| 863 | (Some("\r" | "\u{3}"), Some(modal)) if { | |
| 864 | let responder: *mut AnyObject = msg_send![modal, firstResponder]; | |
| 865 | !responder.is_null() | |
| 866 | && msg_send![responder, isKindOfClass: NSDatePicker::class()] | |
| 867 | } => { | |
| 868 | let cell: *mut AnyObject = msg_send![modal, defaultButtonCell]; | |
| 869 | let _: () = msg_send![cell, performClick: std::ptr::null::<AnyObject>()]; | |
| 870 | } | |
| 871 | _ => { | |
| 872 | let _: () = msg_send![super(self), sendEvent: event]; | |
| 873 | } | |
| 784 | 874 | } |
| 785 | 875 | } |
| 786 | 876 | } |
crates/snowbound/src/main.rs+832-599| ... | ... | @@ -1,4 +1,7 @@ |
| 1 | #[cfg(target_os = "macos")] | |
| 2 | mod aqua; | |
| 1 | 3 | mod art; |
| 4 | mod background; | |
| 2 | 5 | mod commands; |
| 3 | 6 | #[cfg(test)] |
| 4 | 7 | mod conflict_render; |
| ... | ... | @@ -12,6 +15,8 @@ mod menubar; |
| 12 | 15 | mod menus; |
| 13 | 16 | mod navigation; |
| 14 | 17 | mod options; |
| 18 | mod palette; | |
| 19 | mod pane; | |
| 15 | 20 | #[cfg_attr(target_os = "linux", path = "linux.rs")] |
| 16 | 21 | #[cfg_attr(target_os = "macos", path = "macos.rs")] |
| 17 | 22 | mod platform; |
| ... | ... | @@ -36,7 +41,6 @@ use canvas::{ |
| 36 | 41 | use canvas::{ |
| 37 | 42 | gpu::{colorref, tag_sources}, |
| 38 | 43 | outline::TagIcon, |
| 39 | template::PAGE_COLORS, | |
| 40 | 44 | }; |
| 41 | 45 | use draw::Renderer; |
| 42 | 46 | use library::Library; |
| ... | ... | @@ -61,8 +65,12 @@ use winit::{ |
| 61 | 65 | window::{CursorIcon, Window, WindowId}, |
| 62 | 66 | }; |
| 63 | 67 | |
| 64 | /// Height of the title bar, which holds the window's controls. | |
| 65 | const TITLE: f32 = 28.0; | |
| 68 | /// Height of the toolbar's row, which is the title bar where the platform lets it: a | |
| 69 | /// unified compact toolbar's on macOS. | |
| 70 | const TITLE: f32 = 38.0; | |
| 71 | /// Space between toolbar groups, and the toolbar's margin at an edge without window controls. | |
| 72 | const GAP: f32 = 6.0; | |
| 73 | const EDGE: f32 = 8.0; | |
| 66 | 74 | const TAB_ROW: f32 = 28.0; |
| 67 | 75 | /// Width of the section colour around the page. |
| 68 | 76 | const FRAME: f32 = 6.0; |
| ... | ... | @@ -185,11 +193,13 @@ fn cursor_icon(cursor: Cursor) -> CursorIcon { |
| 185 | 193 | Cursor::Default => CursorIcon::Default, |
| 186 | 194 | Cursor::Text => CursorIcon::Text, |
| 187 | 195 | Cursor::Pointer => CursorIcon::Pointer, |
| 188 | Cursor::Move => CursorIcon::Move, | |
| 196 | Cursor::Move => platform::move_cursor(), | |
| 189 | 197 | Cursor::EwResize => CursorIcon::EwResize, |
| 190 | 198 | Cursor::NsResize => CursorIcon::NsResize, |
| 191 | 199 | Cursor::NwseResize => CursorIcon::NwseResize, |
| 192 | 200 | Cursor::NeswResize => CursorIcon::NeswResize, |
| 201 | Cursor::RowResize => CursorIcon::RowResize, | |
| 202 | Cursor::ColResize => CursorIcon::ColResize, | |
| 193 | 203 | } |
| 194 | 204 | } |
| 195 | 205 | |
| ... | ... | @@ -489,6 +499,7 @@ struct State { |
| 489 | 499 | last_pages: HashMap<String, ExGuid>, |
| 490 | 500 | /// Pages visited, for Back and Forward. |
| 491 | 501 | trail: navigation::Trail, |
| 502 | swipe: navigation::Swipe, | |
| 492 | 503 | /// Asks for a frame when a worker thread finishes something the page shows. |
| 493 | 504 | redraw: std::task::Waker, |
| 494 | 505 | surface: surface::Surface, |
| ... | ... | @@ -547,8 +558,6 @@ struct State { |
| 547 | 558 | recent_fonts: Vec<String>, |
| 548 | 559 | /// What the toolbar's buttons apply from their menus' last picks. |
| 549 | 560 | toolbar: settings::Toolbar, |
| 550 | /// The application's icon at the display's scale, for the title bar. | |
| 551 | app_icon: Option<draw::RasterImage>, | |
| 552 | 561 | commands: Vec<Command>, |
| 553 | 562 | /// The page changed during this frame, to be saved and announced after it. |
| 554 | 563 | changed: bool, |
| ... | ... | @@ -567,6 +576,9 @@ struct State { |
| 567 | 576 | strip_held: bool, |
| 568 | 577 | /// Where a replay asked the next frame to be written. |
| 569 | 578 | snapshot: Option<PathBuf>, |
| 579 | /// `SNOWBOUND_FRAMES`: a directory every frame drawn is also written to, named by | |
| 580 | /// milliseconds since the window opened. | |
| 581 | frames: Option<(PathBuf, Instant)>, | |
| 570 | 582 | initial: Vec<(onestore::ExGuid, TextDocument)>, |
| 571 | 583 | initial_layouts: Vec<(onestore::ExGuid, onestore::document::Layout)>, |
| 572 | 584 | initial_date: Option<u64>, |
| ... | ... | @@ -590,6 +602,10 @@ fn frame() -> Id { |
| 590 | 602 | Id::ROOT.child("frame") |
| 591 | 603 | } |
| 592 | 604 | |
| 605 | fn sections() -> Id { | |
| 606 | Id::ROOT.child("sections") | |
| 607 | } | |
| 608 | ||
| 593 | 609 | impl State { |
| 594 | 610 | async fn new( |
| 595 | 611 | event_loop: &ActiveEventLoop, |
| ... | ... | @@ -624,6 +640,7 @@ impl State { |
| 624 | 640 | .with_min_inner_size(LogicalSize::new(600.0, 400.0)), |
| 625 | 641 | )?, |
| 626 | 642 | ); |
| 643 | platform::install_title_bar(&window); | |
| 627 | 644 | platform::install_text_input(&window); |
| 628 | 645 | // Beneath the surface's layer, which is added over it. |
| 629 | 646 | let backdrop = visible && platform::install_backdrop(&window); |
| ... | ... | @@ -743,6 +760,7 @@ impl State { |
| 743 | 760 | let titlebar = platform::titlebar(appearance).unwrap_or([ui.theme.strip; 2]); |
| 744 | 761 | // A window shown but never focused hears no focus event; a hidden one draws as focused. |
| 745 | 762 | ui.window_focused = !visible || window.has_focus(); |
| 763 | ui.scrollers = platform::scrollers(); | |
| 746 | 764 | ui.set_focus(Some(page())); |
| 747 | 765 | for family in FONTS { |
| 748 | 766 | for (face, _) in engine.substitute(family).map_or(&[][..], |s| &s.faces) { |
| ... | ... | @@ -806,7 +824,6 @@ impl State { |
| 806 | 824 | fonts, |
| 807 | 825 | recent_fonts: stored.recent_fonts, |
| 808 | 826 | toolbar: stored.toolbar, |
| 809 | app_icon: platform::app_icon((16.0 * dpr).round() as u32), | |
| 810 | 827 | commands: Vec::new(), |
| 811 | 828 | changed: false, |
| 812 | 829 | moved: false, |
| ... | ... | @@ -816,6 +833,7 @@ impl State { |
| 816 | 833 | strip_press: None, |
| 817 | 834 | strip_held: false, |
| 818 | 835 | snapshot: None, |
| 836 | frames: std::env::var_os("SNOWBOUND_FRAMES").map(|dir| (dir.into(), Instant::now())), | |
| 819 | 837 | initial, |
| 820 | 838 | initial_date, |
| 821 | 839 | initial_layouts, |
| ... | ... | @@ -829,6 +847,7 @@ impl State { |
| 829 | 847 | places: HashMap::new(), |
| 830 | 848 | last_pages: HashMap::new(), |
| 831 | 849 | trail: navigation::Trail::default(), |
| 850 | swipe: navigation::Swipe::default(), | |
| 832 | 851 | access_adapter, |
| 833 | 852 | accessibility: accessibility::Accessibility::default(), |
| 834 | 853 | }; |
| ... | ... | @@ -903,10 +922,32 @@ impl State { |
| 903 | 922 | let (section, open_tab, open_page) = self.build()?; |
| 904 | 923 | self.options_dialog(); |
| 905 | 924 | self.link_dialog()?; |
| 925 | self.palette(); | |
| 906 | 926 | self.sync_popup()?; |
| 907 | 927 | self.text_menu()?; |
| 928 | platform::resize_grip(&mut self.ui, &self.window, size); | |
| 908 | 929 | self.ui.end(); |
| 909 | 930 | self.edges(section, open_tab, open_page); |
| 931 | if platform::cuts_corners() { | |
| 932 | let [width, height] = size; | |
| 933 | let radius = platform::corner_radius(&self.window); | |
| 934 | // Where the toolbar's row is the title bar, its corners are the window's too. | |
| 935 | let top = if platform::system_titlebar(&self.window) { | |
| 936 | 0.0 | |
| 937 | } else { | |
| 938 | radius | |
| 939 | }; | |
| 940 | self.ui.round_corners( | |
| 941 | &[ | |
| 942 | ([0.0, 0.0], top), | |
| 943 | ([width, 0.0], top), | |
| 944 | ([width, height], radius), | |
| 945 | ([0.0, height], radius), | |
| 946 | ], | |
| 947 | [0.0; 4], | |
| 948 | |_| [0.0; 4], | |
| 949 | ); | |
| 950 | } | |
| 910 | 951 | if let Some(rect) = self.ui.rect(page()) { |
| 911 | 952 | let size = [ |
| 912 | 953 | ((rect[2] - rect[0]) * scale).round() as u32, |
| ... | ... | @@ -978,15 +1019,13 @@ impl State { |
| 978 | 1019 | // The page, and the open page's tab joined to it, take the page's colour. |
| 979 | 1020 | let mut theme = self.ui.theme.clone(); |
| 980 | 1021 | theme.paper = self.paper().color; |
| 981 | if !platform::system_titlebar(&self.window) { | |
| 982 | self.title_bar(&theme); | |
| 983 | } | |
| 984 | 1022 | platform::update_menu(|| self.statuses()); |
| 985 | if self.session.is_none() && !self.temporary && self.sectionless.is_none() { | |
| 1023 | let welcome = self.session.is_none() && !self.temporary && self.sectionless.is_none(); | |
| 1024 | self.toolbar(&theme, !welcome)?; | |
| 1025 | if welcome { | |
| 986 | 1026 | self.welcome(&theme); |
| 987 | 1027 | return Ok((section, Id::ROOT, None)); |
| 988 | 1028 | } |
| 989 | self.toolbar(&theme)?; | |
| 990 | 1029 | self.ui.open( |
| 991 | 1030 | "body", |
| 992 | 1031 | Spec { |
| ... | ... | @@ -1017,7 +1056,19 @@ impl State { |
| 1017 | 1056 | ..Spec::default() |
| 1018 | 1057 | }, |
| 1019 | 1058 | ); |
| 1020 | self.sidebar_button(&theme, sidebar); | |
| 1059 | if !self.temporary { | |
| 1060 | // Past the row's padding, the frame's corner and the first tab's shadow, the tabs' | |
| 1061 | // outlines start where the button's square ends; the room shrinks on the sidebar's | |
| 1062 | // easing, so the tabs ease with it. | |
| 1063 | let room = TAB_ROW - FRAME - self.rounding() - ui::SHADOW[0]; | |
| 1064 | self.ui.leaf( | |
| 1065 | "rail", | |
| 1066 | Spec { | |
| 1067 | size: [px(room * (1.0 - sidebar / sidebar::WIDTH)), px(1.0)], | |
| 1068 | ..Spec::default() | |
| 1069 | }, | |
| 1070 | ); | |
| 1071 | } | |
| 1021 | 1072 | self.ui.leaf( |
| 1022 | 1073 | "corner", |
| 1023 | 1074 | Spec { |
| ... | ... | @@ -1025,7 +1076,7 @@ impl State { |
| 1025 | 1076 | ..Spec::default() |
| 1026 | 1077 | }, |
| 1027 | 1078 | ); |
| 1028 | let row = self.ui.id("sections"); | |
| 1079 | let row = sections(); | |
| 1029 | 1080 | let (clicked, open_tab) = match &self.session { |
| 1030 | 1081 | Some(session) => { |
| 1031 | 1082 | // A tab being renamed takes the name typed, which its field covers. |
| ... | ... | @@ -1054,13 +1105,14 @@ impl State { |
| 1054 | 1105 | open: open_tab, |
| 1055 | 1106 | } = ui::shell::section_tabs( |
| 1056 | 1107 | &mut self.ui, |
| 1057 | "sections", | |
| 1108 | row, | |
| 1058 | 1109 | &tabs, |
| 1059 | 1110 | session.tab, |
| 1060 | 1111 | lit, |
| 1061 | 1112 | dragged, |
| 1062 | 1113 | &section, |
| 1063 | 1114 | TAB_ROW, |
| 1115 | theme.strip, | |
| 1064 | 1116 | ); |
| 1065 | 1117 | self.drag_tabs(held, slot, settled, row); |
| 1066 | 1118 | let Some(session) = &self.session else { |
| ... | ... | @@ -1100,19 +1152,27 @@ impl State { |
| 1100 | 1152 | let shown = if self.temporary { &tabs[..] } else { &[] }; |
| 1101 | 1153 | let open_tab = ui::shell::section_tabs( |
| 1102 | 1154 | &mut self.ui, |
| 1103 | "sections", | |
| 1155 | row, | |
| 1104 | 1156 | shown, |
| 1105 | 1157 | 0, |
| 1106 | 1158 | None, |
| 1107 | 1159 | None, |
| 1108 | 1160 | &section, |
| 1109 | 1161 | TAB_ROW, |
| 1162 | theme.strip, | |
| 1110 | 1163 | ) |
| 1111 | 1164 | .open; |
| 1112 | 1165 | (None, open_tab) |
| 1113 | 1166 | } |
| 1114 | 1167 | }; |
| 1115 | 1168 | self.commands.extend(clicked); |
| 1169 | self.ui.leaf( | |
| 1170 | "space", | |
| 1171 | Spec { | |
| 1172 | size: [fill(), px(1.0)], | |
| 1173 | ..Spec::default() | |
| 1174 | }, | |
| 1175 | ); | |
| 1116 | 1176 | if self.session.is_some() { |
| 1117 | 1177 | self.page_tools(&theme); |
| 1118 | 1178 | } |
| ... | ... | @@ -1153,6 +1213,7 @@ impl State { |
| 1153 | 1213 | self.ui.close(); |
| 1154 | 1214 | self.ui.close(); |
| 1155 | 1215 | self.ui.close(); |
| 1216 | self.sidebar_button(&theme, height); | |
| 1156 | 1217 | self.ui.close(); |
| 1157 | 1218 | self.context_menu(); |
| 1158 | 1219 | return Ok((section, open_tab, None)); |
| ... | ... | @@ -1195,6 +1256,8 @@ impl State { |
| 1195 | 1256 | let open_page = self.page_list(&theme, &section, row); |
| 1196 | 1257 | self.ui.close(); |
| 1197 | 1258 | self.ui.close(); |
| 1259 | self.task_pane(&theme); | |
| 1260 | self.sidebar_button(&theme, height); | |
| 1198 | 1261 | self.ui.close(); |
| 1199 | 1262 | self.context_menu(); |
| 1200 | 1263 | // No tab stands on the frame in Full Page View. |
| ... | ... | @@ -1291,10 +1354,16 @@ impl State { |
| 1291 | 1354 | |_| strip, |
| 1292 | 1355 | ); |
| 1293 | 1356 | } |
| 1357 | // The border breaks where the open tab shows, which the row may scroll it out of. | |
| 1358 | let [left, right] = self | |
| 1359 | .ui | |
| 1360 | .rect(sections()) | |
| 1361 | .map_or([end; 2], |row| [row[0], row[2]]); | |
| 1294 | 1362 | let [foot, toe] = self |
| 1295 | 1363 | .ui |
| 1296 | 1364 | .rect(open_tab) |
| 1297 | .map_or([end; 2], |tab| ui::shell::tab_base(tab, TAB_ROW)); | |
| 1365 | .map_or([end; 2], |tab| ui::shell::tab_base(tab, TAB_ROW)) | |
| 1366 | .map(|x| x.clamp(left, right)); | |
| 1298 | 1367 | // The border runs down both sides to the window's bottom, round the bottom corner |
| 1299 | 1368 | // beside the sidebar. |
| 1300 | 1369 | self.ui.border( |
| ... | ... | @@ -1318,62 +1387,75 @@ impl State { |
| 1318 | 1387 | ); |
| 1319 | 1388 | } |
| 1320 | 1389 | |
| 1321 | /// The window's title bar beside the traffic lights: the application's icon, the | |
| 1322 | /// notebook's name and the sync status. | |
| 1323 | fn title_bar(&mut self, theme: &Theme) { | |
| 1324 | self.ui.open_as( | |
| 1325 | strip(), | |
| 1326 | Spec { | |
| 1327 | flags: Flags::CLICKABLE, | |
| 1328 | size: [fill(), px(TITLE)], | |
| 1329 | fill: Some(theme.strip), | |
| 1330 | ..Spec::default() | |
| 1390 | /// The toolbar: one row of groups that fold as the window narrows. Where the platform | |
| 1391 | /// lets it, the row is the window's title bar, and on the welcome page, without `tools`, | |
| 1392 | /// that is all it is. | |
| 1393 | fn toolbar(&mut self, theme: &Theme, tools: bool) -> Result<(), Box<dyn Error>> { | |
| 1394 | let title = !platform::system_titlebar(&self.window); | |
| 1395 | if !tools && !title { | |
| 1396 | return Ok(()); | |
| 1397 | } | |
| 1398 | // Over the title's gradient, the row's empty space drags the window as the title does. | |
| 1399 | let strip_row = title || self.surface.translucent(); | |
| 1400 | let spec = Spec { | |
| 1401 | flags: if strip_row { | |
| 1402 | Flags::CLICKABLE | |
| 1403 | } else { | |
| 1404 | Flags::default() | |
| 1331 | 1405 | }, |
| 1332 | ); | |
| 1406 | size: [fill(), px(TITLE)], | |
| 1407 | fill: Some(theme.strip), | |
| 1408 | pad: [0.0, (TITLE - ui::shell::TOOL) / 2.0], | |
| 1409 | gap: GAP, | |
| 1410 | ..Spec::default() | |
| 1411 | }; | |
| 1412 | if strip_row { | |
| 1413 | self.ui.open_as(strip(), spec); | |
| 1414 | } else { | |
| 1415 | self.ui.open("toolbar", spec); | |
| 1416 | } | |
| 1417 | let lead = if title { platform::LEADING } else { EDGE }; | |
| 1333 | 1418 | self.ui.leaf( |
| 1334 | "lights", | |
| 1419 | "lead", | |
| 1335 | 1420 | Spec { |
| 1336 | size: [px(platform::LEADING), px(1.0)], | |
| 1421 | size: [px(lead - GAP), px(1.0)], | |
| 1337 | 1422 | ..Spec::default() |
| 1338 | 1423 | }, |
| 1339 | 1424 | ); |
| 1340 | // AppKit centres the window's title over the strip. | |
| 1341 | if !cfg!(target_os = "macos") { | |
| 1425 | if tools { | |
| 1426 | self.tools(theme); | |
| 1427 | } else { | |
| 1342 | 1428 | self.ui.leaf( |
| 1343 | "notebook", | |
| 1429 | "space", | |
| 1344 | 1430 | Spec { |
| 1345 | size: [fit(), px(TITLE)], | |
| 1346 | image: self.app_icon.as_ref(), | |
| 1347 | text: Some(match &self.session { | |
| 1348 | Some(session) => &session.library.name, | |
| 1349 | None if self.temporary => "Temporary page", | |
| 1350 | None => "Snowbound", | |
| 1351 | }), | |
| 1431 | size: [fill(), px(1.0)], | |
| 1352 | 1432 | ..Spec::default() |
| 1353 | 1433 | }, |
| 1354 | 1434 | ); |
| 1355 | 1435 | } |
| 1436 | if title { | |
| 1437 | platform::window_controls(&mut self.ui, &self.window); | |
| 1438 | } | |
| 1356 | 1439 | self.ui.leaf( |
| 1357 | "space", | |
| 1440 | "trail", | |
| 1358 | 1441 | Spec { |
| 1359 | size: [fill(), px(1.0)], | |
| 1442 | size: [px(EDGE - GAP), px(1.0)], | |
| 1360 | 1443 | ..Spec::default() |
| 1361 | 1444 | }, |
| 1362 | 1445 | ); |
| 1363 | if let Some(session) = &self.session { | |
| 1364 | sync::control(&mut self.ui, session, theme, TITLE); | |
| 1365 | } | |
| 1366 | platform::window_controls(&mut self.ui, &self.window); | |
| 1367 | 1446 | self.ui.close(); |
| 1447 | Ok(()) | |
| 1368 | 1448 | } |
| 1369 | 1449 | |
| 1370 | /// Two rows of navigation, clipboard, formatting, tag, insert and view buttons, grouped | |
| 1371 | /// as OneNote's ribbon groups them. | |
| 1372 | fn toolbar(&mut self, theme: &Theme) -> Result<(), Box<dyn Error>> { | |
| 1450 | /// The toolbar's groups, as OneNote's ribbon groups its buttons. The groups used most in | |
| 1451 | /// taking notes fold last, and those with chords or a place in the menu bar first. | |
| 1452 | fn tools(&mut self, theme: &Theme) { | |
| 1453 | use Entry::{Open, Rule, Run, Soon}; | |
| 1373 | 1454 | use canvas::editor::{ |
| 1374 | 1455 | Alignment, BULLET_LIBRARY, ListStyle, NUMBER_LIBRARY, NoteTag, Toggle, |
| 1375 | 1456 | }; |
| 1376 | use commands::{Choice, Id as Cmd, shortcut}; | |
| 1457 | use commands::{Choice, Id as Cmd}; | |
| 1458 | use ui::shell::TOOL; | |
| 1377 | 1459 | // A focused picture has no text to show a format for. |
| 1378 | 1460 | let state = self.format_state(); |
| 1379 | 1461 | let statuses: Vec<_> = commands::COMMANDS |
| ... | ... | @@ -1391,52 +1473,39 @@ impl State { |
| 1391 | 1473 | .find(|(listed, _)| *listed == id) |
| 1392 | 1474 | .map_or_else(commands::Status::default, |(_, status)| *status) |
| 1393 | 1475 | }; |
| 1476 | let status_of = &status_of; | |
| 1394 | 1477 | let fonts = &self.fonts; |
| 1395 | 1478 | let recent_fonts = &self.recent_fonts; |
| 1396 | 1479 | let pens = &self.toolbar; |
| 1397 | 1480 | let engine = &self.view.engine; |
| 1398 | // Under the system's title bar the sync status joins the toolbar. | |
| 1399 | let status = platform::system_titlebar(&self.window) | |
| 1400 | .then_some(self.session.as_ref()) | |
| 1401 | .flatten(); | |
| 1481 | let session = self.session.as_ref(); | |
| 1482 | let label = format!("{:.0}%", self.view.zoom() * 100.0); | |
| 1402 | 1483 | let ui = &mut self.ui; |
| 1403 | 1484 | let text = theme.text; |
| 1404 | let on = |toggle| state.toggles.contains(&toggle); | |
| 1405 | let mut choice = None; | |
| 1406 | ui.open( | |
| 1407 | "toolbar", | |
| 1408 | Spec { | |
| 1409 | size: [fill(), children()], | |
| 1410 | fill: Some(theme.strip), | |
| 1411 | pad: [8.0, 3.0], | |
| 1412 | gap: 6.0, | |
| 1413 | ..Spec::default() | |
| 1485 | let tags = NoteTag::ALL.map(Cmd::Tag); | |
| 1486 | let mut choice = group( | |
| 1487 | ui, | |
| 1488 | "navigate", | |
| 1489 | 3, | |
| 1490 | |ui| { | |
| 1491 | let mut choice = None; | |
| 1492 | for id in [Cmd::Back, Cmd::Forward, Cmd::Undo, Cmd::Redo] { | |
| 1493 | choice = tool(ui, id, status_of(id)).or(choice); | |
| 1494 | } | |
| 1495 | choice | |
| 1496 | }, | |
| 1497 | |ui| { | |
| 1498 | let entries = [Run(Cmd::Redo), Rule, Run(Cmd::Back), Run(Cmd::Forward)]; | |
| 1499 | dropdown(ui, "menu", Head::Split(Cmd::Undo), &entries, status_of) | |
| 1414 | 1500 | }, |
| 1415 | 1501 | ); |
| 1416 | group(ui, "navigate", |ui| { | |
| 1417 | for (index, buttons) in [ | |
| 1418 | [ | |
| 1419 | ("back", art::BACK, Cmd::Back), | |
| 1420 | ("forward", art::FORWARD, Cmd::Forward), | |
| 1421 | ], | |
| 1422 | [ | |
| 1423 | ("undo", art::UNDO, Cmd::Undo), | |
| 1424 | ("redo", art::REDO, Cmd::Redo), | |
| 1425 | ], | |
| 1426 | ] | |
| 1427 | .into_iter() | |
| 1428 | .enumerate() | |
| 1429 | { | |
| 1430 | row(ui, index, |ui| { | |
| 1431 | for (part, icon, id) in buttons { | |
| 1432 | choice = tool(ui, part, icon, text, id, status_of(id)).or(choice.take()); | |
| 1433 | } | |
| 1434 | }); | |
| 1435 | } | |
| 1436 | }); | |
| 1437 | divider(ui, "navigate", theme); | |
| 1438 | group(ui, "clipboard", |ui| { | |
| 1439 | row(ui, 0, |ui| { | |
| 1502 | choice = group( | |
| 1503 | ui, | |
| 1504 | "clipboard", | |
| 1505 | 2, | |
| 1506 | |ui| { | |
| 1507 | divider(ui, theme); | |
| 1508 | let mut choice = None; | |
| 1440 | 1509 | let paste = ui.id("paste"); |
| 1441 | 1510 | if !status_of(Cmd::Paste).enabled { |
| 1442 | 1511 | ui::shell::unavailable(ui, "paste", art::PASTE, text, true); |
| ... | ... | @@ -1474,138 +1543,251 @@ impl State { |
| 1474 | 1543 | if ui::popup::menu(ui, toolbar_popup("paste"), anchor, &items, None).is_some() { |
| 1475 | 1544 | choice = Some(Choice::Command(Cmd::Paste)); |
| 1476 | 1545 | } |
| 1477 | choice = tool(ui, "cut", art::CUT, text, Cmd::Cut, status_of(Cmd::Cut)) | |
| 1478 | .or(choice.take()); | |
| 1479 | }); | |
| 1480 | row(ui, 1, |ui| { | |
| 1481 | for (part, icon, id) in [ | |
| 1482 | ("copy", art::COPY, Cmd::Copy), | |
| 1483 | ("painter", art::FORMAT_PAINTER, Cmd::FormatPainter), | |
| 1484 | ] { | |
| 1485 | choice = tool(ui, part, icon, text, id, status_of(id)).or(choice.take()); | |
| 1546 | for id in [Cmd::Cut, Cmd::Copy, Cmd::FormatPainter] { | |
| 1547 | choice = tool(ui, id, status_of(id)).or(choice); | |
| 1486 | 1548 | } |
| 1487 | }); | |
| 1488 | }); | |
| 1489 | divider(ui, "clipboard", theme); | |
| 1549 | choice | |
| 1550 | }, | |
| 1551 | |ui| { | |
| 1552 | let entries = [ | |
| 1553 | Run(Cmd::Cut), | |
| 1554 | Run(Cmd::Copy), | |
| 1555 | Run(Cmd::FormatPainter), | |
| 1556 | Rule, | |
| 1557 | Open("paste", Cmd::Paste), | |
| 1558 | ]; | |
| 1559 | dropdown(ui, "menu", Head::Split(Cmd::Paste), &entries, status_of) | |
| 1560 | }, | |
| 1561 | ) | |
| 1562 | .or(choice); | |
| 1490 | 1563 | let font = state.font.clone().unwrap_or_default(); |
| 1491 | 1564 | let size = state |
| 1492 | 1565 | .font_size |
| 1493 | 1566 | .map_or(String::new(), |size| format!("{size}")); |
| 1494 | group(ui, "text", |ui| { | |
| 1495 | row(ui, 0, |ui| { | |
| 1496 | let combo = ui.id("font"); | |
| 1497 | if ui::shell::combo(ui, "font", &font, 120.0).pressed { | |
| 1498 | ui.open_popup(toolbar_popup("font")); | |
| 1499 | } | |
| 1500 | // Each family is named with the substitute it shows in, and picked by its | |
| 1501 | // own name; a family of None heads a group. Typing searches the full list. | |
| 1502 | let label = |name: &str| { | |
| 1503 | engine.substitute(name).map_or_else( | |
| 1504 | || name.to_owned(), | |
| 1505 | |substitute| format!("{} ({name})", substitute.name), | |
| 1506 | ) | |
| 1507 | }; | |
| 1508 | let mut choices: Vec<(String, Option<&str>, bool)> = Vec::new(); | |
| 1509 | if ui::popup::query(ui, toolbar_popup("font")).is_none_or(str::is_empty) { | |
| 1510 | choices.extend(FONTS.map(|name| (label(name), Some(name), false))); | |
| 1511 | if !recent_fonts.is_empty() { | |
| 1512 | choices.push(("Recent".to_owned(), None, true)); | |
| 1513 | choices.extend( | |
| 1514 | recent_fonts | |
| 1567 | ui.open( | |
| 1568 | "font", | |
| 1569 | Spec { | |
| 1570 | gap: 1.0, | |
| 1571 | ..Spec::default() | |
| 1572 | }, | |
| 1573 | ); | |
| 1574 | divider(ui, theme); | |
| 1575 | let combo = ui.id("font"); | |
| 1576 | // It gives up room last of all, once every group has folded. | |
| 1577 | let width = ui::Extent { | |
| 1578 | size: ui::Size::Pixels(120.0), | |
| 1579 | strictness: 64.0 / 120.0, | |
| 1580 | }; | |
| 1581 | if ui::shell::combo(ui, "font", &font, width).pressed { | |
| 1582 | ui.open_popup(toolbar_popup("font")); | |
| 1583 | } | |
| 1584 | // Each family is named with the substitute it shows in, and picked by its own name; a | |
| 1585 | // family of None heads a group. Typing searches the full list. | |
| 1586 | let named = |name: &str| { | |
| 1587 | engine.substitute(name).map_or_else( | |
| 1588 | || name.to_owned(), | |
| 1589 | |substitute| format!("{} ({name})", substitute.name), | |
| 1590 | ) | |
| 1591 | }; | |
| 1592 | let mut choices: Vec<(String, Option<&str>, bool)> = Vec::new(); | |
| 1593 | if ui::popup::query(ui, toolbar_popup("font")).is_none_or(str::is_empty) { | |
| 1594 | choices.extend(FONTS.map(|name| (named(name), Some(name), false))); | |
| 1595 | if !recent_fonts.is_empty() { | |
| 1596 | choices.push(("Recent".to_owned(), None, true)); | |
| 1597 | choices.extend( | |
| 1598 | recent_fonts | |
| 1599 | .iter() | |
| 1600 | .map(|name| (named(name), Some(name.as_str()), false)), | |
| 1601 | ); | |
| 1602 | } | |
| 1603 | } | |
| 1604 | let listed = choices.len(); | |
| 1605 | choices.extend( | |
| 1606 | fonts | |
| 1607 | .iter() | |
| 1608 | .enumerate() | |
| 1609 | .map(|(index, name)| (named(name), Some(name.as_str()), index == 0 && listed > 0)), | |
| 1610 | ); | |
| 1611 | let items: Vec<_> = choices | |
| 1612 | .iter() | |
| 1613 | .map(|(label, name, separated)| ui::popup::Item { | |
| 1614 | text: label, | |
| 1615 | font: *name, | |
| 1616 | current: *name == Some(font.as_str()), | |
| 1617 | heading: name.is_none(), | |
| 1618 | separated: *separated, | |
| 1619 | ..Default::default() | |
| 1620 | }) | |
| 1621 | .collect(); | |
| 1622 | let anchor = ui::Anchor::Over(ui.rect(combo).unwrap_or_default()); | |
| 1623 | if let Some(index) = | |
| 1624 | ui::popup::menu(ui, toolbar_popup("font"), anchor, &items, Some("Font")) | |
| 1625 | && let Some(name) = choices[index].1 | |
| 1626 | { | |
| 1627 | choice = Some(Choice::Font(name.to_owned())); | |
| 1628 | } | |
| 1629 | let combo = ui.id("size"); | |
| 1630 | if ui::shell::combo(ui, "size", &size, 44.0).pressed { | |
| 1631 | ui.open_popup(toolbar_popup("size")); | |
| 1632 | } | |
| 1633 | // A size typed in the field joins the list, in half points as stored. | |
| 1634 | let mut sizes = SIZES.to_vec(); | |
| 1635 | if let Some(typed) = ui::popup::query(ui, toolbar_popup("size")) | |
| 1636 | .and_then(|query| query.trim().parse::<f32>().ok()) | |
| 1637 | .map(|typed| (typed * 2.0).round() / 2.0) | |
| 1638 | .filter(|typed| onestore::FONT_SIZES.contains(typed) && !sizes.contains(typed)) | |
| 1639 | { | |
| 1640 | let at = sizes.partition_point(|size| *size < typed); | |
| 1641 | sizes.insert(at, typed); | |
| 1642 | } | |
| 1643 | let labels: Vec<_> = sizes.iter().map(|size| format!("{size}")).collect(); | |
| 1644 | let items: Vec<_> = labels | |
| 1645 | .iter() | |
| 1646 | .map(|label| ui::popup::Item { | |
| 1647 | text: label, | |
| 1648 | checked: *label == size, | |
| 1649 | ..Default::default() | |
| 1650 | }) | |
| 1651 | .collect(); | |
| 1652 | let anchor = ui::Anchor::Over(ui.rect(combo).unwrap_or_default()); | |
| 1653 | if let Some(index) = ui::popup::menu(ui, toolbar_popup("size"), anchor, &items, Some(&size)) | |
| 1654 | { | |
| 1655 | choice = Some(Choice::Size(sizes[index])); | |
| 1656 | } | |
| 1657 | for toggle in [Toggle::Bold, Toggle::Italic, Toggle::Underline] { | |
| 1658 | let id = Cmd::Toggle(toggle); | |
| 1659 | choice = tool(ui, id, status_of(id)).or(choice); | |
| 1660 | } | |
| 1661 | ui.close(); | |
| 1662 | choice = group( | |
| 1663 | ui, | |
| 1664 | "character", | |
| 1665 | 6, | |
| 1666 | |ui| { | |
| 1667 | divider(ui, theme); | |
| 1668 | let mut choice = None; | |
| 1669 | // Each button applies its menu's last pick: the highlighter shows it in its | |
| 1670 | // artwork, the font colour in a bar. | |
| 1671 | for (part, id, swatches, columns, none, bar) in [ | |
| 1672 | ( | |
| 1673 | "highlight", | |
| 1674 | Cmd::Highlight, | |
| 1675 | &HIGHLIGHTS.map(|(color, _)| color)[..], | |
| 1676 | 5, | |
| 1677 | "No Color", | |
| 1678 | None, | |
| 1679 | ), | |
| 1680 | ( | |
| 1681 | "color", | |
| 1682 | Cmd::FontColor, | |
| 1683 | &FONT_COLORS[..], | |
| 1684 | 10, | |
| 1685 | "Automatic", | |
| 1686 | Some(pens.font_color.map_or(text, colorref)), | |
| 1687 | ), | |
| 1688 | ] { | |
| 1689 | let split = ui.id(part); | |
| 1690 | let icon = artwork(id).unwrap_or_default(); | |
| 1691 | if ui::shell::split_button(ui, part, icon, bar, false, toolbar_popup(part)) | |
| 1692 | .clicked | |
| 1693 | { | |
| 1694 | choice = Some(Choice::Command(id)); | |
| 1695 | } | |
| 1696 | tip(ui, id); | |
| 1697 | let colors: Vec<_> = swatches.iter().map(|color| colorref(*color)).collect(); | |
| 1698 | let anchor = ui::Anchor::Below(ui.rect(split).unwrap_or_default()); | |
| 1699 | if let Some(chosen) = | |
| 1700 | ui::popup::colors(ui, toolbar_popup(part), anchor, none, &colors, columns) | |
| 1701 | { | |
| 1702 | let color = chosen.and_then(|chosen| { | |
| 1703 | swatches | |
| 1515 | 1704 | .iter() |
| 1516 | .map(|name| (label(name), Some(name.as_str()), false)), | |
| 1517 | ); | |
| 1705 | .zip(&colors) | |
| 1706 | .find(|(_, color)| **color == chosen) | |
| 1707 | .map(|(stored, _)| *stored) | |
| 1708 | }); | |
| 1709 | choice = Some(if id == Cmd::Highlight { | |
| 1710 | Choice::Highlight(color) | |
| 1711 | } else { | |
| 1712 | Choice::Color(color) | |
| 1713 | }); | |
| 1518 | 1714 | } |
| 1519 | 1715 | } |
| 1520 | let listed = choices.len(); | |
| 1521 | choices.extend(fonts.iter().enumerate().map(|(index, name)| { | |
| 1522 | (label(name), Some(name.as_str()), index == 0 && listed > 0) | |
| 1523 | })); | |
| 1524 | let items: Vec<_> = choices | |
| 1525 | .iter() | |
| 1526 | .map(|(label, name, separated)| ui::popup::Item { | |
| 1527 | text: label, | |
| 1528 | font: *name, | |
| 1529 | current: *name == Some(font.as_str()), | |
| 1530 | heading: name.is_none(), | |
| 1531 | separated: *separated, | |
| 1532 | ..Default::default() | |
| 1533 | }) | |
| 1534 | .collect(); | |
| 1535 | let anchor = ui::Anchor::Over(ui.rect(combo).unwrap_or_default()); | |
| 1536 | if let Some(index) = | |
| 1537 | ui::popup::menu(ui, toolbar_popup("font"), anchor, &items, Some("Font")) | |
| 1538 | && let Some(name) = choices[index].1 | |
| 1539 | { | |
| 1540 | choice = Some(Choice::Font(name.to_owned())); | |
| 1541 | } | |
| 1542 | let combo = ui.id("size"); | |
| 1543 | if ui::shell::combo(ui, "size", &size, 44.0).pressed { | |
| 1544 | ui.open_popup(toolbar_popup("size")); | |
| 1545 | } | |
| 1546 | // A size typed in the field joins the list, in half points as stored. | |
| 1547 | let mut sizes = SIZES.to_vec(); | |
| 1548 | if let Some(typed) = ui::popup::query(ui, toolbar_popup("size")) | |
| 1549 | .and_then(|query| query.trim().parse::<f32>().ok()) | |
| 1550 | .map(|typed| (typed * 2.0).round() / 2.0) | |
| 1551 | .filter(|typed| onestore::FONT_SIZES.contains(typed) && !sizes.contains(typed)) | |
| 1552 | { | |
| 1553 | let at = sizes.partition_point(|size| *size < typed); | |
| 1554 | sizes.insert(at, typed); | |
| 1555 | } | |
| 1556 | let labels: Vec<_> = sizes.iter().map(|size| format!("{size}")).collect(); | |
| 1557 | let items: Vec<_> = labels | |
| 1558 | .iter() | |
| 1559 | .map(|label| ui::popup::Item { | |
| 1560 | text: label, | |
| 1561 | checked: *label == size, | |
| 1562 | ..Default::default() | |
| 1563 | }) | |
| 1564 | .collect(); | |
| 1565 | let anchor = ui::Anchor::Over(ui.rect(combo).unwrap_or_default()); | |
| 1566 | if let Some(index) = | |
| 1567 | ui::popup::menu(ui, toolbar_popup("size"), anchor, &items, Some(&size)) | |
| 1568 | { | |
| 1569 | choice = Some(Choice::Size(sizes[index])); | |
| 1570 | } | |
| 1571 | // The buttons apply OneNote's default bullet and number, as its own do. | |
| 1572 | let [bullets, numbering] = [ui.id("bullets"), ui.id("numbering")]; | |
| 1573 | if ui::shell::split_button( | |
| 1716 | dropdown( | |
| 1574 | 1717 | ui, |
| 1575 | "bullets", | |
| 1576 | art::BULLETS, | |
| 1577 | None, | |
| 1578 | state.bullets, | |
| 1579 | toolbar_popup("bullets"), | |
| 1580 | ) | |
| 1581 | .clicked | |
| 1582 | { | |
| 1583 | choice = Some(Choice::Command(Cmd::Bullets)); | |
| 1584 | } | |
| 1585 | tip(ui, Cmd::Bullets); | |
| 1586 | if ui::shell::split_button( | |
| 1587 | ui, | |
| 1588 | "numbering", | |
| 1589 | art::NUMBERING, | |
| 1590 | None, | |
| 1591 | state.numbering, | |
| 1592 | toolbar_popup("numbering"), | |
| 1718 | "script", | |
| 1719 | Head::Menu(art::STRIKETHROUGH), | |
| 1720 | &[ | |
| 1721 | Run(Cmd::Toggle(Toggle::Strikethrough)), | |
| 1722 | Run(Cmd::Toggle(Toggle::Subscript)), | |
| 1723 | Run(Cmd::Toggle(Toggle::Superscript)), | |
| 1724 | Rule, | |
| 1725 | Run(Cmd::ClearFormatting), | |
| 1726 | ], | |
| 1727 | status_of, | |
| 1593 | 1728 | ) |
| 1594 | .clicked | |
| 1595 | { | |
| 1596 | choice = Some(Choice::Command(Cmd::Numbering)); | |
| 1729 | .or(choice) | |
| 1730 | }, | |
| 1731 | |ui| { | |
| 1732 | let entries = [ | |
| 1733 | Open("highlight", Cmd::Highlight), | |
| 1734 | Run(Cmd::FontColor), | |
| 1735 | Open("color", Cmd::FontColor), | |
| 1736 | Rule, | |
| 1737 | Run(Cmd::Toggle(Toggle::Strikethrough)), | |
| 1738 | Run(Cmd::Toggle(Toggle::Subscript)), | |
| 1739 | Run(Cmd::Toggle(Toggle::Superscript)), | |
| 1740 | Rule, | |
| 1741 | Run(Cmd::ClearFormatting), | |
| 1742 | ]; | |
| 1743 | dropdown(ui, "menu", Head::Split(Cmd::Highlight), &entries, status_of) | |
| 1744 | }, | |
| 1745 | ) | |
| 1746 | .or(choice); | |
| 1747 | let alignments = [Alignment::Left, Alignment::Center, Alignment::Right].map(Cmd::Align); | |
| 1748 | let paragraph = [ | |
| 1749 | Rule, | |
| 1750 | Run(alignments[0]), | |
| 1751 | Run(alignments[1]), | |
| 1752 | Run(alignments[2]), | |
| 1753 | Rule, | |
| 1754 | Run(Cmd::Outdent), | |
| 1755 | Run(Cmd::Indent), | |
| 1756 | ]; | |
| 1757 | choice = group( | |
| 1758 | ui, | |
| 1759 | "paragraph", | |
| 1760 | 5, | |
| 1761 | |ui| { | |
| 1762 | divider(ui, theme); | |
| 1763 | let mut choice = None; | |
| 1764 | // The buttons apply OneNote's default bullet and number, as its own do. | |
| 1765 | for id in [Cmd::Bullets, Cmd::Numbering] { | |
| 1766 | let part = if id == Cmd::Bullets { | |
| 1767 | "bullets" | |
| 1768 | } else { | |
| 1769 | "numbering" | |
| 1770 | }; | |
| 1771 | let icon = artwork(id).unwrap_or_default(); | |
| 1772 | let on = status_of(id).checked; | |
| 1773 | if ui::shell::split_button(ui, part, icon, None, on, toolbar_popup(part)) | |
| 1774 | .clicked | |
| 1775 | { | |
| 1776 | choice = Some(Choice::Command(id)); | |
| 1777 | } | |
| 1778 | tip(ui, id); | |
| 1597 | 1779 | } |
| 1598 | tip(ui, Cmd::Numbering); | |
| 1780 | let [bullets, numbering] = | |
| 1781 | ["bullets", "numbering"].map(|part| ui.rect(ui.id(part)).unwrap_or_default()); | |
| 1599 | 1782 | let current = |bullet| match state.list { |
| 1600 | 1783 | Some(ListStyle::Bullet(place)) if bullet => Some(place), |
| 1601 | 1784 | Some(ListStyle::Number(place)) if !bullet => Some(place), |
| 1602 | 1785 | _ => None, |
| 1603 | 1786 | }; |
| 1604 | let anchor = ui::Anchor::Below(ui.rect(bullets).unwrap_or_default()); | |
| 1605 | 1787 | if let Some(place) = list_gallery( |
| 1606 | 1788 | ui, |
| 1607 | 1789 | toolbar_popup("bullets"), |
| 1608 | anchor, | |
| 1790 | ui::Anchor::Below(bullets), | |
| 1609 | 1791 | ["Recently Used Bullets", "Bullet Library"], |
| 1610 | 1792 | (&pens.bullets, BULLET_LIBRARY.len()), |
| 1611 | 1793 | current(true), |
| ... | ... | @@ -1627,11 +1809,10 @@ impl State { |
| 1627 | 1809 | ) { |
| 1628 | 1810 | choice = Some(Choice::List(place.map(ListStyle::Bullet))); |
| 1629 | 1811 | } |
| 1630 | let anchor = ui::Anchor::Below(ui.rect(numbering).unwrap_or_default()); | |
| 1631 | 1812 | if let Some(place) = list_gallery( |
| 1632 | 1813 | ui, |
| 1633 | 1814 | toolbar_popup("numbering"), |
| 1634 | anchor, | |
| 1815 | ui::Anchor::Below(numbering), | |
| 1635 | 1816 | ["Recently Used Number Formats", "Numbering Library"], |
| 1636 | 1817 | (&pens.numbering, NUMBER_LIBRARY.len()), |
| 1637 | 1818 | current(false), |
| ... | ... | @@ -1673,234 +1854,68 @@ impl State { |
| 1673 | 1854 | ) { |
| 1674 | 1855 | choice = Some(Choice::List(place.map(ListStyle::Number))); |
| 1675 | 1856 | } |
| 1676 | let clear = Cmd::ClearFormatting; | |
| 1677 | choice = tool( | |
| 1678 | ui, | |
| 1679 | "clear", | |
| 1680 | art::CLEAR_FORMATTING, | |
| 1681 | text, | |
| 1682 | clear, | |
| 1683 | status_of(clear), | |
| 1684 | ) | |
| 1685 | .or(choice.take()); | |
| 1686 | }); | |
| 1687 | row(ui, 1, |ui| { | |
| 1688 | for (part, icon, toggle) in [ | |
| 1689 | ("bold", art::BOLD, Toggle::Bold), | |
| 1690 | ("italic", art::ITALIC, Toggle::Italic), | |
| 1691 | ("underline", art::UNDERLINE, Toggle::Underline), | |
| 1692 | ("strikethrough", art::STRIKETHROUGH, Toggle::Strikethrough), | |
| 1693 | ] { | |
| 1694 | let id = Cmd::Toggle(toggle); | |
| 1695 | choice = tool(ui, part, icon, text, id, status_of(id)).or(choice.take()); | |
| 1696 | } | |
| 1697 | let script = ui.id("script"); | |
| 1698 | let superscript = on(Toggle::Superscript); | |
| 1699 | let command = Cmd::Toggle(if superscript { | |
| 1700 | Toggle::Superscript | |
| 1701 | } else { | |
| 1702 | Toggle::Subscript | |
| 1703 | }); | |
| 1704 | if ui::shell::split_button( | |
| 1705 | ui, | |
| 1706 | "script", | |
| 1707 | if superscript { | |
| 1708 | art::SUPERSCRIPT | |
| 1709 | } else { | |
| 1710 | art::SUBSCRIPT | |
| 1711 | }, | |
| 1712 | None, | |
| 1713 | on(Toggle::Subscript) || superscript, | |
| 1714 | toolbar_popup("script"), | |
| 1715 | ) | |
| 1716 | .clicked | |
| 1717 | { | |
| 1718 | choice = Some(Choice::Command(command)); | |
| 1719 | } | |
| 1720 | tip(ui, command); | |
| 1721 | let scripts = [ | |
| 1722 | (Toggle::Subscript, art::SUBSCRIPT), | |
| 1723 | (Toggle::Superscript, art::SUPERSCRIPT), | |
| 1724 | ]; | |
| 1725 | let keys = scripts.map(|(toggle, _)| shortcut(Cmd::Toggle(toggle))); | |
| 1726 | let items: Vec<_> = scripts | |
| 1727 | .iter() | |
| 1728 | .zip(&keys) | |
| 1729 | .map(|((toggle, icon), key)| ui::popup::Item { | |
| 1730 | text: commands::command(Cmd::Toggle(*toggle)).title, | |
| 1731 | icon: Some(icon), | |
| 1732 | shortcut: key, | |
| 1733 | checked: on(*toggle), | |
| 1734 | ..Default::default() | |
| 1735 | }) | |
| 1736 | .collect(); | |
| 1737 | let anchor = ui::Anchor::Below(ui.rect(script).unwrap_or_default()); | |
| 1738 | if let Some(index) = | |
| 1739 | ui::popup::menu(ui, toolbar_popup("script"), anchor, &items, None) | |
| 1740 | { | |
| 1741 | choice = Some(Choice::Command(Cmd::Toggle(scripts[index].0))); | |
| 1742 | } | |
| 1743 | // Each button applies its menu's last pick: the highlighter shows it in its | |
| 1744 | // artwork, the font colour in a bar. | |
| 1745 | for (part, icon, swatches, columns, none, bar) in [ | |
| 1746 | ( | |
| 1747 | "highlight", | |
| 1748 | art::HIGHLIGHTER, | |
| 1749 | &HIGHLIGHTS.map(|(color, _)| color)[..], | |
| 1750 | 5, | |
| 1751 | "No Color", | |
| 1752 | None, | |
| 1753 | ), | |
| 1754 | ( | |
| 1755 | "color", | |
| 1756 | art::FONT_COLOR, | |
| 1757 | &FONT_COLORS[..], | |
| 1758 | 10, | |
| 1759 | "Automatic", | |
| 1760 | Some(pens.font_color.map_or(text, colorref)), | |
| 1761 | ), | |
| 1762 | ] { | |
| 1763 | let split = ui.id(part); | |
| 1764 | let button = | |
| 1765 | ui::shell::split_button(ui, part, icon, bar, false, toolbar_popup(part)); | |
| 1766 | let highlight = part == "highlight"; | |
| 1767 | let command = if highlight { | |
| 1768 | Cmd::Highlight | |
| 1769 | } else { | |
| 1770 | Cmd::FontColor | |
| 1771 | }; | |
| 1772 | if button.clicked { | |
| 1773 | choice = Some(Choice::Command(command)); | |
| 1774 | } | |
| 1775 | tip(ui, command); | |
| 1776 | let colors: Vec<_> = swatches.iter().map(|color| colorref(*color)).collect(); | |
| 1777 | let anchor = ui::Anchor::Below(ui.rect(split).unwrap_or_default()); | |
| 1778 | if let Some(chosen) = | |
| 1779 | ui::popup::colors(ui, toolbar_popup(part), anchor, none, &colors, columns) | |
| 1780 | { | |
| 1781 | let color = chosen.and_then(|chosen| { | |
| 1782 | swatches | |
| 1783 | .iter() | |
| 1784 | .zip(&colors) | |
| 1785 | .find(|(_, color)| **color == chosen) | |
| 1786 | .map(|(stored, _)| *stored) | |
| 1787 | }); | |
| 1788 | choice = Some(if highlight { | |
| 1789 | Choice::Highlight(color) | |
| 1790 | } else { | |
| 1791 | Choice::Color(color) | |
| 1792 | }); | |
| 1793 | } | |
| 1794 | } | |
| 1795 | for (part, icon, id) in [ | |
| 1796 | ("outdent", art::OUTDENT, Cmd::Outdent), | |
| 1797 | ("indent", art::INDENT, Cmd::Indent), | |
| 1798 | ] { | |
| 1799 | choice = tool(ui, part, icon, text, id, status_of(id)).or(choice.take()); | |
| 1800 | } | |
| 1801 | let alignments = [ | |
| 1802 | (Alignment::Left, art::ALIGN_LEFT), | |
| 1803 | (Alignment::Center, art::ALIGN_CENTER), | |
| 1804 | (Alignment::Right, art::ALIGN_RIGHT), | |
| 1857 | let aligned = alignments | |
| 1858 | .into_iter() | |
| 1859 | .find(|id| status_of(*id).checked) | |
| 1860 | .unwrap_or(alignments[0]); | |
| 1861 | let icon = artwork(aligned).unwrap_or_default(); | |
| 1862 | dropdown(ui, "align", Head::Menu(icon), &paragraph[1..], status_of).or(choice) | |
| 1863 | }, | |
| 1864 | |ui| { | |
| 1865 | let mut entries = vec![ | |
| 1866 | Open("bullets", Cmd::Bullets), | |
| 1867 | Run(Cmd::Numbering), | |
| 1868 | Open("numbering", Cmd::Numbering), | |
| 1805 | 1869 | ]; |
| 1806 | let keys = alignments.map(|(alignment, _)| shortcut(Cmd::Align(alignment))); | |
| 1807 | let current = alignments | |
| 1808 | .iter() | |
| 1809 | .find(|(alignment, _)| Some(*alignment) == state.alignment) | |
| 1810 | .unwrap_or(&alignments[0]); | |
| 1811 | let anchor = ui::shell::menu_button(ui, "align", current.1, toolbar_popup("align")); | |
| 1812 | tip(ui, Cmd::Align(current.0)); | |
| 1813 | // The current alignment starts highlighted, keeping its icon under the button's. | |
| 1814 | let items: Vec<_> = alignments | |
| 1815 | .iter() | |
| 1816 | .zip(&keys) | |
| 1817 | .map(|((alignment, icon), key)| ui::popup::Item { | |
| 1818 | text: commands::command(Cmd::Align(*alignment)).title, | |
| 1819 | icon: Some(icon), | |
| 1820 | shortcut: key, | |
| 1821 | current: Some(*alignment) == state.alignment, | |
| 1822 | ..Default::default() | |
| 1823 | }) | |
| 1824 | .collect(); | |
| 1825 | if let Some(index) = | |
| 1826 | ui::popup::menu(ui, toolbar_popup("align"), anchor, &items, None) | |
| 1827 | { | |
| 1828 | choice = Some(Choice::Command(Cmd::Align(alignments[index].0))); | |
| 1870 | entries.extend(paragraph); | |
| 1871 | dropdown(ui, "menu", Head::Split(Cmd::Bullets), &entries, status_of) | |
| 1872 | }, | |
| 1873 | ) | |
| 1874 | .or(choice); | |
| 1875 | // The gallery's menu: every tag, then managing them. | |
| 1876 | let mut all_tags: Vec<_> = tags.into_iter().map(Run).collect(); | |
| 1877 | all_tags.extend([ | |
| 1878 | Rule, | |
| 1879 | // Customize Tags awaits #8. | |
| 1880 | Soon("Customize Tags…", None), | |
| 1881 | Run(Cmd::RemoveTags), | |
| 1882 | Rule, | |
| 1883 | Run(Cmd::FindTags), | |
| 1884 | ]); | |
| 1885 | choice = group( | |
| 1886 | ui, | |
| 1887 | "tags", | |
| 1888 | 7, | |
| 1889 | |ui| { | |
| 1890 | divider(ui, theme); | |
| 1891 | let mut choice = None; | |
| 1892 | for id in &tags[..3] { | |
| 1893 | choice = tool(ui, *id, status_of(*id)).or(choice); | |
| 1829 | 1894 | } |
| 1830 | }); | |
| 1831 | }); | |
| 1832 | divider(ui, "text", theme); | |
| 1833 | let tags = NoteTag::ALL.map(|tag| (tag_icon(tag), tag)); | |
| 1834 | group(ui, "tags", |ui| { | |
| 1835 | // OneNote's first nine, Ctrl+1 to Ctrl+9, then the gallery's arrow. | |
| 1836 | for (index, shown) in tags[..9].chunks(5).enumerate() { | |
| 1837 | row(ui, index, |ui| { | |
| 1838 | for (column, (icon, tag)) in shown.iter().enumerate() { | |
| 1839 | let id = Cmd::Tag(*tag); | |
| 1840 | choice = | |
| 1841 | tool(ui, column, icon, [1.0; 4], id, status_of(id)).or(choice.take()); | |
| 1842 | } | |
| 1843 | if index == 1 { | |
| 1844 | let more = ui.id("more"); | |
| 1845 | let open = ui.popup_open(toolbar_popup("tags")); | |
| 1846 | if ui::shell::tool_button(ui, "more", ui::shell::CHEVRON, text, open) | |
| 1847 | .pressed | |
| 1848 | { | |
| 1849 | ui.open_popup(toolbar_popup("tags")); | |
| 1850 | } | |
| 1851 | let shortcuts: Vec<_> = tags | |
| 1852 | .iter() | |
| 1853 | .map(|(_, tag)| shortcut(Cmd::Tag(*tag))) | |
| 1854 | .collect(); | |
| 1855 | let remove = shortcut(Cmd::RemoveTags); | |
| 1856 | let mut items: Vec<_> = tags | |
| 1857 | .iter() | |
| 1858 | .zip(&shortcuts) | |
| 1859 | .map(|((icon, tag), shortcut)| ui::popup::Item { | |
| 1860 | text: commands::command(Cmd::Tag(*tag)).title, | |
| 1861 | icon: Some(*icon), | |
| 1862 | colored: true, | |
| 1863 | shortcut, | |
| 1864 | ..Default::default() | |
| 1865 | }) | |
| 1866 | .collect(); | |
| 1867 | // Customize Tags awaits #8 and Find Tags its summary pane, #28. | |
| 1868 | items.extend([ | |
| 1869 | ui::popup::Item { | |
| 1870 | text: "Customize Tags…", | |
| 1871 | separated: true, | |
| 1872 | disabled: true, | |
| 1873 | ..Default::default() | |
| 1874 | }, | |
| 1875 | ui::popup::Item { | |
| 1876 | text: commands::command(Cmd::RemoveTags).title, | |
| 1877 | shortcut: &remove, | |
| 1878 | ..Default::default() | |
| 1879 | }, | |
| 1880 | ui::popup::Item { | |
| 1881 | text: "Find Tags", | |
| 1882 | icon: Some(art::FIND_TAGS), | |
| 1883 | separated: true, | |
| 1884 | disabled: true, | |
| 1885 | ..Default::default() | |
| 1886 | }, | |
| 1887 | ]); | |
| 1888 | let anchor = ui::Anchor::Below(ui.rect(more).unwrap_or_default()); | |
| 1889 | if let Some(chosen) = | |
| 1890 | ui::popup::menu(ui, toolbar_popup("tags"), anchor, &items, None) | |
| 1891 | { | |
| 1892 | choice = Some(Choice::Command( | |
| 1893 | tags.get(chosen) | |
| 1894 | .map_or(Cmd::RemoveTags, |(_, tag)| Cmd::Tag(*tag)), | |
| 1895 | )); | |
| 1896 | } | |
| 1897 | } | |
| 1898 | }); | |
| 1899 | } | |
| 1900 | }); | |
| 1901 | divider(ui, "tags", theme); | |
| 1902 | group(ui, "insert", |ui| { | |
| 1903 | row(ui, 0, |ui| { | |
| 1895 | dropdown(ui, "tags", Head::More, &all_tags, status_of).or(choice) | |
| 1896 | }, | |
| 1897 | |ui| dropdown(ui, "menu", Head::Split(tags[0]), &all_tags, status_of), | |
| 1898 | ) | |
| 1899 | .or(choice); | |
| 1900 | let inserted = [Cmd::Picture, Cmd::Link, Cmd::Date, Cmd::Equation]; | |
| 1901 | let more = [ | |
| 1902 | Cmd::ScreenClipping, | |
| 1903 | Cmd::Attachment, | |
| 1904 | Cmd::InsertSpace, | |
| 1905 | Cmd::Time, | |
| 1906 | Cmd::DateTime, | |
| 1907 | Cmd::Symbol, | |
| 1908 | Cmd::RecordAudio, | |
| 1909 | Cmd::RecordVideo, | |
| 1910 | ] | |
| 1911 | .map(Run); | |
| 1912 | choice = group( | |
| 1913 | ui, | |
| 1914 | "insert", | |
| 1915 | 1, | |
| 1916 | |ui| { | |
| 1917 | divider(ui, theme); | |
| 1918 | let mut choice = None; | |
| 1904 | 1919 | if status_of(Cmd::Table).enabled { |
| 1905 | 1920 | let anchor = |
| 1906 | 1921 | ui::shell::menu_button(ui, "table", art::TABLE, toolbar_popup("table")); |
| ... | ... | @@ -1913,30 +1928,20 @@ impl State { |
| 1913 | 1928 | } else { |
| 1914 | 1929 | ui::shell::unavailable(ui, "table", art::TABLE, text, true); |
| 1915 | 1930 | } |
| 1916 | for (part, icon, id) in [ | |
| 1917 | ("picture", art::PICTURE, Cmd::Picture), | |
| 1918 | ("clipping", art::SCREEN_CLIPPING, Cmd::ScreenClipping), | |
| 1919 | ("file", art::ATTACHMENT, Cmd::Attachment), | |
| 1920 | ("link", art::LINK, Cmd::Link), | |
| 1921 | ("space", art::INSERT_SPACE, Cmd::InsertSpace), | |
| 1922 | ] { | |
| 1923 | choice = tool(ui, part, icon, text, id, status_of(id)).or(choice.take()); | |
| 1924 | } | |
| 1925 | }); | |
| 1926 | row(ui, 1, |ui| { | |
| 1927 | for (part, icon, id) in [ | |
| 1928 | ("date", art::CALENDAR, Cmd::Date), | |
| 1929 | ("time", art::CLOCK, Cmd::Time), | |
| 1930 | ("date and time", art::DATE_TIME, Cmd::DateTime), | |
| 1931 | ("equation", art::EQUATION, Cmd::Equation), | |
| 1932 | ("symbol", art::SYMBOL, Cmd::Symbol), | |
| 1933 | ("audio", art::RECORD_AUDIO, Cmd::RecordAudio), | |
| 1934 | ("video", art::RECORD_VIDEO, Cmd::RecordVideo), | |
| 1935 | ] { | |
| 1936 | choice = tool(ui, part, icon, text, id, status_of(id)).or(choice.take()); | |
| 1931 | for id in inserted { | |
| 1932 | choice = tool(ui, id, status_of(id)).or(choice); | |
| 1937 | 1933 | } |
| 1938 | }); | |
| 1939 | }); | |
| 1934 | dropdown(ui, "more", Head::More, &more, status_of).or(choice) | |
| 1935 | }, | |
| 1936 | |ui| { | |
| 1937 | let mut entries = vec![Open("table", Cmd::Table)]; | |
| 1938 | entries.extend(inserted.map(Run)); | |
| 1939 | entries.push(Rule); | |
| 1940 | entries.extend(more); | |
| 1941 | dropdown(ui, "menu", Head::Menu(art::PLUS), &entries, status_of) | |
| 1942 | }, | |
| 1943 | ) | |
| 1944 | .or(choice); | |
| 1940 | 1945 | ui.leaf( |
| 1941 | 1946 | "space", |
| 1942 | 1947 | Spec { |
| ... | ... | @@ -1944,13 +1949,35 @@ impl State { |
| 1944 | 1949 | ..Spec::default() |
| 1945 | 1950 | }, |
| 1946 | 1951 | ); |
| 1947 | if let Some(session) = status { | |
| 1948 | sync::control(ui, session, theme, ui::shell::TOOL); | |
| 1952 | if let Some(session) = session { | |
| 1953 | ui.open( | |
| 1954 | "sync", | |
| 1955 | Spec { | |
| 1956 | fold: Some(4), | |
| 1957 | ..Spec::default() | |
| 1958 | }, | |
| 1959 | ); | |
| 1960 | for compact in [false, true] { | |
| 1961 | sync::control(ui, session, theme, compact); | |
| 1962 | } | |
| 1963 | ui.close(); | |
| 1949 | 1964 | } |
| 1950 | let zoom = self.view.zoom(); | |
| 1951 | let label = format!("{:.0}%", zoom * 100.0); | |
| 1952 | group(ui, "view", |ui| { | |
| 1953 | row(ui, 0, |ui| { | |
| 1965 | // Spelling awaits a platform spell checker. | |
| 1966 | let views = [ | |
| 1967 | Run(Cmd::Sidebar), | |
| 1968 | Run(Cmd::PageList), | |
| 1969 | Run(Cmd::DarkPages), | |
| 1970 | Run(Cmd::FullPageView), | |
| 1971 | Rule, | |
| 1972 | Soon("Spelling", Some(art::SPELLING)), | |
| 1973 | ]; | |
| 1974 | choice = group( | |
| 1975 | ui, | |
| 1976 | "view", | |
| 1977 | 0, | |
| 1978 | |ui| { | |
| 1979 | divider(ui, theme); | |
| 1980 | let mut choice = None; | |
| 1954 | 1981 | if status_of(Cmd::PageColor).enabled { |
| 1955 | 1982 | let anchor = ui::shell::menu_button( |
| 1956 | 1983 | ui, |
| ... | ... | @@ -1959,83 +1986,28 @@ impl State { |
| 1959 | 1986 | toolbar_popup("page color"), |
| 1960 | 1987 | ); |
| 1961 | 1988 | tip(ui, Cmd::PageColor); |
| 1962 | let swatches: Vec<_> = PAGE_COLORS | |
| 1963 | .iter() | |
| 1964 | .map(|(_, color)| colorref(*color)) | |
| 1965 | .collect(); | |
| 1966 | if let Some(chosen) = ui::popup::colors( | |
| 1989 | choice = background::menu( | |
| 1967 | 1990 | ui, |
| 1968 | 1991 | toolbar_popup("page color"), |
| 1969 | 1992 | anchor, |
| 1970 | "No Color", | |
| 1971 | &swatches, | |
| 1972 | 4, | |
| 1973 | ) { | |
| 1974 | let index = chosen.and_then(|chosen| { | |
| 1975 | swatches.iter().position(|swatch| *swatch == chosen) | |
| 1976 | }); | |
| 1977 | choice = Some(Choice::PageColor(index)); | |
| 1978 | } | |
| 1993 | &mut self.thumbnails, | |
| 1994 | canvas::gpu::Paper { | |
| 1995 | color: theme.paper, | |
| 1996 | ink: theme.paper_ink, | |
| 1997 | }, | |
| 1998 | self.view.editor.page_color(), | |
| 1999 | self.view.editor.rule_lines(), | |
| 2000 | ) | |
| 2001 | .or(choice.take()); | |
| 1979 | 2002 | } else { |
| 1980 | 2003 | ui::shell::unavailable(ui, "page color", art::PAGE_COLOR, text, true); |
| 1981 | 2004 | } |
| 1982 | // Nothing reads or writes rule lines yet. | |
| 1983 | ui::shell::unavailable(ui, "rule lines", art::RULE_LINES, text, true); | |
| 1984 | let full = Cmd::FullPageView; | |
| 1985 | choice = tool( | |
| 1986 | ui, | |
| 1987 | "full page", | |
| 1988 | art::FULL_PAGE_VIEW, | |
| 1989 | text, | |
| 1990 | full, | |
| 1991 | status_of(full), | |
| 1992 | ) | |
| 1993 | .or(choice.take()); | |
| 1994 | let more = ui.id("more"); | |
| 1995 | let open = ui.popup_open(toolbar_popup("view")); | |
| 1996 | if ui::shell::tool_button(ui, "more", ui::shell::CHEVRON, text, open).pressed { | |
| 1997 | ui.open_popup(toolbar_popup("view")); | |
| 1998 | } | |
| 1999 | let views = [Cmd::Sidebar, Cmd::PageList, Cmd::DarkPages]; | |
| 2000 | let mut items: Vec<_> = views | |
| 2001 | .iter() | |
| 2002 | .map(|id| ui::popup::Item { | |
| 2003 | text: commands::command(*id).title, | |
| 2004 | checked: status_of(*id).checked, | |
| 2005 | disabled: !status_of(*id).enabled, | |
| 2006 | ..Default::default() | |
| 2007 | }) | |
| 2008 | .collect(); | |
| 2009 | // Spelling awaits a platform spell checker. | |
| 2010 | items.push(ui::popup::Item { | |
| 2011 | text: "Spelling", | |
| 2012 | icon: Some(art::SPELLING), | |
| 2013 | separated: true, | |
| 2014 | disabled: true, | |
| 2015 | ..Default::default() | |
| 2016 | }); | |
| 2017 | let anchor = ui::Anchor::Below(ui.rect(more).unwrap_or_default()); | |
| 2018 | if let Some(chosen) = | |
| 2019 | ui::popup::menu(ui, toolbar_popup("view"), anchor, &items, None) | |
| 2020 | { | |
| 2021 | choice = Some(Choice::Command(views[chosen])); | |
| 2022 | } | |
| 2023 | }); | |
| 2024 | row(ui, 1, |ui| { | |
| 2025 | choice = tool( | |
| 2026 | ui, | |
| 2027 | "out", | |
| 2028 | art::ZOOM_OUT, | |
| 2029 | text, | |
| 2030 | Cmd::ZoomOut, | |
| 2031 | status_of(Cmd::ZoomOut), | |
| 2032 | ) | |
| 2033 | .or(choice.take()); | |
| 2005 | choice = tool(ui, Cmd::ZoomOut, status_of(Cmd::ZoomOut)).or(choice); | |
| 2034 | 2006 | let level = ui.leaf( |
| 2035 | 2007 | "level", |
| 2036 | 2008 | Spec { |
| 2037 | 2009 | flags: Flags::CLICKABLE, |
| 2038 | size: [px(44.0), px(ui::shell::TOOL)], | |
| 2010 | size: [px(44.0), px(TOOL)], | |
| 2039 | 2011 | text: Some(&label), |
| 2040 | 2012 | hover_fill: Some(theme.hover()), |
| 2041 | 2013 | radius: 4.0, |
| ... | ... | @@ -2047,51 +2019,76 @@ impl State { |
| 2047 | 2019 | choice = Some(Choice::Command(Cmd::ActualSize)); |
| 2048 | 2020 | } |
| 2049 | 2021 | tip(ui, Cmd::ActualSize); |
| 2050 | choice = tool( | |
| 2051 | ui, | |
| 2052 | "in", | |
| 2053 | art::ZOOM_IN, | |
| 2054 | text, | |
| 2055 | Cmd::ZoomIn, | |
| 2056 | status_of(Cmd::ZoomIn), | |
| 2057 | ) | |
| 2058 | .or(choice.take()); | |
| 2059 | }); | |
| 2060 | }); | |
| 2061 | ui.close(); | |
| 2022 | choice = tool(ui, Cmd::ZoomIn, status_of(Cmd::ZoomIn)).or(choice); | |
| 2023 | dropdown(ui, "view", Head::More, &views, status_of).or(choice) | |
| 2024 | }, | |
| 2025 | |ui| { | |
| 2026 | let mut entries = vec![ | |
| 2027 | Run(Cmd::ZoomIn), | |
| 2028 | Run(Cmd::ZoomOut), | |
| 2029 | Run(Cmd::ActualSize), | |
| 2030 | Rule, | |
| 2031 | Open("page color", Cmd::PageColor), | |
| 2032 | Rule, | |
| 2033 | ]; | |
| 2034 | entries.extend(views); | |
| 2035 | dropdown(ui, "menu", Head::Menu(art::ZOOM_IN), &entries, status_of) | |
| 2036 | }, | |
| 2037 | ) | |
| 2038 | .or(choice); | |
| 2062 | 2039 | if let Some(choice) = choice { |
| 2063 | 2040 | self.choose(choice); |
| 2064 | 2041 | } |
| 2065 | Ok(()) | |
| 2066 | 2042 | } |
| 2067 | 2043 | |
| 2068 | /// The search box and the page list's buttons, above the list. | |
| 2044 | /// The search box and the page list's buttons, above the list. Where the tab row lacks | |
| 2045 | /// room the box folds to a button, which opens the search over the row; while finding | |
| 2046 | /// on the page it stays. | |
| 2069 | 2047 | fn page_tools(&mut self, theme: &Theme) { |
| 2048 | let finding = self.search.finding; | |
| 2070 | 2049 | self.ui.open( |
| 2071 | 2050 | "tools", |
| 2072 | 2051 | Spec { |
| 2073 | size: [px(PAGE_LIST), px(TAB_ROW)], | |
| 2074 | pad: [0.0, (TAB_ROW - ui::shell::TOOL) / 2.0], | |
| 2075 | gap: 3.0, | |
| 2052 | fold: (!finding).then_some(0), | |
| 2076 | 2053 | ..Spec::default() |
| 2077 | 2054 | }, |
| 2078 | 2055 | ); |
| 2079 | if let Err(error) = self.search_box(theme) { | |
| 2080 | eprintln!("{error}"); | |
| 2081 | } | |
| 2082 | if ui::shell::tool_button(&mut self.ui, "new", art::PLUS, theme.text, false).clicked { | |
| 2083 | self.commands.push(Command::NewPage { under: None }); | |
| 2084 | } | |
| 2085 | tip(&mut self.ui, commands::Id::NewPage); | |
| 2086 | let toggle = if self.pages_open { | |
| 2087 | art::SIDEBAR_COLLAPSE | |
| 2088 | } else { | |
| 2089 | art::SIDEBAR_EXPAND | |
| 2090 | }; | |
| 2091 | if ui::shell::tool_button(&mut self.ui, "toggle", toggle, theme.text, false).clicked { | |
| 2092 | self.pages_open = !self.pages_open; | |
| 2056 | for folded in [false, true].into_iter().take(if finding { 1 } else { 2 }) { | |
| 2057 | self.ui.open( | |
| 2058 | folded, | |
| 2059 | Spec { | |
| 2060 | size: [if folded { children() } else { px(PAGE_LIST) }, px(TAB_ROW)], | |
| 2061 | pad: [0.0, (TAB_ROW - ui::shell::TOOL) / 2.0], | |
| 2062 | gap: 3.0, | |
| 2063 | ..Spec::default() | |
| 2064 | }, | |
| 2065 | ); | |
| 2066 | if folded { | |
| 2067 | let open = self.ui.popup_open(search::results()); | |
| 2068 | if ui::shell::tool_button(&mut self.ui, "search", art::SEARCH, theme.text, open) | |
| 2069 | .pressed | |
| 2070 | { | |
| 2071 | self.start_search(); | |
| 2072 | } | |
| 2073 | tip(&mut self.ui, commands::Id::Search); | |
| 2074 | } else if let Err(error) = self.search_box(theme) { | |
| 2075 | eprintln!("{error}"); | |
| 2076 | } | |
| 2077 | if ui::shell::tool_button(&mut self.ui, "new", art::PLUS, theme.text, false).clicked { | |
| 2078 | self.commands.push(Command::NewPage { under: None }); | |
| 2079 | } | |
| 2080 | tip(&mut self.ui, commands::Id::NewPage); | |
| 2081 | let toggle = if self.pages_open { | |
| 2082 | art::SIDEBAR_COLLAPSE | |
| 2083 | } else { | |
| 2084 | art::SIDEBAR_EXPAND | |
| 2085 | }; | |
| 2086 | if ui::shell::tool_button(&mut self.ui, "toggle", toggle, theme.text, false).clicked { | |
| 2087 | self.pages_open = !self.pages_open; | |
| 2088 | } | |
| 2089 | tip(&mut self.ui, commands::Id::PageList); | |
| 2090 | self.ui.close(); | |
| 2093 | 2091 | } |
| 2094 | tip(&mut self.ui, commands::Id::PageList); | |
| 2095 | 2092 | self.ui.close(); |
| 2096 | 2093 | } |
| 2097 | 2094 | |
| ... | ... | @@ -2941,6 +2938,17 @@ impl State { |
| 2941 | 2938 | if let Some(path) = self.snapshot.take() { |
| 2942 | 2939 | self.snapshot(&path)?; |
| 2943 | 2940 | } |
| 2941 | if let Some((dir, opened)) = &self.frames { | |
| 2942 | let path = dir.join(format!("{:09.2}.png", opened.elapsed().as_secs_f64() * 1e3)); | |
| 2943 | let pixels = self.capture()?; | |
| 2944 | let size = self.surface.size; | |
| 2945 | // Encoding off the frame keeps the frames at the pace they are drawn. | |
| 2946 | std::thread::spawn(move || { | |
| 2947 | if let Err(error) = write_png(&path, size, &pixels) { | |
| 2948 | eprintln!("{error}"); | |
| 2949 | } | |
| 2950 | }); | |
| 2951 | } | |
| 2944 | 2952 | if self.occluded { |
| 2945 | 2953 | return Ok(()); |
| 2946 | 2954 | } |
| ... | ... | @@ -3006,6 +3014,7 @@ impl State { |
| 3006 | 3014 | ], |
| 3007 | 3015 | clip: Some(rect.map(|value| value * scale)), |
| 3008 | 3016 | backdrop: Some(paper.color), |
| 3017 | round: None, | |
| 3009 | 3018 | motion: None, |
| 3010 | 3019 | primitives: &page_primitives, |
| 3011 | 3020 | }, |
| ... | ... | @@ -3022,25 +3031,48 @@ impl State { |
| 3022 | 3031 | |
| 3023 | 3032 | /// Writes the frame to a PNG, so a covered window can be reviewed. |
| 3024 | 3033 | fn snapshot(&mut self, path: &Path) -> Result<(), Box<dyn Error>> { |
| 3025 | let size = self.surface.size; | |
| 3034 | let pixels = self.capture()?; | |
| 3035 | write_png(path, self.surface.size, &pixels) | |
| 3036 | } | |
| 3037 | ||
| 3038 | /// The frame's sRGB RGBA rows, drawn offscreen. | |
| 3039 | fn capture(&mut self) -> Result<Vec<u8>, Box<dyn Error>> { | |
| 3026 | 3040 | let offscreen = self.surface.offscreen(&self.renderer)?; |
| 3027 | 3041 | self.paint(&offscreen.target)?; |
| 3028 | let pixels = self.surface.read(&self.renderer, offscreen)?; | |
| 3029 | let partial = path.with_extension("partial"); | |
| 3030 | let mut encoder = png::Encoder::new(std::fs::File::create(&partial)?, size[0], size[1]); | |
| 3031 | encoder.set_color(png::ColorType::Rgba); | |
| 3032 | encoder.set_depth(png::BitDepth::Eight); | |
| 3033 | encoder.write_header()?.write_image_data(&pixels)?; | |
| 3034 | std::fs::rename(partial, path)?; | |
| 3035 | Ok(()) | |
| 3042 | self.surface.read(&self.renderer, offscreen) | |
| 3036 | 3043 | } |
| 3037 | 3044 | |
| 3038 | 3045 | /// Queues input for the next frame. A press on the strip moves the window: at once on |
| 3039 | 3046 | /// macOS, while AppKit still holds the press, and elsewhere once the pointer moves, as |
| 3040 | 3047 | /// the window manager's move would swallow a second click; a double press zooms it. |
| 3048 | fn over_page(&self) -> bool { | |
| 3049 | let [x0, y0, x1, y1] = self.ui.rect(page()).unwrap_or_default(); | |
| 3050 | let [x, y] = self.pointer; | |
| 3051 | (x0..x1).contains(&x) && (y0..y1).contains(&y) | |
| 3052 | } | |
| 3053 | ||
| 3054 | /// Zooms the page by `factor` about the pointer, when it is over the page. | |
| 3055 | fn pinch(&mut self, factor: f32) -> Result<(), Box<dyn Error>> { | |
| 3056 | if self.over_page() { | |
| 3057 | let response = self.view.pinch(factor)?; | |
| 3058 | self.respond(response); | |
| 3059 | } | |
| 3060 | Ok(()) | |
| 3061 | } | |
| 3062 | ||
| 3063 | fn swipe_scroll(&mut self, phase: winit::event::TouchPhase, delta: [f32; 2]) { | |
| 3064 | let scroll = self.view.scroll(); | |
| 3065 | let armed = scroll.max[0] <= scroll.min[0] && self.over_page(); | |
| 3066 | if let Some(forward) = self.swipe.scroll(phase, delta, armed, Instant::now()) { | |
| 3067 | self.travel(forward); | |
| 3068 | self.window.request_redraw(); | |
| 3069 | } | |
| 3070 | } | |
| 3071 | ||
| 3041 | 3072 | fn input(&mut self, event: ui::Event) { |
| 3042 | 3073 | if let ui::Event::PointerMoved(point) = event { |
| 3043 | 3074 | self.pointer = point; |
| 3075 | platform::set_window_drags(self.ui.box_at(point) == Some(strip())); | |
| 3044 | 3076 | if std::mem::take(&mut self.strip_held) { |
| 3045 | 3077 | let _ = self.window.drag_window(); |
| 3046 | 3078 | } |
| ... | ... | @@ -3658,7 +3690,14 @@ fn list_gallery( |
| 3658 | 3690 | size: [f32; 2], |
| 3659 | 3691 | preview: impl Fn(&mut Ui, usize), |
| 3660 | 3692 | ) -> Option<Option<usize>> { |
| 3661 | let groups = [(headings[0], recent.len()), (headings[1], count + 1)]; | |
| 3693 | let groups = [(headings[0], recent.len()), (headings[1], count + 1)].map(|(heading, cells)| { | |
| 3694 | ui::popup::Group { | |
| 3695 | heading, | |
| 3696 | cells, | |
| 3697 | columns: 5, | |
| 3698 | size, | |
| 3699 | } | |
| 3700 | }); | |
| 3662 | 3701 | let place = |index: usize| match index.checked_sub(recent.len()) { |
| 3663 | 3702 | None => Some(recent[index]), |
| 3664 | 3703 | Some(at) => at.checked_sub(1), |
| ... | ... | @@ -3668,9 +3707,7 @@ fn list_gallery( |
| 3668 | 3707 | id, |
| 3669 | 3708 | anchor, |
| 3670 | 3709 | &groups[usize::from(recent.is_empty())..], |
| 3671 | 5, | |
| 3672 | size, | |
| 3673 | current.map(|place| recent.len() + 1 + place), | |
| 3710 | current.map(|place| recent.len() + 1 + place).as_slice(), | |
| 3674 | 3711 | |ui, index| match place(index) { |
| 3675 | 3712 | Some(place) => preview(ui, place), |
| 3676 | 3713 | None => { |
| ... | ... | @@ -3689,44 +3726,161 @@ fn list_gallery( |
| 3689 | 3726 | Some(place(chosen)) |
| 3690 | 3727 | } |
| 3691 | 3728 | |
| 3692 | /// A toolbar group's rows, stacked. | |
| 3693 | fn group(ui: &mut Ui, part: &str, rows: impl FnOnce(&mut Ui)) { | |
| 3729 | /// A toolbar group that folds by `priority`: `full` builds its full form, `folded` the form | |
| 3730 | /// it folds to. Returns what either chose. | |
| 3731 | fn group( | |
| 3732 | ui: &mut Ui, | |
| 3733 | part: &str, | |
| 3734 | priority: u32, | |
| 3735 | full: impl FnOnce(&mut Ui) -> Option<commands::Choice>, | |
| 3736 | folded: impl FnOnce(&mut Ui) -> Option<commands::Choice>, | |
| 3737 | ) -> Option<commands::Choice> { | |
| 3694 | 3738 | ui.open( |
| 3695 | 3739 | part, |
| 3696 | 3740 | Spec { |
| 3697 | axis: Axis::Y, | |
| 3698 | gap: 2.0, | |
| 3741 | fold: Some(priority), | |
| 3699 | 3742 | ..Spec::default() |
| 3700 | 3743 | }, |
| 3701 | 3744 | ); |
| 3702 | rows(ui); | |
| 3703 | ui.close(); | |
| 3704 | } | |
| 3705 | ||
| 3706 | fn row(ui: &mut Ui, part: usize, buttons: impl FnOnce(&mut Ui)) { | |
| 3707 | 3745 | ui.open( |
| 3708 | part, | |
| 3746 | "full", | |
| 3709 | 3747 | Spec { |
| 3710 | 3748 | gap: 1.0, |
| 3711 | 3749 | ..Spec::default() |
| 3712 | 3750 | }, |
| 3713 | 3751 | ); |
| 3714 | buttons(ui); | |
| 3752 | let shown = full(ui); | |
| 3753 | ui.close(); | |
| 3754 | ui.open("folded", Spec::default()); | |
| 3755 | let choice = folded(ui).or(shown); | |
| 3756 | ui.close(); | |
| 3715 | 3757 | ui.close(); |
| 3758 | choice | |
| 3716 | 3759 | } |
| 3717 | 3760 | |
| 3718 | /// A tooltip naming command `id` and its chord on the box built last. It names the command, | |
| 3719 | /// not a dialog it opens, so it leaves off the menu's ellipsis. | |
| 3720 | /// A tool button for command `id`, lit while `status` checks it and faded while it does not | |
| 3721 | /// apply; returns the command when clicked. | |
| 3722 | fn tool( | |
| 3761 | /// A row of a toolbar menu. | |
| 3762 | #[derive(Clone, Copy)] | |
| 3763 | enum Entry { | |
| 3764 | Run(commands::Id), | |
| 3765 | /// Opens the toolbar's popup `name`, which applies the command, under the button the | |
| 3766 | /// popup's own control would open it from, or where that is folded away, this menu's. | |
| 3767 | Open(&'static str, commands::Id), | |
| 3768 | /// A command not built yet, shown disabled. | |
| 3769 | Soon(&'static str, Option<&'static [&'static str]>), | |
| 3770 | /// Rules off the entries after it. | |
| 3771 | Rule, | |
| 3772 | } | |
| 3773 | ||
| 3774 | /// What opens a toolbar menu: the arrow of a button running a command, a button showing | |
| 3775 | /// artwork, or a bare arrow. | |
| 3776 | enum Head { | |
| 3777 | Split(commands::Id), | |
| 3778 | Menu(&'static [&'static str]), | |
| 3779 | More, | |
| 3780 | } | |
| 3781 | ||
| 3782 | /// A toolbar menu `part` of `entries` opened by `head`, whose command enabled and checked as | |
| 3783 | /// `status_of` says. Returns the command chosen, or the head's when clicked. | |
| 3784 | fn dropdown( | |
| 3723 | 3785 | ui: &mut Ui, |
| 3724 | part: impl std::hash::Hash, | |
| 3725 | icon: &'static [&'static str], | |
| 3726 | tint: [f32; 4], | |
| 3727 | id: commands::Id, | |
| 3728 | status: commands::Status, | |
| 3786 | part: &str, | |
| 3787 | head: Head, | |
| 3788 | entries: &[Entry], | |
| 3789 | status_of: &impl Fn(commands::Id) -> commands::Status, | |
| 3729 | 3790 | ) -> Option<commands::Choice> { |
| 3791 | use commands::Choice; | |
| 3792 | let button = ui.id(part); | |
| 3793 | let menu = button.child("menu"); | |
| 3794 | let mut choice = None; | |
| 3795 | let anchor = match head { | |
| 3796 | Head::Split(id) if status_of(id).enabled => { | |
| 3797 | let icon = artwork(id).unwrap_or_default(); | |
| 3798 | let on = status_of(id).checked; | |
| 3799 | if ui::shell::split_button(ui, part, icon, None, on, menu).clicked { | |
| 3800 | choice = Some(Choice::Command(id)); | |
| 3801 | } | |
| 3802 | tip(ui, id); | |
| 3803 | ui::Anchor::Below(ui.rect(button).unwrap_or_default()) | |
| 3804 | } | |
| 3805 | // Where its command does not apply, the button only opens the menu. | |
| 3806 | Head::Split(id) => ui::shell::menu_button(ui, part, artwork(id).unwrap_or_default(), menu), | |
| 3807 | Head::Menu(icon) => ui::shell::menu_button(ui, part, icon, menu), | |
| 3808 | Head::More => { | |
| 3809 | let open = ui.popup_open(menu); | |
| 3810 | let text = ui.theme.text; | |
| 3811 | if ui::shell::tool_button(ui, part, ui::shell::CHEVRON, text, open).pressed { | |
| 3812 | ui.open_popup(menu); | |
| 3813 | } | |
| 3814 | ui::Anchor::Below(ui.rect(button).unwrap_or_default()) | |
| 3815 | } | |
| 3816 | }; | |
| 3817 | let keys: Vec<_> = entries | |
| 3818 | .iter() | |
| 3819 | .map(|entry| match entry { | |
| 3820 | Entry::Run(id) => commands::shortcut(*id), | |
| 3821 | _ => String::new(), | |
| 3822 | }) | |
| 3823 | .collect(); | |
| 3824 | let (mut items, mut actions) = (Vec::new(), Vec::new()); | |
| 3825 | let mut separated = false; | |
| 3826 | for (entry, key) in entries.iter().zip(&keys) { | |
| 3827 | let item = match *entry { | |
| 3828 | Entry::Rule => { | |
| 3829 | separated = true; | |
| 3830 | continue; | |
| 3831 | } | |
| 3832 | Entry::Run(id) => ui::popup::Item { | |
| 3833 | text: commands::command(id).title, | |
| 3834 | icon: artwork(id), | |
| 3835 | colored: matches!(id, commands::Id::Tag(_)), | |
| 3836 | shortcut: key, | |
| 3837 | checked: status_of(id).checked, | |
| 3838 | disabled: !status_of(id).enabled, | |
| 3839 | ..Default::default() | |
| 3840 | }, | |
| 3841 | Entry::Open(_, id) => ui::popup::Item { | |
| 3842 | text: commands::command(id).title, | |
| 3843 | icon: artwork(id), | |
| 3844 | shortcut: "›", | |
| 3845 | disabled: !status_of(id).enabled, | |
| 3846 | ..Default::default() | |
| 3847 | }, | |
| 3848 | Entry::Soon(text, icon) => ui::popup::Item { | |
| 3849 | text, | |
| 3850 | icon, | |
| 3851 | disabled: true, | |
| 3852 | ..Default::default() | |
| 3853 | }, | |
| 3854 | }; | |
| 3855 | items.push(ui::popup::Item { | |
| 3856 | separated: std::mem::take(&mut separated), | |
| 3857 | ..item | |
| 3858 | }); | |
| 3859 | actions.push(*entry); | |
| 3860 | } | |
| 3861 | match ui::popup::menu(ui, menu, anchor, &items, None).map(|index| actions[index]) { | |
| 3862 | Some(Entry::Run(id)) => Some(Choice::Command(id)), | |
| 3863 | Some(Entry::Open(name, _)) => { | |
| 3864 | ui.open_popup(toolbar_popup(name)); | |
| 3865 | None | |
| 3866 | } | |
| 3867 | _ => choice, | |
| 3868 | } | |
| 3869 | } | |
| 3870 | ||
| 3871 | /// A tool button for command `id`, lit while `status` checks it and faded while it does not | |
| 3872 | /// apply; returns the command when clicked. | |
| 3873 | fn tool(ui: &mut Ui, id: commands::Id, status: commands::Status) -> Option<commands::Choice> { | |
| 3874 | let part = commands::COMMANDS | |
| 3875 | .iter() | |
| 3876 | .position(|command| command.id == id); | |
| 3877 | let icon = artwork(id).unwrap_or_default(); | |
| 3878 | // Artwork in its own colours takes white. | |
| 3879 | let tint = if matches!(id, commands::Id::Tag(_)) { | |
| 3880 | [1.0; 4] | |
| 3881 | } else { | |
| 3882 | ui.theme.text | |
| 3883 | }; | |
| 3730 | 3884 | if !status.enabled { |
| 3731 | 3885 | ui::shell::unavailable(ui, part, icon, tint, false); |
| 3732 | 3886 | return None; |
| ... | ... | @@ -3736,6 +3890,57 @@ fn tool( |
| 3736 | 3890 | clicked.then_some(commands::Choice::Command(id)) |
| 3737 | 3891 | } |
| 3738 | 3892 | |
| 3893 | /// The artwork the toolbar and its menus show for command `id`. | |
| 3894 | fn artwork(id: commands::Id) -> Option<&'static [&'static str]> { | |
| 3895 | use canvas::editor::{Alignment, Toggle}; | |
| 3896 | use commands::Id as Cmd; | |
| 3897 | Some(match id { | |
| 3898 | Cmd::Back => art::BACK, | |
| 3899 | Cmd::Forward => art::FORWARD, | |
| 3900 | Cmd::Undo => art::UNDO, | |
| 3901 | Cmd::Redo => art::REDO, | |
| 3902 | Cmd::Cut => art::CUT, | |
| 3903 | Cmd::Copy => art::COPY, | |
| 3904 | Cmd::Paste => art::PASTE, | |
| 3905 | Cmd::FormatPainter => art::FORMAT_PAINTER, | |
| 3906 | Cmd::Toggle(Toggle::Bold) => art::BOLD, | |
| 3907 | Cmd::Toggle(Toggle::Italic) => art::ITALIC, | |
| 3908 | Cmd::Toggle(Toggle::Underline) => art::UNDERLINE, | |
| 3909 | Cmd::Toggle(Toggle::Strikethrough) => art::STRIKETHROUGH, | |
| 3910 | Cmd::Toggle(Toggle::Subscript) => art::SUBSCRIPT, | |
| 3911 | Cmd::Toggle(Toggle::Superscript) => art::SUPERSCRIPT, | |
| 3912 | Cmd::ClearFormatting => art::CLEAR_FORMATTING, | |
| 3913 | Cmd::Highlight => art::HIGHLIGHTER, | |
| 3914 | Cmd::FontColor => art::FONT_COLOR, | |
| 3915 | Cmd::Bullets => art::BULLETS, | |
| 3916 | Cmd::Numbering => art::NUMBERING, | |
| 3917 | Cmd::Align(Alignment::Left) => art::ALIGN_LEFT, | |
| 3918 | Cmd::Align(Alignment::Center) => art::ALIGN_CENTER, | |
| 3919 | Cmd::Align(Alignment::Right) => art::ALIGN_RIGHT, | |
| 3920 | Cmd::Outdent => art::OUTDENT, | |
| 3921 | Cmd::Indent => art::INDENT, | |
| 3922 | Cmd::Tag(tag) => tag_icon(tag), | |
| 3923 | Cmd::FindTags => art::FIND_TAGS, | |
| 3924 | Cmd::Table => art::TABLE, | |
| 3925 | Cmd::Picture => art::PICTURE, | |
| 3926 | Cmd::ScreenClipping => art::SCREEN_CLIPPING, | |
| 3927 | Cmd::Attachment => art::ATTACHMENT, | |
| 3928 | Cmd::Link => art::LINK, | |
| 3929 | Cmd::InsertSpace => art::INSERT_SPACE, | |
| 3930 | Cmd::Date => art::CALENDAR, | |
| 3931 | Cmd::Time => art::CLOCK, | |
| 3932 | Cmd::DateTime => art::DATE_TIME, | |
| 3933 | Cmd::Equation => art::EQUATION, | |
| 3934 | Cmd::Symbol => art::SYMBOL, | |
| 3935 | Cmd::RecordAudio => art::RECORD_AUDIO, | |
| 3936 | Cmd::RecordVideo => art::RECORD_VIDEO, | |
| 3937 | Cmd::PageColor => art::PAGE_COLOR, | |
| 3938 | Cmd::ZoomIn => art::ZOOM_IN, | |
| 3939 | Cmd::ZoomOut => art::ZOOM_OUT, | |
| 3940 | _ => return None, | |
| 3941 | }) | |
| 3942 | } | |
| 3943 | ||
| 3739 | 3944 | /// The artwork the toolbar shows for `tag`. |
| 3740 | 3945 | fn tag_icon(tag: canvas::editor::NoteTag) -> &'static [&'static str] { |
| 3741 | 3946 | use canvas::editor::NoteTag; |
| ... | ... | @@ -3751,18 +3956,21 @@ fn toolbar_popup(name: &str) -> Id { |
| 3751 | 3956 | Id::ROOT.child(("popup", name)) |
| 3752 | 3957 | } |
| 3753 | 3958 | |
| 3959 | /// A tooltip naming command `id` and its chord on the box built last. It names the command, | |
| 3960 | /// not a dialog it opens, so it leaves off the menu's ellipsis. | |
| 3754 | 3961 | fn tip(ui: &mut Ui, id: commands::Id) { |
| 3755 | 3962 | let title = commands::command(id).title.trim_end_matches('…'); |
| 3756 | 3963 | ui::popup::tooltip(ui, title, &commands::shortcut(id), None); |
| 3757 | 3964 | } |
| 3758 | 3965 | |
| 3759 | /// The line between two toolbar groups. | |
| 3760 | fn divider(ui: &mut Ui, part: &str, theme: &Theme) { | |
| 3966 | /// The line leading a toolbar group, as far from its buttons as the groups are apart. | |
| 3967 | fn divider(ui: &mut Ui, theme: &Theme) { | |
| 3761 | 3968 | ui.leaf( |
| 3762 | ("divider", part), | |
| 3969 | "divider", | |
| 3763 | 3970 | Spec { |
| 3764 | size: [px(1.0), px(2.0 * ui::shell::TOOL + 2.0)], | |
| 3971 | size: [px(GAP), px(ui::shell::TOOL)], | |
| 3765 | 3972 | fill: Some(theme.chip), |
| 3973 | inset: [0.0, 0.0, GAP - 1.0, 0.0], | |
| 3766 | 3974 | ..Spec::default() |
| 3767 | 3975 | }, |
| 3768 | 3976 | ); |
| ... | ... | @@ -4054,9 +4262,8 @@ impl ApplicationHandler<UserEvent> for App { |
| 4054 | 4262 | state.set_appearance(state.color_scheme.theme().unwrap_or(appearance)); |
| 4055 | 4263 | state.window.request_redraw(); |
| 4056 | 4264 | } |
| 4057 | WindowEvent::ScaleFactorChanged { scale_factor, .. } => { | |
| 4265 | WindowEvent::ScaleFactorChanged { .. } => { | |
| 4058 | 4266 | state.renderer.clear_glyph_cache(); |
| 4059 | state.app_icon = platform::app_icon((16.0 * scale_factor).round() as u32); | |
| 4060 | 4267 | state.window.request_redraw(); |
| 4061 | 4268 | } |
| 4062 | 4269 | WindowEvent::Focused(focused) => { |
| ... | ... | @@ -4090,7 +4297,10 @@ impl ApplicationHandler<UserEvent> for App { |
| 4090 | 4297 | pressed: pressed == ElementState::Pressed, |
| 4091 | 4298 | at: Instant::now(), |
| 4092 | 4299 | }), |
| 4093 | WindowEvent::MouseWheel { delta, .. } => { | |
| 4300 | WindowEvent::MouseWheel { delta, phase, .. } => { | |
| 4301 | if let MouseScrollDelta::PixelDelta(p) = delta { | |
| 4302 | state.swipe_scroll(phase, [p.x as f32 / scale, p.y as f32 / scale]); | |
| 4303 | } | |
| 4094 | 4304 | state.input(ui::Event::Wheel(match delta { |
| 4095 | 4305 | MouseScrollDelta::LineDelta(x, y) => [x * 32.0, y * 32.0], |
| 4096 | 4306 | MouseScrollDelta::PixelDelta(p) => [p.x as f32 / scale, p.y as f32 / scale], |
| ... | ... | @@ -4105,6 +4315,13 @@ impl ApplicationHandler<UserEvent> for App { |
| 4105 | 4315 | }) |
| 4106 | 4316 | } |
| 4107 | 4317 | WindowEvent::Ime(ime) => state.input(ui::Event::Ime(ime)), |
| 4318 | WindowEvent::PinchGesture { delta, .. } => state.pinch(1.0 + delta as f32)?, | |
| 4319 | // Smart zoom: to 200% about the fingers, or back to 100%. | |
| 4320 | WindowEvent::DoubleTapGesture { .. } => { | |
| 4321 | let zoom = state.view.zoom(); | |
| 4322 | let target = if (zoom - 1.0).abs() < 0.01 { 2.0 } else { 1.0 }; | |
| 4323 | state.pinch(target / zoom)? | |
| 4324 | } | |
| 4108 | 4325 | _ => {} |
| 4109 | 4326 | } |
| 4110 | 4327 | Ok(()) |
| ... | ... | @@ -4137,6 +4354,16 @@ impl ApplicationHandler<UserEvent> for App { |
| 4137 | 4354 | } |
| 4138 | 4355 | } |
| 4139 | 4356 | |
| 4357 | fn write_png(path: &Path, size: [u32; 2], pixels: &[u8]) -> Result<(), Box<dyn Error>> { | |
| 4358 | let partial = path.with_extension("partial"); | |
| 4359 | let mut encoder = png::Encoder::new(std::fs::File::create(&partial)?, size[0], size[1]); | |
| 4360 | encoder.set_color(png::ColorType::Rgba); | |
| 4361 | encoder.set_depth(png::BitDepth::Eight); | |
| 4362 | encoder.write_header()?.write_image_data(pixels)?; | |
| 4363 | std::fs::rename(partial, path)?; | |
| 4364 | Ok(()) | |
| 4365 | } | |
| 4366 | ||
| 4140 | 4367 | /// Feeds a development script to the window from another thread, one command per line |
| 4141 | 4368 | /// in logical pixels: `move X Y`, `press [right]`, `release [right]`, `wheel DX DY`, `key NAME`, `type |
| 4142 | 4369 | /// TEXT`, `modifiers [shift] [command]`, `wait MILLISECONDS`, `snapshot PNG_PATH`, |
| ... | ... | @@ -4214,9 +4441,15 @@ fn replay(script: String, proxy: EventLoopProxy<UserEvent>) -> Result<(), Box<dy |
| 4214 | 4441 | let _ = proxy.send_event(UserEvent::Replay(replay)); |
| 4215 | 4442 | } |
| 4216 | 4443 | Err(duration) => { |
| 4444 | // Ticks keep a 60 Hz display's pace, dropping those a slow frame missed. | |
| 4217 | 4445 | let start = Instant::now(); |
| 4446 | let period = std::time::Duration::from_micros(16_667); | |
| 4447 | let mut tick = start; | |
| 4218 | 4448 | while start.elapsed() < duration { |
| 4219 | std::thread::sleep(std::time::Duration::from_millis(16)); | |
| 4449 | while tick <= Instant::now() { | |
| 4450 | tick += period; | |
| 4451 | } | |
| 4452 | std::thread::sleep(tick - Instant::now()); | |
| 4220 | 4453 | let _ = proxy.send_event(UserEvent::Replay(Replay::Tick)); |
| 4221 | 4454 | } |
| 4222 | 4455 | } |
crates/snowbound/src/manage.rs+142-51| ... | ... | @@ -2,6 +2,7 @@ |
| 2 | 2 | //! structure through `notebook::session::Notebook`, pages as ops on the open section. |
| 3 | 3 | |
| 4 | 4 | use crate::{Command, Library, Loaded, State, notify, platform, read_session}; |
| 5 | use canvas::template::Template; | |
| 5 | 6 | use notebook::session::Notebook; |
| 6 | 7 | use onestore::{ |
| 7 | 8 | ExGuid, PageCreation, PageEdit, |
| ... | ... | @@ -133,13 +134,28 @@ impl State { |
| 133 | 134 | self.edit_page(ops) |
| 134 | 135 | } |
| 135 | 136 | |
| 136 | /// View, Page Color: gives the open page `color`, a COLORREF, or none; its art stays. | |
| 137 | pub(crate) fn color_page(&mut self, color: Option<u32>) -> Result<(), Box<dyn Error>> { | |
| 138 | if self.view.editor.page_color() == color { | |
| 139 | return Ok(()); | |
| 140 | } | |
| 137 | /// Gives the open page `template`'s art, or none, in place of the art it had; its colour | |
| 138 | /// and rule lines stay. | |
| 139 | pub(crate) fn art_page( | |
| 140 | &mut self, | |
| 141 | template: Option<&'static Template>, | |
| 142 | ) -> Result<(), Box<dyn Error>> { | |
| 141 | 143 | self.persist()?; |
| 142 | self.edit_page(vec![PageOp::Color(color)]) | |
| 144 | let session = self.session.as_ref().ok_or("No section is open")?; | |
| 145 | let ops = art_ops(&session.section.page(session.space)?, template)?; | |
| 146 | self.edit_page(ops) | |
| 147 | } | |
| 148 | ||
| 149 | /// View, Page Color and Rule Lines: gives the open page `color`, a COLORREF, and | |
| 150 | /// `rule_lines` as one undo step; its art stays. | |
| 151 | pub(crate) fn paper_page( | |
| 152 | &mut self, | |
| 153 | color: Option<u32>, | |
| 154 | rule_lines: Option<onestore::page::RuleLines>, | |
| 155 | ) -> Result<(), Box<dyn Error>> { | |
| 156 | let response = self.view.set_paper(color, rule_lines)?; | |
| 157 | self.respond(response); | |
| 158 | Ok(()) | |
| 143 | 159 | } |
| 144 | 160 | |
| 145 | 161 | /// Applies `ops` to the open page as one edit and shows the result. |
| ... | ... | @@ -429,60 +445,26 @@ impl State { |
| 429 | 445 | } |
| 430 | 446 | } |
| 431 | 447 | |
| 432 | /// The ops giving `page` `choice`'s background: its template art (OneNote's pictures' | |
| 433 | /// places, our recreations' bytes) or a page colour, in place of the background it had. | |
| 448 | /// The ops giving `page` `choice`'s background: its template art or a page colour, in | |
| 449 | /// place of the background it had. | |
| 434 | 450 | pub fn template_ops( |
| 435 | 451 | page: &Page, |
| 436 | 452 | choice: crate::templates::Choice, |
| 437 | 453 | ) -> Result<Vec<PageOp>, Box<dyn Error>> { |
| 438 | 454 | use crate::templates::Choice; |
| 439 | let mut ops: Vec<PageOp> = page | |
| 440 | .objects | |
| 441 | .iter() | |
| 442 | .filter(|object| matches!(object, PageObject::Image(image) if image.background)) | |
| 443 | .map(|object| PageOp::Delete { | |
| 444 | object: object.id(), | |
| 445 | }) | |
| 446 | .collect(); | |
| 455 | let mut ops = match choice { | |
| 456 | Choice::Template(name) => art_ops( | |
| 457 | page, | |
| 458 | Some(canvas::template::find(name).ok_or("That template is not available")?), | |
| 459 | )?, | |
| 460 | Choice::Color(_) => art_ops(page, None)?, | |
| 461 | Choice::More | Choice::Dismiss | Choice::Colors => return Ok(Vec::new()), | |
| 462 | }; | |
| 447 | 463 | match choice { |
| 448 | 464 | Choice::Template(name) => { |
| 449 | let template = canvas::template::find(name).ok_or("That template is not available")?; | |
| 450 | 465 | if page.color.is_some() { |
| 451 | 466 | ops.push(PageOp::Color(None)); |
| 452 | 467 | } |
| 453 | // Art lies under everything else on the page, first of its children (the title | |
| 454 | // is no child). | |
| 455 | let under = page | |
| 456 | .objects | |
| 457 | .iter() | |
| 458 | .find(|object| match object { | |
| 459 | PageObject::Title(_) => false, | |
| 460 | PageObject::Image(image) => !image.background, | |
| 461 | _ => true, | |
| 462 | }) | |
| 463 | .map(PageObject::id); | |
| 464 | for art in template.art { | |
| 465 | let bytes = canvas::gpu::page::template_picture(art.art) | |
| 466 | .ok_or("That template's art is missing")?; | |
| 467 | ops.push(PageOp::Add { | |
| 468 | object: PageObject::Image(Image { | |
| 469 | id: onestore::page::text::new_id()?, | |
| 470 | layout: onestore::document::Layout { | |
| 471 | x: Some(art.position[0]), | |
| 472 | y: Some(art.position[1]), | |
| 473 | max_width: art.size.map(|size| size[0]), | |
| 474 | max_height: art.size.map(|size| size[1]), | |
| 475 | width_set_by_user: art.size.map(|_| true), | |
| 476 | ..Default::default() | |
| 477 | }, | |
| 478 | size: art.size, | |
| 479 | bytes: Some(bytes.into()), | |
| 480 | alt: None, | |
| 481 | background: true, | |
| 482 | }), | |
| 483 | before: under, | |
| 484 | }); | |
| 485 | } | |
| 486 | 468 | // The one template with content worth keeping. |
| 487 | 469 | if name == "Informal Meeting Notes" { |
| 488 | 470 | ops.extend(onestore::op::lower_page( |
| ... | ... | @@ -494,7 +476,54 @@ pub fn template_ops( |
| 494 | 476 | Choice::Color(index) => { |
| 495 | 477 | ops.push(PageOp::Color(Some(canvas::template::PAGE_COLORS[index].1))) |
| 496 | 478 | } |
| 497 | Choice::More | Choice::Dismiss | Choice::Colors => return Ok(Vec::new()), | |
| 479 | Choice::More | Choice::Dismiss | Choice::Colors => {} | |
| 480 | } | |
| 481 | Ok(ops) | |
| 482 | } | |
| 483 | ||
| 484 | /// The ops giving `page` `template`'s art (OneNote's pictures' places, our recreations' | |
| 485 | /// bytes), or none, in place of the background pictures it had. | |
| 486 | pub fn art_ops(page: &Page, template: Option<&Template>) -> Result<Vec<PageOp>, Box<dyn Error>> { | |
| 487 | let mut ops: Vec<PageOp> = page | |
| 488 | .objects | |
| 489 | .iter() | |
| 490 | .filter(|object| matches!(object, PageObject::Image(image) if image.background)) | |
| 491 | .map(|object| PageOp::Delete { | |
| 492 | object: object.id(), | |
| 493 | }) | |
| 494 | .collect(); | |
| 495 | // Art lies under everything else on the page, first of its children (the title is no | |
| 496 | // child). | |
| 497 | let under = page | |
| 498 | .objects | |
| 499 | .iter() | |
| 500 | .find(|object| match object { | |
| 501 | PageObject::Title(_) => false, | |
| 502 | PageObject::Image(image) => !image.background, | |
| 503 | _ => true, | |
| 504 | }) | |
| 505 | .map(PageObject::id); | |
| 506 | for art in template.map_or(&[][..], |template| template.art) { | |
| 507 | let bytes = | |
| 508 | canvas::gpu::page::template_picture(art.art).ok_or("That template's art is missing")?; | |
| 509 | ops.push(PageOp::Add { | |
| 510 | object: PageObject::Image(Image { | |
| 511 | id: onestore::page::text::new_id()?, | |
| 512 | layout: onestore::document::Layout { | |
| 513 | x: Some(art.position[0]), | |
| 514 | y: Some(art.position[1]), | |
| 515 | max_width: art.size.map(|size| size[0]), | |
| 516 | max_height: art.size.map(|size| size[1]), | |
| 517 | width_set_by_user: art.size.map(|_| true), | |
| 518 | ..Default::default() | |
| 519 | }, | |
| 520 | size: art.size, | |
| 521 | bytes: Some(bytes.into()), | |
| 522 | alt: None, | |
| 523 | background: true, | |
| 524 | }), | |
| 525 | before: under, | |
| 526 | }); | |
| 498 | 527 | } |
| 499 | 528 | Ok(ops) |
| 500 | 529 | } |
| ... | ... | @@ -842,4 +871,66 @@ mod tests { |
| 842 | 871 | "New Section Group 2" |
| 843 | 872 | ); |
| 844 | 873 | } |
| 874 | ||
| 875 | /// Backgrounds the Page Color menu gives OneNote's ruled pages | |
| 876 | /// (`corpus/rule-lines/native`): art on a page with rule lines, keeping them, art taken | |
| 877 | /// away, and a colour outside OneNote's menu. `SNOWBOUND_BACKGROUND_EXPORT` names a new | |
| 878 | /// directory that receives the notebook for a cold reopen in OneNote 2010. | |
| 879 | #[test] | |
| 880 | fn backgrounds_go_on_and_off_pages_that_have_content() { | |
| 881 | let temporary = | |
| 882 | std::env::temp_dir().join(format!("snowbound-background-{}", std::process::id())); | |
| 883 | let _ = std::fs::remove_dir_all(&temporary); | |
| 884 | copy( | |
| 885 | Path::new(concat!( | |
| 886 | env!("CARGO_MANIFEST_DIR"), | |
| 887 | "/../../corpus/rule-lines/native/notebook" | |
| 888 | )), | |
| 889 | &temporary, | |
| 890 | ); | |
| 891 | let file = temporary.join("Rules.one"); | |
| 892 | let titled = |title: &str| { | |
| 893 | pages(&file) | |
| 894 | .into_iter() | |
| 895 | .find(|(_, name, _)| name == title) | |
| 896 | .unwrap() | |
| 897 | .0 | |
| 898 | }; | |
| 899 | let on_page = |space, ops: Vec<PageOp>| { | |
| 900 | edit( | |
| 901 | &file, | |
| 902 | ops.into_iter().map(|op| Op::Page { space, op }).collect(), | |
| 903 | ) | |
| 904 | }; | |
| 905 | let ivy = canvas::template::find("Ivy"); | |
| 906 | let (standard, wide, grid) = (titled("Standard"), titled("Wide"), titled("SmallGrid")); | |
| 907 | on_page(standard, art_ops(&page(&file, standard), ivy).unwrap()); | |
| 908 | on_page(wide, art_ops(&page(&file, wide), ivy).unwrap()); | |
| 909 | on_page(wide, art_ops(&page(&file, wide), None).unwrap()); | |
| 910 | // A warm grey no menu offers. | |
| 911 | on_page(grid, vec![PageOp::Color(Some(0x00c8d8e8))]); | |
| 912 | ||
| 913 | let standard = page(&file, standard); | |
| 914 | assert!(standard.rule_lines.is_some()); | |
| 915 | assert_eq!( | |
| 916 | standard | |
| 917 | .objects | |
| 918 | .iter() | |
| 919 | .filter(|object| matches!(object, PageObject::Image(image) if image.background)) | |
| 920 | .count(), | |
| 921 | ivy.unwrap().art.len() | |
| 922 | ); | |
| 923 | let wide = page(&file, wide); | |
| 924 | assert!( | |
| 925 | !wide | |
| 926 | .objects | |
| 927 | .iter() | |
| 928 | .any(|object| matches!(object, PageObject::Image(_))) | |
| 929 | ); | |
| 930 | assert_eq!(page(&file, grid).color, Some(0x00c8d8e8)); | |
| 931 | if let Some(directory) = std::env::var_os("SNOWBOUND_BACKGROUND_EXPORT") { | |
| 932 | copy(&temporary, Path::new(&directory)); | |
| 933 | } | |
| 934 | std::fs::remove_dir_all(&temporary).unwrap(); | |
| 935 | } | |
| 845 | 936 | } |
crates/snowbound/src/menubar.rs+4| ... | ... | @@ -79,6 +79,7 @@ const MENUS: &[Item] = &[ |
| 79 | 79 | S, |
| 80 | 80 | C(Id::Find), |
| 81 | 81 | C(Id::Search), |
| 82 | C(Id::SearchResults), | |
| 82 | 83 | S, |
| 83 | 84 | Item::System("Start Dictation…", "startDictation:", None), |
| 84 | 85 | Item::System( |
| ... | ... | @@ -92,6 +93,8 @@ const MENUS: &[Item] = &[ |
| 92 | 93 | Item::Menu( |
| 93 | 94 | "View", |
| 94 | 95 | &[ |
| 96 | C(Id::CommandPalette), | |
| 97 | S, | |
| 95 | 98 | C(Id::Back), |
| 96 | 99 | C(Id::Forward), |
| 97 | 100 | S, |
| ... | ... | @@ -192,6 +195,7 @@ const MENUS: &[Item] = &[ |
| 192 | 195 | ), |
| 193 | 196 | S, |
| 194 | 197 | C(Id::RemoveTags), |
| 198 | C(Id::FindTags), | |
| 195 | 199 | ], |
| 196 | 200 | ), |
| 197 | 201 | Item::Menu( |
crates/snowbound/src/navigation.rs+68| ... | ... | @@ -2,6 +2,8 @@ |
| 2 | 2 | //! 2010's Quick Access Toolbar offers them. |
| 3 | 3 | |
| 4 | 4 | use super::*; |
| 5 | use std::time::Duration; | |
| 6 | use winit::event::TouchPhase; | |
| 5 | 7 | |
| 6 | 8 | /// A page shown: its notebook's location, its section's catalog path and the page. |
| 7 | 9 | #[derive(Clone, Debug, PartialEq)] |
| ... | ... | @@ -107,6 +109,50 @@ impl State { |
| 107 | 109 | } |
| 108 | 110 | } |
| 109 | 111 | |
| 112 | /// A two-finger horizontal trackpad swipe, which travels Back or Forward as macOS's | |
| 113 | /// "swipe between pages" does. | |
| 114 | #[derive(Default)] | |
| 115 | pub struct Swipe { | |
| 116 | travelled: Option<[f32; 2]>, | |
| 117 | ended: Option<Instant>, | |
| 118 | } | |
| 119 | ||
| 120 | impl Swipe { | |
| 121 | /// Feeds one trackpad scroll in logical pixels; `armed` when the page cannot scroll | |
| 122 | /// sideways. Returns whether to go forward once a swipe completes. | |
| 123 | pub fn scroll( | |
| 124 | &mut self, | |
| 125 | phase: TouchPhase, | |
| 126 | delta: [f32; 2], | |
| 127 | armed: bool, | |
| 128 | at: Instant, | |
| 129 | ) -> Option<bool> { | |
| 130 | match phase { | |
| 131 | // Momentum arrives as a second gesture right after the fingers lift. | |
| 132 | TouchPhase::Started => { | |
| 133 | let momentum = self | |
| 134 | .ended | |
| 135 | .is_some_and(|ended| at - ended < Duration::from_millis(150)); | |
| 136 | self.travelled = (armed && !momentum).then_some([0.0; 2]); | |
| 137 | None | |
| 138 | } | |
| 139 | TouchPhase::Moved => { | |
| 140 | if let Some(travelled) = &mut self.travelled { | |
| 141 | travelled[0] += delta[0]; | |
| 142 | travelled[1] += delta[1]; | |
| 143 | } | |
| 144 | None | |
| 145 | } | |
| 146 | TouchPhase::Ended | TouchPhase::Cancelled => { | |
| 147 | self.ended = Some(at); | |
| 148 | let [x, y] = self.travelled.take()?; | |
| 149 | (phase == TouchPhase::Ended && x.abs() > 120.0 && x.abs() > 2.0 * y.abs()) | |
| 150 | .then_some(x < 0.0) | |
| 151 | } | |
| 152 | } | |
| 153 | } | |
| 154 | } | |
| 155 | ||
| 110 | 156 | #[cfg(test)] |
| 111 | 157 | mod tests { |
| 112 | 158 | use super::*; |
| ... | ... | @@ -122,6 +168,28 @@ mod tests { |
| 122 | 168 | } |
| 123 | 169 | } |
| 124 | 170 | |
| 171 | #[test] | |
| 172 | fn swipe_travels_on_a_long_sideways_stroke_only() { | |
| 173 | let start = Instant::now(); | |
| 174 | let at = |ms| start + Duration::from_millis(ms); | |
| 175 | let stroke = |swipe: &mut Swipe, dx: f32, dy: f32, armed: bool, t: u64| { | |
| 176 | swipe.scroll(TouchPhase::Started, [0.0; 2], armed, at(t)); | |
| 177 | for _ in 0..10 { | |
| 178 | swipe.scroll(TouchPhase::Moved, [dx / 10.0, dy / 10.0], armed, at(t)); | |
| 179 | } | |
| 180 | swipe.scroll(TouchPhase::Ended, [0.0; 2], armed, at(t)) | |
| 181 | }; | |
| 182 | let mut swipe = Swipe::default(); | |
| 183 | // Fingers right go Back, left go Forward. | |
| 184 | assert_eq!(stroke(&mut swipe, 200.0, 10.0, true, 0), Some(false)); | |
| 185 | assert_eq!(stroke(&mut swipe, -200.0, 10.0, true, 1000), Some(true)); | |
| 186 | // The momentum right after a stroke is not another. | |
| 187 | assert_eq!(stroke(&mut swipe, -200.0, 0.0, true, 1050), None); | |
| 188 | assert_eq!(stroke(&mut swipe, 60.0, 0.0, true, 2000), None); | |
| 189 | assert_eq!(stroke(&mut swipe, 200.0, 150.0, true, 3000), None); | |
| 190 | assert_eq!(stroke(&mut swipe, 200.0, 0.0, false, 4000), None); | |
| 191 | } | |
| 192 | ||
| 125 | 193 | #[test] |
| 126 | 194 | fn back_and_forward_retrace_visits_across_sections() { |
| 127 | 195 | let mut trail = Trail::default(); |
crates/snowbound/src/palette.rs created+147| ... | ... | @@ -0,0 +1,147 @@ |
| 1 | //! The command palette: the command table's commands, then the open notebooks' sections and | |
| 2 | //! every page the search index holds to go to, narrowed as typed. | |
| 3 | ||
| 4 | use crate::{ | |
| 5 | Command, Library, State, | |
| 6 | commands::{self, COMMANDS, Choice}, | |
| 7 | library, | |
| 8 | }; | |
| 9 | use onestore::ExGuid; | |
| 10 | use std::sync::Arc; | |
| 11 | use ui::{Id, popup::Item}; | |
| 12 | ||
| 13 | pub(crate) fn id() -> Id { | |
| 14 | Id::ROOT.child("palette") | |
| 15 | } | |
| 16 | ||
| 17 | enum Target { | |
| 18 | Command(commands::Id), | |
| 19 | Section(Arc<Library>, String), | |
| 20 | Page(String, ExGuid), | |
| 21 | } | |
| 22 | ||
| 23 | /// A row: its text, the dim text after it, and what it runs; a heading runs nothing. | |
| 24 | struct Row { | |
| 25 | text: String, | |
| 26 | after: String, | |
| 27 | disabled: bool, | |
| 28 | target: Option<Target>, | |
| 29 | } | |
| 30 | ||
| 31 | impl State { | |
| 32 | /// Builds the palette while it is open, and runs what is chosen from it. | |
| 33 | pub(crate) fn palette(&mut self) { | |
| 34 | if !self.ui.popup_open(id()) { | |
| 35 | return; | |
| 36 | } | |
| 37 | let format = self.format_state(); | |
| 38 | let mut rows: Vec<Row> = COMMANDS | |
| 39 | .iter() | |
| 40 | .map(|command| Row { | |
| 41 | text: command.title.to_owned(), | |
| 42 | after: commands::shortcut(command.id), | |
| 43 | disabled: !self.status(&Choice::Command(command.id), &format).enabled, | |
| 44 | target: Some(Target::Command(command.id)), | |
| 45 | }) | |
| 46 | .collect(); | |
| 47 | let heading = |text: &str| Row { | |
| 48 | text: text.to_owned(), | |
| 49 | after: String::new(), | |
| 50 | disabled: false, | |
| 51 | target: None, | |
| 52 | }; | |
| 53 | rows.push(heading("Sections")); | |
| 54 | for library in &self.notebooks { | |
| 55 | // A section opened on its own has no catalog, and lists itself at "". | |
| 56 | let mut folders: Vec<_> = library.catalog().into_iter().collect(); | |
| 57 | let mut at = 0; | |
| 58 | while let Some(&folder) = folders.get(at) { | |
| 59 | folders.extend( | |
| 60 | folder | |
| 61 | .groups | |
| 62 | .iter() | |
| 63 | .filter(|group| !library::recycle_bin(&group.path)), | |
| 64 | ); | |
| 65 | at += 1; | |
| 66 | } | |
| 67 | let paths = folders.iter().map(|folder| folder.path.as_str()); | |
| 68 | let paths: Vec<&str> = if folders.is_empty() { | |
| 69 | vec![""] | |
| 70 | } else { | |
| 71 | paths.collect() | |
| 72 | }; | |
| 73 | for path in paths { | |
| 74 | rows.extend(library.tabs(path).into_iter().map(|tab| Row { | |
| 75 | text: tab.name, | |
| 76 | after: library.name.clone(), | |
| 77 | disabled: false, | |
| 78 | target: Some(Target::Section(Arc::clone(library), tab.path)), | |
| 79 | })); | |
| 80 | } | |
| 81 | } | |
| 82 | rows.push(heading("Pages")); | |
| 83 | let index = self | |
| 84 | .search | |
| 85 | .index | |
| 86 | .lock() | |
| 87 | .unwrap_or_else(|poison| poison.into_inner()); | |
| 88 | rows.extend(index.entries().iter().map(|entry| { | |
| 89 | let (location, path) = entry.section.split_once('\n').unwrap_or_default(); | |
| 90 | let mut section = library::section_name(path, &None); | |
| 91 | if section.is_empty() { | |
| 92 | section = (self.notebooks.iter()) | |
| 93 | .find(|library| library.location == location) | |
| 94 | .map_or_else(String::new, |library| library.name.clone()); | |
| 95 | } | |
| 96 | Row { | |
| 97 | text: if entry.title.is_empty() { | |
| 98 | "Untitled page".to_owned() | |
| 99 | } else { | |
| 100 | entry.title.clone() | |
| 101 | }, | |
| 102 | after: section, | |
| 103 | disabled: false, | |
| 104 | target: Some(Target::Page(entry.section.clone(), entry.space)), | |
| 105 | } | |
| 106 | })); | |
| 107 | drop(index); | |
| 108 | let items: Vec<Item> = rows | |
| 109 | .iter() | |
| 110 | .map(|row| Item { | |
| 111 | text: &row.text, | |
| 112 | shortcut: &row.after, | |
| 113 | disabled: row.disabled, | |
| 114 | heading: row.target.is_none(), | |
| 115 | separated: row.target.is_none(), | |
| 116 | ..Item::default() | |
| 117 | }) | |
| 118 | .collect(); | |
| 119 | let chosen = ui::popup::palette( | |
| 120 | &mut self.ui, | |
| 121 | id(), | |
| 122 | &items, | |
| 123 | "Search commands, sections and pages", | |
| 124 | ); | |
| 125 | match chosen.and_then(|index| rows.swap_remove(index).target) { | |
| 126 | Some(Target::Command(command)) => self.choose(Choice::Command(command)), | |
| 127 | Some(Target::Section(library, path)) => { | |
| 128 | self.commands.push(Command::OpenSection(library, path)); | |
| 129 | } | |
| 130 | Some(Target::Page(section, space)) => { | |
| 131 | let open = (self.session.as_ref()) | |
| 132 | .map(|session| session.library.key(&session.tabs[session.tab].path)); | |
| 133 | let (location, path) = section.split_once('\n').unwrap_or_default(); | |
| 134 | if open.as_ref() == Some(&section) { | |
| 135 | self.commands.push(Command::OpenPage(space)); | |
| 136 | } else if let Some(library) = | |
| 137 | (self.notebooks.iter()).find(|library| library.location == location) | |
| 138 | { | |
| 139 | self.commands | |
| 140 | .push(Command::OpenSection(Arc::clone(library), path.to_owned())); | |
| 141 | self.last_pages.insert(section, space); | |
| 142 | } | |
| 143 | } | |
| 144 | None => {} | |
| 145 | } | |
| 146 | } | |
| 147 | } |
crates/snowbound/src/pane.rs created+906| ... | ... | @@ -0,0 +1,906 @@ |
| 1 | //! OneNote 2010's task panes beside the page: Page Search Results (Alt+O), listing the | |
| 2 | //! search's results sorted and grouped, and the Tags Summary, listing tagged paragraphs. | |
| 3 | ||
| 4 | use crate::search::{Scope, marked}; | |
| 5 | use crate::{State, Theme, art, page}; | |
| 6 | use canvas::gpu::tag_sources; | |
| 7 | use canvas::outline::TagIcon; | |
| 8 | use canvas::search::{Found, Tagged}; | |
| 9 | use std::{error::Error, sync::atomic::Ordering}; | |
| 10 | use ui::{Anchor, Axis, Flags, Id, Spec, Ui, fill, fit, px}; | |
| 11 | use winit::keyboard::NamedKey; | |
| 12 | ||
| 13 | /// The pane's width, and its lists' rows and group headings. | |
| 14 | const WIDTH: f32 = 260.0; | |
| 15 | const RESULT: f32 = 40.0; | |
| 16 | const TAGGED: f32 = 24.0; | |
| 17 | const HEADING: f32 = 22.0; | |
| 18 | /// A control's width inside the pane. | |
| 19 | const INSIDE: f32 = WIDTH - 2.0 * PAD; | |
| 20 | const PAD: f32 = 8.0; | |
| 21 | ||
| 22 | #[derive(Clone, Copy, Debug, PartialEq)] | |
| 23 | pub enum Pane { | |
| 24 | Search { | |
| 25 | sort: Sort, | |
| 26 | descending: bool, | |
| 27 | }, | |
| 28 | Tags { | |
| 29 | group: Group, | |
| 30 | unchecked: bool, | |
| 31 | scope: TagScope, | |
| 32 | }, | |
| 33 | } | |
| 34 | ||
| 35 | #[derive(Clone, Copy, Debug, PartialEq, Eq)] | |
| 36 | pub enum Sort { | |
| 37 | Section, | |
| 38 | Title, | |
| 39 | Date, | |
| 40 | } | |
| 41 | ||
| 42 | impl Sort { | |
| 43 | const ALL: [Sort; 3] = [Sort::Section, Sort::Title, Sort::Date]; | |
| 44 | ||
| 45 | fn name(self) -> &'static str { | |
| 46 | match self { | |
| 47 | Sort::Section => "Sort by Section", | |
| 48 | Sort::Title => "Sort by Title", | |
| 49 | Sort::Date => "Sort by Date Modified", | |
| 50 | } | |
| 51 | } | |
| 52 | } | |
| 53 | ||
| 54 | /// What the Tags Summary groups its paragraphs by. | |
| 55 | #[derive(Clone, Copy, Debug, PartialEq, Eq)] | |
| 56 | pub enum Group { | |
| 57 | Tag, | |
| 58 | Section, | |
| 59 | Title, | |
| 60 | Date, | |
| 61 | Text, | |
| 62 | } | |
| 63 | ||
| 64 | impl Group { | |
| 65 | const ALL: [Group; 5] = [ | |
| 66 | Group::Tag, | |
| 67 | Group::Section, | |
| 68 | Group::Title, | |
| 69 | Group::Date, | |
| 70 | Group::Text, | |
| 71 | ]; | |
| 72 | ||
| 73 | fn name(self) -> &'static str { | |
| 74 | match self { | |
| 75 | Group::Tag => "Tag name", | |
| 76 | Group::Section => "Section", | |
| 77 | Group::Title => "Title", | |
| 78 | Group::Date => "Date", | |
| 79 | Group::Text => "Note text", | |
| 80 | } | |
| 81 | } | |
| 82 | } | |
| 83 | ||
| 84 | /// Where the Tags Summary looks: part of the notebooks, or tags applied in a span of days. | |
| 85 | #[derive(Clone, Copy, Debug, PartialEq, Eq)] | |
| 86 | pub enum TagScope { | |
| 87 | PageGroup, | |
| 88 | Section, | |
| 89 | SectionGroup, | |
| 90 | Notebook, | |
| 91 | All, | |
| 92 | Today, | |
| 93 | Yesterday, | |
| 94 | ThisWeek, | |
| 95 | LastWeek, | |
| 96 | Older, | |
| 97 | } | |
| 98 | ||
| 99 | impl TagScope { | |
| 100 | const ALL: [TagScope; 10] = [ | |
| 101 | TagScope::PageGroup, | |
| 102 | TagScope::Section, | |
| 103 | TagScope::SectionGroup, | |
| 104 | TagScope::Notebook, | |
| 105 | TagScope::All, | |
| 106 | TagScope::Today, | |
| 107 | TagScope::Yesterday, | |
| 108 | TagScope::ThisWeek, | |
| 109 | TagScope::LastWeek, | |
| 110 | TagScope::Older, | |
| 111 | ]; | |
| 112 | ||
| 113 | /// Its name, which also heads the paragraphs tagged in its span when grouped by date. | |
| 114 | fn name(self) -> &'static str { | |
| 115 | match self { | |
| 116 | TagScope::PageGroup => "This page group", | |
| 117 | TagScope::Section => "This section", | |
| 118 | TagScope::SectionGroup => "This section group", | |
| 119 | TagScope::Notebook => "This notebook", | |
| 120 | TagScope::All => "All notebooks", | |
| 121 | TagScope::Today => "Today's notes", | |
| 122 | TagScope::Yesterday => "Yesterday's notes", | |
| 123 | TagScope::ThisWeek => "This week's notes", | |
| 124 | TagScope::LastWeek => "Last week's notes", | |
| 125 | TagScope::Older => "Older notes", | |
| 126 | } | |
| 127 | } | |
| 128 | ||
| 129 | /// Whether a tag applied on local day `day` falls in the span, `today` being today. | |
| 130 | fn spans(self, day: i64, today: i64) -> bool { | |
| 131 | // OneNote's weeks start on Sunday; day 0 was a Thursday. | |
| 132 | let week = today - (today + 4).rem_euclid(7); | |
| 133 | match self { | |
| 134 | TagScope::Today => day >= today, | |
| 135 | TagScope::Yesterday => day == today - 1, | |
| 136 | TagScope::ThisWeek => day >= week, | |
| 137 | TagScope::LastWeek => (week - 7..week).contains(&day), | |
| 138 | TagScope::Older => day < week - 7, | |
| 139 | _ => true, | |
| 140 | } | |
| 141 | } | |
| 142 | ||
| 143 | /// The span heading a tag applied on `day` when grouped by date. | |
| 144 | fn of_day(day: i64, today: i64) -> TagScope { | |
| 145 | [ | |
| 146 | TagScope::Today, | |
| 147 | TagScope::Yesterday, | |
| 148 | TagScope::ThisWeek, | |
| 149 | TagScope::LastWeek, | |
| 150 | ] | |
| 151 | .into_iter() | |
| 152 | .find(|span| span.spans(day, today)) | |
| 153 | .unwrap_or(TagScope::Older) | |
| 154 | } | |
| 155 | } | |
| 156 | ||
| 157 | /// The local day, counted from 1970, of Time32 `time`. | |
| 158 | fn local_day(time: u64) -> i64 { | |
| 159 | let unix = time as i64 + 315_532_800; | |
| 160 | // SAFETY: `localtime_r` writes only the `tm` it is given. | |
| 161 | let offset = unsafe { | |
| 162 | let mut tm: libc::tm = std::mem::zeroed(); | |
| 163 | libc::localtime_r(&unix, &mut tm); | |
| 164 | tm.tm_gmtoff | |
| 165 | }; | |
| 166 | (unix + offset as i64).div_euclid(86_400) | |
| 167 | } | |
| 168 | ||
| 169 | /// Day `day` counted from 1970 as a year, month and day. | |
| 170 | fn civil(day: i64) -> (i64, u32, u32) { | |
| 171 | let z = day + 719_468; | |
| 172 | let era = z.div_euclid(146_097); | |
| 173 | let doe = z.rem_euclid(146_097); | |
| 174 | let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; | |
| 175 | let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); | |
| 176 | let mp = (5 * doy + 2) / 153; | |
| 177 | let day = (doy - (153 * mp + 2) / 5 + 1) as u32; | |
| 178 | let month = if mp < 10 { mp + 3 } else { mp - 9 } as u32; | |
| 179 | (yoe + era * 400 + i64::from(month <= 2), month, day) | |
| 180 | } | |
| 181 | ||
| 182 | const MONTHS: [&str; 12] = [ | |
| 183 | "January", | |
| 184 | "February", | |
| 185 | "March", | |
| 186 | "April", | |
| 187 | "May", | |
| 188 | "June", | |
| 189 | "July", | |
| 190 | "August", | |
| 191 | "September", | |
| 192 | "October", | |
| 193 | "November", | |
| 194 | "December", | |
| 195 | ]; | |
| 196 | ||
| 197 | /// Rows under group headings, each heading above the row starting its group. | |
| 198 | struct Grouped { | |
| 199 | count: usize, | |
| 200 | /// The rows starting groups, in order. | |
| 201 | starts: Vec<usize>, | |
| 202 | } | |
| 203 | ||
| 204 | impl ui::Rows for Grouped { | |
| 205 | fn count(&self) -> usize { | |
| 206 | self.count | |
| 207 | } | |
| 208 | ||
| 209 | fn key(&self, index: usize) -> u64 { | |
| 210 | index as u64 | |
| 211 | } | |
| 212 | ||
| 213 | fn find(&self, key: u64) -> Option<usize> { | |
| 214 | ((key as usize) < self.count).then_some(key as usize) | |
| 215 | } | |
| 216 | ||
| 217 | fn space_before(&self, index: usize) -> f32 { | |
| 218 | self.starts.partition_point(|start| *start <= index) as f32 * HEADING | |
| 219 | } | |
| 220 | } | |
| 221 | ||
| 222 | /// `items` in `order`, and each group's first row with its heading. | |
| 223 | fn grouped(order: &[usize], heading: impl Fn(usize) -> String) -> (Grouped, Vec<String>) { | |
| 224 | let mut starts = Vec::new(); | |
| 225 | let mut headings: Vec<String> = Vec::new(); | |
| 226 | for (row, &item) in order.iter().enumerate() { | |
| 227 | let name = heading(item); | |
| 228 | if headings.last() != Some(&name) { | |
| 229 | starts.push(row); | |
| 230 | headings.push(name); | |
| 231 | } | |
| 232 | } | |
| 233 | ( | |
| 234 | Grouped { | |
| 235 | count: order.len(), | |
| 236 | starts, | |
| 237 | }, | |
| 238 | headings, | |
| 239 | ) | |
| 240 | } | |
| 241 | ||
| 242 | /// A section's name from its `Library::key`. | |
| 243 | fn section_name(key: &str) -> String { | |
| 244 | let path = key.split_once('\n').map_or(key, |(_, path)| path); | |
| 245 | crate::library::section_name(path, &None) | |
| 246 | } | |
| 247 | ||
| 248 | fn shown_title(title: &str) -> &str { | |
| 249 | if title.trim().is_empty() { | |
| 250 | "Untitled page" | |
| 251 | } else { | |
| 252 | title | |
| 253 | } | |
| 254 | } | |
| 255 | ||
| 256 | /// The search's results in `sort`'s order, and their groups: sections, first letters of | |
| 257 | /// titles, or months. | |
| 258 | fn arrange(found: &[Found], sort: Sort, descending: bool) -> (Vec<usize>, Grouped, Vec<String>) { | |
| 259 | let mut order: Vec<usize> = (0..found.len()).collect(); | |
| 260 | match sort { | |
| 261 | Sort::Section => order.sort_by_cached_key(|&at| { | |
| 262 | let found = &found[at]; | |
| 263 | ( | |
| 264 | section_name(&found.section).to_lowercase(), | |
| 265 | found.section.clone(), | |
| 266 | found.order, | |
| 267 | ) | |
| 268 | }), | |
| 269 | Sort::Title => { | |
| 270 | order.sort_by_cached_key(|&at| shown_title(&found[at].title).to_lowercase()); | |
| 271 | } | |
| 272 | Sort::Date => order.sort_by_key(|&at| found[at].modified), | |
| 273 | } | |
| 274 | if descending { | |
| 275 | order.reverse(); | |
| 276 | } | |
| 277 | let (rows, headings) = grouped(&order, |at| { | |
| 278 | let found = &found[at]; | |
| 279 | match sort { | |
| 280 | Sort::Section => section_name(&found.section), | |
| 281 | Sort::Title => shown_title(&found.title) | |
| 282 | .chars() | |
| 283 | .next() | |
| 284 | .map_or_else(String::new, |first| first.to_uppercase().collect()), | |
| 285 | Sort::Date => { | |
| 286 | let (year, month, _) = civil(local_day(found.modified)); | |
| 287 | format!("{}, {year}", MONTHS[month as usize - 1]) | |
| 288 | } | |
| 289 | } | |
| 290 | }); | |
| 291 | (order, rows, headings) | |
| 292 | } | |
| 293 | ||
| 294 | /// Tagged paragraphs in `group`'s order and their groups, each group's paragraphs in page | |
| 295 | /// order. | |
| 296 | fn arrange_tags(tagged: &[Tagged], group: Group) -> (Vec<usize>, Grouped, Vec<String>) { | |
| 297 | let today = local_day(crate::search::now()); | |
| 298 | let heading = |at: usize| -> String { | |
| 299 | let tagged = &tagged[at]; | |
| 300 | match group { | |
| 301 | Group::Tag => tagged.name.clone(), | |
| 302 | Group::Section => section_name(&tagged.section), | |
| 303 | Group::Title => shown_title(&tagged.title).to_owned(), | |
| 304 | Group::Date => TagScope::of_day(local_day(tagged.created), today) | |
| 305 | .name() | |
| 306 | .to_owned(), | |
| 307 | Group::Text => Group::Text.name().to_owned(), | |
| 308 | } | |
| 309 | }; | |
| 310 | let mut order: Vec<usize> = (0..tagged.len()).collect(); | |
| 311 | match group { | |
| 312 | Group::Date => order.sort_by_key(|&at| { | |
| 313 | TagScope::ALL | |
| 314 | .iter() | |
| 315 | .position(|span| *span == TagScope::of_day(local_day(tagged[at].created), today)) | |
| 316 | }), | |
| 317 | Group::Text => order.sort_by_cached_key(|&at| tagged[at].text.to_lowercase()), | |
| 318 | _ => order.sort_by_cached_key(|&at| heading(at).to_lowercase()), | |
| 319 | } | |
| 320 | let (rows, headings) = grouped(&order, heading); | |
| 321 | (order, rows, headings) | |
| 322 | } | |
| 323 | ||
| 324 | /// The field of the Search Results pane. | |
| 325 | pub(crate) fn field() -> Id { | |
| 326 | Id::ROOT.child("pane-field") | |
| 327 | } | |
| 328 | ||
| 329 | fn popup(name: &str) -> Id { | |
| 330 | Id::ROOT.child(("pane popup", name)) | |
| 331 | } | |
| 332 | ||
| 333 | /// A combo box `width` wide showing `current` of `names`, returning the one chosen. | |
| 334 | fn choice(ui: &mut Ui, name: &str, current: usize, names: &[&str], width: f32) -> Option<usize> { | |
| 335 | let combo = ui.id(name); | |
| 336 | if ui::shell::combo(ui, name, names[current], width).pressed { | |
| 337 | ui.open_popup(popup(name)); | |
| 338 | } | |
| 339 | let items: Vec<_> = names | |
| 340 | .iter() | |
| 341 | .enumerate() | |
| 342 | .map(|(index, text)| ui::popup::Item { | |
| 343 | text, | |
| 344 | checked: index == current, | |
| 345 | ..Default::default() | |
| 346 | }) | |
| 347 | .collect(); | |
| 348 | let anchor = Anchor::Below(ui.rect(combo).unwrap_or_default()); | |
| 349 | ui::popup::menu(ui, popup(name), anchor, &items, None) | |
| 350 | } | |
| 351 | ||
| 352 | fn label(ui: &mut Ui, theme: &Theme, part: &str, text: &str) { | |
| 353 | ui.leaf( | |
| 354 | part, | |
| 355 | Spec { | |
| 356 | size: [fill(), px(20.0)], | |
| 357 | text: Some(text), | |
| 358 | color: Some(theme.text_dim), | |
| 359 | ..Spec::default() | |
| 360 | }, | |
| 361 | ); | |
| 362 | } | |
| 363 | ||
| 364 | /// A group's heading, floating above the row starting it. | |
| 365 | fn heading(ui: &mut Ui, theme: &Theme, text: &str) { | |
| 366 | ui.leaf( | |
| 367 | "heading", | |
| 368 | Spec { | |
| 369 | flags: Flags::FLOAT, | |
| 370 | size: [fill(), px(HEADING)], | |
| 371 | position: [0.0, -HEADING], | |
| 372 | text: Some(text), | |
| 373 | bold: true, | |
| 374 | color: Some(theme.text), | |
| 375 | pad: [4.0, 0.0], | |
| 376 | ..Spec::default() | |
| 377 | }, | |
| 378 | ); | |
| 379 | } | |
| 380 | ||
| 381 | impl State { | |
| 382 | /// Opens the Search Results pane on the search, as Alt+O does. | |
| 383 | pub(crate) fn open_search_pane(&mut self) { | |
| 384 | if !matches!(self.search.pane, Some(Pane::Search { .. })) { | |
| 385 | self.search.pane = Some(Pane::Search { | |
| 386 | sort: Sort::Date, | |
| 387 | descending: true, | |
| 388 | }); | |
| 389 | } | |
| 390 | self.search.open = false; | |
| 391 | self.search.finding = false; | |
| 392 | self.search.claim = true; | |
| 393 | self.ui.close_popup(crate::search::results()); | |
| 394 | self.sync_index(true); | |
| 395 | } | |
| 396 | ||
| 397 | /// Opens or closes a pane, as its command does. | |
| 398 | pub(crate) fn toggle_pane(&mut self, tags: bool) { | |
| 399 | match self.search.pane { | |
| 400 | Some(Pane::Tags { .. }) if tags => self.close_pane(), | |
| 401 | Some(Pane::Search { .. }) if !tags => self.close_pane(), | |
| 402 | _ if tags => { | |
| 403 | self.close_pane(); | |
| 404 | self.search.pane = Some(Pane::Tags { | |
| 405 | group: Group::Tag, | |
| 406 | unchecked: false, | |
| 407 | scope: TagScope::Notebook, | |
| 408 | }); | |
| 409 | self.sync_index(true); | |
| 410 | } | |
| 411 | _ => self.open_search_pane(), | |
| 412 | } | |
| 413 | } | |
| 414 | ||
| 415 | /// Closes the pane; the Search Results pane takes its search, and the marks, with it. | |
| 416 | fn close_pane(&mut self) { | |
| 417 | if matches!(self.search.pane.take(), Some(Pane::Search { .. })) { | |
| 418 | self.end_search(); | |
| 419 | } | |
| 420 | } | |
| 421 | ||
| 422 | /// The open pane at the window's right, easing open and closed. | |
| 423 | pub(crate) fn task_pane(&mut self, theme: &Theme) { | |
| 424 | let id = self.ui.id("task pane"); | |
| 425 | let width = self.ui.animate( | |
| 426 | id, | |
| 427 | if self.search.pane.is_some() { | |
| 428 | WIDTH | |
| 429 | } else { | |
| 430 | 0.0 | |
| 431 | }, | |
| 432 | ); | |
| 433 | self.ui.open( | |
| 434 | "task pane", | |
| 435 | Spec { | |
| 436 | flags: Flags::CLIP, | |
| 437 | size: [px(width), fill()], | |
| 438 | fill: Some(theme.strip), | |
| 439 | ..Spec::default() | |
| 440 | }, | |
| 441 | ); | |
| 442 | // The content keeps its width as the pane eases, so its labels never reflow. | |
| 443 | self.ui.open( | |
| 444 | "content", | |
| 445 | Spec { | |
| 446 | axis: Axis::Y, | |
| 447 | size: [px(WIDTH), fill()], | |
| 448 | pad: [PAD, 4.0], | |
| 449 | gap: 4.0, | |
| 450 | ..Spec::default() | |
| 451 | }, | |
| 452 | ); | |
| 453 | if let Some(pane) = self.search.pane { | |
| 454 | self.ui.open( | |
| 455 | "header", | |
| 456 | Spec { | |
| 457 | size: [fill(), px(ui::shell::TOOL)], | |
| 458 | ..Spec::default() | |
| 459 | }, | |
| 460 | ); | |
| 461 | self.ui.leaf( | |
| 462 | "title", | |
| 463 | Spec { | |
| 464 | size: [fill(), px(ui::shell::TOOL)], | |
| 465 | text: Some(match pane { | |
| 466 | Pane::Search { .. } => "Search Results", | |
| 467 | Pane::Tags { .. } => "Tags Summary", | |
| 468 | }), | |
| 469 | bold: true, | |
| 470 | ..Spec::default() | |
| 471 | }, | |
| 472 | ); | |
| 473 | if ui::shell::tool_button(&mut self.ui, "close", art::CLOSE, theme.text_dim, false) | |
| 474 | .clicked | |
| 475 | { | |
| 476 | self.close_pane(); | |
| 477 | } | |
| 478 | self.ui.close(); | |
| 479 | match self.search.pane { | |
| 480 | Some(Pane::Search { sort, descending }) => { | |
| 481 | if let Err(error) = self.search_pane(theme, sort, descending) { | |
| 482 | eprintln!("{error}"); | |
| 483 | } | |
| 484 | } | |
| 485 | Some(Pane::Tags { | |
| 486 | group, | |
| 487 | unchecked, | |
| 488 | scope, | |
| 489 | }) => self.tags_pane(theme, group, unchecked, scope), | |
| 490 | None => {} | |
| 491 | } | |
| 492 | } | |
| 493 | self.ui.close(); | |
| 494 | self.ui.close(); | |
| 495 | } | |
| 496 | ||
| 497 | fn search_pane( | |
| 498 | &mut self, | |
| 499 | theme: &Theme, | |
| 500 | mut sort: Sort, | |
| 501 | mut descending: bool, | |
| 502 | ) -> Result<(), Box<dyn Error>> { | |
| 503 | self.refresh_results()?; | |
| 504 | let keys = ui::popup::navigation( | |
| 505 | &mut self.ui, | |
| 506 | &[field()], | |
| 507 | &[ | |
| 508 | NamedKey::ArrowUp, | |
| 509 | NamedKey::ArrowDown, | |
| 510 | NamedKey::PageUp, | |
| 511 | NamedKey::PageDown, | |
| 512 | NamedKey::Enter, | |
| 513 | NamedKey::Escape, | |
| 514 | ], | |
| 515 | ); | |
| 516 | if keys.contains(&NamedKey::Escape) { | |
| 517 | self.ui.set_focus(Some(page())); | |
| 518 | } | |
| 519 | let border = if self.ui.focused() == Some(field()) { | |
| 520 | theme.accent | |
| 521 | } else { | |
| 522 | theme.chip | |
| 523 | }; | |
| 524 | ui::text_field( | |
| 525 | &mut self.ui, | |
| 526 | field(), | |
| 527 | &mut self.search.query, | |
| 528 | "Search", | |
| 529 | Spec { | |
| 530 | size: [fill(), px(ui::shell::TOOL)], | |
| 531 | fill: Some(theme.base), | |
| 532 | border: Some(border), | |
| 533 | radius: 4.0, | |
| 534 | pad: [6.0, 0.0], | |
| 535 | ..Spec::default() | |
| 536 | }, | |
| 537 | ); | |
| 538 | if std::mem::take(&mut self.search.claim) { | |
| 539 | self.ui.set_focus(Some(field())); | |
| 540 | } | |
| 541 | let scopes = Scope::ALL.map(|scope| format!("Search {}", scope.name())); | |
| 542 | let names = scopes.each_ref().map(String::as_str); | |
| 543 | let current = Scope::ALL | |
| 544 | .iter() | |
| 545 | .position(|scope| *scope == self.search.scope) | |
| 546 | .unwrap_or_default(); | |
| 547 | if let Some(index) = choice(&mut self.ui, "scope", current, &names, INSIDE) { | |
| 548 | self.search.scope = Scope::ALL[index]; | |
| 549 | } | |
| 550 | self.ui.open( | |
| 551 | "sort", | |
| 552 | Spec { | |
| 553 | size: [fill(), px(ui::shell::TOOL)], | |
| 554 | gap: 2.0, | |
| 555 | ..Spec::default() | |
| 556 | }, | |
| 557 | ); | |
| 558 | let names = Sort::ALL.map(Sort::name); | |
| 559 | let current = Sort::ALL | |
| 560 | .iter() | |
| 561 | .position(|listed| *listed == sort) | |
| 562 | .unwrap_or_default(); | |
| 563 | let width = INSIDE - ui::shell::TOOL - 2.0; | |
| 564 | if let Some(index) = choice(&mut self.ui, "by", current, &names, width) { | |
| 565 | sort = Sort::ALL[index]; | |
| 566 | // Dates start newest first, names from the start of the alphabet. | |
| 567 | descending = sort == Sort::Date; | |
| 568 | } | |
| 569 | let arrow = if descending { | |
| 570 | ui::shell::CHEVRON | |
| 571 | } else { | |
| 572 | art::CHEVRON_UP | |
| 573 | }; | |
| 574 | if ui::shell::tool_button(&mut self.ui, "direction", arrow, theme.text, false).clicked { | |
| 575 | descending = !descending; | |
| 576 | } | |
| 577 | let tip = if descending { | |
| 578 | "Sort ascending" | |
| 579 | } else { | |
| 580 | "Sort descending" | |
| 581 | }; | |
| 582 | ui::popup::tooltip(&mut self.ui, tip, "", None); | |
| 583 | self.ui.close(); | |
| 584 | self.search.pane = Some(Pane::Search { sort, descending }); | |
| 585 | let found = &self.search.found; | |
| 586 | if found.is_empty() { | |
| 587 | let status = if self.search.query.trim().is_empty() { | |
| 588 | "" | |
| 589 | } else if self.search.busy.load(Ordering::Relaxed) { | |
| 590 | "Searching…" | |
| 591 | } else { | |
| 592 | "No matches" | |
| 593 | }; | |
| 594 | label(&mut self.ui, theme, "status", status); | |
| 595 | return Ok(()); | |
| 596 | } | |
| 597 | let (order, rows, headings) = arrange(found, sort, descending); | |
| 598 | let dates: Vec<String> = order | |
| 599 | .iter() | |
| 600 | .map(|&at| { | |
| 601 | let (year, month, day) = civil(local_day(found[at].modified)); | |
| 602 | format!("{month}/{day}/{year}") | |
| 603 | }) | |
| 604 | .collect(); | |
| 605 | let before = self.search.selected; | |
| 606 | let clicked = ui::list( | |
| 607 | &mut self.ui, | |
| 608 | Id::ROOT.child("pane results"), | |
| 609 | Spec { | |
| 610 | size: [fill(), fill()], | |
| 611 | fill: Some(theme.base), | |
| 612 | border: Some(theme.chip), | |
| 613 | radius: 4.0, | |
| 614 | ..Spec::default() | |
| 615 | }, | |
| 616 | ui::List { | |
| 617 | rows: &rows, | |
| 618 | row: RESULT, | |
| 619 | keys: &keys, | |
| 620 | hover_selects: false, | |
| 621 | }, | |
| 622 | &mut self.search.selected, | |
| 623 | |ui, row| { | |
| 624 | if let Ok(group) = rows.starts.binary_search(&row.index) { | |
| 625 | heading(ui, theme, &headings[group]); | |
| 626 | } | |
| 627 | result_row( | |
| 628 | ui, | |
| 629 | theme, | |
| 630 | &found[order[row.index]], | |
| 631 | &dates[row.index], | |
| 632 | row.selected, | |
| 633 | ); | |
| 634 | }, | |
| 635 | ); | |
| 636 | let moved = before != self.search.selected && !keys.contains(&NamedKey::Enter); | |
| 637 | let chosen = clicked.or_else(|| { | |
| 638 | self.search | |
| 639 | .selected | |
| 640 | .map(|key| key as usize) | |
| 641 | .filter(|_| moved || keys.contains(&NamedKey::Enter)) | |
| 642 | }); | |
| 643 | if let Some(row) = chosen { | |
| 644 | self.search.selected = Some(row as u64); | |
| 645 | let found = &self.search.found[order[row]]; | |
| 646 | self.reveal(found.section.clone(), found.space, None)?; | |
| 647 | if clicked.is_some() || keys.contains(&NamedKey::Enter) { | |
| 648 | self.ui.set_focus(Some(page())); | |
| 649 | } | |
| 650 | } | |
| 651 | Ok(()) | |
| 652 | } | |
| 653 | ||
| 654 | fn tags_pane( | |
| 655 | &mut self, | |
| 656 | theme: &Theme, | |
| 657 | mut group: Group, | |
| 658 | mut unchecked: bool, | |
| 659 | mut scope: TagScope, | |
| 660 | ) { | |
| 661 | let status = if self.search.busy.load(Ordering::Relaxed) { | |
| 662 | "Searching…" | |
| 663 | } else { | |
| 664 | "Search completed" | |
| 665 | }; | |
| 666 | label(&mut self.ui, theme, "status", status); | |
| 667 | label(&mut self.ui, theme, "group by", "Group tags by:"); | |
| 668 | let names = Group::ALL.map(Group::name); | |
| 669 | let current = Group::ALL | |
| 670 | .iter() | |
| 671 | .position(|listed| *listed == group) | |
| 672 | .unwrap_or_default(); | |
| 673 | if let Some(index) = choice(&mut self.ui, "group", current, &names, INSIDE) { | |
| 674 | group = Group::ALL[index]; | |
| 675 | } | |
| 676 | if ui::check_box( | |
| 677 | &mut self.ui, | |
| 678 | "unchecked", | |
| 679 | "Show only unchecked items", | |
| 680 | unchecked, | |
| 681 | ) | |
| 682 | .clicked | |
| 683 | { | |
| 684 | unchecked = !unchecked; | |
| 685 | } | |
| 686 | let tagged = self.tagged(scope, unchecked); | |
| 687 | let (order, rows, headings) = arrange_tags(&tagged, group); | |
| 688 | let mut selected = None; | |
| 689 | let clicked = ui::list( | |
| 690 | &mut self.ui, | |
| 691 | Id::ROOT.child("pane tags"), | |
| 692 | Spec { | |
| 693 | size: [fill(), fill()], | |
| 694 | fill: Some(theme.base), | |
| 695 | border: Some(theme.chip), | |
| 696 | radius: 4.0, | |
| 697 | ..Spec::default() | |
| 698 | }, | |
| 699 | ui::List { | |
| 700 | rows: &rows, | |
| 701 | row: TAGGED, | |
| 702 | keys: &[], | |
| 703 | hover_selects: false, | |
| 704 | }, | |
| 705 | &mut selected, | |
| 706 | |ui, row| { | |
| 707 | if let Ok(at) = rows.starts.binary_search(&row.index) { | |
| 708 | heading(ui, theme, &headings[at]); | |
| 709 | } | |
| 710 | tagged_row(ui, theme, &tagged[order[row.index]]); | |
| 711 | }, | |
| 712 | ); | |
| 713 | label(&mut self.ui, theme, "search", "Search:"); | |
| 714 | let names = TagScope::ALL.map(TagScope::name); | |
| 715 | let current = TagScope::ALL | |
| 716 | .iter() | |
| 717 | .position(|listed| *listed == scope) | |
| 718 | .unwrap_or_default(); | |
| 719 | if let Some(index) = choice(&mut self.ui, "tag scope", current, &names, INSIDE) { | |
| 720 | scope = TagScope::ALL[index]; | |
| 721 | } | |
| 722 | self.search.pane = Some(Pane::Tags { | |
| 723 | group, | |
| 724 | unchecked, | |
| 725 | scope, | |
| 726 | }); | |
| 727 | if let Some(row) = clicked { | |
| 728 | let tagged = &tagged[order[row]]; | |
| 729 | if let Err(error) = | |
| 730 | self.reveal(tagged.section.clone(), tagged.space, Some(tagged.paragraph)) | |
| 731 | { | |
| 732 | eprintln!("{error}"); | |
| 733 | } | |
| 734 | self.ui.set_focus(Some(page())); | |
| 735 | } | |
| 736 | } | |
| 737 | ||
| 738 | /// The tagged paragraphs `scope` takes in, only those not checked off when `unchecked`. | |
| 739 | fn tagged(&self, scope: TagScope, unchecked: bool) -> Vec<Tagged> { | |
| 740 | let session = self.session.as_ref(); | |
| 741 | let open = session.map(|session| session.library.key(&session.tabs[session.tab].path)); | |
| 742 | // The open page with its subpages, or the page it is a subpage of with its others. | |
| 743 | let group: Vec<_> = session | |
| 744 | .and_then(|session| { | |
| 745 | let pages = &session.pages; | |
| 746 | let at = pages | |
| 747 | .iter() | |
| 748 | .position(|(space, ..)| *space == session.space)?; | |
| 749 | let base = pages.iter().map(|(.., level)| *level).min()?; | |
| 750 | let start = pages[..=at] | |
| 751 | .iter() | |
| 752 | .rposition(|(.., level)| *level == base)?; | |
| 753 | let end = pages[start + 1..] | |
| 754 | .iter() | |
| 755 | .position(|(.., level)| *level == base) | |
| 756 | .map_or(pages.len(), |after| start + 1 + after); | |
| 757 | Some(pages[start..end].iter().map(|(space, ..)| *space).collect()) | |
| 758 | }) | |
| 759 | .unwrap_or_default(); | |
| 760 | let place = match scope { | |
| 761 | TagScope::Section => Scope::Section, | |
| 762 | TagScope::SectionGroup => Scope::Group, | |
| 763 | TagScope::Notebook => Scope::Notebook, | |
| 764 | _ => Scope::All, | |
| 765 | }; | |
| 766 | let today = local_day(crate::search::now()); | |
| 767 | let index = self | |
| 768 | .search | |
| 769 | .index | |
| 770 | .lock() | |
| 771 | .unwrap_or_else(|error| error.into_inner()); | |
| 772 | index | |
| 773 | .tagged(|entry| match scope { | |
| 774 | TagScope::PageGroup => { | |
| 775 | Some(&entry.section) == open.as_ref() && group.contains(&entry.space) | |
| 776 | } | |
| 777 | _ => self.in_scope(place, &entry.section), | |
| 778 | }) | |
| 779 | .into_iter() | |
| 780 | .filter(|tagged| scope.spans(local_day(tagged.created), today)) | |
| 781 | .filter(|tagged| !(unchecked && tagged.checked)) | |
| 782 | .collect() | |
| 783 | } | |
| 784 | } | |
| 785 | ||
| 786 | /// A result: its page's icon, title and date, and the snippet around its first match. | |
| 787 | fn result_row(ui: &mut Ui, theme: &Theme, found: &Found, date: &str, selected: bool) { | |
| 788 | ui.open( | |
| 789 | "result", | |
| 790 | Spec { | |
| 791 | axis: Axis::Y, | |
| 792 | size: [fill(), fill()], | |
| 793 | fill: selected.then(|| theme.hover()), | |
| 794 | hover_fill: Some(theme.hover()), | |
| 795 | radius: 4.0, | |
| 796 | pad: [4.0, 2.0], | |
| 797 | ..Spec::default() | |
| 798 | }, | |
| 799 | ); | |
| 800 | ui.open( | |
| 801 | "top", | |
| 802 | Spec { | |
| 803 | size: [fill(), px(18.0)], | |
| 804 | gap: 4.0, | |
| 805 | ..Spec::default() | |
| 806 | }, | |
| 807 | ); | |
| 808 | ui.leaf( | |
| 809 | "icon", | |
| 810 | Spec { | |
| 811 | size: [px(16.0), px(18.0)], | |
| 812 | icon: Some(art::PAGE), | |
| 813 | color: Some(theme.text_dim), | |
| 814 | ..Spec::default() | |
| 815 | }, | |
| 816 | ); | |
| 817 | marked( | |
| 818 | ui, | |
| 819 | "title", | |
| 820 | shown_title(&found.title), | |
| 821 | &found.title_hits, | |
| 822 | Spec { | |
| 823 | size: [fill(), px(18.0)], | |
| 824 | color: Some(theme.text), | |
| 825 | ..Spec::default() | |
| 826 | }, | |
| 827 | ); | |
| 828 | ui.leaf( | |
| 829 | "date", | |
| 830 | Spec { | |
| 831 | size: [fit(), px(18.0)], | |
| 832 | text: Some(date), | |
| 833 | color: Some(theme.text_dim), | |
| 834 | ..Spec::default() | |
| 835 | }, | |
| 836 | ); | |
| 837 | ui.close(); | |
| 838 | marked( | |
| 839 | ui, | |
| 840 | "snippet", | |
| 841 | &found.snippet, | |
| 842 | &found.snippet_hits, | |
| 843 | Spec { | |
| 844 | size: [fill(), px(18.0)], | |
| 845 | color: Some(theme.text_dim), | |
| 846 | pad: [20.0, 0.0], | |
| 847 | ..Spec::default() | |
| 848 | }, | |
| 849 | ); | |
| 850 | ui.close(); | |
| 851 | } | |
| 852 | ||
| 853 | /// A tagged paragraph: its tag's symbol and its text. | |
| 854 | fn tagged_row(ui: &mut Ui, theme: &Theme, tagged: &Tagged) { | |
| 855 | ui.open( | |
| 856 | "tagged", | |
| 857 | Spec { | |
| 858 | size: [fill(), fill()], | |
| 859 | hover_fill: Some(theme.hover()), | |
| 860 | radius: 4.0, | |
| 861 | pad: [4.0, 0.0], | |
| 862 | gap: 6.0, | |
| 863 | ..Spec::default() | |
| 864 | }, | |
| 865 | ); | |
| 866 | ui.leaf( | |
| 867 | "icon", | |
| 868 | Spec { | |
| 869 | size: [px(16.0), px(TAGGED)], | |
| 870 | icon: TagIcon::of(tagged.shape, tagged.checked).map(tag_sources), | |
| 871 | color: Some([1.0; 4]), | |
| 872 | ..Spec::default() | |
| 873 | }, | |
| 874 | ); | |
| 875 | ui.leaf( | |
| 876 | "text", | |
| 877 | Spec { | |
| 878 | size: [fill(), px(TAGGED)], | |
| 879 | text: Some(&tagged.text), | |
| 880 | overflow: ui::Overflow::Ellipsis, | |
| 881 | ..Spec::default() | |
| 882 | }, | |
| 883 | ); | |
| 884 | ui.close(); | |
| 885 | } | |
| 886 | ||
| 887 | #[cfg(test)] | |
| 888 | mod tests { | |
| 889 | use super::*; | |
| 890 | ||
| 891 | #[test] | |
| 892 | fn days_read_as_the_calendar_and_weeks_start_on_sunday() { | |
| 893 | assert_eq!(civil(0), (1970, 1, 1)); | |
| 894 | assert_eq!(civil(20_725), (2026, 9, 29)); | |
| 895 | assert_eq!(civil(-1), (1969, 12, 31)); | |
| 896 | // Tuesday, September 29, 2026: its week began on Sunday the 27th. | |
| 897 | let today = 20_725; | |
| 898 | assert_eq!(TagScope::of_day(today, today), TagScope::Today); | |
| 899 | assert_eq!(TagScope::of_day(today - 1, today), TagScope::Yesterday); | |
| 900 | assert_eq!(TagScope::of_day(today - 2, today), TagScope::ThisWeek); | |
| 901 | assert_eq!(TagScope::of_day(today - 3, today), TagScope::LastWeek); | |
| 902 | assert_eq!(TagScope::of_day(today - 9, today), TagScope::LastWeek); | |
| 903 | assert_eq!(TagScope::of_day(today - 10, today), TagScope::Older); | |
| 904 | assert!(TagScope::ThisWeek.spans(today, today)); | |
| 905 | } | |
| 906 | } |
crates/snowbound/src/screenshot.rs+21-13| ... | ... | @@ -9,13 +9,12 @@ use winit::window::Theme as Appearance; |
| 9 | 9 | /// drawn at `SCALE` pixels per point or fewer where the GPU caps textures. |
| 10 | 10 | const SIZE: [f32; 2] = [1440.0, 900.0]; |
| 11 | 11 | const SCALE: f32 = 2.0; |
| 12 | /// A standard window's close, minimize and zoom buttons as AppKit places them beside a | |
| 13 | /// transparent title bar: centres in points, fill and rim. | |
| 12 | /// The close, minimize and zoom buttons' fill and rim. | |
| 14 | 13 | #[cfg(target_os = "macos")] |
| 15 | const LIGHTS: [(f32, [u8; 3], [u8; 3]); 3] = [ | |
| 16 | (14.0, [0xff, 0x5f, 0x57], [0xe2, 0x46, 0x3f]), | |
| 17 | (34.0, [0xfe, 0xbc, 0x2e], [0xe1, 0xa1, 0x16]), | |
| 18 | (54.0, [0x28, 0xc8, 0x40], [0x12, 0xac, 0x28]), | |
| 14 | const LIGHTS: [([u8; 3], [u8; 3]); 3] = [ | |
| 15 | ([0xff, 0x5f, 0x57], [0xe2, 0x46, 0x3f]), | |
| 16 | ([0xfe, 0xbc, 0x2e], [0xe1, 0xa1, 0x16]), | |
| 17 | ([0x28, 0xc8, 0x40], [0x12, 0xac, 0x28]), | |
| 19 | 18 | ]; |
| 20 | 19 | |
| 21 | 20 | impl State { |
| ... | ... | @@ -32,7 +31,6 @@ impl State { |
| 32 | 31 | let scale = SCALE.min(most / size[0].max(size[1])); |
| 33 | 32 | self.surface.size = size.map(|side| (side * scale).max(1.0) as u32); |
| 34 | 33 | self.renderer.clear_glyph_cache(); |
| 35 | self.app_icon = platform::app_icon((16.0 * scale) as u32); | |
| 36 | 34 | let response = self.view.scale_factor_changed(scale)?; |
| 37 | 35 | self.respond(response); |
| 38 | 36 | self.ui.set_focus(None); |
| ... | ... | @@ -66,16 +64,26 @@ impl State { |
| 66 | 64 | let path = PathBuf::from(format!("{}-{name}.png", prefix.display())); |
| 67 | 65 | self.snapshot(&path)?; |
| 68 | 66 | #[cfg(target_os = "macos")] |
| 69 | window_frame(&path, appearance, scale)?; | |
| 67 | window_frame( | |
| 68 | &path, | |
| 69 | appearance, | |
| 70 | scale, | |
| 71 | platform::traffic_lights(&self.window), | |
| 72 | )?; | |
| 70 | 73 | } |
| 71 | 74 | Ok(()) |
| 72 | 75 | } |
| 73 | 76 | } |
| 74 | 77 | |
| 75 | /// Paints the traffic lights into the PNG at `path`, rounds its corners to transparency | |
| 76 | /// and edges them with the window's hairline. | |
| 78 | /// Paints the traffic lights centred at `lights` into the PNG at `path`, rounds its corners | |
| 79 | /// to transparency and edges them with the window's hairline. | |
| 77 | 80 | #[cfg(target_os = "macos")] |
| 78 | fn window_frame(path: &Path, appearance: Appearance, scale: f32) -> Result<(), Box<dyn Error>> { | |
| 81 | fn window_frame( | |
| 82 | path: &Path, | |
| 83 | appearance: Appearance, | |
| 84 | scale: f32, | |
| 85 | lights: [[f32; 2]; 3], | |
| 86 | ) -> Result<(), Box<dyn Error>> { | |
| 79 | 87 | let mut reader = |
| 80 | 88 | png::Decoder::new(std::io::BufReader::new(std::fs::File::open(path)?)).read_info()?; |
| 81 | 89 | let mut pixels = vec![0; reader.output_buffer_size().ok_or("Snapshot too large")?]; |
| ... | ... | @@ -98,8 +106,8 @@ fn window_frame(path: &Path, appearance: Appearance, scale: f32) -> Result<(), B |
| 98 | 106 | for (index, pixel) in pixels.chunks_exact_mut(4).enumerate() { |
| 99 | 107 | let x = (index as u32 % info.width) as f32 + 0.5; |
| 100 | 108 | let y = (index as u32 / info.width) as f32 + 0.5; |
| 101 | for (centre, fill, rim) in LIGHTS { | |
| 102 | let distance = (x - centre * scale).hypot(y - TITLE / 2.0 * scale); | |
| 109 | for ([cx, cy], (fill, rim)) in lights.into_iter().zip(LIGHTS) { | |
| 110 | let distance = (x - cx * scale).hypot(y - cy * scale); | |
| 103 | 111 | blend( |
| 104 | 112 | pixel, |
| 105 | 113 | [rim[0], rim[1], rim[2], 255], |
crates/snowbound/src/search.rs+95-44| ... | ... | @@ -2,8 +2,9 @@ |
| 2 | 2 | //! results dropping down from it, and Find on This Page. The index is kept on a thread of |
| 3 | 3 | //! its own, from the section files and the open section's replica. |
| 4 | 4 | |
| 5 | use crate::pane::Pane; | |
| 5 | 6 | use crate::{Command, Library, State, Theme, art, commands, lap, page}; |
| 6 | use canvas::search::{Entry, Found, Index, Query, page_matches}; | |
| 7 | use canvas::search::{Entry, Found, Index, PageMatch, Query, page_matches, paragraph_match}; | |
| 7 | 8 | use notebook::Replica; |
| 8 | 9 | use onestore::ExGuid; |
| 9 | 10 | use std::{ |
| ... | ... | @@ -31,9 +32,9 @@ pub enum Scope { |
| 31 | 32 | } |
| 32 | 33 | |
| 33 | 34 | impl Scope { |
| 34 | const ALL: [Scope; 4] = [Scope::Section, Scope::Group, Scope::Notebook, Scope::All]; | |
| 35 | pub(crate) const ALL: [Scope; 4] = [Scope::Section, Scope::Group, Scope::Notebook, Scope::All]; | |
| 35 | 36 | |
| 36 | fn name(self) -> &'static str { | |
| 37 | pub(crate) fn name(self) -> &'static str { | |
| 37 | 38 | match self { |
| 38 | 39 | Scope::Section => "This Section", |
| 39 | 40 | Scope::Group => "This Section Group", |
| ... | ... | @@ -52,7 +53,7 @@ const PLACE: f32 = 150.0; |
| 52 | 53 | /// How long typing pauses before the index reads the pages it changed. |
| 53 | 54 | const SETTLE: Duration = Duration::from_millis(400); |
| 54 | 55 | |
| 55 | fn results() -> Id { | |
| 56 | pub(crate) fn results() -> Id { | |
| 56 | 57 | Id::ROOT.child("results") |
| 57 | 58 | } |
| 58 | 59 | |
| ... | ... | @@ -71,7 +72,12 @@ fn find_field() -> Id { |
| 71 | 72 | |
| 72 | 73 | /// Whether `focus` is one of the box's fields, which take typed text. |
| 73 | 74 | pub fn takes_text(focus: Option<Id>) -> bool { |
| 74 | [Some(field()), Some(find_field())].contains(&focus) | |
| 75 | [ | |
| 76 | Some(field()), | |
| 77 | Some(find_field()), | |
| 78 | Some(crate::pane::field()), | |
| 79 | ] | |
| 80 | .contains(&focus) | |
| 75 | 81 | } |
| 76 | 82 | |
| 77 | 83 | /// Work for the index thread. |
| ... | ... | @@ -97,26 +103,27 @@ pub struct Search { |
| 97 | 103 | /// Scope searches start in, as "Set This Scope as Default" leaves it. |
| 98 | 104 | pub default: Scope, |
| 99 | 105 | /// Find on This Page is open, at the match selected. |
| 100 | finding: bool, | |
| 106 | pub(crate) finding: bool, | |
| 101 | 107 | current: Option<usize>, |
| 102 | /// The page a result opened, whose first match is selected once it shows. | |
| 103 | reveal: Option<ExGuid>, | |
| 104 | index: Arc<Mutex<Index>>, | |
| 108 | /// The page a result opened, whose first match, or paragraph, is selected once it shows. | |
| 109 | reveal: Option<(ExGuid, Option<ExGuid>)>, | |
| 110 | pub(crate) index: Arc<Mutex<Index>>, | |
| 105 | 111 | /// Counts the index's changes, so results are made again when it changes. |
| 106 | 112 | version: Arc<AtomicU64>, |
| 107 | busy: Arc<AtomicBool>, | |
| 113 | pub(crate) busy: Arc<AtomicBool>, | |
| 108 | 114 | jobs: mpsc::Sender<Job>, |
| 109 | found: Vec<Found>, | |
| 115 | pub(crate) found: Vec<Found>, | |
| 110 | 116 | found_for: Option<(Query, Scope, u64, String)>, |
| 111 | selected: Option<u64>, | |
| 117 | pub(crate) selected: Option<u64>, | |
| 112 | 118 | /// The notebooks and open section the index was last brought to, by identity. |
| 113 | 119 | synced: Vec<usize>, |
| 114 | 120 | /// The results drop down, until chosen from or dismissed. |
| 115 | open: bool, | |
| 121 | pub(crate) open: bool, | |
| 116 | 122 | /// Where the scope menu opens. |
| 117 | 123 | anchor: Option<Anchor>, |
| 118 | /// The find field takes the keys once built. | |
| 119 | claim: bool, | |
| 124 | /// The find field, or the Search Results pane's, takes the keys once built. | |
| 125 | pub(crate) claim: bool, | |
| 126 | pub(crate) pane: Option<Pane>, | |
| 120 | 127 | } |
| 121 | 128 | |
| 122 | 129 | impl Search { |
| ... | ... | @@ -148,6 +155,7 @@ impl Search { |
| 148 | 155 | open: false, |
| 149 | 156 | anchor: None, |
| 150 | 157 | claim: false, |
| 158 | pane: None, | |
| 151 | 159 | } |
| 152 | 160 | } |
| 153 | 161 | |
| ... | ... | @@ -188,7 +196,7 @@ fn stamp(file: &Path) -> Stamp { |
| 188 | 196 | } |
| 189 | 197 | |
| 190 | 198 | /// Now in Time32, seconds since 1980, as page modification times are kept. |
| 191 | fn now() -> u64 { | |
| 199 | pub(crate) fn now() -> u64 { | |
| 192 | 200 | SystemTime::now() |
| 193 | 201 | .duration_since(SystemTime::UNIX_EPOCH) |
| 194 | 202 | .map_or(0, |since| since.as_secs().saturating_sub(315_532_800)) |
| ... | ... | @@ -395,7 +403,13 @@ fn reread( |
| 395 | 403 | } |
| 396 | 404 | |
| 397 | 405 | /// A text box's label with `hits`, byte ranges of `text`, marked in OneNote's yellow. |
| 398 | fn marked(ui: &mut Ui, part: &str, text: &str, hits: &[std::ops::Range<usize>], spec: Spec) { | |
| 406 | pub(crate) fn marked( | |
| 407 | ui: &mut Ui, | |
| 408 | part: &str, | |
| 409 | text: &str, | |
| 410 | hits: &[std::ops::Range<usize>], | |
| 411 | spec: Spec, | |
| 412 | ) { | |
| 399 | 413 | let height = match spec.size[1].size { |
| 400 | 414 | ui::Size::Pixels(height) => height, |
| 401 | 415 | _ => 0.0, |
| ... | ... | @@ -481,7 +495,7 @@ impl State { |
| 481 | 495 | } |
| 482 | 496 | |
| 483 | 497 | /// Whether `key` names a section the search's scope takes in. |
| 484 | fn in_scope(&self, scope: Scope, key: &str) -> bool { | |
| 498 | pub(crate) fn in_scope(&self, scope: Scope, key: &str) -> bool { | |
| 485 | 499 | let Some(session) = &self.session else { |
| 486 | 500 | return scope == Scope::All; |
| 487 | 501 | }; |
| ... | ... | @@ -499,7 +513,7 @@ impl State { |
| 499 | 513 | } |
| 500 | 514 | |
| 501 | 515 | /// The results for the query in its scope, made again when either or the index changed. |
| 502 | fn refresh_results(&mut self) -> Result<(), Box<dyn Error>> { | |
| 516 | pub(crate) fn refresh_results(&mut self) -> Result<(), Box<dyn Error>> { | |
| 503 | 517 | let query = Query::new(&self.search.query); |
| 504 | 518 | let version = self.search.version.load(Ordering::Relaxed); |
| 505 | 519 | let open = self |
| ... | ... | @@ -538,33 +552,37 @@ impl State { |
| 538 | 552 | } |
| 539 | 553 | |
| 540 | 554 | /// Marks the query's matches on the page; with `select`, selects the match Find on |
| 541 | /// This Page is at, or the first when a result opened the page. | |
| 555 | /// This Page is at, or the first, or the paragraph, when a result opened the page. | |
| 542 | 556 | pub(crate) fn refind(&mut self, select: bool) -> Result<(), Box<dyn Error>> { |
| 543 | 557 | self.view.found = page_matches(&self.view.editor, &self.search.shown()); |
| 544 | 558 | if !select { |
| 545 | 559 | return Ok(()); |
| 546 | 560 | } |
| 547 | 561 | let space = self.session.as_ref().map(|session| session.space); |
| 548 | let at = if self.search.finding { | |
| 562 | let target = if self.search.finding { | |
| 549 | 563 | self.search.current = match self.search.current { |
| 550 | 564 | Some(at) if at < self.view.found.len() => Some(at), |
| 551 | 565 | _ => (!self.view.found.is_empty()).then_some(0), |
| 552 | 566 | }; |
| 553 | self.search.current | |
| 554 | } else if self.search.reveal.is_some() && self.search.reveal == space { | |
| 567 | self.search | |
| 568 | .current | |
| 569 | .and_then(|at| self.view.found.get(at).copied()) | |
| 570 | } else if let Some((_, paragraph)) = | |
| 571 | self.search.reveal.filter(|(page, _)| Some(*page) == space) | |
| 572 | { | |
| 555 | 573 | self.search.reveal = None; |
| 556 | (!self.view.found.is_empty()).then_some(0) | |
| 574 | match paragraph { | |
| 575 | Some(paragraph) => paragraph_match(&self.view.editor, paragraph), | |
| 576 | None => self.view.found.first().copied(), | |
| 577 | } | |
| 557 | 578 | } else { |
| 558 | 579 | None |
| 559 | 580 | }; |
| 560 | match at { | |
| 561 | Some(at) => self.select_match(at), | |
| 562 | None => Ok(()), | |
| 563 | } | |
| 581 | self.select(target) | |
| 564 | 582 | } |
| 565 | 583 | |
| 566 | fn select_match(&mut self, at: usize) -> Result<(), Box<dyn Error>> { | |
| 567 | let Some(&(outline, selection)) = self.view.found.get(at) else { | |
| 584 | fn select(&mut self, target: Option<PageMatch>) -> Result<(), Box<dyn Error>> { | |
| 585 | let Some((outline, selection)) = target else { | |
| 568 | 586 | return Ok(()); |
| 569 | 587 | }; |
| 570 | 588 | self.view.editor.focus_outline(outline)?; |
| ... | ... | @@ -598,26 +616,38 @@ impl State { |
| 598 | 616 | } |
| 599 | 617 | |
| 600 | 618 | /// Ends the search or find: the box empties, the marks go and the page takes the keys, |
| 601 | /// keeping any match selected. | |
| 602 | fn end_search(&mut self) { | |
| 619 | /// keeping any match selected. The Search Results pane keeps the search while open. | |
| 620 | pub(crate) fn end_search(&mut self) { | |
| 603 | 621 | self.search.open = false; |
| 604 | self.search.query.clear(); | |
| 605 | 622 | self.search.finding = false; |
| 606 | 623 | self.search.current = None; |
| 607 | self.search.scope = self.search.default; | |
| 608 | self.view.found.clear(); | |
| 609 | 624 | self.ui.close_popup(results()); |
| 610 | 625 | self.ui.set_focus(Some(page())); |
| 626 | if !matches!(self.search.pane, Some(Pane::Search { .. })) { | |
| 627 | self.search.query.clear(); | |
| 628 | self.search.scope = self.search.default; | |
| 629 | self.view.found.clear(); | |
| 630 | } | |
| 611 | 631 | } |
| 612 | 632 | |
| 613 | 633 | /// Opens a result's page, whose first match is selected once it shows. |
| 614 | 634 | fn open_result(&mut self, index: usize) -> Result<(), Box<dyn Error>> { |
| 615 | let Some(found) = self.search.found.get(index) else { | |
| 616 | return Ok(()); | |
| 617 | }; | |
| 618 | let (location, path) = found.section.split_once('\n').unwrap_or_default(); | |
| 619 | let (space, section) = (found.space, found.section.clone()); | |
| 620 | self.search.reveal = Some(space); | |
| 635 | match self.search.found.get(index) { | |
| 636 | Some(found) => self.reveal(found.section.clone(), found.space, None), | |
| 637 | None => Ok(()), | |
| 638 | } | |
| 639 | } | |
| 640 | ||
| 641 | /// Opens page `space` of `section`, whose first match, or `paragraph`, is selected once | |
| 642 | /// it shows. | |
| 643 | pub(crate) fn reveal( | |
| 644 | &mut self, | |
| 645 | section: String, | |
| 646 | space: ExGuid, | |
| 647 | paragraph: Option<ExGuid>, | |
| 648 | ) -> Result<(), Box<dyn Error>> { | |
| 649 | let (location, path) = section.split_once('\n').unwrap_or_default(); | |
| 650 | self.search.reveal = Some((space, paragraph)); | |
| 621 | 651 | let open = self.session.as_ref().map(|session| { |
| 622 | 652 | ( |
| 623 | 653 | session.library.key(&session.tabs[session.tab].path), |
| ... | ... | @@ -633,9 +663,9 @@ impl State { |
| 633 | 663 | .iter() |
| 634 | 664 | .find(|library| library.location == location) |
| 635 | 665 | .ok_or("The notebook is no longer open")?; |
| 636 | self.last_pages.insert(section, space); | |
| 637 | 666 | self.commands |
| 638 | 667 | .push(Command::OpenSection(Arc::clone(library), path.to_owned())); |
| 668 | self.last_pages.insert(section, space); | |
| 639 | 669 | } |
| 640 | 670 | } |
| 641 | 671 | Ok(()) |
| ... | ... | @@ -984,8 +1014,29 @@ impl State { |
| 984 | 1014 | .or_else(|| entered.then_some(0)) |
| 985 | 1015 | }); |
| 986 | 1016 | } |
| 1017 | let link = format!( | |
| 1018 | "Open Search Results Pane ({})", | |
| 1019 | commands::shortcut(commands::Id::SearchResults) | |
| 1020 | ); | |
| 1021 | let pane = self | |
| 1022 | .ui | |
| 1023 | .leaf( | |
| 1024 | "pane", | |
| 1025 | Spec { | |
| 1026 | flags: Flags::CLICKABLE, | |
| 1027 | size: [fit(), px(22.0)], | |
| 1028 | icon: Some(art::SEARCH), | |
| 1029 | text: Some(&link), | |
| 1030 | color: Some(theme.accent), | |
| 1031 | pad: [6.0, 0.0], | |
| 1032 | ..Spec::default() | |
| 1033 | }, | |
| 1034 | ) | |
| 1035 | .clicked; | |
| 987 | 1036 | self.ui.close(); |
| 988 | if let Some(index) = chosen { | |
| 1037 | if pane { | |
| 1038 | self.open_search_pane(); | |
| 1039 | } else if let Some(index) = chosen { | |
| 989 | 1040 | self.open_result(index)?; |
| 990 | 1041 | self.search.open = false; |
| 991 | 1042 | self.ui.close_popup(results()); |
| ... | ... | @@ -1084,7 +1135,7 @@ impl State { |
| 1084 | 1135 | (at + count - 1) % count |
| 1085 | 1136 | }; |
| 1086 | 1137 | self.search.current = Some(next); |
| 1087 | self.select_match(next)?; | |
| 1138 | self.select(self.view.found.get(next).copied())?; | |
| 1088 | 1139 | } |
| 1089 | 1140 | Ok(()) |
| 1090 | 1141 | } |
crates/snowbound/src/sidebar.rs+6-15| ... | ... | @@ -139,7 +139,7 @@ fn sidebar(ui: &mut Ui, tree: &mut Tree, notebooks: &[Arc<Library>], header: f32 |
| 139 | 139 | ..Spec::default() |
| 140 | 140 | }, |
| 141 | 141 | ); |
| 142 | // The button's square, which floats over this place from the section tabs. | |
| 142 | // The notebook button's square, which floats over this place. | |
| 143 | 143 | ui.leaf( |
| 144 | 144 | "toggle", |
| 145 | 145 | Spec { |
| ... | ... | @@ -710,26 +710,17 @@ impl crate::State { |
| 710 | 710 | /// The sidebar's button in its square at the window's edge, and the room the section |
| 711 | 711 | /// tabs, standing `width` from that edge, leave it; the button stays put as the sidebar |
| 712 | 712 | /// eases open and shut. |
| 713 | pub(crate) fn sidebar_button(&mut self, theme: &Theme, width: f32) { | |
| 713 | /// The notebook button, floating at the body's corner over the section tabs' row, `height` | |
| 714 | /// tall as it eases, or over the sidebar's header while that is open. | |
| 715 | pub(crate) fn sidebar_button(&mut self, theme: &Theme, height: f32) { | |
| 714 | 716 | if self.temporary { |
| 715 | 717 | return; |
| 716 | 718 | } |
| 717 | // Past the tab row's padding and the frame's corner, the tabs start where the square | |
| 718 | // ends; the room shrinks on the sidebar's own easing, so the tabs ease with it. | |
| 719 | let room = RAIL - crate::FRAME - self.rounding(); | |
| 720 | self.ui.leaf( | |
| 721 | "rail", | |
| 722 | Spec { | |
| 723 | size: [px(room * (1.0 - width / WIDTH)), px(1.0)], | |
| 724 | ..Spec::default() | |
| 725 | }, | |
| 726 | ); | |
| 727 | 719 | self.ui.open( |
| 728 | 720 | "toggle", |
| 729 | 721 | Spec { |
| 730 | flags: Flags::FLOAT, | |
| 731 | size: [px(RAIL), px(RAIL)], | |
| 732 | position: [-width, 0.0], | |
| 722 | flags: Flags::FLOAT | Flags::CLIP, | |
| 723 | size: [px(RAIL), px(height)], | |
| 733 | 724 | pad: [(RAIL - ui::shell::TOOL) / 2.0; 2], |
| 734 | 725 | ..Spec::default() |
| 735 | 726 | }, |
crates/snowbound/src/surface.rs+16-12| ... | ... | @@ -58,6 +58,12 @@ impl Surface { |
| 58 | 58 | config.view_formats.push(window_format); |
| 59 | 59 | draw::Translucent::new(&device, config.format, window_format) |
| 60 | 60 | }); |
| 61 | if platform::cuts_corners() { | |
| 62 | let alpha = &surface.get_capabilities(&adapter).alpha_modes; | |
| 63 | if alpha.contains(&wgpu::CompositeAlphaMode::PreMultiplied) { | |
| 64 | config.alpha_mode = wgpu::CompositeAlphaMode::PreMultiplied; | |
| 65 | } | |
| 66 | } | |
| 61 | 67 | surface.configure(&device, &config); |
| 62 | 68 | eprintln!("Canvas GPU: {:?}", adapter.get_info()); |
| 63 | 69 | let renderer = Renderer::new(device, queue, config.format); |
| ... | ... | @@ -205,17 +211,15 @@ impl Surface { |
| 205 | 211 | self.config.format, |
| 206 | 212 | wgpu::TextureFormat::Bgra8Unorm | wgpu::TextureFormat::Bgra8UnormSrgb |
| 207 | 213 | ); |
| 208 | Ok(buffer | |
| 209 | .get_mapped_range(..)? | |
| 210 | .chunks_exact(row as usize) | |
| 211 | .flat_map(|line| line[..size[0] as usize * 4].chunks_exact(4)) | |
| 212 | .flat_map(|pixel| { | |
| 213 | if bgra { | |
| 214 | [pixel[2], pixel[1], pixel[0], pixel[3]] | |
| 215 | } else { | |
| 216 | [pixel[0], pixel[1], pixel[2], pixel[3]] | |
| 217 | } | |
| 218 | }) | |
| 219 | .collect()) | |
| 214 | let mut pixels = Vec::with_capacity(size[0] as usize * size[1] as usize * 4); | |
| 215 | for line in buffer.get_mapped_range(..)?.chunks_exact(row as usize) { | |
| 216 | pixels.extend_from_slice(&line[..size[0] as usize * 4]); | |
| 217 | } | |
| 218 | if bgra { | |
| 219 | for pixel in pixels.chunks_exact_mut(4) { | |
| 220 | pixel.swap(0, 2); | |
| 221 | } | |
| 222 | } | |
| 223 | Ok(pixels) | |
| 220 | 224 | } |
| 221 | 225 | } |
crates/snowbound/src/surface_gl.rs+18-5| ... | ... | @@ -18,6 +18,9 @@ pub struct Surface { |
| 18 | 18 | /// Device pixels frames and snapshots are drawn at; `configure` gives the window it. |
| 19 | 19 | pub size: [u32; 2], |
| 20 | 20 | context: Retained<AnyObject>, |
| 21 | /// Frames leave the window's textured backdrop showing through their transparent | |
| 22 | /// pixels. | |
| 23 | translucent: bool, | |
| 21 | 24 | /// The frame target, kept between frames. |
| 22 | 25 | target: Option<Target>, |
| 23 | 26 | } |
| ... | ... | @@ -29,10 +32,10 @@ pub struct Frame { |
| 29 | 32 | pub type Offscreen = Frame; |
| 30 | 33 | |
| 31 | 34 | impl Surface { |
| 32 | /// No backdrop shows through an OpenGL view. | |
| 35 | /// With `backdrop`, the surface is transparent where frames are. | |
| 33 | 36 | pub async fn new( |
| 34 | 37 | window: Arc<Window>, |
| 35 | _backdrop: bool, | |
| 38 | backdrop: bool, | |
| 36 | 39 | ) -> Result<(Self, Renderer), Box<dyn Error>> { |
| 37 | 40 | let RawWindowHandle::AppKit(handle) = window.window_handle()?.as_raw() else { |
| 38 | 41 | unreachable!() |
| ... | ... | @@ -59,6 +62,11 @@ impl Surface { |
| 59 | 62 | // NSOpenGLCPSwapInterval: present on the display's refresh. |
| 60 | 63 | let interval: i32 = 1; |
| 61 | 64 | let _: () = msg_send![&context, setValues: &interval, forParameter: 222 as NSInteger]; |
| 65 | if backdrop { | |
| 66 | // NSOpenGLCPSurfaceOpacity. | |
| 67 | let opaque: i32 = 0; | |
| 68 | let _: () = msg_send![&context, setValues: &opaque, forParameter: 236 as NSInteger]; | |
| 69 | } | |
| 62 | 70 | context |
| 63 | 71 | }; |
| 64 | 72 | let renderer = Renderer::new()?; |
| ... | ... | @@ -68,6 +76,7 @@ impl Surface { |
| 68 | 76 | Self { |
| 69 | 77 | size: [size.width, size.height], |
| 70 | 78 | context, |
| 79 | translucent: backdrop, | |
| 71 | 80 | target: None, |
| 72 | 81 | }, |
| 73 | 82 | renderer, |
| ... | ... | @@ -75,7 +84,7 @@ impl Surface { |
| 75 | 84 | } |
| 76 | 85 | |
| 77 | 86 | pub fn translucent(&self) -> bool { |
| 78 | false | |
| 87 | self.translucent | |
| 79 | 88 | } |
| 80 | 89 | |
| 81 | 90 | pub fn configure(&mut self, _: &Renderer) { |
| ... | ... | @@ -94,8 +103,12 @@ impl Surface { |
| 94 | 103 | Ok(Some(Frame { target })) |
| 95 | 104 | } |
| 96 | 105 | |
| 97 | pub fn present(&mut self, _: &Renderer, frame: Frame) { | |
| 98 | frame.target.present(); | |
| 106 | pub fn present(&mut self, renderer: &Renderer, frame: Frame) { | |
| 107 | if self.translucent { | |
| 108 | renderer.present_translucent(&frame.target); | |
| 109 | } else { | |
| 110 | frame.target.present(); | |
| 111 | } | |
| 99 | 112 | let _: () = unsafe { msg_send![&self.context, flushBuffer] }; |
| 100 | 113 | self.target = Some(frame.target); |
| 101 | 114 | } |
crates/snowbound/src/sync.rs+73-22| ... | ... | @@ -1,10 +1,12 @@ |
| 1 | 1 | //! The sync status at the top right, and the popup it opens: OneNote's Shared Notebook |
| 2 | 2 | //! Synchronization for the open section. |
| 3 | 3 | |
| 4 | use crate::{Session, State, filetime, library, platform}; | |
| 4 | use crate::{Session, State, art, filetime, library, platform}; | |
| 5 | 5 | use notebook::session::SyncStatus; |
| 6 | 6 | use std::{error::Error, io::ErrorKind}; |
| 7 | use ui::{Anchor, Axis, Flags, Id, Overflow, Spec, Theme, Ui, children, fill, fit, px}; | |
| 7 | use ui::{ | |
| 8 | Anchor, Axis, Flags, Id, Overflow, Spec, Theme, Ui, children, fill, fit, px, shell::TOOL, | |
| 9 | }; | |
| 8 | 10 | |
| 9 | 11 | const WIDTH: f32 = 380.0; |
| 10 | 12 | |
| ... | ... | @@ -16,24 +18,27 @@ fn button() -> Id { |
| 16 | 18 | Id::ROOT.child("sync-button") |
| 17 | 19 | } |
| 18 | 20 | |
| 19 | /// The status's label, and what the reader can do about an error. | |
| 20 | fn describe(sync: &SyncStatus) -> (&'static str, Option<&'static str>) { | |
| 21 | /// The status's label and icon, and what the reader can do about an error. | |
| 22 | fn describe(sync: &SyncStatus) -> (&'static str, &'static [&'static str], Option<&'static str>) { | |
| 21 | 23 | use ErrorKind::*; |
| 22 | 24 | if library::offline() { |
| 23 | 25 | return ( |
| 24 | 26 | "Working offline", |
| 27 | art::SYNC_OFFLINE, | |
| 25 | 28 | Some("Changes stay on this computer until you sync."), |
| 26 | 29 | ); |
| 27 | 30 | } |
| 28 | 31 | match sync.error.as_ref().map(std::io::Error::kind) { |
| 29 | 32 | Some(PermissionDenied | ReadOnlyFilesystem) => ( |
| 30 | 33 | "Unable to sync", |
| 34 | art::SYNC_ERROR, | |
| 31 | 35 | Some( |
| 32 | 36 | "You can’t change this notebook where it’s stored. Changes stay on this computer.", |
| 33 | 37 | ), |
| 34 | 38 | ), |
| 35 | 39 | Some(WouldBlock | ResourceBusy) => ( |
| 36 | 40 | "Section in use", |
| 41 | art::SYNC_BUSY, | |
| 37 | 42 | Some("Someone else is saving this section. Sync continues when they finish."), |
| 38 | 43 | ), |
| 39 | 44 | Some( |
| ... | ... | @@ -42,11 +47,12 @@ fn describe(sync: &SyncStatus) -> (&'static str, Option<&'static str>) { |
| 42 | 47 | | AddrNotAvailable, |
| 43 | 48 | ) => ( |
| 44 | 49 | "Not connected", |
| 50 | art::SYNC_OFFLINE, | |
| 45 | 51 | Some("Changes stay on this computer and sync when the notebook is back."), |
| 46 | 52 | ), |
| 47 | Some(_) => ("Unable to sync", None), | |
| 48 | None if sync.synced.is_none() || sync.queued > 0 => ("Syncing…", None), | |
| 49 | None => ("Up to date", None), | |
| 53 | Some(_) => ("Unable to sync", art::SYNC_ERROR, None), | |
| 54 | None if sync.synced.is_none() || sync.queued > 0 => ("Syncing…", art::SYNC_BUSY, None), | |
| 55 | None => ("Up to date", art::SYNC_DONE, None), | |
| 50 | 56 | } |
| 51 | 57 | } |
| 52 | 58 | |
| ... | ... | @@ -65,25 +71,47 @@ fn when(time: u64) -> String { |
| 65 | 71 | } |
| 66 | 72 | } |
| 67 | 73 | |
| 68 | /// The status beside the window's controls, which opens the popup. | |
| 69 | pub(crate) fn control(ui: &mut Ui, session: &Session, theme: &Theme, height: f32) { | |
| 74 | /// The status in the toolbar, which opens the popup: its label, or where `compact` its icon, | |
| 75 | /// naming it in a tooltip. | |
| 76 | pub(crate) fn control(ui: &mut Ui, session: &Session, theme: &Theme, compact: bool) { | |
| 70 | 77 | let strong = ui.popup_open(id()) || session.sync.error.is_some() && !library::offline(); |
| 78 | let (label, icon, _) = describe(&session.sync); | |
| 79 | let spec = Spec { | |
| 80 | flags: Flags::CLICKABLE, | |
| 81 | color: Some(if strong { theme.text } else { theme.text_dim }), | |
| 82 | hover_fill: Some(theme.hover()), | |
| 83 | radius: 4.0, | |
| 84 | center: true, | |
| 85 | ..Spec::default() | |
| 86 | }; | |
| 87 | // The popup opens from the label's box, which stands where the icon shows. | |
| 88 | let shown = if compact { | |
| 89 | button().child("icon") | |
| 90 | } else { | |
| 91 | button() | |
| 92 | }; | |
| 71 | 93 | ui.open_as( |
| 72 | button(), | |
| 73 | Spec { | |
| 74 | flags: Flags::CLICKABLE, | |
| 75 | size: [fit(), px(height)], | |
| 76 | text: Some(label(&session.sync)), | |
| 77 | color: Some(if strong { theme.text } else { theme.text_dim }), | |
| 78 | hover_fill: Some(theme.hover()), | |
| 79 | radius: 4.0, | |
| 80 | pad: [8.0, 0.0], | |
| 81 | center: true, | |
| 82 | ..Spec::default() | |
| 94 | shown, | |
| 95 | if compact { | |
| 96 | Spec { | |
| 97 | size: [px(TOOL), px(TOOL)], | |
| 98 | icon: Some(icon), | |
| 99 | ..spec | |
| 100 | } | |
| 101 | } else { | |
| 102 | Spec { | |
| 103 | size: [fit(), px(TOOL)], | |
| 104 | text: Some(label), | |
| 105 | pad: [8.0, 0.0], | |
| 106 | ..spec | |
| 107 | } | |
| 83 | 108 | }, |
| 84 | 109 | ); |
| 85 | 110 | ui.close(); |
| 86 | if ui.signal(button()).clicked { | |
| 111 | if compact { | |
| 112 | ui::popup::tooltip(ui, label, "", None); | |
| 113 | } | |
| 114 | if ui.signal(shown).clicked { | |
| 87 | 115 | if ui.popup_open(id()) { |
| 88 | 116 | ui.close_popup(id()); |
| 89 | 117 | } else { |
| ... | ... | @@ -107,7 +135,7 @@ impl State { |
| 107 | 135 | session.sync = session.section.sync_status()?; |
| 108 | 136 | let theme = ui.theme.clone(); |
| 109 | 137 | let offline = library::offline(); |
| 110 | let (progress, advice) = describe(&session.sync); | |
| 138 | let (progress, _, advice) = describe(&session.sync); | |
| 111 | 139 | ui.open_as( |
| 112 | 140 | id(), |
| 113 | 141 | Spec { |
| ... | ... | @@ -209,6 +237,26 @@ impl State { |
| 209 | 237 | ..Spec::default() |
| 210 | 238 | }, |
| 211 | 239 | ); |
| 240 | let file = session.library.local(session.section.file()); | |
| 241 | let show = match file { | |
| 242 | Some(_) => ui::button(ui, "show-file", platform::SHOW_FILE).clicked, | |
| 243 | None => { | |
| 244 | ui.leaf( | |
| 245 | "show-file", | |
| 246 | Spec { | |
| 247 | size: [fit(), px(theme.font_size * 2.0)], | |
| 248 | text: Some(platform::SHOW_FILE), | |
| 249 | color: Some(theme.text_dim), | |
| 250 | fill: Some(theme.chip), | |
| 251 | radius: 4.0, | |
| 252 | pad: [theme.font_size * 0.75, 0.0], | |
| 253 | center: true, | |
| 254 | ..Spec::default() | |
| 255 | }, | |
| 256 | ); | |
| 257 | false | |
| 258 | } | |
| 259 | }; | |
| 212 | 260 | let now = ui::button(ui, "sync-now", "Sync Now").clicked; |
| 213 | 261 | ui.close(); |
| 214 | 262 | ui.close(); |
| ... | ... | @@ -219,6 +267,9 @@ impl State { |
| 219 | 267 | if now { |
| 220 | 268 | session.section.wake(); |
| 221 | 269 | } |
| 270 | if let Some(file) = file.filter(|_| show) { | |
| 271 | platform::show_file(&file); | |
| 272 | } | |
| 222 | 273 | Ok(()) |
| 223 | 274 | } |
| 224 | 275 | } |
crates/snowbound/src/templates.rs+2-2| ... | ... | @@ -12,7 +12,7 @@ use ui::{Axis, Flags, Spec, Theme, Ui, fill, px}; |
| 12 | 12 | |
| 13 | 13 | /// What a thumbnail shows: the top of a letter page, in points from the page origin. |
| 14 | 14 | const REGION: [f32; 4] = [-72.0, -40.0, 540.0, 419.0]; |
| 15 | const TILE: [f32; 2] = [112.0, 84.0]; | |
| 15 | pub(crate) const TILE: [f32; 2] = [112.0, 84.0]; | |
| 16 | 16 | const SWATCH: f32 = 24.0; |
| 17 | 17 | const LABEL: f32 = 22.0; |
| 18 | 18 | const GAP: f32 = 12.0; |
| ... | ... | @@ -59,7 +59,7 @@ pub struct Thumbnails(HashMap<(&'static str, bool), Art>); |
| 59 | 59 | |
| 60 | 60 | impl Thumbnails { |
| 61 | 61 | /// `template`'s art and where it lies in a `TILE`, drawn at `scale` for `paper`. |
| 62 | fn art( | |
| 62 | pub(crate) fn art( | |
| 63 | 63 | &mut self, |
| 64 | 64 | template: &'static Template, |
| 65 | 65 | paper: Paper, |
crates/ui/examples/popups.rs+1-1| ... | ... | @@ -761,7 +761,7 @@ fn palette(scene: &mut Scene) { |
| 761 | 761 | }); |
| 762 | 762 | let id = Id::ROOT.child("palette"); |
| 763 | 763 | scene.open(id, typed("ne"), |ui, _| { |
| 764 | ui::popup::palette(ui, id, &commands); | |
| 764 | ui::popup::palette(ui, id, &commands, "Search commands"); | |
| 765 | 765 | }); |
| 766 | 766 | } |
| 767 | 767 |
crates/ui/src/layout.rs+112-18| ... | ... | @@ -2,8 +2,9 @@ use crate::{Anchor, Axis, Built, Flags, ICON, ICON_GAP, Id, Overflow, Size, Stat |
| 2 | 2 | use std::collections::HashMap; |
| 3 | 3 | |
| 4 | 4 | /// Sizes each box on both axes, then places it: standalone sizes, sizes taken from |
| 5 | /// ancestors (pre-order), sizes summed from children (post-order), overflow shared out | |
| 6 | /// by strictness, and positions along each parent's flow. Labels too wide for their | |
| 5 | /// ancestors (pre-order), sizes summed from children (post-order), overflow taken back | |
| 6 | /// (from space sized by ancestors, then by folding a row's groups, then from the least | |
| 7 | /// strict boxes first), and positions along each parent's flow. Labels too wide for their | |
| 7 | 8 | /// solved width wrap or shorten before heights are solved. Boxes are in build order, so |
| 8 | 9 | /// index order is pre-order. |
| 9 | 10 | pub(crate) fn solve( |
| ... | ... | @@ -13,6 +14,12 @@ pub(crate) fn solve( |
| 13 | 14 | texts: &mut Texts, |
| 14 | 15 | frame: u64, |
| 15 | 16 | ) { |
| 17 | for index in 0..nodes.len() { | |
| 18 | if nodes[index].fold.is_some() { | |
| 19 | let folded = nodes[index].children[1]; | |
| 20 | nodes[folded].hidden = true; | |
| 21 | } | |
| 22 | } | |
| 16 | 23 | for axis in 0..2 { |
| 17 | 24 | if axis == 1 { |
| 18 | 25 | fit_labels(nodes, texts, frame); |
| ... | ... | @@ -48,7 +55,7 @@ pub(crate) fn solve( |
| 48 | 55 | while ancestor != 0 && nodes[ancestor].size[axis].size == Size::Children { |
| 49 | 56 | ancestor = nodes[ancestor].parent; |
| 50 | 57 | } |
| 51 | let room = nodes[ancestor].computed[axis] - 2.0 * nodes[ancestor].pad[axis]; | |
| 58 | let room = across(nodes, ancestor, index, axis) - 2.0 * nodes[ancestor].pad[axis]; | |
| 52 | 59 | nodes[index].computed[axis] = room.max(0.0) * fraction; |
| 53 | 60 | } |
| 54 | 61 | } |
| ... | ... | @@ -66,22 +73,48 @@ pub(crate) fn solve( |
| 66 | 73 | let room = (node.computed[axis] - 2.0 * node.pad[axis]).max(0.0); |
| 67 | 74 | let children: Vec<_> = in_flow(nodes, index).collect(); |
| 68 | 75 | if along(node, axis) { |
| 69 | let excess = flow(nodes, index, axis) - room; | |
| 70 | let give: f32 = children | |
| 76 | let space = | |
| 77 | |child: &&usize| matches!(nodes[**child].size[axis].size, Size::Fraction(_)); | |
| 78 | let (space, sized): (Vec<_>, Vec<_>) = children.iter().partition(space); | |
| 79 | let mut excess = flow(nodes, index, axis) - room; | |
| 80 | excess = give_back(nodes, &space, axis, excess); | |
| 81 | while excess > 0.0 && axis == 0 { | |
| 82 | let Some(group) = children | |
| 83 | .iter() | |
| 84 | .copied() | |
| 85 | .filter(|child| { | |
| 86 | nodes[*child].fold.is_some() && !nodes[nodes[*child].children[0]].hidden | |
| 87 | }) | |
| 88 | .min_by_key(|child| nodes[*child].fold) | |
| 89 | else { | |
| 90 | break; | |
| 91 | }; | |
| 92 | let [full, folded] = [nodes[group].children[0], nodes[group].children[1]]; | |
| 93 | nodes[full].hidden = true; | |
| 94 | nodes[folded].hidden = false; | |
| 95 | let width = nodes[folded].computed[0] + 2.0 * nodes[group].pad[0]; | |
| 96 | excess -= nodes[group].computed[0] - width; | |
| 97 | nodes[group].computed[0] = width; | |
| 98 | } | |
| 99 | let mut tiers: Vec<f32> = sized | |
| 71 | 100 | .iter() |
| 72 | .map(|child| { | |
| 73 | nodes[*child].computed[axis] * (1.0 - strictness(nodes, *child, axis)) | |
| 74 | }) | |
| 75 | .sum(); | |
| 76 | if excess > 0.0 && give > 0.0 { | |
| 77 | for child in children { | |
| 78 | let share = | |
| 79 | nodes[child].computed[axis] * (1.0 - strictness(nodes, child, axis)); | |
| 80 | nodes[child].computed[axis] -= excess.min(give) * share / give; | |
| 81 | } | |
| 101 | .map(|child| strictness(nodes, *child, axis)) | |
| 102 | .filter(|strictness| *strictness < 1.0) | |
| 103 | .collect(); | |
| 104 | tiers.sort_by(f32::total_cmp); | |
| 105 | tiers.dedup(); | |
| 106 | for tier in tiers { | |
| 107 | let members: Vec<_> = sized | |
| 108 | .iter() | |
| 109 | .copied() | |
| 110 | .filter(|child| strictness(nodes, *child, axis) == tier) | |
| 111 | .collect(); | |
| 112 | excess = give_back(nodes, &members, axis, excess); | |
| 82 | 113 | } |
| 83 | 114 | } else { |
| 84 | 115 | for child in children { |
| 116 | let room = | |
| 117 | (across(nodes, index, child, axis) - 2.0 * nodes[index].pad[axis]).max(0.0); | |
| 85 | 118 | let over = nodes[child].computed[axis] - room; |
| 86 | 119 | if over > 0.0 { |
| 87 | 120 | nodes[child].computed[axis] -= |
| ... | ... | @@ -93,6 +126,13 @@ pub(crate) fn solve( |
| 93 | 126 | let window = nodes[0].computed[axis]; |
| 94 | 127 | for index in 0..nodes.len() { |
| 95 | 128 | let mut cursor = nodes[index].pad[axis]; |
| 129 | // Where a popup widening over its anchor lays its children out, from where it is. | |
| 130 | let shift = match (nodes[index].anchor, nodes[index].size[axis].size) { | |
| 131 | (Some(anchor @ Anchor::Over(_)), Size::Pixels(full)) if axis == 0 => { | |
| 132 | anchor.place(axis, full, full, window) - nodes[index].relative[axis] | |
| 133 | } | |
| 134 | _ => 0.0, | |
| 135 | }; | |
| 96 | 136 | for child in nodes[index].children.clone() { |
| 97 | 137 | nodes[child].relative[axis] = if let Some(anchor) = nodes[child].anchor { |
| 98 | 138 | let shown = nodes[child].computed[axis]; |
| ... | ... | @@ -103,6 +143,8 @@ pub(crate) fn solve( |
| 103 | 143 | anchor.place(axis, size, shown, window) |
| 104 | 144 | } else if nodes[child].flags.contains(Flags::FLOAT) { |
| 105 | 145 | nodes[child].position[axis] |
| 146 | } else if nodes[child].hidden { | |
| 147 | nodes[index].pad[axis] | |
| 106 | 148 | } else if along(&nodes[index], axis) { |
| 107 | 149 | let at = cursor; |
| 108 | 150 | cursor += nodes[child].computed[axis] + nodes[index].gap; |
| ... | ... | @@ -110,6 +152,9 @@ pub(crate) fn solve( |
| 110 | 152 | } else { |
| 111 | 153 | nodes[index].pad[axis] |
| 112 | 154 | }; |
| 155 | if !nodes[child].flags.contains(Flags::STILL) { | |
| 156 | nodes[child].relative[axis] += shift; | |
| 157 | } | |
| 113 | 158 | } |
| 114 | 159 | if axis == 1 { |
| 115 | 160 | nodes[index].content = flow(nodes, index, axis) + 2.0 * nodes[index].pad[axis]; |
| ... | ... | @@ -124,7 +169,13 @@ pub(crate) fn solve( |
| 124 | 169 | snap(nodes[0].computed[1]), |
| 125 | 170 | ]; |
| 126 | 171 | for index in 1..nodes.len() { |
| 127 | let parent = &nodes[nodes[index].parent]; | |
| 172 | let parent = nodes[index].parent; | |
| 173 | if nodes[index].hidden || nodes[parent].hidden { | |
| 174 | nodes[index].hidden = true; | |
| 175 | nodes[index].rect = nodes[parent].rect; | |
| 176 | continue; | |
| 177 | } | |
| 178 | let parent = &nodes[parent]; | |
| 128 | 179 | let scroll = |
| 129 | 180 | if parent.flags.contains(Flags::SCROLL) && !nodes[index].flags.contains(Flags::FLOAT) { |
| 130 | 181 | states.get(&parent.id).map_or(0.0, |state| state.scroll) |
| ... | ... | @@ -143,17 +194,60 @@ pub(crate) fn solve( |
| 143 | 194 | } |
| 144 | 195 | } |
| 145 | 196 | |
| 197 | /// The length `parent` lays `child` out across: a popup widening over its anchor lays its | |
| 198 | /// contents out at its full width, but for boxes standing in for the anchor, which widen with it. | |
| 199 | fn across(nodes: &[Built], parent: usize, child: usize, axis: usize) -> f32 { | |
| 200 | let node = &nodes[parent]; | |
| 201 | match (node.anchor, node.size[axis].size) { | |
| 202 | (Some(Anchor::Over(_)), Size::Pixels(full)) | |
| 203 | if axis == 0 && !nodes[child].flags.contains(Flags::STILL) => | |
| 204 | { | |
| 205 | full | |
| 206 | } | |
| 207 | _ => node.computed[axis], | |
| 208 | } | |
| 209 | } | |
| 210 | ||
| 211 | /// Takes up to `excess` back from `boxes`, each giving in proportion to what its strictness | |
| 212 | /// lets it, and returns what is left. | |
| 213 | fn give_back(nodes: &mut [Built], boxes: &[usize], axis: usize, excess: f32) -> f32 { | |
| 214 | let give = |nodes: &[Built], child: usize| { | |
| 215 | nodes[child].computed[axis] * (1.0 - strictness(nodes, child, axis)) | |
| 216 | }; | |
| 217 | let total: f32 = boxes.iter().map(|child| give(nodes, *child)).sum(); | |
| 218 | if excess <= 0.0 || total <= 0.0 { | |
| 219 | return excess; | |
| 220 | } | |
| 221 | let taken = excess.min(total); | |
| 222 | for child in boxes { | |
| 223 | nodes[*child].computed[axis] -= taken * give(nodes, *child) / total; | |
| 224 | } | |
| 225 | excess - taken | |
| 226 | } | |
| 227 | ||
| 146 | 228 | fn along(node: &Built, axis: usize) -> bool { |
| 147 | 229 | (node.axis == Axis::Y) == (axis == 1) |
| 148 | 230 | } |
| 149 | 231 | |
| 232 | /// The share of its size a box keeps when its siblings overflow. A box sized by the children | |
| 233 | /// along its flow keeps what they keep. | |
| 150 | 234 | fn strictness(nodes: &[Built], index: usize, axis: usize) -> f32 { |
| 151 | nodes[index].size[axis].strictness.clamp(0.0, 1.0) | |
| 235 | let node = &nodes[index]; | |
| 236 | let declared = node.size[axis].strictness.clamp(0.0, 1.0); | |
| 237 | if node.size[axis].size != Size::Children || !along(node, axis) || node.computed[axis] <= 0.0 { | |
| 238 | return declared; | |
| 239 | } | |
| 240 | let give: f32 = in_flow(nodes, index) | |
| 241 | .map(|child| nodes[child].computed[axis] * (1.0 - strictness(nodes, child, axis))) | |
| 242 | .sum(); | |
| 243 | declared.min(1.0 - give / node.computed[axis]) | |
| 152 | 244 | } |
| 153 | 245 | |
| 154 | 246 | fn in_flow(nodes: &[Built], index: usize) -> impl Iterator<Item = usize> + '_ { |
| 155 | 247 | nodes[index].children.iter().copied().filter(|child| { |
| 156 | !nodes[*child].flags.contains(Flags::FLOAT) && nodes[*child].anchor.is_none() | |
| 248 | !nodes[*child].flags.contains(Flags::FLOAT) | |
| 249 | && nodes[*child].anchor.is_none() | |
| 250 | && !nodes[*child].hidden | |
| 157 | 251 | }) |
| 158 | 252 | } |
| 159 | 253 |
crates/ui/src/lib.rs+169-26| ... | ... | @@ -13,7 +13,10 @@ mod widgets; |
| 13 | 13 | |
| 14 | 14 | pub use list::{List, Row, Rows, list}; |
| 15 | 15 | pub use theme::{Menu, PopupMotion, Section, Shades, Shadow, Theme}; |
| 16 | pub use widgets::{button, check_box, edit_key, edit_modifiers, scrollbar, text_field}; | |
| 16 | pub use widgets::{ | |
| 17 | PaintedScroller, Scroller, ScrollerPart, Scrollers, button, check_box, edit_key, | |
| 18 | edit_modifiers, scrollbar, text_field, | |
| 19 | }; | |
| 17 | 20 | |
| 18 | 21 | use draw::{ |
| 19 | 22 | PathStyle, Primitive, RasterImage, Stroke, |
| ... | ... | @@ -38,7 +41,7 @@ use winit::{ |
| 38 | 41 | const HALF_LIFE: f32 = 0.03; |
| 39 | 42 | /// How far a box's shadow spreads, in logical pixels, and how far below the box it |
| 40 | 43 | /// falls; a popup's, floating higher, spreads and falls further. |
| 41 | const SHADOW: [f32; 2] = [3.0, 0.0]; | |
| 44 | pub const SHADOW: [f32; 2] = [3.0, 0.0]; | |
| 42 | 45 | const POPUP_SHADOW: [f32; 2] = [12.0, 4.0]; |
| 43 | 46 | /// Logical size of a box's icon, and its distance from the label. |
| 44 | 47 | const ICON: f32 = 16.0; |
| ... | ... | @@ -93,6 +96,13 @@ impl Default for Extent { |
| 93 | 96 | } |
| 94 | 97 | } |
| 95 | 98 | |
| 99 | /// Logical pixels, as `px`. | |
| 100 | impl From<f32> for Extent { | |
| 101 | fn from(pixels: f32) -> Self { | |
| 102 | px(pixels) | |
| 103 | } | |
| 104 | } | |
| 105 | ||
| 96 | 106 | pub fn px(pixels: f32) -> Extent { |
| 97 | 107 | Extent { |
| 98 | 108 | size: Size::Pixels(pixels), |
| ... | ... | @@ -202,6 +212,9 @@ pub enum Anchor { |
| 202 | 212 | Point([f32; 2]), |
| 203 | 213 | /// Centred in the window over the interface, which dims, as a dialog opens. |
| 204 | 214 | Dialog, |
| 215 | /// Centred across the window near its top, swinging in as a dialog does, as a command | |
| 216 | /// palette opens. | |
| 217 | Top, | |
| 205 | 218 | } |
| 206 | 219 | |
| 207 | 220 | impl Anchor { |
| ... | ... | @@ -215,6 +228,8 @@ impl Anchor { |
| 215 | 228 | Anchor::Over(rect) => (rect, None), |
| 216 | 229 | Anchor::Point([x, y]) => ([x, y, x, y], Some(1)), |
| 217 | 230 | Anchor::Dialog => return ((room - size) / 2.0).max(0.0), |
| 231 | Anchor::Top if axis == 0 => return ((room - size) / 2.0).max(0.0), | |
| 232 | Anchor::Top => return room / 8.0, | |
| 218 | 233 | }; |
| 219 | 234 | let [low, high] = [rect[axis], rect[axis + 2]]; |
| 220 | 235 | let (first, second) = if along == Some(axis) { |
| ... | ... | @@ -231,31 +246,37 @@ impl Anchor { |
| 231 | 246 | } |
| 232 | 247 | } |
| 233 | 248 | |
| 234 | /// How a popup laid out at `rect` beside the anchor shows `open` of the way open: it | |
| 235 | /// grows out of the anchor as it fades in where it `grows`, and a dialog swings up into | |
| 236 | /// place. | |
| 249 | /// How a popup laid out at `rect` beside the anchor shows `open` of the way open: where it | |
| 250 | /// `grows`, it swings out of the anchor's edge as it grows and fades in, and a dialog swings | |
| 251 | /// up into place. | |
| 237 | 252 | fn motion(self, rect: [f32; 4], open: f32, grows: bool) -> Motion { |
| 238 | 253 | let pivot = match self { |
| 239 | 254 | Anchor::Below(anchor) => [anchor[0], anchor[3]], |
| 240 | 255 | Anchor::Right(anchor) => [anchor[2], anchor[1]], |
| 241 | 256 | Anchor::Over(anchor) => [anchor[0], anchor[1]], |
| 242 | 257 | Anchor::Point(point) => point, |
| 243 | Anchor::Dialog => [(rect[0] + rect[2]) / 2.0, rect[1]], | |
| 258 | Anchor::Dialog | Anchor::Top => [(rect[0] + rect[2]) / 2.0, rect[1]], | |
| 244 | 259 | }; |
| 245 | 260 | let (from, tilt) = match self { |
| 246 | 261 | _ if !grows => (1.0, 0.0), |
| 247 | Anchor::Dialog => (0.95, 0.4), | |
| 262 | Anchor::Dialog | Anchor::Top => (0.95, 0.2), | |
| 248 | 263 | // Over a box, the popup widens out of it in layout instead of growing. |
| 249 | 264 | Anchor::Over(_) => (1.0, 0.2), |
| 250 | Anchor::Point(_) => (0.94, 0.2), | |
| 251 | _ => (0.94, 0.0), | |
| 265 | _ => (0.94, 0.2), | |
| 266 | }; | |
| 267 | let pivot = [ | |
| 268 | pivot[0].clamp(rect[0], rect[2]), | |
| 269 | pivot[1].clamp(rect[1], rect[3]), | |
| 270 | ]; | |
| 271 | // A popup flipped above its anchor swings out of its bottom edge, its top leaning away. | |
| 272 | let tilt = if pivot[1] > (rect[1] + rect[3]) / 2.0 { | |
| 273 | -tilt | |
| 274 | } else { | |
| 275 | tilt | |
| 252 | 276 | }; |
| 253 | 277 | Motion { |
| 254 | 278 | zoom: from + (1.0 - from) * open, |
| 255 | pivot: [ | |
| 256 | pivot[0].clamp(rect[0], rect[2]), | |
| 257 | pivot[1].clamp(rect[1], rect[3]), | |
| 258 | ], | |
| 279 | pivot, | |
| 259 | 280 | tilt: tilt * (1.0 - open), |
| 260 | 281 | opacity: open, |
| 261 | 282 | } |
| ... | ... | @@ -263,7 +284,7 @@ impl Anchor { |
| 263 | 284 | |
| 264 | 285 | /// Seconds the popup takes to open and to close. |
| 265 | 286 | fn durations(self) -> [f32; 2] { |
| 266 | if self == Anchor::Dialog { | |
| 287 | if matches!(self, Anchor::Dialog | Anchor::Top) { | |
| 267 | 288 | DIALOG |
| 268 | 289 | } else { |
| 269 | 290 | POPUP |
| ... | ... | @@ -332,6 +353,16 @@ pub struct Spec<'a> { |
| 332 | 353 | /// Floats the box over all others beside a rectangle in the window, as a popup |
| 333 | 354 | /// `Ui::open_popup` opened; boxes beneath take no input while one is open. |
| 334 | 355 | pub anchor: Option<Anchor>, |
| 356 | /// Makes the box a group of a row in two forms, its first two children, both built every | |
| 357 | /// frame: its full form, and the one it folds to where the row lacks room. The row folds | |
| 358 | /// its groups by ascending priority until it fits, once the space sized by ancestors has | |
| 359 | /// yielded and before any other box gives up room. The form not shown takes no room, | |
| 360 | /// paint or input, and its boxes take the group's rectangle, so what opens from them | |
| 361 | /// opens from the form shown. | |
| 362 | pub fold: Option<u32>, | |
| 363 | /// How far the children fade out into the box's fill towards its leading and trailing | |
| 364 | /// edges, as a row of them cut there does. | |
| 365 | pub fade: [f32; 2], | |
| 335 | 366 | } |
| 336 | 367 | |
| 337 | 368 | /// Input the host forwards; positions and wheel distances are logical pixels. |
| ... | ... | @@ -387,6 +418,8 @@ pub enum Layer<'a> { |
| 387 | 418 | /// opening or closing. |
| 388 | 419 | pub struct Primitives<'a> { |
| 389 | 420 | pub clip: Option<[f32; 4]>, |
| 421 | /// A rounded rectangle and its corner radius the primitives paint only inside. | |
| 422 | round: Option<([f32; 4], f32)>, | |
| 390 | 423 | motion: Option<Motion>, |
| 391 | 424 | pub primitives: Vec<Primitive<'a>>, |
| 392 | 425 | } |
| ... | ... | @@ -411,14 +444,18 @@ impl Primitives<'_> { |
| 411 | 444 | pivot[axis] + (point[axis] - pivot[axis]) * zoom |
| 412 | 445 | })) |
| 413 | 446 | }; |
| 447 | let moved_rect = |[left, top, right, bottom]: [f32; 4]| { | |
| 448 | let [[left, top], [right, bottom]] = [moved([left, top]), moved([right, bottom])]; | |
| 449 | [left, top, right, bottom] | |
| 450 | }; | |
| 414 | 451 | draw::Layer { |
| 415 | 452 | scale: scale * zoom, |
| 416 | 453 | origin: moved([0.0; 2]), |
| 417 | clip: self.clip.map(|[left, top, right, bottom]| { | |
| 418 | let [[left, top], [right, bottom]] = [moved([left, top]), moved([right, bottom])]; | |
| 419 | [left, top, right, bottom] | |
| 420 | }), | |
| 454 | clip: self.clip.map(moved_rect), | |
| 421 | 455 | backdrop: None, |
| 456 | round: self | |
| 457 | .round | |
| 458 | .map(|(rect, radius)| (moved_rect(rect), radius * scale * zoom)), | |
| 422 | 459 | motion: self.motion.map(|_| draw::Motion { |
| 423 | 460 | opacity, |
| 424 | 461 | tilt, |
| ... | ... | @@ -462,6 +499,11 @@ struct Built { |
| 462 | 499 | motion: Option<Motion>, |
| 463 | 500 | /// Rectangles relative to the box, painted over its fill. |
| 464 | 501 | marks: Vec<([f32; 4], [f32; 4], f32)>, |
| 502 | fold: Option<u32>, | |
| 503 | fade: [f32; 2], | |
| 504 | /// Folded away with the form it belongs to: it takes no room, paint or input, and takes | |
| 505 | /// its group's rectangle. | |
| 506 | hidden: bool, | |
| 465 | 507 | computed: [f32; 2], |
| 466 | 508 | relative: [f32; 2], |
| 467 | 509 | rect: [f32; 4], |
| ... | ... | @@ -486,6 +528,8 @@ struct State { |
| 486 | 528 | select_all: bool, |
| 487 | 529 | /// Where a dragged scrollbar thumb was taken, from its start. |
| 488 | 530 | grab: f32, |
| 531 | /// A system scroller's part held down, and when a held arrow or track next repeats. | |
| 532 | held: Option<(ScrollerPart, Instant)>, | |
| 489 | 533 | /// An animated value and its target, for `Ui::animate`. |
| 490 | 534 | tween: Option<[f32; 2]>, |
| 491 | 535 | } |
| ... | ... | @@ -531,6 +575,8 @@ struct Hit { |
| 531 | 575 | #[derive(Clone)] |
| 532 | 576 | enum Display { |
| 533 | 577 | Clip(Option<[f32; 4]>), |
| 578 | /// A rounded rectangle and its corner radius, clipping what follows. | |
| 579 | Round(Option<([f32; 4], f32)>), | |
| 534 | 580 | Motion(Option<Motion>), |
| 535 | 581 | Rect { |
| 536 | 582 | rect: [f32; 4], |
| ... | ... | @@ -582,6 +628,9 @@ pub struct Ui { |
| 582 | 628 | /// Whether the window has keyboard focus; without it a field hides its caret and dims |
| 583 | 629 | /// its selection. |
| 584 | 630 | pub window_focused: bool, |
| 631 | /// The platform's scrollers, where it draws fixed ones beside the content; scrollbars | |
| 632 | /// overlay the content otherwise. | |
| 633 | pub scrollers: Option<Scrollers>, | |
| 585 | 634 | frame: u64, |
| 586 | 635 | now: Instant, |
| 587 | 636 | scale: f32, |
| ... | ... | @@ -608,6 +657,8 @@ pub struct Ui { |
| 608 | 657 | modifiers: ModifiersState, |
| 609 | 658 | clicks: Clicks, |
| 610 | 659 | display: Vec<Display>, |
| 660 | /// The painting of what holds still in the popup being painted, which goes over it. | |
| 661 | still: Vec<Display>, | |
| 611 | 662 | /// Where the popups' painting starts in `display`, for edges drawn beneath them. |
| 612 | 663 | popups_painted: usize, |
| 613 | 664 | /// Each popup painted, its anchor and rectangle, and its painting's place after |
| ... | ... | @@ -634,6 +685,7 @@ impl Ui { |
| 634 | 685 | theme, |
| 635 | 686 | icon_palette: draw::Palette::default(), |
| 636 | 687 | window_focused: true, |
| 688 | scrollers: None, | |
| 637 | 689 | frame: 0, |
| 638 | 690 | now: Instant::now(), |
| 639 | 691 | scale: 1.0, |
| ... | ... | @@ -656,6 +708,7 @@ impl Ui { |
| 656 | 708 | modifiers: ModifiersState::empty(), |
| 657 | 709 | clicks: Clicks::new(double_click), |
| 658 | 710 | display: Vec::new(), |
| 711 | still: Vec::new(), | |
| 659 | 712 | popups_painted: 0, |
| 660 | 713 | painted: Vec::new(), |
| 661 | 714 | closing: Vec::new(), |
| ... | ... | @@ -1188,6 +1241,15 @@ impl Ui { |
| 1188 | 1241 | } |
| 1189 | 1242 | let from = self.display.len() - self.popups_painted; |
| 1190 | 1243 | self.paint(index, None, None); |
| 1244 | if !self.still.is_empty() { | |
| 1245 | let still = std::mem::take(&mut self.still); | |
| 1246 | self.display.extend(still); | |
| 1247 | self.display.extend([ | |
| 1248 | Display::Motion(None), | |
| 1249 | Display::Clip(None), | |
| 1250 | Display::Round(None), | |
| 1251 | ]); | |
| 1252 | } | |
| 1191 | 1253 | painted.push(( |
| 1192 | 1254 | id, |
| 1193 | 1255 | anchor, |
| ... | ... | @@ -1279,9 +1341,10 @@ impl Ui { |
| 1279 | 1341 | }) |
| 1280 | 1342 | } |
| 1281 | 1343 | |
| 1282 | /// How popups beside `anchor` open and close; dialogs always swing as Snowbound's own. | |
| 1344 | /// How popups beside `anchor` open and close; dialogs and the palette always swing as | |
| 1345 | /// Snowbound's own. | |
| 1283 | 1346 | fn popup_motion(&self, anchor: Anchor) -> PopupMotion { |
| 1284 | if anchor == Anchor::Dialog { | |
| 1347 | if matches!(anchor, Anchor::Dialog | Anchor::Top) { | |
| 1285 | 1348 | PopupMotion::Grow |
| 1286 | 1349 | } else { |
| 1287 | 1350 | self.theme.menu().motion |
| ... | ... | @@ -1311,6 +1374,18 @@ impl Ui { |
| 1311 | 1374 | } |
| 1312 | 1375 | |
| 1313 | 1376 | fn paint(&mut self, index: usize, clip: Option<[f32; 4]>, motion: Option<Motion>) { |
| 1377 | // Painted over the popup it holds still in, which appears as one picture beneath. | |
| 1378 | if self.nodes[index].flags.contains(Flags::STILL) && motion.is_some() { | |
| 1379 | let from = self.display.len(); | |
| 1380 | self.paint_box(index, clip, motion); | |
| 1381 | let still = self.display.split_off(from); | |
| 1382 | self.still.extend(still); | |
| 1383 | } else { | |
| 1384 | self.paint_box(index, clip, motion); | |
| 1385 | } | |
| 1386 | } | |
| 1387 | ||
| 1388 | fn paint_box(&mut self, index: usize, clip: Option<[f32; 4]>, motion: Option<Motion>) { | |
| 1314 | 1389 | let node = &self.nodes[index]; |
| 1315 | 1390 | // The motion the box and its children paint with, where it changes it. |
| 1316 | 1391 | let own = if node.flags.contains(Flags::STILL) { |
| ... | ... | @@ -1485,7 +1560,9 @@ impl Ui { |
| 1485 | 1560 | }); |
| 1486 | 1561 | } |
| 1487 | 1562 | } |
| 1488 | let inner_clip = if node.flags.contains(Flags::CLIP) { | |
| 1563 | // A popup's contents show only inside its rounded outline. | |
| 1564 | let round = node.anchor.map(|_| (rect, node.radius)); | |
| 1565 | let inner_clip = if node.flags.contains(Flags::CLIP) || round.is_some() { | |
| 1489 | 1566 | Some(visible.unwrap_or([rect[0], rect[1], rect[0], rect[1]])) |
| 1490 | 1567 | } else { |
| 1491 | 1568 | clip |
| ... | ... | @@ -1493,11 +1570,18 @@ impl Ui { |
| 1493 | 1570 | if inner_clip != clip { |
| 1494 | 1571 | self.display.push(Display::Clip(inner_clip)); |
| 1495 | 1572 | } |
| 1573 | if round.is_some() { | |
| 1574 | self.display.push(Display::Round(round)); | |
| 1575 | } | |
| 1496 | 1576 | for child in node.children.clone() { |
| 1497 | if self.nodes[child].anchor.is_none() { | |
| 1577 | if self.nodes[child].anchor.is_none() && !self.nodes[child].hidden { | |
| 1498 | 1578 | self.paint(child, inner_clip, own.unwrap_or(motion)); |
| 1499 | 1579 | } |
| 1500 | 1580 | } |
| 1581 | if round.is_some() { | |
| 1582 | self.display.push(Display::Round(None)); | |
| 1583 | } | |
| 1584 | self.fade(index); | |
| 1501 | 1585 | if inner_clip != clip { |
| 1502 | 1586 | self.display.push(Display::Clip(clip)); |
| 1503 | 1587 | } |
| ... | ... | @@ -1506,21 +1590,69 @@ impl Ui { |
| 1506 | 1590 | } |
| 1507 | 1591 | } |
| 1508 | 1592 | |
| 1593 | /// Fades box `index`'s children out towards its ends by its `fade`, in steps of a point, | |
| 1594 | /// into its fill however opaque that is: each step clears what lies beneath towards | |
| 1595 | /// transparency and lays the fill over it. | |
| 1596 | fn fade(&mut self, index: usize) { | |
| 1597 | let node = &self.nodes[index]; | |
| 1598 | let [left, top, right, bottom] = node.rect; | |
| 1599 | let fill = node.fill.unwrap_or_default(); | |
| 1600 | let mut steps = Vec::new(); | |
| 1601 | for (side, width) in node.fade.into_iter().enumerate() { | |
| 1602 | let count = width.round() as usize; | |
| 1603 | for step in 0..count { | |
| 1604 | // Through the step's middle, eased so the fade leaves the children softly. | |
| 1605 | let alpha = 1.0 - (step as f32 + 0.5) / count as f32; | |
| 1606 | let alpha = alpha * alpha * (3.0 - 2.0 * alpha); | |
| 1607 | let x = if side == 0 { | |
| 1608 | left + step as f32 | |
| 1609 | } else { | |
| 1610 | right - 1.0 - step as f32 | |
| 1611 | }; | |
| 1612 | steps.push(([x, top, x + 1.0, bottom], alpha)); | |
| 1613 | } | |
| 1614 | } | |
| 1615 | let data = format!("M0 0H1V{}H0Z", bottom - top); | |
| 1616 | for (rect, alpha) in steps { | |
| 1617 | // Laid over what the erasing leaves, the fill's share makes up the rest of the | |
| 1618 | // children's lost opacity: all of it where the fill is opaque, none where clear. | |
| 1619 | let laid = alpha * fill[3]; | |
| 1620 | if laid < 1.0 { | |
| 1621 | self.display.push(Display::Path { | |
| 1622 | data: data.clone(), | |
| 1623 | origin: [rect[0], rect[1]], | |
| 1624 | style: PathStyle::Erase, | |
| 1625 | colors: [[0.0, 0.0, 0.0, 1.0 - (1.0 - alpha) / (1.0 - laid)]; 2], | |
| 1626 | }); | |
| 1627 | } | |
| 1628 | self.display.push(Display::Rect { | |
| 1629 | rect, | |
| 1630 | fill: [fill[0], fill[1], fill[2], laid], | |
| 1631 | shade: None, | |
| 1632 | border: None, | |
| 1633 | radius: 0.0, | |
| 1634 | }); | |
| 1635 | } | |
| 1636 | } | |
| 1637 | ||
| 1509 | 1638 | /// This frame's painting in order, in logical pixels. |
| 1510 | 1639 | pub fn layers(&self) -> Vec<Layer<'_>> { |
| 1511 | 1640 | let mut layers = Vec::new(); |
| 1512 | 1641 | let mut clip = None; |
| 1642 | let mut round = None; | |
| 1513 | 1643 | let mut motion = None; |
| 1514 | 1644 | let mut primitives = Vec::new(); |
| 1515 | 1645 | fn flush<'a>( |
| 1516 | 1646 | layers: &mut Vec<Layer<'a>>, |
| 1517 | 1647 | clip: Option<[f32; 4]>, |
| 1648 | round: Option<([f32; 4], f32)>, | |
| 1518 | 1649 | motion: Option<Motion>, |
| 1519 | 1650 | primitives: &mut Vec<Primitive<'a>>, |
| 1520 | 1651 | ) { |
| 1521 | 1652 | if !primitives.is_empty() { |
| 1522 | 1653 | layers.push(Layer::Primitives(Primitives { |
| 1523 | 1654 | clip, |
| 1655 | round, | |
| 1524 | 1656 | motion, |
| 1525 | 1657 | primitives: std::mem::take(primitives), |
| 1526 | 1658 | })); |
| ... | ... | @@ -1529,11 +1661,15 @@ impl Ui { |
| 1529 | 1661 | for item in &self.display { |
| 1530 | 1662 | match item { |
| 1531 | 1663 | Display::Clip(next) => { |
| 1532 | flush(&mut layers, clip, motion, &mut primitives); | |
| 1664 | flush(&mut layers, clip, round, motion, &mut primitives); | |
| 1533 | 1665 | clip = *next; |
| 1534 | 1666 | } |
| 1667 | Display::Round(next) => { | |
| 1668 | flush(&mut layers, clip, round, motion, &mut primitives); | |
| 1669 | round = *next; | |
| 1670 | } | |
| 1535 | 1671 | Display::Motion(next) => { |
| 1536 | flush(&mut layers, clip, motion, &mut primitives); | |
| 1672 | flush(&mut layers, clip, round, motion, &mut primitives); | |
| 1537 | 1673 | motion = *next; |
| 1538 | 1674 | } |
| 1539 | 1675 | Display::Rect { |
| ... | ... | @@ -1617,7 +1753,7 @@ impl Ui { |
| 1617 | 1753 | ink: *color, |
| 1618 | 1754 | }), |
| 1619 | 1755 | Display::Custom { id, rect } => { |
| 1620 | flush(&mut layers, clip, motion, &mut primitives); | |
| 1756 | flush(&mut layers, clip, round, motion, &mut primitives); | |
| 1621 | 1757 | layers.push(Layer::Custom { |
| 1622 | 1758 | id: *id, |
| 1623 | 1759 | rect: *rect, |
| ... | ... | @@ -1625,7 +1761,7 @@ impl Ui { |
| 1625 | 1761 | } |
| 1626 | 1762 | } |
| 1627 | 1763 | } |
| 1628 | flush(&mut layers, clip, motion, &mut primitives); | |
| 1764 | flush(&mut layers, clip, round, motion, &mut primitives); | |
| 1629 | 1765 | layers |
| 1630 | 1766 | } |
| 1631 | 1767 | |
| ... | ... | @@ -1648,6 +1784,10 @@ impl Ui { |
| 1648 | 1784 | pub(crate) fn grab(&mut self, id: Id) -> &mut f32 { |
| 1649 | 1785 | &mut self.states.entry(id).or_default().grab |
| 1650 | 1786 | } |
| 1787 | ||
| 1788 | pub(crate) fn held(&mut self, id: Id) -> &mut Option<(ScrollerPart, Instant)> { | |
| 1789 | &mut self.states.entry(id).or_default().held | |
| 1790 | } | |
| 1651 | 1791 | } |
| 1652 | 1792 | |
| 1653 | 1793 | impl Built { |
| ... | ... | @@ -1699,6 +1839,9 @@ impl Built { |
| 1699 | 1839 | open: 1.0, |
| 1700 | 1840 | motion: None, |
| 1701 | 1841 | marks: Vec::new(), |
| 1842 | fold: spec.fold, | |
| 1843 | fade: spec.fade, | |
| 1844 | hidden: false, | |
| 1702 | 1845 | computed: [0.0; 2], |
| 1703 | 1846 | relative: [0.0; 2], |
| 1704 | 1847 | rect: [0.0; 4], |
crates/ui/src/list.rs+5-5| ... | ... | @@ -189,18 +189,18 @@ pub fn list<R: Rows>( |
| 189 | 189 | let scroll = state.scroll; |
| 190 | 190 | ui.animating |= scroll != state.target; |
| 191 | 191 | |
| 192 | // Rows fill all but a gutter for the scrollbar, from their first frame: the padding | |
| 193 | // narrows what they fill, and they float from the corner. | |
| 194 | let gutter = if most > 0.0 { GUTTER / 2.0 } else { 0.0 }; | |
| 192 | 195 | ui.open_as( |
| 193 | 196 | id, |
| 194 | 197 | Spec { |
| 195 | 198 | flags: spec.flags | Flags::SCROLL | Flags::CLIP, |
| 199 | pad: [spec.pad[0] + gutter, spec.pad[1]], | |
| 196 | 200 | ..spec |
| 197 | 201 | }, |
| 198 | 202 | ); |
| 199 | // Rows leave the scrollbar a gutter. | |
| 200 | let width = match rect { | |
| 201 | Some(rect) if most > 0.0 => px(rect[2] - rect[0] - GUTTER), | |
| 202 | _ => fill(), | |
| 203 | }; | |
| 203 | let width = fill(); | |
| 204 | 204 | let in_view = row_at(scroll)..(row_at(scroll + view) + 1).min(len); |
| 205 | 205 | let mut shown = Vec::with_capacity(in_view.len()); |
| 206 | 206 | for index in in_view { |
crates/ui/src/popup.rs+47-41| ... | ... | @@ -209,24 +209,15 @@ pub fn tooltip(ui: &mut Ui, title: &str, keys: &str, description: Option<&str>) |
| 209 | 209 | } |
| 210 | 210 | |
| 211 | 211 | /// Builds popup `id` as a command palette across the top of the window while it is open, |
| 212 | /// listing `commands` that match what is typed, most relevant first. Returns the index of | |
| 213 | /// the command chosen. | |
| 214 | pub fn palette(ui: &mut Ui, id: Id, commands: &[Item]) -> Option<usize> { | |
| 212 | /// listing `items` that match what is typed, most relevant first, under a filter field | |
| 213 | /// showing `placeholder` while empty. Returns the index of the item chosen. | |
| 214 | pub fn palette(ui: &mut Ui, id: Id, items: &[Item], placeholder: &str) -> Option<usize> { | |
| 215 | 215 | if !ui.popup_open(id) { |
| 216 | 216 | return None; |
| 217 | 217 | } |
| 218 | 218 | let window = ui.rect(Id::ROOT).unwrap_or_default(); |
| 219 | 219 | let width = PALETTE.min(window[2] - 8.0 * PAD).max(NARROWEST); |
| 220 | let [left, top] = [(window[2] - width) / 2.0, window[3] / 8.0]; | |
| 221 | choose( | |
| 222 | ui, | |
| 223 | id, | |
| 224 | Anchor::Below([left, top, left, top]), | |
| 225 | commands, | |
| 226 | Some("Search commands"), | |
| 227 | width, | |
| 228 | ROW, | |
| 229 | ) | |
| 220 | choose(ui, id, Anchor::Top, items, Some(placeholder), width, ROW) | |
| 230 | 221 | } |
| 231 | 222 | |
| 232 | 223 | fn choose( |
| ... | ... | @@ -694,26 +685,42 @@ pub fn table_picker(ui: &mut Ui, id: Id, anchor: Anchor, size: [usize; 2]) -> Op |
| 694 | 685 | None |
| 695 | 686 | } |
| 696 | 687 | |
| 697 | /// Builds popup `id` as a gallery beside `anchor` while it is open: `groups` of cells, each | |
| 698 | /// a heading and a count, in rows of `columns` cells `size` large, with cell `current` | |
| 699 | /// outlined. `cell` builds a cell's contents from its index, counted through the groups. | |
| 700 | /// Returns the index of the cell chosen. | |
| 701 | #[allow(clippy::too_many_arguments)] | |
| 688 | /// A gallery's run of `cells` under `heading`, in rows of `columns` cells `size` large. | |
| 689 | #[derive(Clone, Copy, Debug)] | |
| 690 | pub struct Group<'a> { | |
| 691 | pub heading: &'a str, | |
| 692 | pub cells: usize, | |
| 693 | pub columns: usize, | |
| 694 | pub size: [f32; 2], | |
| 695 | } | |
| 696 | ||
| 697 | /// Builds popup `id` as a gallery beside `anchor` while it is open: `groups` of cells, with | |
| 698 | /// the cells in `current` outlined. `cell` builds a cell's contents from its index, counted | |
| 699 | /// through the groups. Returns the index of the cell chosen. | |
| 702 | 700 | pub fn gallery( |
| 703 | 701 | ui: &mut Ui, |
| 704 | 702 | id: Id, |
| 705 | 703 | anchor: Anchor, |
| 706 | groups: &[(&str, usize)], | |
| 707 | columns: usize, | |
| 708 | size: [f32; 2], | |
| 709 | current: Option<usize>, | |
| 704 | groups: &[Group], | |
| 705 | current: &[usize], | |
| 710 | 706 | mut cell: impl FnMut(&mut Ui, usize), |
| 711 | 707 | ) -> Option<usize> { |
| 712 | 708 | if !ui.popup_open(id) { |
| 713 | 709 | return None; |
| 714 | 710 | } |
| 715 | 711 | let cell_id = |index: usize| id.child(("cell", index)); |
| 716 | let count: usize = groups.iter().map(|(_, count)| count).sum(); | |
| 712 | let count: usize = groups.iter().map(|group| group.cells).sum(); | |
| 713 | // The columns of the group holding cell `at`. | |
| 714 | let columns = |at: usize| { | |
| 715 | let mut start = 0; | |
| 716 | groups | |
| 717 | .iter() | |
| 718 | .find(|group| { | |
| 719 | start += group.cells; | |
| 720 | at < start | |
| 721 | }) | |
| 722 | .map_or(1, |group| group.columns) | |
| 723 | }; | |
| 717 | 724 | let keys = navigation( |
| 718 | 725 | ui, |
| 719 | 726 | &[id], |
| ... | ... | @@ -737,7 +744,7 @@ pub fn gallery( |
| 737 | 744 | } |
| 738 | 745 | } |
| 739 | 746 | for key in keys { |
| 740 | let at = highlight.or(current).unwrap_or(0); | |
| 747 | let at = highlight.or(current.first().copied()).unwrap_or(0); | |
| 741 | 748 | highlight = Some(match key { |
| 742 | 749 | NamedKey::Enter => { |
| 743 | 750 | chosen = chosen.or(highlight); |
| ... | ... | @@ -745,8 +752,8 @@ pub fn gallery( |
| 745 | 752 | } |
| 746 | 753 | NamedKey::ArrowLeft => at.saturating_sub(1), |
| 747 | 754 | NamedKey::ArrowRight => (at + 1).min(count - 1), |
| 748 | NamedKey::ArrowUp => at.saturating_sub(columns), | |
| 749 | _ => (at + columns).min(count - 1), | |
| 755 | NamedKey::ArrowUp => at.saturating_sub(columns(at)), | |
| 756 | _ => (at + columns(at)).min(count - 1), | |
| 750 | 757 | }); |
| 751 | 758 | } |
| 752 | 759 | if let Some(index) = chosen { |
| ... | ... | @@ -754,19 +761,18 @@ pub fn gallery( |
| 754 | 761 | return Some(index); |
| 755 | 762 | } |
| 756 | 763 | let theme = ui.theme.clone(); |
| 757 | surface( | |
| 758 | ui, | |
| 759 | id, | |
| 760 | anchor, | |
| 761 | columns as f32 * size[0] + 2.0 * ui.theme.menu().pad, | |
| 762 | ); | |
| 764 | let width = groups | |
| 765 | .iter() | |
| 766 | .map(|group| group.columns as f32 * group.size[0]) | |
| 767 | .fold(0.0, f32::max); | |
| 768 | surface(ui, id, anchor, width + 2.0 * ui.theme.menu().pad); | |
| 763 | 769 | let mut index = 0; |
| 764 | for (group, (heading, cells)) in groups.iter().enumerate() { | |
| 770 | for (number, group) in groups.iter().enumerate() { | |
| 765 | 771 | ui.leaf( |
| 766 | ("heading", group), | |
| 772 | ("heading", number), | |
| 767 | 773 | Spec { |
| 768 | 774 | size: [fill(), px(MENU_ROW)], |
| 769 | text: Some(heading), | |
| 775 | text: Some(group.heading), | |
| 770 | 776 | font_size: Some(theme.font_size - 2.0), |
| 771 | 777 | bold: true, |
| 772 | 778 | color: Some(theme.text_dim), |
| ... | ... | @@ -774,23 +780,23 @@ pub fn gallery( |
| 774 | 780 | ..Spec::default() |
| 775 | 781 | }, |
| 776 | 782 | ); |
| 777 | for row in 0..cells.div_ceil(columns) { | |
| 783 | for row in 0..group.cells.div_ceil(group.columns) { | |
| 778 | 784 | ui.open( |
| 779 | ("row", group, row), | |
| 785 | ("row", number, row), | |
| 780 | 786 | Spec { |
| 781 | size: [fill(), px(size[1])], | |
| 787 | size: [fill(), px(group.size[1])], | |
| 782 | 788 | ..Spec::default() |
| 783 | 789 | }, |
| 784 | 790 | ); |
| 785 | for _ in 0..columns.min(cells - row * columns) { | |
| 791 | for _ in 0..group.columns.min(group.cells - row * group.columns) { | |
| 786 | 792 | ui.open_as( |
| 787 | 793 | cell_id(index), |
| 788 | 794 | Spec { |
| 789 | 795 | flags: Flags::CLICKABLE, |
| 790 | 796 | axis: Axis::Y, |
| 791 | size: [px(size[0]), px(size[1])], | |
| 797 | size: [px(group.size[0]), px(group.size[1])], | |
| 792 | 798 | fill: (highlight == Some(index)).then(|| theme.hover()), |
| 793 | border: (current == Some(index)).then_some(theme.accent), | |
| 799 | border: current.contains(&index).then_some(theme.accent), | |
| 794 | 800 | radius: 4.0, |
| 795 | 801 | pad: [PAD, PAD], |
| 796 | 802 | ..Spec::default() |
crates/ui/src/shell.rs+71-24| ... | ... | @@ -4,7 +4,8 @@ |
| 4 | 4 | use draw::{PathStyle, edit::SelectionUnit}; |
| 5 | 5 | |
| 6 | 6 | use crate::{ |
| 7 | Anchor, Corner, Display, Flags, Id, Section, Shape, Signal, Spec, Ui, children, fill, mix, px, | |
| 7 | Anchor, Corner, Display, Extent, Flags, Id, Section, Shape, Signal, Spec, Ui, children, fill, | |
| 8 | mix, px, | |
| 8 | 9 | }; |
| 9 | 10 | use std::hash::Hash; |
| 10 | 11 | |
| ... | ... | @@ -18,34 +19,34 @@ pub const TOOL: f32 = 22.0; |
| 18 | 19 | const ARROW: f32 = 10.0; |
| 19 | 20 | /// Where a section tab's label starts inside it. |
| 20 | 21 | pub const TAB_PAD: f32 = 10.0; |
| 22 | /// How far section tabs fade out at an end of their row they are cut at, and how near it a | |
| 23 | /// dragged tab scrolls the row on. | |
| 24 | const EDGE: f32 = 20.0; | |
| 21 | 25 | |
| 22 | 26 | /// Section tabs in a row `height` tall, as OneNote draws them: each leans over the next |
| 23 | 27 | /// at 45°, and the open one lies on top in `section`'s colours, rising to meet the frame |
| 24 | 28 | /// below as its outline and shadow fade in. `tabs` are names and section colours; `lit` |
| 25 | 29 | /// shows as hovered, as a tab something is dragged onto. A `dragged` tab follows the |
| 26 | 30 | /// pointer, lifted over the others, which slide aside to open the gap it would land in; |
| 27 | /// each eases to its place once let go. | |
| 31 | /// each eases to its place once let go, and near the row's ends the row scrolls on. | |
| 32 | /// | |
| 33 | /// The row is as wide as the tabs, and gives up all of that where its own row lacks room. | |
| 34 | /// Then the tabs scroll sideways under the wheel, either way it turns, fading out at the | |
| 35 | /// ends they are cut at into the row's `fill`, and the open tab scrolls into view as it | |
| 36 | /// opens. | |
| 28 | 37 | #[allow(clippy::too_many_arguments)] |
| 29 | 38 | pub fn section_tabs( |
| 30 | 39 | ui: &mut Ui, |
| 31 | part: impl Hash, | |
| 40 | row: Id, | |
| 32 | 41 | tabs: &[(&str, [f32; 4])], |
| 33 | 42 | active: usize, |
| 34 | 43 | lit: Option<usize>, |
| 35 | 44 | dragged: Option<Dragged>, |
| 36 | 45 | section: &Section, |
| 37 | 46 | height: f32, |
| 47 | fill: [f32; 4], | |
| 38 | 48 | ) -> Tabs { |
| 39 | 49 | let theme = ui.theme.clone(); |
| 40 | ui.open( | |
| 41 | part, | |
| 42 | Spec { | |
| 43 | flags: Flags::CLIP, | |
| 44 | size: [fill(), px(height)], | |
| 45 | ..Spec::default() | |
| 46 | }, | |
| 47 | ); | |
| 48 | let [low, tallest] = [height - 6.0, height - 2.0]; | |
| 49 | 50 | // Room for the first tab's outline and shadow, which the row would clip. |
| 50 | 51 | let mut left = crate::SHADOW[0]; |
| 51 | 52 | let placed: Vec<_> = tabs |
| ... | ... | @@ -56,14 +57,65 @@ pub fn section_tabs( |
| 56 | 57 | (left - width, width) |
| 57 | 58 | }) |
| 58 | 59 | .collect(); |
| 60 | // The last tab's slant and shadow reach past its box. | |
| 61 | let wide = left + lean(height) + crate::SHADOW[0]; | |
| 62 | let view = ui.rect(row).map_or(wide, |rect| rect[2] - rect[0]); | |
| 63 | let most = (wide - view).max(0.0); | |
| 64 | let [scroll, shown] = [row.child("scroll"), row.child("shown")]; | |
| 65 | let held_value = |ui: &Ui, id: Id| ui.states.get(&id).and_then(|state| state.tween); | |
| 66 | let mut target = held_value(ui, scroll).map_or(0.0, |[_, target]| target); | |
| 67 | for event in ui.signal(row).events { | |
| 68 | if let crate::Event::Wheel([x, y]) = event { | |
| 69 | target -= x + y; | |
| 70 | } | |
| 71 | } | |
| 72 | if held_value(ui, shown).is_none_or(|[_, was]| was != active as f32) | |
| 73 | && let Some((x, width)) = placed.get(active) | |
| 74 | { | |
| 75 | target = target.min(*x).max(x + width + lean(height) - view); | |
| 76 | } | |
| 77 | ui.hold(shown, active as f32); | |
| 78 | // A tab dragged near either end scrolls the row on, as long as it is held there. | |
| 79 | let start = dragged.and_then(|dragged| { | |
| 80 | let start = dragged.start?; | |
| 81 | let width = placed[dragged.index].1; | |
| 82 | let push = (start - EDGE).min(0.0) + (start + width - view + EDGE).max(0.0); | |
| 83 | if push != 0.0 && (target > 0.0 || push > 0.0) && (target < most || push < 0.0) { | |
| 84 | target += push * ui.dt * 8.0; | |
| 85 | ui.animating = true; | |
| 86 | } | |
| 87 | Some(start) | |
| 88 | }); | |
| 89 | let target = ui.hold(scroll, target.clamp(0.0, most)); | |
| 90 | let offset = ui.animate(row.child("offset"), target); | |
| 91 | let fade = |cut: bool| if cut { EDGE } else { 0.0 }; | |
| 92 | ui.open_as( | |
| 93 | row, | |
| 94 | Spec { | |
| 95 | flags: Flags::CLIP | Flags::SCROLL, | |
| 96 | size: [ | |
| 97 | Extent { | |
| 98 | size: crate::Size::Pixels(wide), | |
| 99 | strictness: 0.0, | |
| 100 | }, | |
| 101 | px(height), | |
| 102 | ], | |
| 103 | fill: Some(fill), | |
| 104 | fade: [fade(offset > 0.5), fade(offset < most - 0.5)], | |
| 105 | ..Spec::default() | |
| 106 | }, | |
| 107 | ); | |
| 108 | let [low, tallest] = [height - 6.0, height - 2.0]; | |
| 59 | 109 | let (mut clicked, mut context, mut held, mut renamed) = (None, None, None, None); |
| 60 | 110 | let spans: Vec<[f32; 2]> = placed.iter().map(|(x, width)| [*x, *width]).collect(); |
| 111 | // The dragged tab's place along the tabs, however far they are scrolled. | |
| 112 | let start = start.map(|start| start + offset); | |
| 61 | 113 | let slot = dragged.and_then(|dragged| { |
| 62 | 114 | let width = placed[dragged.index].1; |
| 63 | 115 | Some(crate::drop_slot( |
| 64 | 116 | &spans, |
| 65 | 117 | dragged.index, |
| 66 | dragged.start? + width / 2.0, | |
| 118 | start? + width / 2.0, | |
| 67 | 119 | )) |
| 68 | 120 | }); |
| 69 | 121 | let lifted = dragged.map(|dragged| dragged.index); |
| ... | ... | @@ -79,14 +131,9 @@ pub fn section_tabs( |
| 79 | 131 | let (x, width) = placed[index]; |
| 80 | 132 | // Keyed by name, so a tab eases from where it stood when the tabs are reordered. |
| 81 | 133 | let key = ui.id(("x", name)); |
| 82 | let x = match (dragged, slot) { | |
| 83 | ( | |
| 84 | Some(Dragged { | |
| 85 | start: Some(start), .. | |
| 86 | }), | |
| 87 | _, | |
| 88 | ) if lifted == Some(index) => ui.hold(key, start), | |
| 89 | (Some(dragged), Some(slot)) => ui.animate( | |
| 134 | let x = match (dragged, slot, start) { | |
| 135 | (_, _, Some(start)) if lifted == Some(index) => ui.hold(key, start), | |
| 136 | (Some(dragged), Some(slot), _) => ui.animate( | |
| 90 | 137 | key, |
| 91 | 138 | x + crate::slide(index, dragged.index, slot, placed[dragged.index].1), |
| 92 | 139 | ), |
| ... | ... | @@ -110,7 +157,7 @@ pub fn section_tabs( |
| 110 | 157 | Spec { |
| 111 | 158 | flags: Flags::CLICKABLE | Flags::FLOAT, |
| 112 | 159 | size: [px(width), px(tall)], |
| 113 | position: [x, height - tall], | |
| 160 | position: [x - offset, height - tall], | |
| 114 | 161 | text: Some(name), |
| 115 | 162 | color: Some(mix(mix(theme.ink, fill, 0.2), theme.ink, open)), |
| 116 | 163 | fill: Some(if lit == Some(index) { |
| ... | ... | @@ -409,13 +456,13 @@ pub fn menu_button( |
| 409 | 456 | } |
| 410 | 457 | |
| 411 | 458 | /// A drop-down box `width` wide showing `text`. |
| 412 | pub fn combo(ui: &mut Ui, part: impl Hash, text: &str, width: f32) -> Signal { | |
| 459 | pub fn combo(ui: &mut Ui, part: impl Hash, text: &str, width: impl Into<Extent>) -> Signal { | |
| 413 | 460 | let theme = ui.theme.clone(); |
| 414 | 461 | let id = ui.open( |
| 415 | 462 | part, |
| 416 | 463 | Spec { |
| 417 | 464 | flags: Flags::CLICKABLE, |
| 418 | size: [px(width), px(TOOL)], | |
| 465 | size: [width.into(), px(TOOL)], | |
| 419 | 466 | fill: Some(theme.base), |
| 420 | 467 | hover_border: Some(theme.accent), |
| 421 | 468 | border: Some(theme.chip), |
crates/ui/src/tests.rs+340-30| ... | ... | @@ -127,6 +127,146 @@ fn children_sum_with_gaps_and_padding() { |
| 127 | 127 | assert_eq!(ui.rect(second.unwrap()), Some([4.0, 18.0, 34.0, 38.0])); |
| 128 | 128 | } |
| 129 | 129 | |
| 130 | /// A row `width` wide of a space, a box that yields all but a quarter of its 80 pixels, and | |
| 131 | /// groups 100 wide or 20 folded at each priority; returns the rectangles of the loose box and | |
| 132 | /// of each group's full and folded button. | |
| 133 | fn folding_row(ui: &mut Ui, width: f32, priorities: &[u32]) -> Vec<Option<[f32; 4]>> { | |
| 134 | let mut ids = Vec::new(); | |
| 135 | frame(ui, |ui| { | |
| 136 | ui.open( | |
| 137 | "row", | |
| 138 | Spec { | |
| 139 | size: [px(width), px(20.0)], | |
| 140 | ..Spec::default() | |
| 141 | }, | |
| 142 | ); | |
| 143 | ui.leaf( | |
| 144 | "space", | |
| 145 | Spec { | |
| 146 | size: [fill(), px(1.0)], | |
| 147 | ..Spec::default() | |
| 148 | }, | |
| 149 | ); | |
| 150 | let loose = Extent { | |
| 151 | size: Size::Pixels(80.0), | |
| 152 | strictness: 0.25, | |
| 153 | }; | |
| 154 | ids.push(ui.open( | |
| 155 | "loose", | |
| 156 | Spec { | |
| 157 | size: [loose, px(20.0)], | |
| 158 | ..Spec::default() | |
| 159 | }, | |
| 160 | )); | |
| 161 | ui.close(); | |
| 162 | for priority in priorities { | |
| 163 | let group = ui.open( | |
| 164 | priority, | |
| 165 | Spec { | |
| 166 | fold: Some(*priority), | |
| 167 | ..Spec::default() | |
| 168 | }, | |
| 169 | ); | |
| 170 | for (form, width) in [("full", 100.0), ("folded", 20.0)] { | |
| 171 | ui.open(form, Spec::default()); | |
| 172 | ui.leaf( | |
| 173 | "button", | |
| 174 | Spec { | |
| 175 | flags: Flags::CLICKABLE, | |
| 176 | size: [px(width), px(20.0)], | |
| 177 | ..Spec::default() | |
| 178 | }, | |
| 179 | ); | |
| 180 | ui.close(); | |
| 181 | } | |
| 182 | ui.close(); | |
| 183 | for form in ["full", "folded"] { | |
| 184 | ids.push(group.child(form).child("button")); | |
| 185 | } | |
| 186 | } | |
| 187 | ui.close(); | |
| 188 | }); | |
| 189 | ids.into_iter().map(|id| ui.rect(id)).collect() | |
| 190 | } | |
| 191 | ||
| 192 | #[test] | |
| 193 | fn a_row_folds_its_lowest_priorities_first_until_it_fits() { | |
| 194 | let mut ui = Ui::new(Theme::dark(), DOUBLE_CLICK); | |
| 195 | // 80 + 3 × 100 fits 380 with nothing folded or squeezed. | |
| 196 | let rects = folding_row(&mut ui, 380.0, &[2, 0, 1]); | |
| 197 | assert_eq!(rects[0], Some([0.0, 0.0, 80.0, 20.0])); | |
| 198 | assert_eq!(rects[1], Some([80.0, 0.0, 180.0, 20.0])); | |
| 199 | // At 300, folding priority 0 alone leaves 80 + 100 + 20 + 100; the full form's button | |
| 200 | // stands where the folded one shows. | |
| 201 | let rects = folding_row(&mut ui, 300.0, &[2, 0, 1]); | |
| 202 | assert_eq!(rects[1], Some([80.0, 0.0, 180.0, 20.0])); | |
| 203 | assert_eq!(rects[3..5], [Some([180.0, 0.0, 200.0, 20.0]); 2]); | |
| 204 | assert_eq!(rects[5], Some([200.0, 0.0, 300.0, 20.0])); | |
| 205 | } | |
| 206 | ||
| 207 | #[test] | |
| 208 | fn a_row_squeezes_its_boxes_only_once_every_group_is_folded() { | |
| 209 | let mut ui = Ui::new(Theme::dark(), DOUBLE_CLICK); | |
| 210 | let rects = folding_row(&mut ui, 130.0, &[0, 1]); | |
| 211 | // 80 + 20 + 20 fits 130 with 10 to spare, taken by the space. | |
| 212 | assert_eq!(rects[0], Some([0.0, 0.0, 80.0, 20.0])); | |
| 213 | let rects = folding_row(&mut ui, 100.0, &[0, 1]); | |
| 214 | assert_eq!(rects[0], Some([0.0, 0.0, 60.0, 20.0])); | |
| 215 | assert_eq!(rects[4], Some([80.0, 0.0, 100.0, 20.0])); | |
| 216 | } | |
| 217 | ||
| 218 | #[test] | |
| 219 | fn a_box_sized_by_its_children_yields_what_they_yield() { | |
| 220 | let mut ui = Ui::new(Theme::dark(), DOUBLE_CLICK); | |
| 221 | let mut combo = None; | |
| 222 | frame(&mut ui, |ui| { | |
| 223 | ui.open( | |
| 224 | "row", | |
| 225 | Spec { | |
| 226 | size: [px(100.0), px(20.0)], | |
| 227 | ..Spec::default() | |
| 228 | }, | |
| 229 | ); | |
| 230 | ui.open("group", Spec::default()); | |
| 231 | let loose = Extent { | |
| 232 | size: Size::Pixels(80.0), | |
| 233 | strictness: 0.5, | |
| 234 | }; | |
| 235 | combo = Some(ui.open( | |
| 236 | "combo", | |
| 237 | Spec { | |
| 238 | size: [loose, px(20.0)], | |
| 239 | ..Spec::default() | |
| 240 | }, | |
| 241 | )); | |
| 242 | ui.close(); | |
| 243 | ui.leaf( | |
| 244 | "button", | |
| 245 | Spec { | |
| 246 | size: [px(40.0), px(20.0)], | |
| 247 | ..Spec::default() | |
| 248 | }, | |
| 249 | ); | |
| 250 | ui.close(); | |
| 251 | ui.close(); | |
| 252 | }); | |
| 253 | assert_eq!(ui.rect(combo.unwrap()), Some([0.0, 0.0, 60.0, 20.0])); | |
| 254 | } | |
| 255 | ||
| 256 | #[test] | |
| 257 | fn a_folded_away_form_takes_no_input() { | |
| 258 | let mut ui = Ui::new(Theme::dark(), DOUBLE_CLICK); | |
| 259 | folding_row(&mut ui, 110.0, &[0]); | |
| 260 | let folded = Id::ROOT | |
| 261 | .child("row") | |
| 262 | .child(0_u32) | |
| 263 | .child("folded") | |
| 264 | .child("button"); | |
| 265 | assert_eq!(ui.box_at([90.0, 10.0]), Some(folded)); | |
| 266 | // Where the full form's button would lie past the folded one. | |
| 267 | assert_eq!(ui.box_at([150.0, 10.0]), None); | |
| 268 | } | |
| 269 | ||
| 130 | 270 | fn button_frame(ui: &mut Ui) -> Signal { |
| 131 | 271 | let mut signal = Signal::default(); |
| 132 | 272 | frame(ui, |ui| { |
| ... | ... | @@ -709,6 +849,86 @@ fn scrollbar_thumbs_track_the_offset_and_drags_reach_both_ends() { |
| 709 | 849 | assert_eq!(offset, 0.0); |
| 710 | 850 | } |
| 711 | 851 | |
| 852 | #[test] | |
| 853 | fn system_scrollers_step_page_and_drag_by_the_parts_the_platform_paints() { | |
| 854 | let mut ui = Ui::new(Theme::dark(), DOUBLE_CLICK); | |
| 855 | // Arrows together at the far end, as 10.6 places them. | |
| 856 | ui.scrollers = Some(Scrollers { | |
| 857 | thickness: 15.0, | |
| 858 | paint: Box::new(|request: &Scroller| { | |
| 859 | let slot = request.length - 30.0; | |
| 860 | let knob = slot * request.proportion; | |
| 861 | let start = request.value * (slot - knob); | |
| 862 | PaintedScroller { | |
| 863 | image: RasterImage::new( | |
| 864 | [15, request.length as u32], | |
| 865 | vec![0; 60 * request.length as usize], | |
| 866 | ) | |
| 867 | .unwrap(), | |
| 868 | knob: [start, start + knob], | |
| 869 | slot: [0.0, slot], | |
| 870 | decrement: [slot, slot + 15.0], | |
| 871 | increment: [slot + 15.0, request.length], | |
| 872 | } | |
| 873 | }), | |
| 874 | }); | |
| 875 | let mut offset = 0.0; | |
| 876 | let build = |ui: &mut Ui, offset: &mut f32| { | |
| 877 | frame(ui, |ui| { | |
| 878 | ui.open( | |
| 879 | "view", | |
| 880 | Spec { | |
| 881 | flags: Flags::CUSTOM, | |
| 882 | size: [px(200.0), px(215.0)], | |
| 883 | ..Spec::default() | |
| 884 | }, | |
| 885 | ); | |
| 886 | if let Some(chosen) = | |
| 887 | scrollbar(ui, "bar", Axis::Y, *offset, [0.0, 1000.0], 400.0, [0.5; 4]) | |
| 888 | { | |
| 889 | *offset = chosen; | |
| 890 | } | |
| 891 | ui.close(); | |
| 892 | }); | |
| 893 | }; | |
| 894 | build(&mut ui, &mut offset); | |
| 895 | build(&mut ui, &mut offset); | |
| 896 | let bar = Id::ROOT.child("view").child("bar"); | |
| 897 | assert_eq!( | |
| 898 | ui.rect(bar).unwrap(), | |
| 899 | [185.0, 0.0, 200.0, 200.0], | |
| 900 | "the corner stays clear" | |
| 901 | ); | |
| 902 | let at = Instant::now(); | |
| 903 | let click = |ui: &mut Ui, y: f32, pressed: bool| { | |
| 904 | ui.event(Event::PointerMoved([190.0, y])); | |
| 905 | ui.event(Event::Button { | |
| 906 | button: MouseButton::Left, | |
| 907 | pressed, | |
| 908 | at, | |
| 909 | }); | |
| 910 | }; | |
| 911 | click(&mut ui, 190.0, true); | |
| 912 | build(&mut ui, &mut offset); | |
| 913 | assert_eq!( | |
| 914 | offset, 20.0, | |
| 915 | "the increment arrow steps a twentieth of the view" | |
| 916 | ); | |
| 917 | click(&mut ui, 190.0, false); | |
| 918 | build(&mut ui, &mut offset); | |
| 919 | // Below the knob, 170 × 400 / 1400 long, the track pages. | |
| 920 | click(&mut ui, 160.0, true); | |
| 921 | build(&mut ui, &mut offset); | |
| 922 | assert_eq!(offset, 400.0); | |
| 923 | click(&mut ui, 160.0, false); | |
| 924 | build(&mut ui, &mut offset); | |
| 925 | click(&mut ui, 60.0, true); | |
| 926 | build(&mut ui, &mut offset); | |
| 927 | ui.event(Event::PointerMoved([190.0, 500.0])); | |
| 928 | build(&mut ui, &mut offset); | |
| 929 | assert_eq!(offset, 1000.0, "the knob drags to the end"); | |
| 930 | } | |
| 931 | ||
| 712 | 932 | fn items() -> Vec<popup::Item<'static>> { |
| 713 | 933 | let item = |text| popup::Item { |
| 714 | 934 | text, |
| ... | ... | @@ -1071,13 +1291,15 @@ fn nothing_matching_leaves_nothing_to_choose() { |
| 1071 | 1291 | } |
| 1072 | 1292 | |
| 1073 | 1293 | #[test] |
| 1074 | fn the_palette_centres_across_the_window_and_chooses_the_best_match() { | |
| 1294 | fn the_palette_centres_near_the_windows_top_and_chooses_the_best_match() { | |
| 1075 | 1295 | let mut ui = Ui::new(Theme::dark(), DOUBLE_CLICK); |
| 1076 | 1296 | let palette = Id::ROOT.child("palette"); |
| 1077 | 1297 | let commands = items(); |
| 1078 | 1298 | let build = |ui: &mut Ui| { |
| 1079 | 1299 | let mut chosen = None; |
| 1080 | frame(ui, |ui| chosen = popup::palette(ui, palette, &commands)); | |
| 1300 | frame(ui, |ui| { | |
| 1301 | chosen = popup::palette(ui, palette, &commands, "Search commands") | |
| 1302 | }); | |
| 1081 | 1303 | chosen |
| 1082 | 1304 | }; |
| 1083 | 1305 | build(&mut ui); |
| ... | ... | @@ -1087,6 +1309,7 @@ fn the_palette_centres_across_the_window_and_chooses_the_best_match() { |
| 1087 | 1309 | } |
| 1088 | 1310 | let rect = ui.rect(palette).unwrap(); |
| 1089 | 1311 | assert_eq!(rect[0] + rect[2], 400.0, "centred"); |
| 1312 | assert_eq!(rect[1], 300.0 / 8.0, "an eighth down"); | |
| 1090 | 1313 | ui.event(typed("a")); |
| 1091 | 1314 | ui.event(key(NamedKey::Enter)); |
| 1092 | 1315 | assert_eq!( |
| ... | ... | @@ -1748,7 +1971,7 @@ fn popups_ease_open_and_closed_then_stop_asking_for_frames() { |
| 1748 | 1971 | build(ui); |
| 1749 | 1972 | }); |
| 1750 | 1973 | let first = motion(&ui).unwrap(); |
| 1751 | assert!(first.opacity < 0.1 && first.zoom < 0.96 && first.tilt > 0.3); | |
| 1974 | assert!(first.opacity < 0.1 && first.zoom < 0.96 && first.tilt > 0.15); | |
| 1752 | 1975 | assert_eq!( |
| 1753 | 1976 | first.pivot, |
| 1754 | 1977 | [200.0, 125.0], |
| ... | ... | @@ -1895,7 +2118,7 @@ fn every_control_in_an_open_dialog_takes_clicks_where_it_rests() { |
| 1895 | 2118 | } |
| 1896 | 2119 | |
| 1897 | 2120 | #[test] |
| 1898 | fn a_combo_s_field_holds_its_place_as_its_list_opens_and_its_rows_take_clicks() { | |
| 2121 | fn a_combo_s_field_widens_over_rows_laid_out_where_they_end_and_its_rows_take_clicks() { | |
| 1899 | 2122 | let mut ui = Ui::new(Theme::light(), DOUBLE_CLICK); |
| 1900 | 2123 | let fonts = fonts(); |
| 1901 | 2124 | let combo = Id::ROOT.child("combo"); |
| ... | ... | @@ -1915,25 +2138,45 @@ fn a_combo_s_field_holds_its_place_as_its_list_opens_and_its_rows_take_clicks() |
| 1915 | 2138 | let [box_rect, first] = [ui.rect(combo).unwrap(), ui.rect(field).unwrap()]; |
| 1916 | 2139 | assert_eq!(first[..2], box_rect[..2], "the field starts on the box"); |
| 1917 | 2140 | assert!(first[2] - first[0] < box_rect[2] - box_rect[0] + 20.0); |
| 1918 | let moving: Vec<_> = ui | |
| 1919 | .layers() | |
| 2141 | let shown = ui.rect(menu_id()).unwrap(); | |
| 2142 | let row = menu_id().child("rows").child(3_u64); | |
| 2143 | let opening = ui.rect(row).unwrap(); | |
| 2144 | assert!( | |
| 2145 | opening[2] > shown[2], | |
| 2146 | "the rows lie out past the popup as it opens" | |
| 2147 | ); | |
| 2148 | let layers = ui.layers(); | |
| 2149 | let moving: Vec<_> = layers | |
| 1920 | 2150 | .iter() |
| 1921 | 2151 | .filter_map(|layer| match layer { |
| 1922 | Layer::Primitives(layer) => Some(layer.motion.is_some()), | |
| 2152 | Layer::Primitives(layer) => Some(layer), | |
| 1923 | 2153 | Layer::Custom { .. } => None, |
| 1924 | 2154 | }) |
| 2155 | .skip_while(|layer| layer.motion.is_none()) | |
| 1925 | 2156 | .collect(); |
| 1926 | let panel = moving.iter().position(|moving| *moving).unwrap(); | |
| 2157 | let split = moving | |
| 2158 | .iter() | |
| 2159 | .position(|layer| layer.motion.is_none()) | |
| 2160 | .unwrap(); | |
| 2161 | assert!( | |
| 2162 | moving[split..].iter().all(|layer| layer.motion.is_none()), | |
| 2163 | "the field paints over the popup, which appears as one" | |
| 2164 | ); | |
| 1927 | 2165 | assert!( |
| 1928 | moving[panel..].contains(&false), | |
| 1929 | "the field paints without the popup's motion" | |
| 2166 | moving[..split] | |
| 2167 | .iter() | |
| 2168 | .filter(|layer| layer.round.is_some()) | |
| 2169 | .all(|layer| layer.round == Some((shown, ui.theme.menu().radius))), | |
| 2170 | "the rows show only inside the popup's outline" | |
| 1930 | 2171 | ); |
| 2172 | drop(layers); | |
| 1931 | 2173 | for _ in 0..20 { |
| 1932 | 2174 | build(&mut ui); |
| 1933 | 2175 | } |
| 1934 | 2176 | assert!(!ui.wants_frame()); |
| 1935 | 2177 | let [panel, last] = [ui.rect(menu_id()).unwrap(), ui.rect(field).unwrap()]; |
| 1936 | 2178 | assert_eq!(last[2], panel[2] - 4.0, "the field widens across the popup"); |
| 2179 | assert_eq!(ui.rect(row), Some(opening), "the rows never move"); | |
| 1937 | 2180 | click_box(&mut ui, menu_id().child("rows").child(3_u64)); |
| 1938 | 2181 | assert_eq!(build(&mut ui), Some(3)); |
| 1939 | 2182 | } |
| ... | ... | @@ -1955,6 +2198,8 @@ fn a_popup_over_a_box_by_the_window_s_edge_widens_away_from_it() { |
| 1955 | 2198 | let edges = |
| 1956 | 2199 | |ui: &Ui| [ui.rect(field).unwrap(), ui.rect(menu_id()).unwrap()].map(|rect| rect[2]); |
| 1957 | 2200 | let first = ui.rect(field).unwrap(); |
| 2201 | let row = menu_id().child("rows").child(3_u64); | |
| 2202 | let opening = ui.rect(row).unwrap(); | |
| 1958 | 2203 | assert_eq!( |
| 1959 | 2204 | edges(&ui), |
| 1960 | 2205 | [390.0, 394.0], |
| ... | ... | @@ -1969,6 +2214,7 @@ fn a_popup_over_a_box_by_the_window_s_edge_widens_away_from_it() { |
| 1969 | 2214 | ui.rect(field).unwrap()[0] < first[0] - 30.0, |
| 1970 | 2215 | "the field widens leftward" |
| 1971 | 2216 | ); |
| 2217 | assert_eq!(ui.rect(row), Some(opening), "the rows never move"); | |
| 1972 | 2218 | } |
| 1973 | 2219 | |
| 1974 | 2220 | fn built(ui: &Ui, id: Id) -> &Built { |
| ... | ... | @@ -2113,29 +2359,25 @@ fn a_menu_button_opens_its_menu_with_the_icons_under_its_own() { |
| 2113 | 2359 | fn galleries_choose_across_their_groups_by_keys_and_clicks() { |
| 2114 | 2360 | let mut ui = Ui::new(Theme::dark(), DOUBLE_CLICK); |
| 2115 | 2361 | let gallery = Id::ROOT.child("gallery"); |
| 2116 | let groups = [("Recent", 2), ("Library", 5)]; | |
| 2362 | let groups = [("Recent", 2), ("Library", 5)].map(|(heading, cells)| popup::Group { | |
| 2363 | heading, | |
| 2364 | cells, | |
| 2365 | columns: 3, | |
| 2366 | size: [30.0, 30.0], | |
| 2367 | }); | |
| 2117 | 2368 | let build = |ui: &mut Ui| { |
| 2118 | 2369 | let mut chosen = None; |
| 2119 | 2370 | frame(ui, |ui| { |
| 2120 | chosen = popup::gallery( | |
| 2121 | ui, | |
| 2122 | gallery, | |
| 2123 | BELOW, | |
| 2124 | &groups, | |
| 2125 | 3, | |
| 2126 | [30.0, 30.0], | |
| 2127 | Some(3), | |
| 2128 | |ui, index| { | |
| 2129 | ui.leaf( | |
| 2130 | "label", | |
| 2131 | Spec { | |
| 2132 | size: [fill(), fill()], | |
| 2133 | text: Some(["a", "b", "c", "d", "e", "f", "g"][index]), | |
| 2134 | ..Spec::default() | |
| 2135 | }, | |
| 2136 | ); | |
| 2137 | }, | |
| 2138 | ); | |
| 2371 | chosen = popup::gallery(ui, gallery, BELOW, &groups, &[3], |ui, index| { | |
| 2372 | ui.leaf( | |
| 2373 | "label", | |
| 2374 | Spec { | |
| 2375 | size: [fill(), fill()], | |
| 2376 | text: Some(["a", "b", "c", "d", "e", "f", "g"][index]), | |
| 2377 | ..Spec::default() | |
| 2378 | }, | |
| 2379 | ); | |
| 2380 | }); | |
| 2139 | 2381 | }); |
| 2140 | 2382 | chosen |
| 2141 | 2383 | }; |
| ... | ... | @@ -2206,3 +2448,71 @@ fn desktop_menus_cut_or_fade_without_growing() { |
| 2206 | 2448 | assert!((closing.opacity - (1.0 - 0.096 / 0.6_f32).powi(4)).abs() < 0.01); |
| 2207 | 2449 | assert!(ui.wants_frame()); |
| 2208 | 2450 | } |
| 2451 | ||
| 2452 | /// A row 200 wide of twelve section tabs with `active` open; returns the row's id. | |
| 2453 | fn tab_row(ui: &mut Ui, active: usize) -> Id { | |
| 2454 | let names = [ | |
| 2455 | "One", "Two", "Three", "Four", "Five", "Six", "Seven", "Eight", "Nine", "Ten", "Eleven", | |
| 2456 | "Twelve", | |
| 2457 | ]; | |
| 2458 | let tabs: Vec<_> = names | |
| 2459 | .iter() | |
| 2460 | .map(|name| (*name, [0.5, 0.6, 0.9, 1.0])) | |
| 2461 | .collect(); | |
| 2462 | let section = ui.theme.section([0.5, 0.6, 0.9, 1.0]); | |
| 2463 | let row = Id::ROOT.child("tabs"); | |
| 2464 | frame(ui, |ui| { | |
| 2465 | ui.open( | |
| 2466 | "tab row", | |
| 2467 | Spec { | |
| 2468 | size: [px(200.0), px(28.0)], | |
| 2469 | ..Spec::default() | |
| 2470 | }, | |
| 2471 | ); | |
| 2472 | shell::section_tabs(ui, row, &tabs, active, None, None, &section, 28.0, [0.0; 4]); | |
| 2473 | ui.close(); | |
| 2474 | }); | |
| 2475 | row | |
| 2476 | } | |
| 2477 | ||
| 2478 | /// Frames until the tabs' easing settles. | |
| 2479 | fn settle_tabs(ui: &mut Ui, active: usize) -> Id { | |
| 2480 | let mut row = tab_row(ui, active); | |
| 2481 | for _ in 0..60 { | |
| 2482 | row = tab_row(ui, active); | |
| 2483 | } | |
| 2484 | row | |
| 2485 | } | |
| 2486 | ||
| 2487 | #[test] | |
| 2488 | fn overflowing_tabs_scroll_the_open_one_into_view() { | |
| 2489 | let mut ui = Ui::new(Theme::dark(), DOUBLE_CLICK); | |
| 2490 | let row = settle_tabs(&mut ui, 0); | |
| 2491 | let view = ui.rect(row).unwrap(); | |
| 2492 | assert_eq!(view[2] - view[0], 200.0); | |
| 2493 | let row = settle_tabs(&mut ui, 11); | |
| 2494 | let last = ui.rect(shell::tab_id(row, 11)).unwrap(); | |
| 2495 | assert!( | |
| 2496 | last[0] >= view[0] && last[2] <= view[2], | |
| 2497 | "{last:?} in {view:?}" | |
| 2498 | ); | |
| 2499 | } | |
| 2500 | ||
| 2501 | #[test] | |
| 2502 | fn the_wheel_scrolls_overflowing_tabs_sideways_either_way_it_turns() { | |
| 2503 | let mut ui = Ui::new(Theme::dark(), DOUBLE_CLICK); | |
| 2504 | let row = settle_tabs(&mut ui, 0); | |
| 2505 | let first = ui.rect(shell::tab_id(row, 0)).unwrap()[0]; | |
| 2506 | ui.event(Event::PointerMoved([100.0, 14.0])); | |
| 2507 | ui.event(Event::Wheel([0.0, -40.0])); | |
| 2508 | settle_tabs(&mut ui, 0); | |
| 2509 | let scrolled = ui.rect(shell::tab_id(row, 0)).unwrap()[0]; | |
| 2510 | assert!( | |
| 2511 | (first - scrolled - 40.0).abs() < 0.5, | |
| 2512 | "{first} to {scrolled}" | |
| 2513 | ); | |
| 2514 | ui.event(Event::Wheel([30.0, 0.0])); | |
| 2515 | settle_tabs(&mut ui, 0); | |
| 2516 | let back = ui.rect(shell::tab_id(row, 0)).unwrap()[0]; | |
| 2517 | assert!((back - scrolled - 30.0).abs() < 0.5, "{scrolled} to {back}"); | |
| 2518 | } |
crates/ui/src/widgets.rs+164-4| ... | ... | @@ -1,10 +1,11 @@ |
| 1 | 1 | use crate::{Axis, Event, Flags, Id, Signal, Spec, Ui, fit, px}; |
| 2 | use draw::RasterImage; | |
| 2 | 3 | use draw::edit::{self, Command, Movement}; |
| 3 | 4 | use parley::{ |
| 4 | 5 | Affinity, |
| 5 | 6 | editing::{Cursor, Selection}, |
| 6 | 7 | }; |
| 7 | use std::hash::Hash; | |
| 8 | use std::{hash::Hash, time::Duration}; | |
| 8 | 9 | use winit::{ |
| 9 | 10 | event::Ime, |
| 10 | 11 | keyboard::{Key, ModifiersState, NamedKey}, |
| ... | ... | @@ -75,9 +76,56 @@ pub fn check_box(ui: &mut Ui, part: impl Hash, label: &str, checked: bool) -> Si |
| 75 | 76 | signal |
| 76 | 77 | } |
| 77 | 78 | |
| 78 | /// An overlay scrollbar along the far edge of the current box, for content whose scroll | |
| 79 | /// offset ranges over `range` while `view` of it shows, all in one unit. Returns the offset | |
| 80 | /// a drag of its thumb chose. | |
| 79 | /// Scrollers the platform paints, fixed along the content's edges, as Mac OS X 10.6's are. | |
| 80 | pub struct Scrollers { | |
| 81 | /// Across a scroller, in logical pixels. | |
| 82 | pub thickness: f32, | |
| 83 | pub paint: Box<dyn FnMut(&Scroller) -> PaintedScroller>, | |
| 84 | } | |
| 85 | ||
| 86 | /// A scroller as it is to be painted, in logical pixels. | |
| 87 | pub struct Scroller { | |
| 88 | pub axis: Axis, | |
| 89 | pub length: f32, | |
| 90 | pub scale: f32, | |
| 91 | /// Where the offset lies in its range, from 0 to 1. | |
| 92 | pub value: f32, | |
| 93 | /// How much of the content shows, from 0 to 1. | |
| 94 | pub proportion: f32, | |
| 95 | /// The part held down. | |
| 96 | pub held: Option<ScrollerPart>, | |
| 97 | /// Whether the window is key, which colours the knob. | |
| 98 | pub active: bool, | |
| 99 | } | |
| 100 | ||
| 101 | #[derive(Clone, Copy, Debug, PartialEq, Eq)] | |
| 102 | pub enum ScrollerPart { | |
| 103 | Decrement, | |
| 104 | Increment, | |
| 105 | Knob, | |
| 106 | /// The track either side of the knob, which pages. | |
| 107 | Slot, | |
| 108 | } | |
| 109 | ||
| 110 | /// A painted scroller and where its parts lie along it, from its start in logical pixels. | |
| 111 | #[derive(Clone)] | |
| 112 | pub struct PaintedScroller { | |
| 113 | /// `length` by `thickness`, at the scroller's scale. | |
| 114 | pub image: RasterImage, | |
| 115 | pub knob: [f32; 2], | |
| 116 | pub slot: [f32; 2], | |
| 117 | pub decrement: [f32; 2], | |
| 118 | pub increment: [f32; 2], | |
| 119 | } | |
| 120 | ||
| 121 | /// How long a held arrow or track waits before repeating, and then between steps. | |
| 122 | const REPEAT_DELAY: Duration = Duration::from_millis(300); | |
| 123 | const REPEAT: Duration = Duration::from_millis(50); | |
| 124 | ||
| 125 | /// A scrollbar along the far edge of the current box, for content whose scroll offset | |
| 126 | /// ranges over `range` while `view` of it shows, all in one unit: the platform's scroller | |
| 127 | /// where `Ui::scrollers` has one, otherwise a thumb in `color` over the content. Returns | |
| 128 | /// the offset the scrollbar chose. | |
| 81 | 129 | pub fn scrollbar( |
| 82 | 130 | ui: &mut Ui, |
| 83 | 131 | part: impl Hash, |
| ... | ... | @@ -87,6 +135,9 @@ pub fn scrollbar( |
| 87 | 135 | view: f32, |
| 88 | 136 | color: [f32; 4], |
| 89 | 137 | ) -> Option<f32> { |
| 138 | if ui.scrollers.is_some() { | |
| 139 | return system_scrollbar(ui, part, axis, offset, range, view); | |
| 140 | } | |
| 90 | 141 | let along = usize::from(axis == Axis::Y); |
| 91 | 142 | let rect = ui.rect(ui.current())?; |
| 92 | 143 | let length = rect[along + 2] - rect[along]; |
| ... | ... | @@ -133,6 +184,115 @@ pub fn scrollbar( |
| 133 | 184 | chosen |
| 134 | 185 | } |
| 135 | 186 | |
| 187 | fn system_scrollbar( | |
| 188 | ui: &mut Ui, | |
| 189 | part: impl Hash, | |
| 190 | axis: Axis, | |
| 191 | offset: f32, | |
| 192 | range: [f32; 2], | |
| 193 | view: f32, | |
| 194 | ) -> Option<f32> { | |
| 195 | let along = usize::from(axis == Axis::Y); | |
| 196 | let rect = ui.rect(ui.current())?; | |
| 197 | let span = range[1] - range[0]; | |
| 198 | let thickness = ui.scrollers.as_ref()?.thickness; | |
| 199 | // The far end leaves the corner beside the other axis's scroller. | |
| 200 | let length = rect[along + 2] - rect[along] - thickness; | |
| 201 | if span <= 0.0 || length <= 0.0 { | |
| 202 | return None; | |
| 203 | } | |
| 204 | let id = ui.id(&part); | |
| 205 | let signal = ui.signal(id); | |
| 206 | let pointer = ui.pointer().map(|pointer| pointer[along] - rect[along]); | |
| 207 | let now = ui.now; | |
| 208 | if !signal.pressed && !signal.dragging { | |
| 209 | *ui.held(id) = None; | |
| 210 | } | |
| 211 | let request = Scroller { | |
| 212 | axis, | |
| 213 | length, | |
| 214 | scale: ui.scale, | |
| 215 | value: ((offset - range[0]) / span).clamp(0.0, 1.0), | |
| 216 | proportion: (view / (view + span)).clamp(0.0, 1.0), | |
| 217 | held: ui.held(id).map(|(part, _)| part), | |
| 218 | active: ui.window_focused, | |
| 219 | }; | |
| 220 | let painted = (ui.scrollers.as_mut()?.paint)(&request); | |
| 221 | let within = |[start, end]: [f32; 2], at: f32| start <= at && at < end; | |
| 222 | let line = (view / 20.0).max(1.0); | |
| 223 | let mut chosen = None; | |
| 224 | if let Some(at) = pointer | |
| 225 | && (signal.pressed || signal.dragging) | |
| 226 | { | |
| 227 | if signal.pressed { | |
| 228 | let hit = [ | |
| 229 | (ScrollerPart::Decrement, painted.decrement), | |
| 230 | (ScrollerPart::Increment, painted.increment), | |
| 231 | (ScrollerPart::Knob, painted.knob), | |
| 232 | (ScrollerPart::Slot, painted.slot), | |
| 233 | ] | |
| 234 | .into_iter() | |
| 235 | .find(|(_, extent)| within(*extent, at)); | |
| 236 | *ui.held(id) = hit.map(|(part, _)| (part, now)); | |
| 237 | *ui.grab(id) = at - painted.knob[0]; | |
| 238 | } | |
| 239 | match *ui.held(id) { | |
| 240 | Some((ScrollerPart::Knob, _)) => { | |
| 241 | let travel = | |
| 242 | (painted.slot[1] - painted.slot[0]) - (painted.knob[1] - painted.knob[0]); | |
| 243 | let fraction = ((at - *ui.grab(id) - painted.slot[0]) / travel.max(f32::EPSILON)) | |
| 244 | .clamp(0.0, 1.0); | |
| 245 | chosen = Some(range[0] + fraction * span); | |
| 246 | } | |
| 247 | Some((part, due)) if now >= due => { | |
| 248 | let step = match part { | |
| 249 | ScrollerPart::Decrement => -line, | |
| 250 | ScrollerPart::Increment => line, | |
| 251 | // The track pages towards the pointer and stops once the knob is under it. | |
| 252 | _ if within(painted.knob, at) => 0.0, | |
| 253 | _ if at < painted.knob[0] => line - view, | |
| 254 | _ => view - line, | |
| 255 | }; | |
| 256 | let delay = if signal.pressed { REPEAT_DELAY } else { REPEAT }; | |
| 257 | *ui.held(id) = Some((part, now + delay)); | |
| 258 | chosen = Some((offset + step).clamp(range[0], range[1])); | |
| 259 | } | |
| 260 | _ => {} | |
| 261 | } | |
| 262 | // A held part repeats on later frames. | |
| 263 | ui.animating = true; | |
| 264 | } | |
| 265 | let mut position = [0.0; 2]; | |
| 266 | position[1 - along] = rect[3 - along] - rect[1 - along] - thickness; | |
| 267 | let mut extent = [px(thickness); 2]; | |
| 268 | extent[along] = px(length); | |
| 269 | ui.leaf( | |
| 270 | &part, | |
| 271 | Spec { | |
| 272 | flags: Flags::CLICKABLE | Flags::FLOAT, | |
| 273 | size: extent, | |
| 274 | position, | |
| 275 | image: Some(&painted.image), | |
| 276 | cursor: Some(CursorIcon::Default), | |
| 277 | ..Spec::default() | |
| 278 | }, | |
| 279 | ); | |
| 280 | if axis == Axis::Y { | |
| 281 | // The corner between the two scrollers shows the content's white, as AppKit's does. | |
| 282 | ui.leaf( | |
| 283 | ("corner", &part), | |
| 284 | Spec { | |
| 285 | flags: Flags::FLOAT, | |
| 286 | size: [px(thickness); 2], | |
| 287 | position: [position[0], length], | |
| 288 | fill: Some([1.0; 4]), | |
| 289 | ..Spec::default() | |
| 290 | }, | |
| 291 | ); | |
| 292 | } | |
| 293 | chosen | |
| 294 | } | |
| 295 | ||
| 136 | 296 | /// A single-line field editing `text`, showing `placeholder` while empty. Presses, drags |
| 137 | 297 | /// and keys select and edit as on the page: a double press selects a word, a triple press |
| 138 | 298 | /// everything, and dragging extends by that unit. |
platform/snow-leopard/console/input.c+42-10| ... | ... | @@ -1,5 +1,7 @@ |
| 1 | 1 | // Posts input to the logged-in session as the HID system would, for driving the app |
| 2 | // over ssh: `input click X Y`, `input type TEXT`, `input key KEYCODE [command]`. | |
| 2 | // over ssh: `input click X Y`, `input double X Y`, `input drag X Y X2 Y2`, | |
| 3 | // `input scroll X Y LINES`, `input type TEXT`, | |
| 4 | // `input key KEYCODE [command] [shift] [option] [control]`. | |
| 3 | 5 | // Points are the display's, from its top left. |
| 4 | 6 | #include <ApplicationServices/ApplicationServices.h> |
| 5 | 7 | #include <stdlib.h> |
| ... | ... | @@ -12,8 +14,15 @@ static void post(CGEventRef event) { |
| 12 | 14 | usleep(20000); |
| 13 | 15 | } |
| 14 | 16 | |
| 15 | static void mouse(CGEventType type, CGPoint at) { | |
| 16 | post(CGEventCreateMouseEvent(NULL, type, at, kCGMouseButtonLeft)); | |
| 17 | static void mouse(CGEventType type, CGPoint at, int clicks) { | |
| 18 | CGEventRef event = CGEventCreateMouseEvent(NULL, type, at, kCGMouseButtonLeft); | |
| 19 | CGEventSetIntegerValueField(event, kCGMouseEventClickState, clicks); | |
| 20 | post(event); | |
| 21 | } | |
| 22 | ||
| 23 | static void click(CGPoint at, int clicks) { | |
| 24 | mouse(kCGEventLeftMouseDown, at, clicks); | |
| 25 | mouse(kCGEventLeftMouseUp, at, clicks); | |
| 17 | 26 | } |
| 18 | 27 | |
| 19 | 28 | static void key(CGKeyCode code, CGEventFlags flags, const UniChar *text, UniCharCount length) { |
| ... | ... | @@ -26,11 +35,27 @@ static void key(CGKeyCode code, CGEventFlags flags, const UniChar *text, UniChar |
| 26 | 35 | } |
| 27 | 36 | |
| 28 | 37 | int main(int argc, char **argv) { |
| 29 | if (argc == 4 && !strcmp(argv[1], "click")) { | |
| 38 | if ((argc == 4 && !strcmp(argv[1], "click")) || (argc == 4 && !strcmp(argv[1], "double"))) { | |
| 39 | CGPoint at = CGPointMake(atof(argv[2]), atof(argv[3])); | |
| 40 | mouse(kCGEventMouseMoved, at, 0); | |
| 41 | click(at, 1); | |
| 42 | if (!strcmp(argv[1], "double")) click(at, 2); | |
| 43 | } else if (argc == 6 && !strcmp(argv[1], "drag")) { | |
| 44 | CGPoint from = CGPointMake(atof(argv[2]), atof(argv[3])); | |
| 45 | CGPoint to = CGPointMake(atof(argv[4]), atof(argv[5])); | |
| 46 | mouse(kCGEventMouseMoved, from, 0); | |
| 47 | mouse(kCGEventLeftMouseDown, from, 1); | |
| 48 | for (int step = 1; step <= 10; step++) { | |
| 49 | CGPoint at = CGPointMake(from.x + (to.x - from.x) * step / 10, from.y + (to.y - from.y) * step / 10); | |
| 50 | mouse(kCGEventLeftMouseDragged, at, 1); | |
| 51 | } | |
| 52 | mouse(kCGEventLeftMouseUp, to, 1); | |
| 53 | } else if (argc == 5 && !strcmp(argv[1], "scroll")) { | |
| 30 | 54 | CGPoint at = CGPointMake(atof(argv[2]), atof(argv[3])); |
| 31 | mouse(kCGEventMouseMoved, at); | |
| 32 | mouse(kCGEventLeftMouseDown, at); | |
| 33 | mouse(kCGEventLeftMouseUp, at); | |
| 55 | mouse(kCGEventMouseMoved, at, 0); | |
| 56 | CGEventRef event = CGEventCreateScrollWheelEvent(NULL, kCGScrollEventUnitLine, 1, atoi(argv[4])); | |
| 57 | CGEventSetLocation(event, at); | |
| 58 | post(event); | |
| 34 | 59 | } else if (argc == 3 && !strcmp(argv[1], "type")) { |
| 35 | 60 | CFStringRef text = CFStringCreateWithCString(NULL, argv[2], kCFStringEncodingUTF8); |
| 36 | 61 | for (CFIndex i = 0; i < CFStringGetLength(text); i++) { |
| ... | ... | @@ -38,10 +63,17 @@ int main(int argc, char **argv) { |
| 38 | 63 | key(0, 0, &c, 1); |
| 39 | 64 | } |
| 40 | 65 | CFRelease(text); |
| 41 | } else if ((argc == 3 || argc == 4) && !strcmp(argv[1], "key")) { | |
| 42 | key(atoi(argv[2]), argc == 4 && !strcmp(argv[3], "command") ? kCGEventFlagMaskCommand : 0, NULL, 0); | |
| 66 | } else if (argc >= 3 && !strcmp(argv[1], "key")) { | |
| 67 | CGEventFlags flags = 0; | |
| 68 | for (int i = 3; i < argc; i++) { | |
| 69 | if (!strcmp(argv[i], "command")) flags |= kCGEventFlagMaskCommand; | |
| 70 | else if (!strcmp(argv[i], "shift")) flags |= kCGEventFlagMaskShift; | |
| 71 | else if (!strcmp(argv[i], "option")) flags |= kCGEventFlagMaskAlternate; | |
| 72 | else if (!strcmp(argv[i], "control")) flags |= kCGEventFlagMaskControl; | |
| 73 | } | |
| 74 | key(atoi(argv[2]), flags, NULL, 0); | |
| 43 | 75 | } else { |
| 44 | fprintf(stderr, "usage: input click X Y | input type TEXT | input key KEYCODE [command]\n"); | |
| 76 | fprintf(stderr, "usage: input click|double X Y | drag X Y X2 Y2 | scroll X Y LINES | type TEXT | key KEYCODE [command] [shift] [option] [control]\n"); | |
| 45 | 77 | return 2; |
| 46 | 78 | } |
| 47 | 79 | return 0; |
platform/snow-leopard/remote.sh+3-2| ... | ... | @@ -9,7 +9,8 @@ |
| 9 | 9 | # remote.sh stop NAME end what start started (a bundle by its executable) |
| 10 | 10 | # remote.sh screen LOCAL.png wake the display and capture it (screencapture |
| 11 | 11 | # writes nothing from ssh) |
| 12 | # remote.sh input ARGS... click X Y | type TEXT | key KEYCODE [command] | |
| 12 | # remote.sh input ARGS... click|double X Y | drag X Y X2 Y2 | scroll X Y LINES | |
| 13 | # | type TEXT | key KEYCODE [command|shift|option|control]... | |
| 13 | 14 | # remote.sh selectors BINARY selector-like names BINARY holds that nothing on |
| 14 | 15 | # 10.6 implements |
| 15 | 16 | # remote.sh mirror PATH... copy repo paths to the same absolute paths there, so |
| ... | ... | @@ -119,7 +120,7 @@ sh) |
| 119 | 120 | ssh "$host" "$@" |
| 120 | 121 | ;; |
| 121 | 122 | *) |
| 122 | sed -n '2,20s/^# \{0,1\}//p' "$0" >&2 | |
| 123 | sed -n '2,21s/^# \{0,1\}//p' "$0" >&2 | |
| 123 | 124 | exit 2 |
| 124 | 125 | ;; |
| 125 | 126 | esac |
platform/snow-leopard/rt/appkit.c-1| ... | ... | @@ -112,7 +112,6 @@ __attribute__((constructor)) static void polyfill(void) { |
| 112 | 112 | add("NSWindow", 0, "setTabbingIdentifier:", ignore, "v@:@"); |
| 113 | 113 | add("NSWindow", 1, "setAllowsAutomaticWindowTabbing:", ignore, "v@:c"); |
| 114 | 114 | add("NSWindow", 0, "setTitlebarAppearsTransparent:", ignore, "v@:c"); |
| 115 | add("NSWindow", 0, "setTitleVisibility:", ignore, "v@:q"); | |
| 116 | 115 | add("NSView", 0, "setWantsBestResolutionOpenGLSurface:", ignore, "v@:c"); |
| 117 | 116 | add("NSView", 0, "setAccessibilityLabel:", set_accessibility_label, "v@:@"); |
| 118 | 117 | add("NSDatePicker", 0, "setPresentsCalendarOverlay:", ignore, "v@:c"); |
readme.md+2-5| ... | ... | @@ -10,8 +10,7 @@ feels more like a standard text editor software as textboxes automatically |
| 10 | 10 | create and resize as you would expect. In addition, Snowbound supports |
| 11 | 11 | pen and drawing tools [(WIP)](https://shale.paperclover.net/snowbound/issues/21), recording audio and video |
| 12 | 12 | [(WIP)](https://shale.paperclover.net/snowbound/issues/36), revision |
| 13 | history[(WIP)](https://shale.paperclover.net/snowbound/issues/37), multi-machine | |
| 14 | live collaboration, and much more. | |
| 13 | history, multi-machine live collaboration, and much more. | |
| 15 | 14 | |
| 16 | 15 | <!-- Regenerate from the sample notebook (edit it freely in OneNote or Snowbound): |
| 17 | 16 | python3 tools/canvas/build_macos.py --release && d=$(mktemp -d) && cp -R docs/sample-notebook/Personal "$d" && target/Snowbound.app/Contents/MacOS/Snowbound --notebook "$d/Personal" --cache "$d/cache" --settings "$d/settings.json" --screenshot docs/screenshot |
| ... | ... | @@ -23,9 +22,7 @@ python3 tools/canvas/build_macos.py --release && d=$(mktemp -d) && cp -R docs/sa |
| 23 | 22 | |
| 24 | 23 | Snowbound implements the file and sync protocol used in 2010 Microsoft OneNote, |
| 25 | 24 | so notebooks are fully compatible, including collaboration features. It supports |
| 26 | OS X 10.6 Snow Leopard | |
| 27 | [(WIP)](https://shale.paperclover.net/snowbound/issues/13), all the way to | |
| 28 | modern macOS, Linux, Windows | |
| 25 | OS X 10.6 Snow Leopard, all the way to modern macOS, Linux, Windows | |
| 29 | 26 | [(WIP)](https://shale.paperclover.net/snowbound/issues/11), and fully capable |
| 30 | 27 | iOS[(WIP)](https://shale.paperclover.net/snowbound/issues/36) and |
| 31 | 28 | Android[(WIP)](https://shale.paperclover.net/snowbound/issues/24) apps. |
tools/TESTING.md+11| ... | ... | @@ -49,6 +49,17 @@ leaves `soak/*.log` ending in the command that replays it; crash inputs stay in |
| 49 | 49 | toolchains, `cargo install cargo-fuzz`, and `build-essential pkg-config |
| 50 | 50 | libfontconfig-dev`; copy the checkout and run the script under `tmux`. |
| 51 | 51 | |
| 52 | ## Motion capture | |
| 53 | ||
| 54 | `SNOWBOUND_FRAMES=DIRECTORY` writes every frame the app draws to | |
| 55 | `DIRECTORY/MILLISECONDS.png`, timed from when the window opened. With a | |
| 56 | hidden-window replay (`SNOWBOUND_REPLAY` plus `--screenshot`, see | |
| 57 | `tools/canvas/README.md`) this records an animation at its real pace without | |
| 58 | touching the screen: waits tick at 60 Hz, and the app draws as fast as it can | |
| 59 | while it animates. Point it at a copy of a notebook, end the script with a | |
| 60 | short `wait` so the last frames finish writing, and assemble strips or GIFs | |
| 61 | with `ffmpeg`. | |
| 62 | ||
| 52 | 63 | ## Private and native verification |
| 53 | 64 | |
| 54 | 65 | Private notebooks stay outside versioned fixtures. Materialize a copy before |
tools/test_page_background.py created+42| ... | ... | @@ -0,0 +1,42 @@ |
| 1 | from pathlib import Path | |
| 2 | import runpy | |
| 3 | import shutil | |
| 4 | from tempfile import TemporaryDirectory | |
| 5 | import unittest | |
| 6 | import xml.etree.ElementTree as ET | |
| 7 | ||
| 8 | from native_xml import ns | |
| 9 | ||
| 10 | ROOT = Path(__file__).resolve().parent.parent | |
| 11 | FIXTURE = ROOT / 'corpus/page-background' | |
| 12 | compare = runpy.run_path(str(ROOT / 'tools/verify-document.py'))['compare'] | |
| 13 | ||
| 14 | ||
| 15 | class PageBackgroundTest(unittest.TestCase): | |
| 16 | def test_backgrounds_read_back_as_the_menu_gave_them(self): | |
| 17 | with TemporaryDirectory() as temporary: | |
| 18 | read = Path(temporary) / 'read' | |
| 19 | shutil.copytree(FIXTURE / 'cold/read', read) | |
| 20 | compare(FIXTURE / 'candidate', read) | |
| 21 | pages = {} | |
| 22 | for path in sorted((FIXTURE / 'cold/read').glob('page-*.xml')): | |
| 23 | page = ET.parse(path).getroot() | |
| 24 | pages[page.get('name')] = page | |
| 25 | ||
| 26 | def art(title): | |
| 27 | return [image.get('backgroundImage') for image in pages[title].findall('one:Image', ns)] | |
| 28 | ||
| 29 | def rules(title): | |
| 30 | return pages[title].find('one:PageSettings/one:RuleLines', ns).get('visible') | |
| 31 | ||
| 32 | # Art joins a ruled page, which keeps its lines; art taken away leaves none. | |
| 33 | self.assertEqual(art('Standard'), ['true']) | |
| 34 | self.assertEqual(rules('Standard'), 'true') | |
| 35 | self.assertEqual(art('Wide'), []) | |
| 36 | self.assertEqual(rules('Wide'), 'true') | |
| 37 | # A colour no menu offers reads back as written. | |
| 38 | self.assertEqual(pages['SmallGrid'].find('one:PageSettings', ns).get('color'), '#E8D8C8') | |
| 39 | ||
| 40 | ||
| 41 | if __name__ == '__main__': | |
| 42 | unittest.main() |
tools/test_rule_lines.py created+83| ... | ... | @@ -0,0 +1,83 @@ |
| 1 | from pathlib import Path | |
| 2 | import runpy | |
| 3 | import shutil | |
| 4 | from tempfile import TemporaryDirectory | |
| 5 | import unittest | |
| 6 | import xml.etree.ElementTree as ET | |
| 7 | ||
| 8 | from native_xml import ns | |
| 9 | ||
| 10 | ROOT = Path(__file__).resolve().parent.parent | |
| 11 | FIXTURE = ROOT / 'corpus/rule-lines' | |
| 12 | compare = runpy.run_path(str(ROOT / 'tools/verify-document.py'))['compare'] | |
| 13 | ||
| 14 | BLUE = '#CAEBFD' | |
| 15 | MARGIN = ('Margin', '#FF5050', None) | |
| 16 | ||
| 17 | ||
| 18 | def rule_lines(capture, notebook): | |
| 19 | """Each page's rule lines as OneNote's page XML reports them, by title, once the Rust | |
| 20 | reading of `notebook` matches the capture.""" | |
| 21 | with TemporaryDirectory() as temporary: | |
| 22 | read = Path(temporary) / 'read' | |
| 23 | shutil.copytree(capture / 'read', read) | |
| 24 | compare(notebook, read) | |
| 25 | pages = {} | |
| 26 | for path in sorted((capture / 'read').glob('page-*.xml')): | |
| 27 | page = ET.parse(path).getroot() | |
| 28 | rules = page.find('one:PageSettings/one:RuleLines', ns) | |
| 29 | lines = [(line.tag.rsplit('}', 1)[-1], line.get('color'), | |
| 30 | None if line.get('spacing') is None else round(float(line.get('spacing')), 3)) | |
| 31 | for line in rules] | |
| 32 | pages[page.get('name')] = (rules.get('visible'), lines) | |
| 33 | return pages | |
| 34 | ||
| 35 | ||
| 36 | def ruled(spacing, color=BLUE): | |
| 37 | return ('true', [('Horizontal', color, spacing), MARGIN]) | |
| 38 | ||
| 39 | ||
| 40 | def grid(spacing, color=BLUE): | |
| 41 | return ('true', [('Horizontal', color, spacing), ('Vertical', color, spacing)]) | |
| 42 | ||
| 43 | ||
| 44 | NONE = ('false', []) | |
| 45 | ||
| 46 | ||
| 47 | class RuleLinesTest(unittest.TestCase): | |
| 48 | def test_onenote_rules_pages_as_its_menu_names_them(self): | |
| 49 | pages = rule_lines(FIXTURE / 'native', FIXTURE / 'native/notebook') | |
| 50 | self.assertEqual(pages, { | |
| 51 | 'None': NONE, | |
| 52 | 'Narrow': NONE, | |
| 53 | 'College': ruled(23.76), | |
| 54 | 'Standard': ruled(33.12), | |
| 55 | 'Wide': ruled(46.8), | |
| 56 | 'SmallGrid': grid(12.0), | |
| 57 | 'MediumGrid': grid(28.346, '#E0C9FF'), | |
| 58 | 'LargeGrid': grid(42.52), | |
| 59 | 'VeryLargeGrid': grid(56.693), | |
| 60 | 'ColorRed': ruled(33.12, '#FFD4D6'), | |
| 61 | 'Hidden': ruled(33.12, '#FFFFFF'), | |
| 62 | }) | |
| 63 | ||
| 64 | def test_rust_rule_lines_read_back_as_written(self): | |
| 65 | pages = rule_lines(FIXTURE / 'cold', FIXTURE / 'candidate') | |
| 66 | self.assertEqual(pages, { | |
| 67 | 'None': ruled(46.8), | |
| 68 | 'Narrow': NONE, | |
| 69 | 'College': NONE, | |
| 70 | 'Standard': grid(12.0), | |
| 71 | 'Wide': ruled(46.8), | |
| 72 | 'SmallGrid': grid(12.0), | |
| 73 | 'MediumGrid': ruled(13.428, '#FFD4D6'), | |
| 74 | 'LargeGrid': grid(42.52), | |
| 75 | 'VeryLargeGrid': grid(56.693), | |
| 76 | 'ColorRed': ruled(33.12, '#FFD4D6'), | |
| 77 | 'Hidden': ruled(33.12, '#FFFFFF'), | |
| 78 | 'Created': grid(56.693), | |
| 79 | }) | |
| 80 | ||
| 81 | ||
| 82 | if __name__ == '__main__': | |
| 83 | unittest.main() |
vendor/sctk-adwaita/SNOWBOUND.md+26-2| ... | ... | @@ -6,7 +6,7 @@ crates.io (the version winit resolves to in `Cargo.lock`), copied without its |
| 6 | 6 | `[patch.crates-io]` here. |
| 7 | 7 | |
| 8 | 8 | `Cargo.toml` gains a `[lints.rust]` table silencing a lint newer than the |
| 9 | release, since Cargo does not cap a path dependency's warnings. The one source | |
| 9 | release, since Cargo does not cap a path dependency's warnings. The first source | |
| 10 | 10 | change drops the 1 px border line under the header bar, so the title |
| 11 | 11 | bar flows into Snowbound's toolbar as it does in a GTK 4 app: |
| 12 | 12 | |
| ... | ... | @@ -28,7 +28,31 @@ bar flows into Snowbound's toolbar as it does in a GTK 4 app: |
| 28 | 28 | } |
| 29 | 29 | ``` |
| 30 | 30 | |
| 31 | The second change serves GNOME, where Snowbound cuts its window's bottom | |
| 32 | corners as libadwaita does. With `LIBADWAITA_EDGE` set, the frame draws | |
| 33 | libadwaita 1.7's `window.csd` edge in place of its own border | |
| 34 | (`draw_libadwaita_edge`): the 15 px `WINDOW_RADIUS`, the box shadows focused | |
| 35 | and not with their 1 px ring, and the 7 % white outline 1 px inside the | |
| 36 | edge. The header keeps the body's width, the sides overlap the body by the | |
| 37 | outline and the bottom by the radius, so the frame paints the outline and the | |
| 38 | bottom corners' shadow over the application's pixels. | |
| 39 | ||
| 40 | The third change makes the header bar zero height, so Snowbound's toolbar row | |
| 41 | is the title bar, as a GTK 4 header bar holding tools is (`theme.rs`): | |
| 42 | ||
| 43 | ```diff | |
| 44 | -pub(crate) const HEADER_SIZE: u32 = 35; | |
| 45 | +pub(crate) const HEADER_SIZE: u32 = 0; | |
| 46 | ``` | |
| 47 | ||
| 48 | Around it: `FRAMED` is set once a frame is made, which tells Snowbound to draw | |
| 49 | the title bar and the window's buttons itself; the empty header part is not | |
| 50 | drawn; the top part draws a border line as the bottom does; and with | |
| 51 | `LIBADWAITA_EDGE` the top part reaches down over the body's top corners by the | |
| 52 | radius, as the bottom reaches up, owning them from the sides, since Snowbound | |
| 53 | cuts those corners too. | |
| 54 | ||
| 31 | 55 | To bump: copy the new release's `src/`, `Cargo.toml`, `LICENSE`, `README.md` |
| 32 | and `CHANGELOG.md` over this folder, reapply the diff, and update the version | |
| 56 | and `CHANGELOG.md` over this folder, reapply the three changes, and update the version | |
| 33 | 57 | above. The patch applies only while the version here satisfies winit's |
| 34 | 58 | requirement; `cargo tree -i sctk-adwaita` shows the path source when it does. |
vendor/sctk-adwaita/src/lib.rs+200-11| ... | ... | @@ -1,6 +1,7 @@ |
| 1 | 1 | use std::error::Error; |
| 2 | 2 | use std::mem; |
| 3 | 3 | use std::num::NonZeroU32; |
| 4 | use std::sync::atomic::{AtomicBool, Ordering}; | |
| 4 | 5 | use std::sync::Arc; |
| 5 | 6 | use std::time::Duration; |
| 6 | 7 | |
| ... | ... | @@ -49,6 +50,42 @@ use wl_typed::WlTyped; |
| 49 | 50 | /// XXX this is not result, so `must_use` when needed. |
| 50 | 51 | type SkiaResult = Option<()>; |
| 51 | 52 | |
| 53 | /// Whether the frame draws libadwaita's window edge in place of its own border: the | |
| 54 | /// radius, box shadows and outline of libadwaita 1.7's `window.csd`, round a body whose | |
| 55 | /// bottom corners the application cuts to match. | |
| 56 | pub static LIBADWAITA_EDGE: AtomicBool = AtomicBool::new(false); | |
| 57 | ||
| 58 | /// libadwaita's `--window-radius`, in logical pixels. | |
| 59 | pub const WINDOW_RADIUS: f32 = 15.; | |
| 60 | ||
| 61 | /// `window.csd`'s box shadows focused and not, as y offset, blur, spread and alpha of black; | |
| 62 | /// the last is its 1 px ring. | |
| 63 | const WINDOW_SHADOWS: [[[f32; 4]; 4]; 2] = [ | |
| 64 | [ | |
| 65 | [2., 8., 2., 0.13], | |
| 66 | [3., 20., 10., 0.09], | |
| 67 | [6., 32., 16., 0.04], | |
| 68 | [0., 0., 1., 0.005], | |
| 69 | ], | |
| 70 | [ | |
| 71 | [1., 3., 3., 0.09], | |
| 72 | [2., 14., 5., 0.05], | |
| 73 | [4., 28., 12., 0.03], | |
| 74 | [0., 0., 1., 0.02], | |
| 75 | ], | |
| 76 | ]; | |
| 77 | ||
| 78 | /// The alpha of `window.csd`'s white outline, 1 px inside its edge. | |
| 79 | const WINDOW_OUTLINE: f32 = 0.07; | |
| 80 | ||
| 81 | /// Set once a frame is made. Its header has no height, so the application draws the title | |
| 82 | /// bar, and its window buttons, itself. | |
| 83 | pub static FRAMED: AtomicBool = AtomicBool::new(false); | |
| 84 | ||
| 85 | fn libadwaita_edge() -> bool { | |
| 86 | LIBADWAITA_EDGE.load(Ordering::Relaxed) | |
| 87 | } | |
| 88 | ||
| 52 | 89 | /// A simple set of decorations |
| 53 | 90 | #[derive(Debug)] |
| 54 | 91 | pub struct AdwaitaFrame<State> { |
| ... | ... | @@ -106,6 +143,7 @@ where |
| 106 | 143 | let base_surface = WlTyped::wrap::<State>(base_surface.wl_surface().clone()); |
| 107 | 144 | |
| 108 | 145 | let pool = SlotPool::new(1, shm)?; |
| 146 | FRAMED.store(true, Ordering::Relaxed); | |
| 109 | 147 | |
| 110 | 148 | let decorations = Some(DecorationParts::new( |
| 111 | 149 | &base_surface, |
| ... | ... | @@ -226,12 +264,16 @@ where |
| 226 | 264 | true |
| 227 | 265 | }; |
| 228 | 266 | let border_paint = colors.border_paint(); |
| 267 | let libadwaita = libadwaita_edge() && !self.state.intersects(WindowState::TILED); | |
| 268 | let window = ( | |
| 269 | decorations.header().surface_rect.width, | |
| 270 | decorations.side_height(), | |
| 271 | ); | |
| 229 | 272 | |
| 230 | 273 | // Draw the borders. |
| 231 | for (idx, part) in decorations | |
| 232 | .parts() | |
| 233 | .filter(|(idx, _)| *idx == DecorationParts::HEADER || draw_borders) | |
| 234 | { | |
| 274 | for (idx, part) in decorations.parts().filter(|(idx, part)| { | |
| 275 | (*idx == DecorationParts::HEADER || draw_borders) && part.surface_rect.height > 0 | |
| 276 | }) { | |
| 235 | 277 | let scale = self.scale_factor; |
| 236 | 278 | |
| 237 | 279 | let mut rect = part.surface_rect; |
| ... | ... | @@ -240,10 +282,28 @@ where |
| 240 | 282 | // start. To achieve that, we enlargen the width of the header by |
| 241 | 283 | // 2 * `VISIBLE_BORDER_SIZE`, and move `x` by `VISIBLE_BORDER_SIZE` |
| 242 | 284 | // to the left. |
| 243 | if idx == DecorationParts::HEADER && draw_borders { | |
| 285 | if idx == DecorationParts::HEADER && draw_borders && !libadwaita { | |
| 244 | 286 | rect.width += 2 * VISIBLE_BORDER_SIZE; |
| 245 | 287 | rect.x -= VISIBLE_BORDER_SIZE as i32; |
| 246 | 288 | } |
| 289 | // libadwaita's outline lies inside the body, and its bottom corners are cut from | |
| 290 | // it: the sides overlap the body by the outline, the bottom by the radius. | |
| 291 | let (inset, reach) = match idx { | |
| 292 | _ if !libadwaita || !draw_borders => (0, 0), | |
| 293 | DecorationParts::LEFT | DecorationParts::RIGHT => (1, 0), | |
| 294 | DecorationParts::BOTTOM => (0, WINDOW_RADIUS as u32), | |
| 295 | _ => (0, 0), | |
| 296 | }; | |
| 297 | if idx == DecorationParts::RIGHT { | |
| 298 | rect.x -= inset as i32; | |
| 299 | } | |
| 300 | rect.width += inset; | |
| 301 | rect.y -= reach as i32; | |
| 302 | rect.height += reach; | |
| 303 | // Without a header the body's top corners are cut too, and the top reaches over them. | |
| 304 | if libadwaita && draw_borders && idx == DecorationParts::TOP { | |
| 305 | rect.height += WINDOW_RADIUS as u32; | |
| 306 | } | |
| 247 | 307 | |
| 248 | 308 | rect.width *= scale; |
| 249 | 309 | rect.height *= scale; |
| ... | ... | @@ -265,7 +325,23 @@ where |
| 265 | 325 | // do invisible borders to enlarge the input zone. |
| 266 | 326 | pixmap.fill(Color::TRANSPARENT); |
| 267 | 327 | |
| 268 | if !self.state.intersects(WindowState::TILED) { | |
| 328 | let edge = libadwaita && draw_borders; | |
| 329 | let origin = [rect.x, rect.y + HEADER_SIZE as i32].map(|v| (v * scale as i32) as f32); | |
| 330 | let active = self.state.contains(WindowState::ACTIVATED); | |
| 331 | let edge_layer = |pixmap: &mut PixmapMut, outline| { | |
| 332 | draw_libadwaita_edge( | |
| 333 | pixmap, | |
| 334 | idx, | |
| 335 | origin, | |
| 336 | [window.0 * scale, window.1 * scale], | |
| 337 | scale as f32, | |
| 338 | active, | |
| 339 | outline, | |
| 340 | ) | |
| 341 | }; | |
| 342 | if edge { | |
| 343 | edge_layer(&mut pixmap, false); | |
| 344 | } else if !self.state.intersects(WindowState::TILED) { | |
| 269 | 345 | self.shadow.draw( |
| 270 | 346 | &mut pixmap, |
| 271 | 347 | scale, |
| ... | ... | @@ -292,6 +368,7 @@ where |
| 292 | 368 | self.mouse.location, |
| 293 | 369 | ); |
| 294 | 370 | } |
| 371 | _ if edge => {} | |
| 295 | 372 | border => { |
| 296 | 373 | // The visible border is one pt. |
| 297 | 374 | let visible_border_size = VISIBLE_BORDER_SIZE * scale; |
| ... | ... | @@ -318,8 +395,16 @@ where |
| 318 | 395 | (rect.height - y) as f32, |
| 319 | 396 | ) |
| 320 | 397 | } |
| 321 | // We draw small visible border only bellow the window surface, no need to | |
| 322 | // handle `TOP`. | |
| 398 | // Without a header, the top has its border too. | |
| 399 | DecorationParts::TOP => { | |
| 400 | let x = (rect.x.unsigned_abs() * scale) - visible_border_size; | |
| 401 | Rect::from_xywh( | |
| 402 | x as f32, | |
| 403 | (rect.height - visible_border_size) as f32, | |
| 404 | (rect.width - 2 * x) as f32, | |
| 405 | visible_border_size as f32, | |
| 406 | ) | |
| 407 | } | |
| 323 | 408 | DecorationParts::BOTTOM => { |
| 324 | 409 | let x = (rect.x.unsigned_abs() * scale) - visible_border_size; |
| 325 | 410 | Rect::from_xywh( |
| ... | ... | @@ -339,6 +424,10 @@ where |
| 339 | 424 | } |
| 340 | 425 | }; |
| 341 | 426 | |
| 427 | if edge { | |
| 428 | edge_layer(&mut pixmap, true); | |
| 429 | } | |
| 430 | ||
| 342 | 431 | if should_sync { |
| 343 | 432 | part.subsurface.set_sync(); |
| 344 | 433 | } else { |
| ... | ... | @@ -359,8 +448,13 @@ where |
| 359 | 448 | if let Some(input_rect) = part.input_rect { |
| 360 | 449 | let input_region = Region::new(&*self.compositor).ok()?; |
| 361 | 450 | input_region.add( |
| 362 | input_rect.x, | |
| 363 | input_rect.y, | |
| 451 | input_rect.x | |
| 452 | + if idx == DecorationParts::RIGHT { | |
| 453 | inset as i32 | |
| 454 | } else { | |
| 455 | 0 | |
| 456 | }, | |
| 457 | input_rect.y + reach as i32, | |
| 364 | 458 | input_rect.width as i32, |
| 365 | 459 | input_rect.height as i32, |
| 366 | 460 | ); |
| ... | ... | @@ -685,6 +779,8 @@ fn draw_headerbar_bg( |
| 685 | 779 | |
| 686 | 780 | let radius = if state.intersects(WindowState::MAXIMIZED | WindowState::TILED) { |
| 687 | 781 | 0. |
| 782 | } else if libadwaita_edge() { | |
| 783 | WINDOW_RADIUS * scale | |
| 688 | 784 | } else { |
| 689 | 785 | CORNER_RADIUS as f32 * scale |
| 690 | 786 | }; |
| ... | ... | @@ -702,6 +798,99 @@ fn draw_headerbar_bg( |
| 702 | 798 | Some(()) |
| 703 | 799 | } |
| 704 | 800 | |
| 801 | /// One layer of libadwaita's window edge in part `idx`, whose pixmap's origin lies at | |
| 802 | /// `origin` from the header's top left in a `window` of the header and body together, all | |
| 803 | /// in device pixels: the box shadows outside the window, under the part's content, or the | |
| 804 | /// outline inside it, over the content. Each part draws only where the others do not. | |
| 805 | fn draw_libadwaita_edge( | |
| 806 | pixmap: &mut PixmapMut, | |
| 807 | idx: usize, | |
| 808 | origin: [f32; 2], | |
| 809 | window: [u32; 2], | |
| 810 | scale: f32, | |
| 811 | active: bool, | |
| 812 | outline: bool, | |
| 813 | ) { | |
| 814 | let [width, height] = window.map(|v| v as f32); | |
| 815 | let radius = WINDOW_RADIUS * scale; | |
| 816 | // The top owns the body's top corners where no header rounds them. | |
| 817 | let top = (HEADER_SIZE as f32 * scale).max(radius); | |
| 818 | let owned = |x: f32, y: f32| match idx { | |
| 819 | DecorationParts::LEFT => x < 0. || (y >= top && y < height - radius), | |
| 820 | DecorationParts::RIGHT => x >= width || (y >= top && y < height - radius), | |
| 821 | DecorationParts::BOTTOM => y >= height || (0. ..width).contains(&x), | |
| 822 | _ => true, | |
| 823 | }; | |
| 824 | // Signed distance from the window grown by `spread` and moved down by `offset`. | |
| 825 | let distance = |x: f32, y: f32, offset: f32, spread: f32| { | |
| 826 | let radius = radius + spread; | |
| 827 | let [qx, qy] = [ | |
| 828 | (x - width / 2.).abs() - width / 2. - spread + radius, | |
| 829 | (y - offset - height / 2.).abs() - height / 2. - spread + radius, | |
| 830 | ]; | |
| 831 | qx.max(0.).hypot(qy.max(0.)) + qx.max(qy).min(0.) - radius | |
| 832 | }; | |
| 833 | let shadows = &WINDOW_SHADOWS[usize::from(!active)]; | |
| 834 | let columns = pixmap.width() as usize; | |
| 835 | for (index, pixel) in pixmap.pixels_mut().iter_mut().enumerate() { | |
| 836 | let [x, y] = [ | |
| 837 | (index % columns) as f32 + 0.5 + origin[0], | |
| 838 | (index / columns) as f32 + 0.5 + origin[1], | |
| 839 | ]; | |
| 840 | if !owned(x, y) { | |
| 841 | continue; | |
| 842 | } | |
| 843 | let edge = distance(x, y, 0., 0.); | |
| 844 | let (color, alpha) = if outline { | |
| 845 | // The share of the pixel inside the band 1 px wide inside the edge. | |
| 846 | let cover = (edge + 0.5).min(0.) - (edge - 0.5).max(-scale); | |
| 847 | (255., WINDOW_OUTLINE * cover.max(0.)) | |
| 848 | } else { | |
| 849 | let clear = shadows | |
| 850 | .iter() | |
| 851 | .fold(1., |clear, [offset, blur, spread, alpha]| { | |
| 852 | let reach = distance(x, y, offset * scale, spread * scale); | |
| 853 | // A Gaussian blur of standard deviation half the blur radius, as GSK's. | |
| 854 | let cover = if *blur == 0. { | |
| 855 | (0.5 - reach).clamp(0., 1.) | |
| 856 | } else { | |
| 857 | 0.5 * erfc(reach / (blur * scale / 2. * std::f32::consts::SQRT_2)) | |
| 858 | }; | |
| 859 | clear * (1. - alpha * cover) | |
| 860 | }); | |
| 861 | // Box shadows show only outside the window. | |
| 862 | (0., (1. - clear) * (edge + 0.5).clamp(0., 1.)) | |
| 863 | }; | |
| 864 | if alpha <= 0. { | |
| 865 | continue; | |
| 866 | } | |
| 867 | let over = |channel: u8| (color * alpha + f32::from(channel) * (1. - alpha)).round() as u8; | |
| 868 | let alpha_over = (alpha * 255. + f32::from(pixel.alpha()) * (1. - alpha)).round() as u8; | |
| 869 | if let Some(blended) = tiny_skia::PremultipliedColorU8::from_rgba( | |
| 870 | over(pixel.red()).min(alpha_over), | |
| 871 | over(pixel.green()).min(alpha_over), | |
| 872 | over(pixel.blue()).min(alpha_over), | |
| 873 | alpha_over, | |
| 874 | ) { | |
| 875 | *pixel = blended; | |
| 876 | } | |
| 877 | } | |
| 878 | } | |
| 879 | ||
| 880 | /// The complementary error function, to within 1.5e-7 (Abramowitz and Stegun 7.1.26). | |
| 881 | fn erfc(x: f32) -> f32 { | |
| 882 | let t = 1. / (1. + 0.327_591_1 * x.abs()); | |
| 883 | let poly = t | |
| 884 | * (0.254_829_6 | |
| 885 | + t * (-0.284_496_74 + t * (1.421_413_7 + t * (-1.453_152_1 + t * 1.061_405_4)))); | |
| 886 | let erfc = poly * (-x * x).exp(); | |
| 887 | if x >= 0. { | |
| 888 | erfc | |
| 889 | } else { | |
| 890 | 2. - erfc | |
| 891 | } | |
| 892 | } | |
| 893 | ||
| 705 | 894 | fn rounded_headerbar_shape(x: f32, y: f32, width: f32, height: f32, radius: f32) -> Option<Path> { |
| 706 | 895 | // https://stackoverflow.com/a/27863181 |
| 707 | 896 | let cubic_bezier_circle = 0.552_284_8 * radius; |
| ... | ... | @@ -765,7 +954,7 @@ fn rounded_headerbar_shape(x: f32, y: f32, width: f32, height: f32, radius: f32) |
| 765 | 954 | |
| 766 | 955 | // returns horizontal margin, logical points |
| 767 | 956 | fn get_margin_h_lp(state: &WindowState) -> f32 { |
| 768 | if state.intersects(WindowState::MAXIMIZED | WindowState::TILED) { | |
| 957 | if state.intersects(WindowState::MAXIMIZED | WindowState::TILED) || libadwaita_edge() { | |
| 769 | 958 | 0. |
| 770 | 959 | } else { |
| 771 | 960 | VISIBLE_BORDER_SIZE as f32 |
vendor/sctk-adwaita/src/theme.rs+1-1| ... | ... | @@ -6,7 +6,7 @@ pub(crate) const RESIZE_HANDLE_SIZE: u32 = 12; |
| 6 | 6 | // https://gitlab.gnome.org/GNOME/gtk/-/blob/1bf88f1d81043fd99740e2f91e56ade7ede7303b/gtk/gtkwindow.c#L166 |
| 7 | 7 | pub(crate) const RESIZE_HANDLE_CORNER_SIZE: u32 = 24; |
| 8 | 8 | pub(crate) const BORDER_SIZE: u32 = crate::shadow::SHADOW_SIZE + VISIBLE_BORDER_SIZE; |
| 9 | pub(crate) const HEADER_SIZE: u32 = 35; | |
| 9 | pub(crate) const HEADER_SIZE: u32 = 0; | |
| 10 | 10 | pub(crate) const CORNER_RADIUS: u32 = 10; |
| 11 | 11 | pub(crate) const VISIBLE_BORDER_SIZE: u32 = 1; |
| 12 | 12 |