authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-09-29 00:49:15-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-09-29 04:16:10-07:00
log2d9ae5806189ab5ae2c042699773045856976c65
tree3e0c9f989a52fe904626cb7812a6b6b8e85c80ce
parent074834705a828432d90e244347ec8f0913adb5e9
signature Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU

feat: one-row toolbar in the title bar, page background menu, rule lines, Insert Space, search pane, command palette

- 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.5

240 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.
2929
3030### macOS
3131
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
3439 window lies AppKit's title bar material, an `NSVisualEffectView`, and the
3540 chrome is drawn transparent over it, so the title bar, toolbar, tab row and
3641 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.
5257### Linux
5358
5459- 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.
5868- The toolbar and the rest of the chrome continue the title bar's fill,
5969 focused and not: on KDE the colour scheme's header colours from
6070 `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
2323ease exponentially toward their targets with a short half-life. That is
2424frame-rate independent, a retargeted animation continues smoothly from where
2525it is, and it settles fast. Popups open and close on short timed curves,
26slow enough to follow: a menu grows out of the pointer or its button as it
26slow enough to follow: a menu swings out of the pointer or its button as it
2727fades in, a combo's field widens into its list, and a dialog swings up into
28place over a dimmed window, as Windows opens a window. On GNOME and KDE, menus instead
28place over a dimmed window, as Windows opens a window; the command palette swings
29in the same way near the window's top. On GNOME and KDE, menus instead
2930look and move as the desktop's own (see [platforms](platforms.md)).
3031
3132## Why not native widgets
......@@ -62,7 +63,7 @@ frame N
6263 route input ─── against frame N-1's layout (hover, press, focus, drags, wheel)
6364 build ───────── app code declares boxes; each reads its Signal (clicked, dragging, events…)
6465 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
6667 paint ───────── Layers: primitives under a clip, or a Custom box the host paints
6768 after paint ─── work the frame asked for (page requests, saving) runs, then asks for a frame
6869```
......@@ -75,8 +76,17 @@ frame N
7576 it is being declared. It sounds odd and is invisible in practice.
7677- **Layout is solved after building**, per axis. A box can be sized in fixed
7778 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.
8090- **Nothing runs while nothing changes.** The kit reports whether it wants
8191 another frame (queued input, an animation still settling) and when a timed
8292 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.
113123 ordinary ones.
114124- **Batches** merge consecutive primitives that share a texture and clip,
115125 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.
116131- **Caches are bounded.** The glyph atlas and image cache have fixed budgets.
117132 Eviction keeps everything the current frame needs and rebuilds after
118133 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
6Ivy'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
10background 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`
12shows OneNote drawing the art over the rule lines. `tools/test_page_background.py`
13checks 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
3EQACEQEDEQH/2wBDAAMCAgMCAgMDAwMEAwMEBQgFBQQEBQoHBwYIDAoMDAsKCwsNDhIQDQ4RDgsL
4EBYQERMUFRUVDA8XGBYUGBIUFRT/2wBDAQMEBAUEBQkFBQkUDQsNFBQUFBQUFBQUFBQUFBQUFBQU
5FBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBT/xAAfAAABBQEBAQEBAQAAAAAAAAAAAQID
6BAUGBwgJCgv/xAC1EAACAQMDAgQDBQUEBAAAAX0BAgMABBEFEiExQQYTUWEHInEUMoGRoQgjQrHB
7FVLR8CQzYnKCCQoWFxgZGiUmJygpKjQ1Njc4OTpDREVGR0hJSlNUVVZXWFlaY2RlZmdoaWpzdHV2
8d3h5eoOEhYaHiImKkpOUlZaXmJmaoqOkpaanqKmqsrO0tba3uLm6wsPExcbHyMnK0tPU1dbX2Nna
94eLj5OXm5+jp6vHy8/T19vf4+fr/xAAfAQADAQEBAQEBAQEBAAAAAAAAAQIDBAUGBwgJCgv/xAC1
10EQACAQIEBAMEBwUEBAABAncAAQIDEQQFITEGEkFRB2FxEyIygQgUQpGhscEJIzNS8BVictEKFiQ0
114SXxFxgZGiYnKCkqNTY3ODk6Q0RFRkdISUpTVFVWV1hZWmNkZWZnaGlqc3R1dnd4eXqCg4SFhoeI
12iYqSk5SVlpeYmZqio6Slpqeoqaqys7S1tre4ubrCw8TFxsfIycrS09TV1tfY2dri4+Tl5ufo6ery
138/T19vf4+fr/2gAMAwEAAhEDEQA/AP1ToAKAOS8f+KbvwbFYamkX2nThL5V3EB8wDfdcH2wRjoc1
145GYYqpg+SqleN7NfkzGtV9jDnauup0el6nbazp8F7aSCW2nUOjjuK9KlVhXgqkHdM1TUldbHK/Cf
15/kW7r/sIXH/odeblf8F/4pfmcGB/hv1Z2leuegFABQAUAFABQAUAFABQAUAFABQAUAFABQAUAFAB
16QAUAFABQAUAFABQBW1HTrbVrGazu4lntpl2PG3QisqtKFaDpzV0xNKSszmNP0uP4X+ENUZLh7y1t
17vMuIUkGCgxwme/PfjrXlU6X9l4ao+bmSu1/l95lCEcPTstlf/Mz/AIM3Dt4GM03+ta6mZsjHzFuf
181qMok3hOZ73Zx4Bv2Lb7s7yBzJErHqRXtxd0ekSVQBQAUAFABQAUAFABQAUAFABQAUAFABQAUAFA
19BQAUAFABQAUAFADZHWJGdyFVQSWPQCk2oq7ASGZLiJZInWSNxlXQ5DD1BpRkppSi7oDn4/FunXmt
203+g6gi211EC3lXGDHPFjO5SeDx1HbB64zXmrGUZ1Z4asrNd9mt7/AHb/APAJU1z8iepj/C8ed4Su
219ufmv7gjj/brDLfew7a/mf5nDg1+6a83+Z3ESeXGq+gr2krI9AZ9oVpNi5Y98dBU82tkFyarAKAC
22gAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgBCAwIIyD2NG4HkFrf6j8J/GI0+5Ly+F7+
23U+Q7ZIhLHoD2weCO45r46E6mUYn2Uv4Unp5f8N17rU83mnh63LL4JbeT7eh1/ivwtb+MNQSVVMd1
24puDFdREK3mnDKpJB+UDkjB+9x3B9PGYVY6p7u8Ovnul6Ld+unVP03Sg+WUt07ry/4crfClhH4cnE
25gDZv7gb8cZ31eWaUXzfzP8zz8F/Dfq/zOz3gswUHaOCe1evfsd5IkaxjCgCrSS2AfTAKACgAoAKA
26CgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgDGu/GGi6fqDWN3qMFrdLg+XO2zIPcE4B/CuGeNw
279Obpzmk/PQlyinZtXIPFHiPw/p2j+bq9xay2UudsbYk83H91ec1ni8VhIUr12nF9N7+iCUlTXNJ2
28RzHgb4mw6/dw6fY6PfTb2Lz3QVRHGWJJJ56DOB344FeTl2ZKs1ShTbb1b0sr6v5L/hjOOJhXn7id
29u9tC98Kw58Pz44UX9znHf5+ld2Wp+yf+KX5nLgf4b9WdkgckqfuZyDjH4V6yu9D0AnhkY5jkK+3a
30nJN7CYsJlHEij/eFC5uoK/UmqxhQAUAFABQAUAFABQAUAFABQAUAFABQAUAFABQAUAFABQBzPjfw
31Hp/jiwEVyPJuowfJukHzJ7e49q83G4CnjY2lpJbP+uhz16EMRHlmeG6r4L8WeA5J9kU01kwKtLbf
32vIXU/wB5e34ivhcRgsTg+bmT5e61X9eqPHjRxuDlej7y/rp/ke3fD3xUfFGjbpdPk027gIWWExFE
33J7FMjpx07fln7bLsX9apax5Wt9LL5f1oe3SqSqwUpRafZlD4USn+w7iMDj7dckn/AIGajLH+6a/v
34S/M48C/3b9X+Z2lyWWIsnVea9eV7aHoMdE/mRq3qKad1cY+mAUAFABQAUAFABQAUAFABQAUAFABQ
35AUAFABQAUAFABQAUAFAFW81Oz05d11dQWw9ZpAv86xqVqdL+JJL1dgMbU/Eujalpt3bQavYtPJEw
36QC4TO7HHf1xXn4jFYetRnCFRXadtVv0KpySktTctLpb2yhuYsMssYkT3BGRXo06iq01Uj1VxNcrs
37zi/g3E8fhKTzcGYXk6uR6huf1rycoTWH97e7POwX8N+rO6r2z0BEQRrgdKSVgHUwCgAoAKACgAoA
38KACgAoAKACgAoAKACgAoAKACgAoAKACgDP1zSTrenvai8urAtz51pJsce2fSubEUPrEOTmcfNOzB
39q6seN678AtXed5bPVYr7P/P2WV/z5zXyFbIq6d6clL10f6niVsulUd1Ub9f6/Qx4PgP4mlkCv9jh
40Xu7TZx+QrmWS4xuzSXzOVZVUvrJfieo/DTwZL4Ma7tLjUp7qbapEOSIAp/iVT3yCPw96+hyvBywc
41pwnLXt0s+q873X/Dnv0KHsKaXM5evQn+E/8AyLd1/wBhC4/9Drpyv+C/8UvzOXA/w36s7OvXPQFo
42AKACgBKAFoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKAKl9ZtP5csJCXMOTG56c9V
43Psf8D1Arnq03O0oaSW3+T8n/AMHdFRdtHscp8JCW8MXBKlSb+4yp7fP0rz8qd6Dv/M/zPNwX8N+r
44OuvSRASOCCDmvVnsd72FtbgTp/tDqKIyuCdyerGFAFO5nxcxoOgIzWUnrYlvWxcrUoKAEoAiNyvm
45BF+Zj6dqhy6IVyarGFABQAUAFABQAUAFABQAUAFABQAUAFABQAnSgBEdZEDIwZT0KnINJNSV0Bxv
46wn/5Fu6/7CFx/wCh15OV/wAF/wCKX5nn4H+G/VnYTp5kLj2r1ZK6PQ3MuGUwyBh26iudOxinZmur
47BlBHQ11J3NhaAMkHzbr6tXNuzLeRrV0mohOBk0AZ11eGQ7U4T+dYSlczcuxLYQYHmHv0qoLqOKLt
48alhQAUAFABQAUAFABQAUAFABQAUAFABQAUAFAHnGreDtd8Ma2dU8JzB7WVt1xpMr4jJ77AeB+hH6
49V8zVwWIwlX22B2e8en9fijknTqxnz03p1T2+XY0PhE5k8LTsylGa+uCUPVTv6V3ZU70G3/M/zMsF
50/Dfqztq9g9Ay7uHyZT/dPIrnkrMykrFuwfdDj+6a0g9C47FqtCijbW5FyxPRTxWMY6kJa3L1bFlL
51UJsARjvyaym+hEmQWtqZmyeEH61EY3JjG5pgYGB0FdBqLQAUAFABQAUAFABQAUAFABQAUAFABQAU
52AFABQBn63rdr4esGvb0ulshAeRELbM9yBzj3rnxGIhhoe0qbfeJtRTb2OX+EM6XHhaaSNgyPezup
539QX4NedlUlKg2u7/ADODAu9J+rO4r2T0BksSyptYUmrhuRW0P2bcCc5PGKiK5dxJWJdzHov5mqu+
54wxcsP4R+Bo1AAwJx0PoadwImtFklLtk+1TyXd2KxMBgYHAqxi0AJmgAoAWgAoAKACgAoAKACgAoA
55KACgAoAKACgAoAjngjuYXilRZIpFKsjDIYHqDUyippxkrpgeffC61+zeF5BCCI4rydBjsA/FeHls
56eWg0tk3+Z5eDVqbt3Z21tfHIWTkf3q9iM+56Cl3LrHHTqa1bNAC4+vrQkA6mAUAV/PEk/ljkDqaz
57vd2FfUmU84PX+dWuwxJZREhY0N2DYzpbySQ8HaPQVg5NmTk2JHBLMeAfqaSTYWbLtvaiHkks1bRh
58Y0SsWKsYUAFABQAUAFABQAUAFABQAUAFABQAUAFAHFfCf/kW7r/r/uP/AEOvIyz+C/8AFL8zz8D/
59AA36s657SKQ524PtXqOCZ3tJj4l2jvgcDPpTihjLgyoN0ZzjquKUrrVCd+hFFqAJxINvuKlT7kqR
60PcSbIGYenBq5PS5T0INPiIBkPfgVEF1Jiiy/GD6GrfcsdVAJsA7ClZAOpgFABQAUAFABQAUAFABQ
61AUAFABQAUAFABQAUAFAHF/Cf/kW7r/sIXH/odeRlf8F/4pfmefgf4b9WdnXrnoABgUALQBC0Ecjs
62WUcVHKmwsL+78j/pnRpbyAEuI3OFYfSmpJivcc/QD1IoYx1UBEbuJT98fhU8yFdDkmST7rA+1Ckm
63MkqgCgAoAKACgAoAKACgAoAKACgAoAKAGSyLDE8jnCICxPoBSk1FNvoBwt98bfC1lKUW4nuSDgmG
64E4/XFeFPO8JF2V36L/OxxTxmHg7OX5mn4e+Jvh7xNcLb2l8EuW4WGdSjMfQZ4P4GunD5nhsTLkjK
65z7PT/gGtPEUq2kJXKvwn/wCRbuv+whcf+h1OV/wX/il+Zz4H+G/VnaV656AUAFAFK/kK/IP4utYz
66ZMn0LFumyBVPpWkVZDWhXmsB95G2+xqJQ7EuPYltllHEnOOhpxv1KV+pPWgynLZeYMpww4IPeseS
67+xMo3KjwvEfmUj3rNpozs0TQXrRnDfMv6iqUmilLuaEciyLuU5Fbp3NB1MAoAKACgAoAKACgAoAK
68ACgAoAKAOC8Y/B3R/FMz3URbTb5+WlhAKOfVl/qMV4OLyijiXzwfLL8Pu/yOKvg6WI1krPujk7D9
69ndku1a71oGBSDiCHa5/Ek4/WvLhkEub36mnktTjhlcIyu5v8jsvhFH5XhadMs22+uBljkn5+5r2s
70qXLQa/vP8zpwP8N+rO1ZwgyxAHvXrt2PQI1uo3farZJ9BU8yYromqxkBtg9wZG5x0FRy3d2K2tyX
71YO2R9DTsMAgB9frTsA6mAxZVZioI3DtSumADh2HrzQtwHUwGmND1UH8KVkAqoqdAB9KEkgHUwCgA
72oAKACgAoAKACgAoAKACgAoAKACgDg/hhdCDw1cgcub+4/wDQ68XLpWov/FL8zzcG7Un6s6XEly/d
73j/KvR1kdmsi/a2ogGTy571tGNjRKxYqyhMjOM8jtRcBaAEyM47+lAC0AZ19GUl3jv396wmrMzl3J
74bS58xlVvvDIz604yu9SoyuXK2KCgAoAKACgAoAKACgAoAKACgAoAKACgAoAKAEoA4f4TW8f/AAjU
75+Ru231wAWOT9+vGyuK9g/wDE/wAzz8F/DfqzuAAowBge1eyegLQBXu7nyFwPvn9KiUrEt2ItOyzS
76Mec4qICiXa2LM6ecpebv7vFYN+8Zt2ZoA5Fbmg2WMSoVPek1cNzMkje1lHtyD61ztOLMbcrNSN/M
77QMO4roTujYdTAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoA4v4T/wDIt3X/AGELj/0OvIyv+C/8
78UvzPPwP8N+rO0r1z0BCcCgDImkM0pb16Vyt3MW7s0rWHyYgO55NdEVZGqViaqGZV4u24euaW5lLc
790of9Un0FdEdjUfTAjmQSBVYZBNTJXDcIYvJTbnIHSiKsGxJVAFABQAUAFABQAUAFABQAUAFABQBz
80/h3xZDr2qa3ZABJNNuPKP+0uPvfmGH4V52FxixFSrT/kdvl/w9yIzjJtLp/w5e/tC5vObG3Vou1x
81cMVVh6qACWHucA9ia09tUq/wY6d3ovkt3+CfRm/Ko/Ewxq8fzbrKb/pmFeM/99Zb+VH+1R191+Wq
82/G7/ACD3PMsWd+l0zRlWhuE5eGT7w9/Qj3HH45ranWVT3WrSW6f9arzRLjbXocr8J/8AkW7r/sIX
83H/odeflf8F/4pfmebgf4b9WdpXrnoEN2+y3b34qJ7CexBZ2m3DuOewqYx6smMbF2tSwoAo6hFl0Y
84d+KxmtbkSVy6o2qB6CtVoWLTAY331/E1L3QD6oAoAKACgAoAKACgAoAKACgAoAKACgDyjwp4dng+
85KniiGTJsZAJ3x0k3MGVD7cnP0IPBNfH4XDN5jWpv4d353aaX+fe1tmzmowlSr1J20la34nq9fYHS
86FAFe8slu1Xlo5UOY5U+8h/z26GsalJVLdGtn1RSlY5H4S5Hhm43EFvt9xkjjJ315uV39g7/zP8zz
87sF/Dfqztq9g7xGUNjIzjmi1wFoAKACgBrKG69uaTVwHUwCgBg5kPsKnqA+qAKAI5bhIfvHn0qXJI
88Tdiq+o/3U/E1m59ieciN/KT2H4VPOyedifbpf7w/KjmYczHLqEg67T+FPnY+clTUR/En5VSn3Hzo
89mS7if+LHsapTTKTTJutWMWgAoAKAOO0PU9OTXdVhuLK3spLS6EFvcvDjePLU/wCsI5YknvnBr57D
90VcPDEVIzgouLsnby/m7/ADvqa805J32/roa/ijxdpvhDTftmoTbVbiONOXkPoo/r0r1MVjKWEhz1
91HvsurOapUhRjzTdkeX3Hx81C6Mr6docf2eMZaWeQkKP9rGAPzr5eefVXd06at53f+R5yx7nrTpu3
92duyOa8Q/GXxF4jRLW0xp6twRZbt8h+vUfQYrgr5tisQuW/L6f57/AHHFWzCpVfJQX+fy/q/oem/B
93KBl8Gq0u7zVuJVYN1B3c596+nyeP+zK/dnfl6ao673Z6FXunphQAUAFABQAUAFABQAxORn15qV3A
94dVAUrm95Kx/99VjKfYhy7FInJ5rMyCkAUAFABQA6ONpGwoJppXGlcvQWKpy/zH07VsodzVRsWq0K
95FoAKACgDzt9EXX9VbRpdPe70a1t4mguNQjcvG5dvMO6QbmJAAx06dq+UdH61V+rcjcIpWck7rXXV
966u+1ttuhfIl8aTX366nRXF94WNzHpNxLpfnW+ES0l8vMfTgKenavXlPA3VCfLeOlnbQzcle19Sv4
97j+Hem+KBbx3Mk8NjBylnalY4i394gDJOOP5dTU4jLaWJaTbUV0VkvXYzq0oVlyz1Rd0DwRofhk7t
98O06KGXGPOI3P/wB9HJrfD4DD4bWnDXvu/wARwpQpK0FYyPhP/wAi3df9hC4/9Drnyv8Agv8AxS/M
9948D/AA36s7SvXPQCgAoAKACgAoAKAGv0x3PFJ9gFAwKYEV0kkkeEIHr71Ek3sJmY8Txn5lIrBqxi
10000NpCCgA60ASx20knRT9TxVKLZSiy1Fp4HLnPsK0UO5aii0iKgwoAHtWiVix1MAoAKACgAoA4S98
101ezaV8UYdBuAv2C6t0ETY5WU7sHPcH7v4D3rwJ4+VLMPq8/haX3/8HY5pV+WuqL6q/wAyLx78OrPx
1023dS9NP1eBAY5xys8Z6bh7HI45HHYipx2AhjpyS92a69GvP0enlp3QsTg4YiKb0fcyvAw8X+B71NM
1031izm1HR3bZHc2583yPfj5tvqCOO3cHkwTxuAmqVaDcO61t+tvL7vPLDxxFJ8lT3l0f8Ameq19Yd5
104xfwn/wCRbuv+whcf+h15GV/wX/il+Z5+B/hv1Z2leuegFABQAUAFABQAUAMX5ju/KktdQH0wCgBC
105M0AMaCNuqL+VTyoBBbRD+BaOVBYeqKvRQPoKdkgHUwCgAoAKACgAoAKACgDzn4meEpr/AMQeG9at
106ELPbXkMU+3rsMgIb8Dkf8Cr5rNMLKdaliIdGk/v0Zy1qLqVKc1vF/gdxqkEjRpcQLuubc7kUfxr/
107ABJ+I/UA9q9vEQk0qkPijt5918/zs+h2xfR7MtW86XUEc0Tbo5FDKfUGt4TVSKnHZktWdmSVYji/
108hP8A8i3df9hC4/8AQ68jK/4L/wAUvzPPwP8ADfqztK9c9AKACgAoAKACgBrfMdv50nroAtMBaACg
109AoAKACgAoAKACgAoAKACgAoAKACgAoAKAM2w/wBE1C6s/wCA/wCkRD0DE7h+DZP/AAMVxUf3dWdH
110p8S+e6+/X5mktUpfIZd+JbG112z0cuZL+5DMIk52IATub0HGKc8VThWjQ3lL8PNmPNHm5b6mB8J/
111+Rbuv+whcf8AodcmV/wX/il+Zw4H+G/VnaV656AUAFABQAUANY46dTSbAUDAoSsAtMAoAKACgAoA
112KACgAoAKACgAoAKACgAoAKACgAoA5/xdqY8PWqawV3C2SVWX1UoWA/76RPzrysfV+rKOJ7XX3q/5
113pFppQlfor/ccR8G/D9/eXt94r1bcbm9BWEyDkqTkt7DgAew+leTk+HqTnLGVt3t593/keVg4TfNX
114qby/I6P4T/8AIt3X/YQuP/Q69TK/4L/xS/MrA/w36s7SvXPQCgAoAKAEJx/hSbsBFJMsHLnlvSpb
1155dwbsM+3xep/KlzonmRItzE/Rx+NVzJjumSVQxaACgAoAKACgAoAKACgAoAKACgAoAKACgAoAyfF
116MEdxoV0Jo1liQLIyOMghWDEEenFefj4xlh5cyulZ/c7/AKGlP4rGqqhVAAAA4AHavQStojM4z4T/
117APIt3X/YQuP/AEOvIyv+C/8AFL8zz8D/AA36s7SvXPQCgAoAazY9z6UmwADHJ60WAhvo98Ge681E
1181oTJXRmVgYhQBJHO8R+Vj9KpNopSaL0F6svDfK36VrGd9zRSuWa0KCgAoAKACgAoAKACgAoAKACg
119AoAKACgClrSeZo98vXdBIMf8BNc2KXNQqLyf5Fw0kie0fzbWF+u5FP6VrTfNBPyJejOR+E//ACLd
1201/2ELj/0OvMyv+C/8UvzPOwP8N+rO0r1z0AoAbuzwv5+lK/YBQuP8aErALTAQjIxQBk3EPkyle3a
121uZqzMZKxHUkhQAUAXLS8x8jn6E1rGVtGaxl0ZfrYsKACgAoAKACgAoAKACgAoAKACgBgmjL7A6lh
122/CDzU88W7XHZlXWn8vR75s42wSHP/ATWGKfLQqPyf5FQ1kie0j8q1hTptRR+la01ywS8iXqzkfhP
123/wAi3df9hC4/9DrzMs/gv/FL8zzsD/DfqzsTIB05r1eZHoABv6nj0FG+4DulUAtABQAUAQ3MAnTH
1248Q6GplG4mrmW6FGKkYIrn2MWrCUhBQAUAX7K53jY3UdD61tCXQ1i7lytSwoAKACgAoAKACgAoAKA
125CgAoA4/xn8M9N8XSfagz6fqYGBdwcFh6MO/16142Nyuji3zr3Z9/8zCrRjV6tPutynpHh7XvD2iX
126djqmsJq1rNsghLIfNUs4XGSeRg9DnHrXJHD4nD0JUq1Tmi7Jb31aX3am2GhOm/flzW8rHeV9IM4f
1274Wxeb4YugGKn7fcdP9+vGy1c1B/4pfmefgv4T9WdDNA8R+YH616DTR1NMYCVPBI+lSK5PFeyRnk7
128h71ak0UpF2G5SfpwfQ1spJmidyaqGFABQBDcWyzr6MOhqJRuJq5myxNC2GFYNWMWrDKQgoAVWKMC
129OopjWhrQSiaMN+Yroi7o2TuSVQwoAKACgAoAKACgAoAKACgAoAzdS/f6jp1t/ttcMP8AZQY/9CZf
130yrir+/Vp0/Nv5Jf5tGkdItmlXaZnE/CpxH4ZumY4A1C4/wDQ68fLHag/8T/M8/Bfwn6s7JZo5OAy
131n2r1uZM9Aa1rE/VB+HFLlTFZDPsEXofzo5ELlQ5bOJTkA5+tHIh2SJqsYtABQAUANdFkXDAEe9Jq
1324FKbTyOYzn2NZOHYhx7FR0ZDhgQfes9jNqwlIRZsZtku09G/nWkHZmkX0NKtzQKACgAoAKACgAoA
133KACgAoAQkKMkgAdzRsBm6d/pt7c33BjP7iEjoUX7zfi2fwUVw0P3tSVbpsvRbv5v8EjSXupRNOu4
134zOE+GYz4SvP+whcf+h14uXf7vL/E/wAzzsH/AAn6v8zoK7zpJI7mSLo3HoapNopSaLsF8snDfK36
135Vqp33NFJMs1oULQAUAFABQAUAFADWUOMEAj3oauBBJYRv0yp9qzcETyogbT3U5VgcfhUcjJ5bbF5
136CSoyMHuK2WxoOpgFABQAUAFABQAUAFABQB5r4l8Ia54t1uCPVNTW2093Jj0+xLcRj7zuxxk9B0PL
137DpXyuLwmKxlWMK07J7RXZbt7frq1sZOi6kuaUvdXRaX9Wei2trFY2sVvBGsUESBEReiqBgAV9PCE
138aUVCCslojUlqwOH+Fy7/AArej1vrn/0OvGy3WhL/ABS/M8/Bfwn6s3a7joCgAoAs2t4YjtblP5Vp
139GVjSMu5oghhkcg1uaC0AFABQAUAFABQAUAFABQAUAFABQAUAFABQAUAFABQBm6Z/pV3eXh/ifyI/
140ZEJB/Nt34Y9K4qH7yc6z72Xov+Df8DSWiUTSrtMwoA4r4T/8i3df9f8Acf8AodeRln8F/wCKX5nn
1414H+G/Vm9cR+VMy+/FdzVmdUlZkdSSFABQBasrny22N909PatIytoaRfQ0a3NAoAKACgAoAKACgAo
142AKACgAoAKACgAoAKACgAoAQnAoAzvDo/4kdk3eSISn6t8x/nXFgv92g+6v8AfqaVPjZpV2mYUAcX
1438J/+Rbuv+whcf+h15GV/wX/il+Z5+B/hv1Z1F/DuXzB1HX6V6U11O2SKFYmQUAFABQBpWU/mx4P3
144lreDvobRd0Wa0KCgAoAKACgAoAKACgAoAKACgAoAKACgAoAKAIbttlrM3ojH9KzqO0G/Ia3INGXZ
145o9ivpBGP/HRWWFVqFNeS/IqfxMi0nX7TWrrUYLZi7WM/2eU9t20E4/PH1BpUcTCvKcYfZdn/AF+B
146mmnsaVdQzi/hP/yLd1/2ELj/ANDryMr/AIL/AMUvzPPwP8N+rOzI3DB6GvXPQMq5gMEmP4T0NczV
147mYyViKpJCgAoAkt5fJlB7dDVJ2ZUXZmtXSbC0AFABQAUAFABQAUAFABQAUAFABQAUAFABQA1lDqV
148YBlIwQehFJpNWYEF7L9h06eSNQPJiZlUcDgcCsqj9lSk49F+Q+p5d+z550+na3dykt51wvzn+JsE
149sf8Ax4V8zkCk41JPrb79b/meXl7lKnKcusn+h3/i3xdp/g3SzeX7nk7Y4U+/K3oB/M9q9/F4ung4
150c9T5Lqzuq1YUY883ocx8H7iS/wDCxkXMaS3U0pA7ZbOM152VSdShfu2/xOHAPmpXXdnoPSvePTGT
151QiZNp/A+lS1cTVzLliaF9rVztWMWrDKQgoAKANS0k8yAeo4rog7o3TuierGFABQAUAFABQAUAFAB
152QAUAFABQAUAFABQB5z8M/iuni1/7O1FUttWUfKV4WYDrgdiPT8q+dy3NPrT9lV0l08/+CefhcWsR
1537slaS6HoF2kslu6wtGsh4BlUsvvkAjt7171RSlFqFr+eq/NHoqyepznhfSv+EWsZNO0/7NdwW0p8
1546OIFJVZsN3JBOCOOOMV5OEpSwcHSp2kovW2ju9eraeluw4wpxXLFWOO8R/DjVPiN4pfULu9jtdGT
155EduqHfJsHX5eiknOc8joRxXl1sBXzGu60pJQ6dXb06fPY8zE4N4ir+8laK6HQ/B63W08JywpnZHe
156zoueuA2K9XKYqGHcV0b/ADDAK1Jpd2dzXsnohQBFPAs64PXsfSpcbiauZk0LQthh9D61g1YyasMq
157SQoAuac+HZfXmtYPU0gX62NAoAKACgAoAKACgAoAKACgAoAKACgAoAKAMK68D6HeatFqb6fGl/E4
158kWeIlGLA5ydpGfxrz54DDTqKq4+8ne60/Ihwg5c7Sv3M7xJ8RbPQL5bD7Jey3bnCsLVzH7kHHzY/
1592c/UVyYvNIYefslFuXo7f8H5FOUYtKV/kmzN0e9tfA+q61d69qlvB/atws9ujBlYrtHJQ5K8nHP9
1602ubD1I4GpVnippc7TW+3pq1vbXsZtxpNylKyb6+iRsw66HTVLnQ401lTHHcRxQTKFZ23Kw3dBwgO
161PUn1rqhiburLCrn0Tsn1d0/yvb/M6JO8FJa7mJ8JLuc+FXZoCrveTs6c/KxbkVGVzl9Xu11f5nlY
162Fv2T06s76KQyDlCn1r207npElUAUANeNZFwwyKTVwM+4smiyV+Zf1FYyjYycexWrMgltW2XCfXFV
163HRlR3Nauk2CgAoAKACgAoAKACgAoAKACgAoAKACgAoA848AeGPGdjeLca5rb/Zh1sywmZ/qx+7+F
164fN4DC4+E1OvUaXa97/5fI48PCvHWtO/lb9Tur+2kM0F1AoeaDcPLJxvRsbgD2PAI+mOM5HtVoSco
1651Ybx6d09/ns16W03XfFrVM4vwSJptAeDWdNn1C8F5OyJcW2QoLn+Nht65714OAk5UHCvTcpczteP
166n3em9+pMISS99rd/m7fgbV94h0zwS0H9qyraPfMcPGhMSbQAEyBwAD1wM8njOK9B16WAa9vo59lo
167rW0+X46vS9iatWELczsttSh8I3WXwxcOhDK1/cEMOhG+jK2nQbX8z/M4MD/Dfqztq9g9AKACgAoA
168KAK89mkvI+VvUVm4JkuKZRkgeBskcDuKyaaIs0aoORXSai0AFABQAUAFABQAUAFABQAUAFABQAUA
169FAHK/ETxvB4J0Rpid17NlLaIdS3dvoP8B3rycyxqwdH3fie3+fyMa1eGHjzz+7uZPwl1rW/FFrf6
170vq0pMErrHbRKoVFC53ED6kDPtXJlNXEYnnrVno9F203McLVqVoe0mrX29C94l8fDw/400PRmRTDe
171g+a56qWO2PH4g5+tb4rMPq+Lp0Oj3+eiNZ1lCpCm/tXNHx14Qh8aeH5bCRhHMD5kEpH3HHTPt2P1
172rqx2EWMouns90/MK1KNem6cupzXwZL2HhJLeXAKXMqMB2IbFcGUXhh+V92cWX+7S5X3Z6LXvnqC0
173AFABQAUAFACEZFAABgUALQAUAFABQAUAFABQAUAFABQAUAFABQAUAePz+Bb/AOJ3i+61TVWey0a3
174kNvBCeJXRT2H8OTkknnnpXxywdTNMRKtV92Cdl3svyPOrYSVerzVfhWy7+fz/I9ZsrKDTrSK1tol
175gt4VCJGgwFA7V9dTpxpRUIKyR6CVlZHkfxU0ia6+JvhVkB2ztGgI7FJMt+hr5DNaUpY6lbrb8Ged
176iYOWIoyXd/hqex19kekcR8K4ll8NXQYZ/wCJhcf+h14+WJOg/wDFL8zz8F/CfqztVXaoGc47mvXW
177h6A6mAmecUALQAUAJQAtABQAUAFABQAUAFABQAUAFABQAUAFABQAUAFAGb4f/wCQWhP3jJKW/wB7
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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 @@
1param([Parameter(Mandatory=$true)][string]$Root, [string]$CloneHost = '')
2Set-StrictMode -Version Latest
3$ErrorActionPreference = 'Stop'
4$root = [IO.Path]::GetFullPath($Root).TrimEnd('\')
5if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') {
6 throw 'Choose a run directly below C:\one-tests\runs.'
7}
8if (Get-Process ONENOTE -ErrorAction SilentlyContinue) { throw 'Close OneNote before resetting its test cache.' }
9$key = 'HKCU:\Software\Microsoft\Office\14.0\OneNote'
10if ($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))
22if (Test-Path $parked) { throw 'Choose a new run; its parked cache already exists.' }
23New-Item -ItemType Directory -Path 'C:\one-tests\caches' -Force | Out-Null
24if (Test-Path $cache) { Move-Item -LiteralPath $cache -Destination $parked }
25if (Test-Path "$key\OpenNotebooks") { Remove-Item "$key\OpenNotebooks" -Recurse }
26New-Item "$key\OpenNotebooks" | Out-Null
27New-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 @@
1param(
2 [Parameter(Mandatory=$true)][string]$Root,
3 [int]$ExpectedPages = -1,
4 [switch]$UseCurrentCache,
5 [switch]$Pdf,
6 [switch]$KeepOpen,
7 [string]$CloneHost = ''
8)
9Set-StrictMode -Version Latest
10$ErrorActionPreference = 'Stop'
11$root = [IO.Path]::GetFullPath($Root).TrimEnd('\')
12if ([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'
17if (Test-Path $output) { throw 'Choose a new read destination.' }
18if ($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}
25New-Item -ItemType Directory -Path $output | Out-Null
26$app = New-Object -ComObject OneNote.Application
27$notebookId = ''
28$failure = $null
29try {
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
4pages 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
6Very Large Grid; ColorRed (Standard, then Rule Line Color, Red); Hidden (Standard, then
7Rule Line Color, `<none>`, on a Teal page). MediumGrid was then given Rule Line Color,
8Purple. `read` is its cold read in a fresh clone.
9
10The page node stores six properties MS-ONE does not document, in this order: the
11horizontal 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,
131 grid), spacing (`0x14001cd7`, 0 for a margin line) and colour (`0x14001cd8`). Colours
14are 0xAARRGGBB with alpha 0xFF, unlike the page colour's COLORREF; the menu's `<none>`
15colour is white. None removes all six. OneNote draws lines 1/96 inch wide: horizontal ones
16every spacing from the margin origin (36, 14.4) down across the page, grid lines through
17it 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
22Ruled, removes College's, turns Standard into a Small Grid and MediumGrid into Narrow
23Ruled in Red, and gives a created page, "Created", a Very Large Grid. `cold` is its fresh
24OneNote 2010 read: every page reads back with the lines written, and `read/page-*.png`
25shows 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 @@
1param([Parameter(Mandatory=$true)][string]$Root, [string]$CloneHost = '')
2Set-StrictMode -Version Latest
3$ErrorActionPreference = 'Stop'
4$root = [IO.Path]::GetFullPath($Root).TrimEnd('\')
5if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') {
6 throw 'Choose a run directly below C:\one-tests\runs.'
7}
8if (Get-Process ONENOTE -ErrorAction SilentlyContinue) { throw 'Close OneNote before resetting its test cache.' }
9$key = 'HKCU:\Software\Microsoft\Office\14.0\OneNote'
10if ($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))
22if (Test-Path $parked) { throw 'Choose a new run; its parked cache already exists.' }
23New-Item -ItemType Directory -Path 'C:\one-tests\caches' -Force | Out-Null
24if (Test-Path $cache) { Move-Item -LiteralPath $cache -Destination $parked }
25if (Test-Path "$key\OpenNotebooks") { Remove-Item "$key\OpenNotebooks" -Recurse }
26New-Item "$key\OpenNotebooks" | Out-Null
27New-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 @@
1param(
2 [Parameter(Mandatory=$true)][string]$Root,
3 [int]$ExpectedPages = -1,
4 [switch]$UseCurrentCache,
5 [switch]$Pdf,
6 [switch]$KeepOpen,
7 [string]$CloneHost = ''
8)
9Set-StrictMode -Version Latest
10$ErrorActionPreference = 'Stop'
11$root = [IO.Path]::GetFullPath($Root).TrimEnd('\')
12if ([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'
17if (Test-Path $output) { throw 'Choose a new read destination.' }
18if ($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}
25New-Item -ItemType Directory -Path $output | Out-Null
26$app = New-Object -ComObject OneNote.Application
27$notebookId = ''
28$failure = $null
29try {
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 @@
1none #FFFFFF
2Light Blue #CAEBFD
3Blue #D1E5FC
4Yellow #FFF2AE
5Green #D9E2C7
6Red #FFD4D6
7Purple #E0C9FF
8Cyan #BBE8DB
9Orange #F5DFB4
10Magenta #F5D3E8
11Blue Mist #BED3E2
12Purple Mist #C5BACD
13Tan #F1E3C2
14Lemon #F9FBA9
15Apple #CFF1BF
16Teal #94D7DF
17Red Chalk #D6ACAC
18Silver #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 @@
1param([Parameter(Mandatory=$true)][string]$Root, [string]$CloneHost = '')
2Set-StrictMode -Version Latest
3$ErrorActionPreference = 'Stop'
4$root = [IO.Path]::GetFullPath($Root).TrimEnd('\')
5if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') {
6 throw 'Choose a run directly below C:\one-tests\runs.'
7}
8if (Get-Process ONENOTE -ErrorAction SilentlyContinue) { throw 'Close OneNote before resetting its test cache.' }
9$key = 'HKCU:\Software\Microsoft\Office\14.0\OneNote'
10if ($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))
22if (Test-Path $parked) { throw 'Choose a new run; its parked cache already exists.' }
23New-Item -ItemType Directory -Path 'C:\one-tests\caches' -Force | Out-Null
24if (Test-Path $cache) { Move-Item -LiteralPath $cache -Destination $parked }
25if (Test-Path "$key\OpenNotebooks") { Remove-Item "$key\OpenNotebooks" -Recurse }
26New-Item "$key\OpenNotebooks" | Out-Null
27New-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 @@
1param(
2 [Parameter(Mandatory=$true)][string]$Root,
3 [int]$ExpectedPages = -1,
4 [switch]$UseCurrentCache,
5 [switch]$Pdf,
6 [switch]$KeepOpen,
7 [string]$CloneHost = ''
8)
9Set-StrictMode -Version Latest
10$ErrorActionPreference = 'Stop'
11$root = [IO.Path]::GetFullPath($Root).TrimEnd('\')
12if ([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'
17if (Test-Path $output) { throw 'Choose a new read destination.' }
18if ($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}
25New-Item -ItemType Directory -Path $output | Out-Null
26$app = New-Object -ComObject OneNote.Application
27$notebookId = ''
28$failure = $null
29try {
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;
3131mod link;
3232mod ops;
3333pub(crate) mod page;
34mod space;
3435mod table;
3536pub use format::{
3637 Alignment, BULLET_LIBRARY, FormatState, Formatting, ListStyle, NUMBER_LIBRARY, NoteTag, Toggle,
......@@ -126,6 +127,7 @@ struct PageHeader {
126127 created: Option<u64>,
127128 margin_origin: [f32; 2],
128129 color: Option<u32>,
130 rule_lines: Option<onestore::page::RuleLines>,
129131 areas: Vec<page::TitleArea>,
130132}
131133
......@@ -754,6 +756,11 @@ enum RestoreFocus {
754756
755757enum History {
756758 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 },
757764 Draft {
758765 outlines: Box<[TextOutline; 2]>,
759766 index: usize,
......@@ -765,10 +772,25 @@ enum History {
765772 outline: onestore::ExGuid,
766773 change: Box<TextChange>,
767774 },
775 /// A page object's position: an outline, picture or ink drawing.
768776 Position {
769 outline: onestore::ExGuid,
777 object: onestore::ExGuid,
770778 position: [Option<f32>; 2],
771779 },
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 },
772794 Layout {
773795 outline: ExGuid,
774796 layout: onestore::document::Layout,
......@@ -797,8 +819,12 @@ enum History {
797819 selection: Selection,
798820 focus: RestoreFocus,
799821 },
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 },
802828}
803829
804830impl CanvasEditor {
......@@ -876,6 +902,7 @@ impl CanvasEditor {
876902 created: page.created,
877903 margin_origin: page.margin_origin,
878904 color: page.color,
905 rule_lines: page.rule_lines,
879906 areas,
880907 };
881908 let mut ids = BTreeSet::new();
......@@ -1019,6 +1046,7 @@ impl CanvasEditor {
10191046 let gone = |focus: &RestoreFocus| matches!(focus, RestoreFocus::Outline(id) if !fresh.outlines.iter().any(|o| o.id == *id));
10201047 let reaches_one = |history: &History| match history {
10211048 History::Date(_) => date_changed,
1049 History::Paper { .. } => false,
10221050 History::Image { .. } | History::Picture { .. } => objects_changed,
10231051 History::Draft { outlines, .. } => changed.contains(&outlines[0].id),
10241052 History::Text { outline, change } => {
......@@ -1028,16 +1056,18 @@ impl CanvasEditor {
10281056 .iter()
10291057 .any(|placement| objects_changed || changed.contains(&placement.id))
10301058 }
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
10331063 }
10341064 History::Remove { outline, focus } => changed.contains(outline) || gone(focus),
10351065 History::Insert { outline, focus, .. } => changed.contains(&outline.id) || gone(focus),
10361066 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"),
10381068 };
10391069 let reaches = |history: &History| match history {
1040 History::Group(entries) => entries.iter().any(reaches_one),
1070 History::Group { entries, .. } => entries.iter().any(reaches_one),
10411071 history => reaches_one(history),
10421072 };
10431073 fresh.undo = std::mem::take(&mut self.undo)
......@@ -1143,6 +1173,7 @@ impl CanvasEditor {
11431173 .or(self.header.created),
11441174 margin_origin: self.header.margin_origin,
11451175 color: self.header.color,
1176 rule_lines: self.header.rule_lines,
11461177 objects,
11471178 definitions,
11481179 })
......@@ -1215,6 +1246,11 @@ impl CanvasEditor {
12151246 self.header.color
12161247 }
12171248
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
12181254 /// Where the page's first body line starts, in points, under its title.
12191255 pub fn body_start(&self) -> Option<[f32; 2]> {
12201256 let title = self
......@@ -1419,6 +1455,44 @@ impl CanvasEditor {
14191455 Ok(true)
14201456 }
14211457
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
14221496 /// Stored content; arrow-created paragraphs appear only in `visible_outlines`.
14231497 pub fn outlines(&self) -> &[TextOutline] {
14241498 &self.outlines
......@@ -1665,7 +1739,7 @@ impl CanvasEditor {
16651739 let layout = &mut self.outlines[index].layout;
16661740 let previous = [layout.x, layout.y];
16671741 self.undo.push(History::Position {
1668 outline: id,
1742 object: id,
16691743 position: previous,
16701744 });
16711745 self.redo.clear();
......@@ -2145,7 +2219,10 @@ impl CanvasEditor {
21452219 let result = edit(self);
21462220 let entries = self.undo.split_off(depth);
21472221 if !entries.is_empty() {
2148 self.undo.push(History::Group(entries));
2222 self.undo.push(History::Group {
2223 entries,
2224 page: true,
2225 });
21492226 }
21502227 result
21512228 }
......@@ -3212,10 +3289,9 @@ impl CanvasEditor {
32123289 };
32133290 match self.apply_history(engine, history) {
32143291 Ok(inverse) => {
3215 let group = matches!(inverse, History::Group(_));
3292 let page = matches!(inverse, History::Group { page: true, .. });
32163293 self.redo.push(inverse);
3217 // Undoing an edit to the page selection selects the page again.
3218 if group {
3294 if page {
32193295 self.select_page()?;
32203296 }
32213297 Ok(true)
......@@ -3255,6 +3331,7 @@ impl CanvasEditor {
32553331 .date
32563332 .as_ref()
32573333 .is_some_and(|current| current.source().id == date.source().id),
3334 History::Paper { .. } => true,
32583335 History::Draft {
32593336 outlines, index, ..
32603337 } => {
......@@ -3265,11 +3342,22 @@ impl CanvasEditor {
32653342 .position(|item| item.id == outlines[0].id)
32663343 .is_none_or(|found| found == *index)
32673344 }
3268 History::Text { outline, .. }
3269 | History::Position { outline, .. }
3270 | History::Layout { outline, .. } => {
3345 History::Text { outline, .. } | History::Layout { outline, .. } => {
32713346 self.outlines.iter().any(|item| item.id == *outline)
32723347 }
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)),
32733361 History::Image { image, .. } => self.image(*image).is_some(),
32743362 History::Picture {
32753363 index,
......@@ -3290,7 +3378,7 @@ impl CanvasEditor {
32903378 History::Insert { index, .. } | History::Restore { index, .. } => {
32913379 *index <= self.outlines.len()
32923380 }
3293 History::Group(_) => true,
3381 History::Group { .. } => true,
32943382 };
32953383 if !valid {
32963384 return Err((history, EditError::InvalidRange.into()));
......@@ -3301,6 +3389,7 @@ impl CanvasEditor {
33013389 self.record(Ok(self.date_ops()));
33023390 History::Date(Box::new(shown))
33033391 }
3392 History::Paper { color, rule_lines } => self.paper(color, rule_lines),
33043393 History::Draft {
33053394 outlines,
33063395 index,
......@@ -3387,25 +3476,67 @@ impl CanvasEditor {
33873476 change: Box::new(inverse),
33883477 }
33893478 }
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();
33973481 let previous = [layout.x, layout.y];
33983482 [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 }
34003486 self.record(self.placement_ops(&Placement {
3401 id: outline,
3487 id: object,
34023488 position: previous,
34033489 }));
34043490 History::Position {
3405 outline,
3491 object,
34063492 position: previous,
34073493 }
34083494 }
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 },
34093540 History::Layout { outline, layout } => {
34103541 let index = self
34113542 .outlines
......@@ -3554,18 +3685,21 @@ impl CanvasEditor {
35543685 self.active = Focus::Outline(index);
35553686 History::Remove { outline: id, focus }
35563687 }
3557 History::Group(mut entries) => {
3688 History::Group { mut entries, page } => {
35583689 let mut inverses = Vec::with_capacity(entries.len());
35593690 while let Some(entry) = entries.pop() {
35603691 match self.apply_history(engine, entry) {
35613692 Ok(inverse) => inverses.push(inverse),
35623693 Err((entry, error)) => {
35633694 entries.push(entry);
3564 return Err((History::Group(entries), error));
3695 return Err((History::Group { entries, page }, error));
35653696 }
35663697 }
35673698 }
3568 History::Group(inverses)
3699 History::Group {
3700 entries: inverses,
3701 page,
3702 }
35693703 }
35703704 };
35713705 self.preferred_x = None;
......@@ -5445,6 +5579,7 @@ mod tests {
54455579 created: None,
54465580 margin_origin: [36.0, 14.4],
54475581 color: None,
5582 rule_lines: None,
54485583 definitions: BTreeMap::new(),
54495584 objects: vec![PageObject::Image(background), PageObject::Image(picture)],
54505585 },
......@@ -5569,6 +5704,7 @@ mod tests {
55695704 created: None,
55705705 margin_origin: [36.0, 14.4],
55715706 color: None,
5707 rule_lines: None,
55725708 definitions: BTreeMap::new(),
55735709 objects,
55745710 },
......@@ -5627,6 +5763,7 @@ mod tests {
56275763 created: None,
56285764 margin_origin: [36.0, 14.4],
56295765 color: None,
5766 rule_lines: None,
56305767 definitions: BTreeMap::new(),
56315768 objects: vec![PageObject::Outline(source)],
56325769 },
......@@ -5767,6 +5904,7 @@ mod tests {
57675904 created: None,
57685905 margin_origin: [36.0, 14.4],
57695906 color: None,
5907 rule_lines: None,
57705908 definitions: BTreeMap::new(),
57715909 objects: vec![
57725910 PageObject::Outline(mixed),
......@@ -5858,6 +5996,7 @@ mod tests {
58585996 created: None,
58595997 margin_origin: [36.0, 14.4],
58605998 color: None,
5999 rule_lines: None,
58616000 definitions: BTreeMap::new(),
58626001 objects,
58636002 },
crates/canvas/src/editor/format.rs+12-3
......@@ -1895,13 +1895,19 @@ mod tests {
18951895 |editor: &CanvasEditor| editor.active_outline().shaped.paragraphs[1].tags[0].icon;
18961896 assert_eq!(
18971897 icon(&editor),
1898 crate::outline::TagIcon::CheckBox { checked: false }
1898 crate::outline::TagIcon::CheckBox {
1899 checked: false,
1900 mark: None
1901 }
18991902 );
19001903 editor.format(&mut engine, Formatting::Check).unwrap();
19011904 assert_eq!(text_tags(&editor, 1), [("To Do".into(), 1, true)]);
19021905 assert_eq!(
19031906 icon(&editor),
1904 crate::outline::TagIcon::CheckBox { checked: true }
1907 crate::outline::TagIcon::CheckBox {
1908 checked: true,
1909 mark: None
1910 }
19051911 );
19061912 editor.select([at(1, 0); 2].into()).unwrap();
19071913 editor.format(&mut engine, Formatting::Check).unwrap();
......@@ -1976,7 +1982,10 @@ mod tests {
19761982 assert_eq!(
19771983 icons[..7],
19781984 [
1979 TagIcon::CheckBox { checked: false },
1985 TagIcon::CheckBox {
1986 checked: false,
1987 mark: None
1988 },
19801989 TagIcon::Star,
19811990 TagIcon::Question,
19821991 TagIcon::Highlight,
crates/canvas/src/editor/ops.rs+30-24
......@@ -173,7 +173,7 @@ fn lower(
173173
174174/// An empty paragraph keeping a container from emptying while its paragraphs are removed
175175/// and inserted again; the lowering removes it after.
176fn anchor() -> Result<PageParagraph, EditError> {
176pub(super) fn anchor() -> Result<PageParagraph, EditError> {
177177 crate::document::node(
178178 Paragraph::new(String::new(), Format::default()),
179179 Format::default(),
......@@ -327,6 +327,9 @@ impl CanvasEditor {
327327 for object in &self.objects {
328328 match object {
329329 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 }
330333 page::Content::Outline { source, .. } if source.id == placement.id => {
331334 return layout_ops(source.id, &old, &moved(&source.layout));
332335 }
......@@ -352,29 +355,7 @@ impl CanvasEditor {
352355 match (old, new) {
353356 (None, None) => Ok(Vec::new()),
354357 (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()),
378359 (Some(old), Some(new)) => {
379360 let (a, b) = (old.document.nodes(), new.document.nodes());
380361 let prefix = a.iter().zip(b).take_while(|(x, y)| x == y).count();
......@@ -397,6 +378,31 @@ impl CanvasEditor {
397378 }
398379 }
399380
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
400406 /// The page's date as the editor shows it.
401407 pub(super) fn date_ops(&self) -> Vec<PageOp> {
402408 let Some(date) = &self.date else {
crates/canvas/src/editor/page.rs+15-4
......@@ -76,9 +76,9 @@ impl Content {
7676 match self {
7777 Self::Outline { source, .. } => Some((source.id, &mut source.layout)),
7878 Self::Image(source) => Some((source.id, &mut source.layout)),
79 Self::Ink(source) => Some((source.id, &mut source.layout)),
7980 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,
8282 }
8383 }
8484}
......@@ -357,8 +357,19 @@ pub(crate) fn build(
357357 })
358358}
359359
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.
361362pub(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
367pub(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.
372fn stroke_bounds(ink: &Ink) -> Option<[f32; 4]> {
362373 ink.strokes
363374 .iter()
364375 .flat_map(|stroke| {
......@@ -368,7 +379,7 @@ pub(crate) fn ink_bounds(ink: &Ink) -> Option<[f32; 4]> {
368379 .iter()
369380 .map(move |[x, y]| [x - r, y - r, x + r, y + r])
370381 })
371 .chain(ink.groups.iter().filter_map(ink_bounds))
382 .chain(ink.groups.iter().filter_map(stroke_bounds))
372383 .reduce(|a, b| {
373384 [
374385 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
4use super::*;
5
6/// Something Insert Space moves.
7enum 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
14impl 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;
44
55use crate::{
66 layout::{TextBrush, TextLayout},
7 outline::TagIcon,
7 outline::{BoxMark, TagIcon},
88};
99use draw::{GlyphRun, Glyphs, Layer, Primitive, RenderError};
1010use parley::PositionedLayoutItem;
......@@ -33,6 +33,7 @@ impl Viewport {
3333 origin: self.origin,
3434 clip: None,
3535 backdrop: None,
36 round: None,
3637 motion: None,
3738 primitives,
3839 }
......@@ -70,7 +71,7 @@ impl Paper {
7071 /// A fill OneNote draws on white paper, moved onto this paper: as far from it in
7172 /// OKLab lightness as from white, in the same hue and chroma, so text keeps its
7273 /// contrast on it.
73 pub(crate) fn tint(&self, light: [f32; 4]) -> [f32; 4] {
74 pub fn tint(&self, light: [f32; 4]) -> [f32; 4] {
7475 let [lightness, a, b] = draw::oklab(light);
7576 let [paper, ..] = draw::oklab(self.color);
7677 let distance = 1.0 - lightness;
......@@ -153,13 +154,82 @@ const RED_SQUARE: &str = include_str!("../../assets/tags/red-square.svg");
153154const YELLOW_SQUARE: &str = include_str!("../../assets/tags/yellow-square.svg");
154155const BLUE_SQUARE: &str = include_str!("../../assets/tags/blue-square.svg");
155156const FLAG: &str = include_str!("../../assets/tags/flag.svg");
157const IDEA: &str = include_str!("../../assets/tags/idea.svg");
158const PASSWORD: &str = include_str!("../../assets/tags/password.svg");
159const MOVIE: &str = include_str!("../../assets/tags/movie.svg");
160const BOOK: &str = include_str!("../../assets/tags/book.svg");
161const WEB: &str = include_str!("../../assets/tags/web.svg");
162const BLOG: &str = include_str!("../../assets/tags/blog.svg");
163const EMAIL: &str = include_str!("../../assets/tags/email.svg");
164const BOX_SMALL: &str = include_str!("../../assets/tags/box-small.svg");
165const CHECK_SMALL: &str = include_str!("../../assets/tags/check-small.svg");
166const MARK_PERSON: &str = include_str!("../../assets/tags/mark-person.svg");
167const MARK_MANAGER: &str = include_str!("../../assets/tags/mark-manager.svg");
168const MARK_ARROW: &str = include_str!("../../assets/tags/mark-arrow.svg");
169const MARK_ONE: &str = include_str!("../../assets/tags/mark-one.svg");
170const MARK_TWO: &str = include_str!("../../assets/tags/mark-two.svg");
171const MARK_CLIENT: &str = include_str!("../../assets/tags/mark-client.svg");
156172const TAG: &str = include_str!("../../assets/tags/tag.svg");
157173
158174/// The tag's artwork, drawn in order.
159175pub fn tag_sources(icon: TagIcon) -> &'static [&'static str] {
160176 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],
163233 TagIcon::Star => &[STAR],
164234 TagIcon::Question => &[QUESTION],
165235 TagIcon::Highlight => &[HIGHLIGHT],
......@@ -172,6 +242,13 @@ pub fn tag_sources(icon: TagIcon) -> &'static [&'static str] {
172242 TagIcon::YellowSquare => &[YELLOW_SQUARE],
173243 TagIcon::BlueSquare => &[BLUE_SQUARE],
174244 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],
175252 TagIcon::Other => &[TAG],
176253 }
177254}
......@@ -288,8 +365,14 @@ mod tests {
288365 });
289366 let mut renderer = draw::Renderer::new(device, queue, wgpu::TextureFormat::Rgba8UnormSrgb);
290367 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 },
293376 TagIcon::Star,
294377 TagIcon::Question,
295378 TagIcon::Highlight,
crates/canvas/src/gpu/page.rs+12-1
......@@ -733,7 +733,8 @@ impl PageScene {
733733 continue;
734734 }
735735 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);
737738 continue;
738739 }
739740 };
......@@ -1086,6 +1087,7 @@ mod tests {
10861087 title: "Header".into(),
10871088 margin_origin: [36.0, 14.4],
10881089 color: None,
1090 rule_lines: None,
10891091 definitions: BTreeMap::new(),
10901092 objects: vec![PageObject::Title(Title {
10911093 date: Some(fields[1].id),
......@@ -1528,6 +1530,7 @@ mod tests {
15281530 title: "Header".into(),
15291531 margin_origin: [36.0, 14.4],
15301532 color: None,
1533 rule_lines: None,
15311534 definitions: BTreeMap::new(),
15321535 objects: vec![PageObject::Title(Title {
15331536 date: None,
......@@ -1603,6 +1606,7 @@ mod tests {
16031606 title: String::new(),
16041607 margin_origin: [0.0; 2],
16051608 color: None,
1609 rule_lines: None,
16061610 definitions: BTreeMap::new(),
16071611 objects,
16081612 };
......@@ -1686,6 +1690,7 @@ mod tests {
16861690 title: String::new(),
16871691 margin_origin: [36.0, 14.0],
16881692 color: None,
1693 rule_lines: None,
16891694 objects: vec![unsupported, PageObject::Outline(editable)],
16901695 definitions: BTreeMap::from([
16911696 (
......@@ -1773,6 +1778,7 @@ mod tests {
17731778 title: String::new(),
17741779 margin_origin: [0.0; 2],
17751780 color: None,
1781 rule_lines: None,
17761782 definitions: BTreeMap::new(),
17771783 objects: vec![
17781784 PageObject::Outline(Outline {
......@@ -1953,6 +1959,7 @@ mod tests {
19531959 title: String::new(),
19541960 margin_origin: [0.0; 2],
19551961 color: None,
1962 rule_lines: None,
19561963 definitions: BTreeMap::new(),
19571964 objects: vec![
19581965 outline(2.0, "first"),
......@@ -2127,6 +2134,7 @@ mod tests {
21272134 title: String::new(),
21282135 margin_origin: [0.0; 2],
21292136 color: None,
2137 rule_lines: None,
21302138 definitions: BTreeMap::new(),
21312139 objects: vec![PageObject::Ink(ink.clone())],
21322140 };
......@@ -2187,6 +2195,7 @@ mod tests {
21872195 title: String::new(),
21882196 margin_origin: [0.0; 2],
21892197 color: None,
2198 rule_lines: None,
21902199 definitions: BTreeMap::new(),
21912200 objects: vec![PageObject::Image(Image {
21922201 size: None,
......@@ -2273,6 +2282,7 @@ mod tests {
22732282 title: String::new(),
22742283 margin_origin: [0.0; 2],
22752284 color: None,
2285 rule_lines: None,
22762286 definitions: BTreeMap::new(),
22772287 objects: vec![PageObject::Image(background), PageObject::Image(picture)],
22782288 };
......@@ -2341,6 +2351,7 @@ mod tests {
23412351 title: String::new(),
23422352 margin_origin: [0.0; 2],
23432353 color: None,
2354 rule_lines: None,
23442355 definitions: BTreeMap::new(),
23452356 objects: pictures.iter().cloned().map(PageObject::Image).collect(),
23462357 };
crates/canvas/src/gpu/page/background.rs+1
......@@ -910,6 +910,7 @@ mod tests {
910910 title: String::new(),
911911 margin_origin: [0.0; 2],
912912 color: None,
913 rule_lines: None,
913914 definitions: BTreeMap::new(),
914915 objects: vec![PageObject::Image(background)],
915916 };
crates/canvas/src/interaction/accessibility.rs+11-2
......@@ -609,6 +609,13 @@ fn runs_of(shaped: &ParagraphLayout, breaks: bool) -> Result<Vec<(Node, Vec<u32>
609609 TagIcon::YellowSquare => "Project B",
610610 TagIcon::BlueSquare => "Project C",
611611 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",
612619 TagIcon::Other => "Tag",
613620 };
614621 let label = if tag.label.is_empty() {
......@@ -617,8 +624,8 @@ fn runs_of(shaped: &ParagraphLayout, breaks: bool) -> Result<Vec<(Node, Vec<u32>
617624 &tag.label
618625 };
619626 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",
622629 _ => "",
623630 };
624631 format!(
......@@ -739,6 +746,7 @@ pub(super) mod tests {
739746 created: None,
740747 margin_origin: [36.0, 14.4],
741748 color: None,
749 rule_lines: None,
742750 definitions: Default::default(),
743751 objects: vec![
744752 PageObject::Title(Title {
......@@ -832,6 +840,7 @@ pub(super) mod tests {
832840 title: String::new(),
833841 margin_origin: [0.0; 2],
834842 color: None,
843 rule_lines: None,
835844 definitions: Default::default(),
836845 objects: vec![PageObject::Unsupported(Unsupported {
837846 id: Default::default(),
crates/canvas/src/interaction/mod.rs+175-6
......@@ -6,6 +6,7 @@ pub mod accessibility;
66#[cfg(test)]
77mod profile;
88mod scroll;
9mod space;
910
1011pub use scroll::Scroll;
1112#[cfg(test)]
......@@ -42,6 +43,10 @@ pub enum Cursor {
4243 NsResize,
4344 NwseResize,
4445 NeswResize,
46 /// Insert Space's line across the page.
47 RowResize,
48 /// Insert Space's line down the page.
49 ColResize,
4550}
4651
4752/// Work only the host can do, asked for by an event.
......@@ -151,6 +156,8 @@ enum Drag {
151156 press: [f32; 2],
152157 pending_press: Option<[f32; 2]>,
153158 },
159 /// Insert Space from a press on `line` along `axis`, 0 for x.
160 Space { axis: usize, line: f32 },
154161}
155162
156163#[derive(Clone, Copy)]
......@@ -205,6 +212,8 @@ pub struct PageView {
205212 pointer: [f32; 2],
206213 pointer_inside: bool,
207214 drag: Option<Drag>,
215 /// Insert Space waits for its press: its line follows the pointer.
216 space: bool,
208217 object_focus: Option<ObjectFocus>,
209218 modifiers: Modifiers,
210219 focused: bool,
......@@ -269,6 +278,7 @@ impl PageView {
269278 pointer: [0.0; 2],
270279 pointer_inside: false,
271280 drag: None,
281 space: false,
272282 object_focus: None,
273283 modifiers: Modifiers::default(),
274284 focused: true,
......@@ -287,6 +297,7 @@ impl PageView {
287297 self.editor = editor;
288298 self.scene = scene;
289299 self.drag = None;
300 self.space = false;
290301 self.object_focus = None;
291302 self.found.clear();
292303 }
......@@ -349,6 +360,32 @@ impl PageView {
349360 self.object_focus
350361 }
351362
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
352389 /// Text input goes to the editor unless an object holds focus.
353390 pub fn accepts_text(&self) -> bool {
354391 self.object_focus.is_none()
......@@ -622,6 +659,12 @@ impl PageView {
622659 self.moved()
623660 }
624661
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
625668 /// Scales the view by `factor`, keeping the document point under `anchor` in place.
626669 fn zoom_about(&mut self, factor: f32, anchor: [f32; 2]) {
627670 let point = self.viewport.document_point(anchor);
......@@ -721,7 +764,7 @@ impl PageView {
721764 .map(|(id, origin, size)| PointerFeedback::Image(id, origin, size))
722765 })
723766 .or_else(|| {
724 if !self.pointer_inside {
767 if !self.pointer_inside || self.space {
725768 return None;
726769 }
727770 match page_hit_test(
......@@ -737,7 +780,18 @@ impl PageView {
737780 }
738781 }),
739782 };
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(
741795 &self.editor,
742796 self.scene.as_ref(),
743797 preview,
......@@ -746,7 +800,12 @@ impl PageView {
746800 caret: if self.focused
747801 && !matches!(
748802 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 )
750809 ) {
751810 self.caret
752811 } else {
......@@ -760,11 +819,36 @@ impl PageView {
760819 chrome: !self.touch || self.focused,
761820 found: &self.found,
762821 },
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)
764845 }
765846
766847 /// The pointer shape at the pointer's position.
767848 pub fn cursor(&self) -> Cursor {
849 if let Some(axis) = self.space_axis() {
850 return [Cursor::ColResize, Cursor::RowResize][axis];
851 }
768852 let hit = self.hit_test(self.viewport.document_point(self.pointer));
769853 match (&self.drag, hit) {
770854 (Some(Drag::Image { handle, .. }), _) => handle_cursor(*handle),
......@@ -816,6 +900,7 @@ impl PageView {
816900 self.focused = focused;
817901 if !focused {
818902 self.drag = None;
903 self.space = false;
819904 }
820905 self.changed()
821906 }
......@@ -864,6 +949,7 @@ impl PageView {
864949 }
865950 self.changed()
866951 }
952 Some(Drag::Space { .. }) => Ok(Response::redraw()),
867953 Some(Drag::Resize { outline, grab }) => {
868954 let point = self.viewport.document_point(self.pointer);
869955 let width = (point[0] - self.editor.active_outline().origin()[0] - *grab).max(36.0);
......@@ -886,6 +972,15 @@ impl PageView {
886972 /// A primary-button press at the pointer's position, at `now` for click counting.
887973 pub fn pointer_pressed(&mut self, now: Instant) -> Result<Response> {
888974 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 }
889984 let unit = self
890985 .clicks
891986 .press(now, self.pointer, 4.0 * self.display_scale);
......@@ -987,6 +1082,13 @@ impl PageView {
9871082 }
9881083
9891084 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 }
9901092 // As in OneNote, an outline or picture dropped outside the view stays where it was.
9911093 let inside =
9921094 (0..2).all(|axis| (0.0..self.viewport.size[axis] as f32).contains(&self.pointer[axis]));
......@@ -1302,6 +1404,18 @@ impl PageView {
13021404 self.changed()
13031405 }
13041406
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
13051419 /// Gives keyboard focus to a read-only object, as an assistive technology asks.
13061420 pub fn focus_read_only(&mut self, index: usize) -> Result<Response> {
13071421 self.set_object_focus(Some(ObjectFocus::ReadOnly(index)));
......@@ -1355,9 +1469,16 @@ impl PageView {
13551469 }
13561470 if matches!(
13571471 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 {
13601480 self.drag = None;
1481 self.space = false;
13611482 if key == &Key::Named(NamedKey::Escape) {
13621483 return self.changed();
13631484 }
......@@ -1712,6 +1833,54 @@ fn page_hit(
17121833 .find_map(pass)
17131834}
17141835
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.
1839fn 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
17151884fn page_primitives<'a>(
17161885 editor: &'a CanvasEditor,
17171886 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
5use crate::gpu::colorref;
6use 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.
11pub(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()
5252 created: Some(1),
5353 margin_origin: [36.0, 14.4],
5454 color: None,
55 rule_lines: None,
5556 definitions: Default::default(),
5657 objects: vec![onestore::page::PageObject::Title(onestore::page::Title {
5758 id: onestore::ExGuid::default(),
......@@ -584,6 +585,7 @@ fn extension_hits_yield_to_objects_and_keep_their_source_coordinate_frame() {
584585 created: None,
585586 margin_origin: [0.0; 2],
586587 color: None,
588 rule_lines: None,
587589 definitions: Default::default(),
588590 objects: vec![onestore::page::PageObject::Outline(source)],
589591 };
......@@ -707,6 +709,7 @@ fn a_picture_in_an_outline_takes_the_click_over_its_text() {
707709 created: None,
708710 margin_origin: [36.0, 14.4],
709711 color: None,
712 rule_lines: None,
710713 definitions: Default::default(),
711714 objects: vec![PageObject::Outline(source)],
712715 },
......@@ -1219,6 +1222,7 @@ fn overlapping_objects_follow_paint_order_through_creation_movement_and_undo() {
12191222 title: String::new(),
12201223 margin_origin: [0.0; 2],
12211224 color: None,
1225 rule_lines: None,
12221226 definitions: Default::default(),
12231227 objects,
12241228 };
......@@ -1416,6 +1420,7 @@ fn read_only_focus_retires_text_overlays_and_draws_a_scaled_focus_border() {
14161420 title: String::new(),
14171421 margin_origin: [0.0; 2],
14181422 color: None,
1423 rule_lines: None,
14191424 definitions: Default::default(),
14201425 objects: vec![PageObject::Unsupported(Unsupported {
14211426 id: Default::default(),
......@@ -1531,6 +1536,7 @@ fn picture_view() -> (PageView, onestore::ExGuid) {
15311536 created: None,
15321537 margin_origin: [36.0, 14.4],
15331538 color: None,
1539 rule_lines: None,
15341540 definitions: Default::default(),
15351541 objects: vec![PageObject::Outline(source)],
15361542 },
......@@ -1568,6 +1574,7 @@ fn a_page_outline_emptied_by_backspace_still_draws() {
15681574 created: None,
15691575 margin_origin: [36.0, 14.4],
15701576 color: None,
1577 rule_lines: None,
15711578 definitions: Default::default(),
15721579 objects,
15731580 };
......@@ -2018,6 +2025,7 @@ fn page_keys_scroll_or_carry_the_caret_as_the_platform_does() {
20182025 created: None,
20192026 margin_origin: [36.0, 14.4],
20202027 color: None,
2028 rule_lines: None,
20212029 definitions: Default::default(),
20222030 objects: vec![onestore::page::PageObject::Outline(outline.snapshot())],
20232031 },
......@@ -2109,6 +2117,7 @@ fn page_selection_view() -> (PageView, [onestore::ExGuid; 4]) {
21092117 created: None,
21102118 margin_origin: [36.0, 14.4],
21112119 color: None,
2120 rule_lines: None,
21122121 definitions: Default::default(),
21132122 objects: std::iter::once(onestore::page::PageObject::Title(onestore::page::Title {
21142123 id: onestore::page::text::new_id().unwrap(),
......@@ -2425,3 +2434,194 @@ fn the_page_selection_does_not_return_with_its_text_selection() {
24252434 view.editor.select(whole).unwrap();
24262435 assert_eq!(view.editor.whole(), None);
24272436}
2437
2438#[test]
2439fn 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
2469fn 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.
2479fn 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]
2491fn 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]
2534fn 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]
2566fn 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]
2587fn 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 {
213213pub enum TagIcon {
214214 CheckBox {
215215 checked: bool,
216 /// The badge on the small box of OneNote's check box variants.
217 mark: Option<BoxMark>,
216218 },
217219 Star,
218220 Question,
......@@ -227,10 +229,28 @@ pub enum TagIcon {
227229 BlueSquare,
228230 /// An Outlook task flag.
229231 Flag,
232 Idea,
233 Password,
234 Movie,
235 Book,
236 Web,
237 Blog,
238 Email,
230239 /// A tag symbol without artwork of its own.
231240 Other,
232241}
233242
243/// What OneNote draws on a check box variant's small box.
244#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq)]
245pub enum BoxMark {
246 Person,
247 Manager,
248 Arrow,
249 One,
250 Two,
251 Client,
252}
253
234254impl TagIcon {
235255 /// How the page draws tag symbol `shape`, `checked` where it is a check box; none for
236256 /// shape 0, which marks the text instead.
......@@ -238,7 +258,21 @@ impl TagIcon {
238258 Some(match shape {
239259 0 => return None,
240260 // 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 },
242276 13 => Self::Star,
243277 15 => Self::Question,
244278 17 => Self::Exclamation,
......@@ -250,6 +284,13 @@ impl TagIcon {
250284 101 => Self::YellowSquare,
251285 102 => Self::BlueSquare,
252286 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,
253294 _ => Self::Other,
254295 })
255296 }
......@@ -1214,6 +1255,19 @@ impl OutlineLayout {
12141255 .flatten()
12151256 }
12161257
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
12171271 /// The paragraphs, tables and objects of root nodes `range`.
12181272 pub(crate) fn pieces(&self, range: Range<usize>) -> [Range<usize>; 3] {
12191273 let first = |root: usize| {
......@@ -2039,7 +2093,13 @@ mod tests {
20392093 extra_set: 0,
20402094 });
20412095 for (shape, expected) in [
2042 (3, TagIcon::CheckBox { checked: true }),
2096 (
2097 3,
2098 TagIcon::CheckBox {
2099 checked: true,
2100 mark: None,
2101 },
2102 ),
20432103 (13, TagIcon::Star),
20442104 (15, TagIcon::Question),
20452105 (17, TagIcon::Exclamation),
crates/canvas/src/search.rs+116-13
......@@ -6,6 +6,7 @@ use crate::document::TextPosition;
66use crate::editor::{CanvasEditor, Selection};
77use icu_normalizer::DecomposingNormalizerBorrowed;
88use onestore::ExGuid;
9use onestore::document::Kind;
910use onestore::page::{Page, PageObject, PageParagraph, ParagraphContent, text::Paragraph};
1011use std::ops::Range;
1112
......@@ -176,32 +177,96 @@ pub fn shown(paragraph: &Paragraph) -> String {
176177 .collect()
177178}
178179
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
180181/// cell by cell.
181pub fn page_text(page: &Page) -> String {
182 fn walk(paragraphs: &[PageParagraph], out: &mut Vec<String>) {
182fn 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 ) {
183187 for paragraph in paragraphs {
184188 match &paragraph.content {
185 ParagraphContent::Text(text) => out.push(shown(&text.text)),
189 ParagraphContent::Text(text) => visit(paragraph, &text.text),
186190 ParagraphContent::Table(table) => {
187191 for cell in table.rows.iter().flat_map(|row| &row.cells) {
188 walk(&cell.paragraphs, out);
192 walk(&cell.paragraphs, visit);
189193 }
190194 }
191195 _ => {}
192196 }
193197 }
194198 }
195 let mut out = Vec::new();
196199 for object in &page.objects {
197200 if let PageObject::Outline(outline) = object {
198 walk(&outline.paragraphs, &mut out);
201 walk(&outline.paragraphs, &mut visit);
199202 }
200203 }
204}
205
206/// A page's text outside its title, a paragraph to a line.
207pub fn page_text(page: &Page) -> String {
208 let mut out = Vec::new();
209 text_paragraphs(page, |_, text| out.push(shown(text)));
201210 out.retain(|line| !line.trim().is_empty());
202211 out.join("\n")
203212}
204213
214/// A tagged paragraph, as OneNote's Tags Summary lists it: once for each of its tags.
215#[derive(Clone, Debug, PartialEq)]
216pub 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.
235fn 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
205270/// A page as search knows it.
206271pub struct Entry {
207272 /// The section's key, which the host chooses.
......@@ -213,6 +278,7 @@ pub struct Entry {
213278 text: String,
214279 folded_title: String,
215280 folded_text: String,
281 tagged: Vec<Tagged>,
216282}
217283
218284impl Entry {
......@@ -227,6 +293,7 @@ impl Entry {
227293 title: page.title.clone(),
228294 modified,
229295 text,
296 tagged: tagged(section, space, page, modified),
230297 }
231298 }
232299}
......@@ -237,6 +304,9 @@ pub struct Found {
237304 pub section: String,
238305 pub space: ExGuid,
239306 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,
240310 /// Every word is in the title, as OneNote's "Title contains" lists it.
241311 pub in_title: bool,
242312 /// Byte ranges of the title the words match.
......@@ -261,6 +331,11 @@ impl Index {
261331 self.entries.is_empty()
262332 }
263333
334 /// Every page, each section's in order.
335 pub fn entries(&self) -> &[Entry] {
336 &self.entries
337 }
338
264339 /// Adds `entry`, replacing the page it names.
265340 pub fn set(&mut self, entry: Entry) {
266341 match self
......@@ -291,29 +366,32 @@ impl Index {
291366 if query.is_empty() {
292367 return Vec::new();
293368 }
294 let mut found: Vec<(&Entry, bool)> = self
369 let mut found: Vec<(usize, &Entry, bool)> = self
295370 .entries
296371 .iter()
297 .filter(|entry| scope(&entry.section))
298 .filter_map(|entry| {
372 .enumerate()
373 .filter(|(_, entry)| scope(&entry.section))
374 .filter_map(|(order, entry)| {
299375 let in_title = query.all_in(&entry.folded_title);
300376 let matches = in_title
301377 || query.terms.iter().all(|term| {
302378 starts_word(term, &entry.folded_title)
303379 || starts_word(term, &entry.folded_text)
304380 });
305 matches.then_some((entry, in_title))
381 matches.then_some((order, entry, in_title))
306382 })
307383 .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)));
309385 found
310386 .into_iter()
311 .map(|(entry, in_title)| {
387 .map(|(order, entry, in_title)| {
312388 let (snippet, snippet_hits) = snippet(entry, query);
313389 Found {
314390 section: entry.section.clone(),
315391 space: entry.space,
316392 title: entry.title.clone(),
393 modified: entry.modified,
394 order,
317395 in_title,
318396 title_hits: query.find(&entry.title),
319397 snippet,
......@@ -322,6 +400,16 @@ impl Index {
322400 })
323401 .collect()
324402 }
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 }
325413}
326414
327415/// 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> {
428516 }
429517 matches
430518}
519
520/// The whole of paragraph `id` on the page `editor` shows, as a tag summary selects it.
521pub 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 @@
11//! 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
5use onestore::page::{RuleLines, VerticalRule};
46
57/// One background picture of a template: the recreated art's name, and the picture's
68/// 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)] = &[
623625 ("Teal", 0xf2f9d4),
624626];
625627
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`).
631pub 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
642const RULE_COLOR: u32 = 0xfdebca;
643
644/// Lines `spacing` apart, the bits of the half inches OneNote stores.
645const 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
653const 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
626664/// The template named `name`.
627665pub fn find(name: &str) -> Option<&'static Template> {
628666 TEMPLATES.iter().find(|template| template.name == name)
crates/canvas/tests/ops_differential.rs+33-1
......@@ -95,11 +95,14 @@ enum Kind {
9595 PageType,
9696 PageMove,
9797 PageBold,
98 InsertSpace,
99 RemoveSpace,
100 InsertSpaceRight,
98101 Undo,
99102 Redo,
100103}
101104
102const KINDS: [Kind; 52] = [
105const KINDS: [Kind; 55] = [
103106 Kind::Type,
104107 Kind::TypeEnd,
105108 Kind::Compose,
......@@ -150,6 +153,9 @@ const KINDS: [Kind; 52] = [
150153 Kind::PageType,
151154 Kind::PageMove,
152155 Kind::PageBold,
156 Kind::InsertSpace,
157 Kind::RemoveSpace,
158 Kind::InsertSpaceRight,
153159 Kind::Undo,
154160 Kind::Redo,
155161];
......@@ -438,6 +444,24 @@ fn perform(
438444 .format(engine, Formatting::Toggle(Toggle::Bold))
439445 .is_ok()
440446 }
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 }
441465 Kind::Undo => editor.undo(engine).unwrap_or(false),
442466 Kind::Redo => editor.redo(engine).unwrap_or(false),
443467 }
......@@ -812,4 +836,12 @@ fn editor_ops_store_the_editor_s_page() {
812836 println!("{report}");
813837 let differs = totals[4];
814838 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 }
815847}
crates/canvas/tests/search.rs+45-1
......@@ -5,7 +5,7 @@ use canvas::{
55 document::TextDocument,
66 editor::CanvasEditor,
77 layout::TextEngine,
8 search::{Entry, Index, Query, fold, page_matches},
8 search::{Entry, Index, Query, fold, page_matches, paragraph_match},
99};
1010use onestore::{
1111 ExGuid, RevisionIndex, Store,
......@@ -214,3 +214,47 @@ fn words_match_word_starts_and_pages_rank_by_title_then_recency() {
214214 assert_eq!(order(&index, "cafe"), ["Café list"]);
215215 assert_eq!(index.len(), 2);
216216}
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]
221fn 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;
88attribute vec2 local;
99attribute vec4 shape;
1010attribute float stroke;
11attribute vec2 clip_local;
12attribute vec3 clip;
1113#endif
1214varying vec2 v_uv;
1315varying vec4 v_color;
1416varying vec2 v_local;
1517varying vec4 v_shape;
1618varying float v_stroke;
19varying vec2 v_clip_local;
20varying vec3 v_clip;
1721
1822#ifdef VERTEX
1923void main() {
......@@ -22,6 +26,8 @@ void main() {
2226 v_local = local;
2327 v_shape = shape;
2428 v_stroke = stroke;
29 v_clip_local = clip_local;
30 v_clip = clip;
2531 gl_Position = vec4(position, 0.0, 1.0);
2632}
2733#else
......@@ -97,6 +103,11 @@ void main() {
97103 }
98104 color *= coverage;
99105 }
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 }
100111 gl_FragColor = color;
101112}
102113#endif
crates/draw/src/draw.wgsl+10-2
......@@ -5,6 +5,8 @@ struct Vertex {
55 @location(2) local: vec2<f32>,
66 @location(3) @interpolate(flat) shape: vec4<f32>,
77 @location(4) @interpolate(flat) stroke: f32,
8 @location(5) clip_local: vec2<f32>,
9 @location(6) @interpolate(flat) clip: vec3<f32>,
810}
911
1012@group(0) @binding(0) var atlas: texture_2d<f32>;
......@@ -13,8 +15,9 @@ struct Vertex {
1315@vertex
1416fn vertex(@location(0) position: vec2<f32>, @location(1) uv: vec2<f32>,
1517 @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);
1821}
1922
2023fn ellipse_arc(angle: f32, radius: vec2<f32>) -> f32 {
......@@ -83,5 +86,10 @@ fn fragment(input: Vertex) -> @location(0) vec4<f32> {
8386 }
8487 color *= coverage;
8588 }
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 }
8694 return color;
8795}
crates/draw/src/render.rs+179-23
......@@ -170,6 +170,12 @@ struct Batch {
170170 scissor: [u32; 4],
171171}
172172
173/// Batches that paint offscreen as one, then onto the target by `composite`'s vertices.
174struct Group {
175 batches: Range<usize>,
176 composite: Range<u32>,
177}
178
173179/// How a batch meets what lies beneath it.
174180#[derive(Clone, Copy, PartialEq)]
175181enum Blend {
......@@ -189,6 +195,10 @@ struct Vertex {
189195 local: [f32; 2],
190196 shape: [f32; 4],
191197 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],
192202}
193203
194204#[derive(Hash, PartialEq, Eq)]
......@@ -244,6 +254,10 @@ pub struct Layer<'a> {
244254 /// The colour behind the layer: on a dark one, text in dark colours of its own is
245255 /// lifted to stay legible against what lies behind it, as OneNote's dark page does.
246256 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.
247261 pub motion: Option<Motion>,
248262 pub primitives: &'a [Primitive<'a>],
249263}
......@@ -260,28 +274,16 @@ pub struct Motion {
260274}
261275
262276impl 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] {
272279 // Seen from twice the target's height away, so a small turn reads as depth.
273280 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 ];
278281 let below = y - self.pivot[1];
279282 let near = distance / (distance + below * self.tilt.sin());
280 let [x, y] = [
283 [
281284 self.pivot[0] + (x - self.pivot[0]) * near,
282285 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 ]
285287 }
286288}
287289
......@@ -292,7 +294,7 @@ struct Space {
292294 scale: f32,
293295 origin: [f32; 2],
294296 backdrop: Option<[f32; 4]>,
295 motion: Option<Motion>,
297 round: Option<([f32; 4], f32)>,
296298}
297299
298300impl Space {
......@@ -458,6 +460,9 @@ pub struct Renderer {
458460 glyphs: HashMap<AtlasKey, Option<AtlasGlyph>>,
459461 images: HashMap<u64, CachedImage>,
460462 batches: Vec<Batch>,
463 groups: Vec<Group>,
464 /// Batches before this one take no more primitives.
465 barrier: usize,
461466 scaler: ScaleContext,
462467 pen: [u32; 2],
463468 row_height: u32,
......@@ -479,6 +484,8 @@ impl Renderer {
479484 glyphs: HashMap::new(),
480485 images: HashMap::new(),
481486 batches: Vec::new(),
487 groups: Vec::new(),
488 barrier: 0,
482489 scaler: ScaleContext::with_max_entries(32),
483490 pen: [1, 0],
484491 row_height: 1,
......@@ -557,7 +564,7 @@ impl Renderer {
557564 scale: layer.scale,
558565 origin: layer.origin,
559566 backdrop: layer.backdrop,
560 motion: layer.motion,
567 round: layer.round,
561568 };
562569 for primitive in layer.primitives {
563570 if let Primitive::Image { image, rect } = primitive
......@@ -574,6 +581,8 @@ impl Renderer {
574581 for attempt in 0..2 {
575582 self.vertices.clear();
576583 self.batches.clear();
584 self.groups.clear();
585 self.barrier = 0;
577586 match self.prepare(size, layers, &active_images) {
578587 Err(RenderError::AtlasFull) if attempt == 0 => self.clear_glyph_cache(),
579588 result => {
......@@ -582,7 +591,7 @@ impl Renderer {
582591 }
583592 }
584593 }
585 self.submit(target, clear);
594 self.submit(target, size, clear);
586595 Ok(())
587596 }
588597
......@@ -592,13 +601,26 @@ impl Renderer {
592601 layers: &[Layer<'_>],
593602 active_images: &HashSet<u64>,
594603 ) -> 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;
595606 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 }
596618 let space = Space {
597619 size,
598620 scale: layer.scale,
599621 origin: layer.origin,
600622 backdrop: layer.backdrop,
601 motion: layer.motion,
623 round: layer.round,
602624 };
603625 let bounds = match layer.clip {
604626 Some(clip) => space.scissor(clip),
......@@ -611,6 +633,67 @@ impl Renderer {
611633 self.primitive(space, bounds, primitive, active_images)?;
612634 }
613635 }
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 });
614697 Ok(())
615698 }
616699
......@@ -714,13 +797,21 @@ impl Renderer {
714797 }
715798 }
716799 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]];
718804 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])];
720810 }
721811 }
722812 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()
724815 && last.blend == blend
725816 && last.scissor == scissor
726817 {
......@@ -1232,6 +1323,8 @@ impl Renderer {
12321323 local,
12331324 shape: [half[0], half[1], corner, corner],
12341325 stroke: 0.0,
1326 clip_local: [0.0; 2],
1327 clip: [0.0; 3],
12351328 });
12361329 }
12371330 Ok(())
......@@ -1283,6 +1376,8 @@ impl Renderer {
12831376 local,
12841377 shape: [0.0; 4],
12851378 stroke: 0.0,
1379 clip_local: [0.0; 2],
1380 clip: [0.0; 3],
12861381 });
12871382 }
12881383 Ok(())
......@@ -1715,11 +1810,66 @@ mod tests {
17151810 origin: [24.0; 2],
17161811 clip: None,
17171812 backdrop: None,
1813 round: None,
17181814 motion: None,
17191815 primitives,
17201816 }]
17211817 }
17221818
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
17231873 #[test]
17241874 #[ignore = "requires a native GPU adapter"]
17251875 fn eviction_and_repaint_reproduce_pixels() {
......@@ -2119,6 +2269,7 @@ mod tests {
21192269 origin: [0.0; 2],
21202270 clip: None,
21212271 backdrop: None,
2272 round: None,
21222273 motion: None,
21232274 primitives: &primitives,
21242275 }],
......@@ -2191,6 +2342,7 @@ mod tests {
21912342 origin: [0.0; 2],
21922343 clip: None,
21932344 backdrop: None,
2345 round: None,
21942346 motion: None,
21952347 primitives: &primitives,
21962348 }],
......@@ -2236,6 +2388,7 @@ mod tests {
22362388 origin: [0.0; 2],
22372389 clip: None,
22382390 backdrop: None,
2391 round: None,
22392392 motion: None,
22402393 primitives: &primitives,
22412394 }],
......@@ -2288,6 +2441,7 @@ mod tests {
22882441 origin: [0.0; 2],
22892442 clip: Some([100.0, 50.0, 200.5, 80.0]),
22902443 backdrop: None,
2444 round: None,
22912445 motion: None,
22922446 primitives: &fill,
22932447 },
......@@ -2296,6 +2450,7 @@ mod tests {
22962450 origin: [300.0, 10.0],
22972451 clip: None,
22982452 backdrop: None,
2453 round: None,
22992454 motion: None,
23002455 primitives: &chrome,
23012456 },
......@@ -2324,6 +2479,7 @@ mod tests {
23242479 origin: [0.0; 2],
23252480 clip: None,
23262481 backdrop: None,
2482 round: None,
23272483 motion: None,
23282484 primitives: &fill,
23292485 }]
crates/draw/src/render/gl.rs+164-55
......@@ -38,6 +38,7 @@ const CLAMP_TO_EDGE: GLint = 0x812F;
3838const TEXTURE0: GLenum = 0x84C0;
3939const ARRAY_BUFFER: GLenum = 0x8892;
4040const STREAM_DRAW: GLenum = 0x88E0;
41const STATIC_DRAW: GLenum = 0x88E4;
4142const FRAGMENT_SHADER: GLenum = 0x8B30;
4243const VERTEX_SHADER: GLenum = 0x8B31;
4344const COMPILE_STATUS: GLenum = 0x8B81;
......@@ -103,6 +104,7 @@ unsafe extern "C" {
103104 fn glBindBuffer(target: GLenum, buffer: GLuint);
104105 fn glBufferData(target: GLenum, size: isize, data: *const c_void, usage: GLenum);
105106 fn glEnableVertexAttribArray(index: GLuint);
107 fn glDisableVertexAttribArray(index: GLuint);
106108 fn glVertexAttribPointer(
107109 index: GLuint,
108110 size: GLint,
......@@ -132,6 +134,7 @@ unsafe extern "C" {
132134 fn glUseProgram(program: GLuint);
133135 fn glGetUniformLocation(program: GLuint, name: *const c_char) -> GLint;
134136 fn glUniform1i(location: GLint, value: GLint);
137 fn glUniform2f(location: GLint, x: f32, y: f32);
135138
136139 fn glGenFramebuffersEXT(n: GLsizei, framebuffers: *mut GLuint);
137140 fn glDeleteFramebuffersEXT(n: GLsizei, framebuffers: *const GLuint);
......@@ -158,6 +161,9 @@ unsafe extern "C" {
158161 );
159162}
160163
164/// Blending a premultiplied picture over what lies beneath.
165const PREMULTIPLIED: [GLenum; 4] = [ONE, ONE_MINUS_SRC_ALPHA, ONE, ONE_MINUS_SRC_ALPHA];
166
161167/// An sRGB RGBA texture, deleted with its value.
162168pub(super) struct Image {
163169 name: GLuint,
......@@ -283,65 +289,96 @@ impl Drop for Target {
283289 }
284290}
285291
292/// Whether an offscreen picture's rows run bottom first.
293pub(super) const FLIPPED: bool = true;
294
286295pub(super) struct Gpu {
287296 program: GLuint,
288297 buffer: GLuint,
298 /// `translucent.glsl`, and the triangle it covers a window with.
299 translucent: GLuint,
300 triangle: GLuint,
289301 atlas: Image,
290302 max_texture: u32,
303 /// Offscreen pictures for groups, the target's size.
304 groups: Vec<Target>,
291305}
292306
293307/// Each attribute's name, component count and offset, bound to its index in the shader.
294const ATTRIBUTES: [(&CStr, GLint, usize); 6] = [
308const ATTRIBUTES: [(&CStr, GLint, usize); 8] = [
295309 (c"position", 2, offset_of!(Vertex, position)),
296310 (c"uv", 2, offset_of!(Vertex, uv)),
297311 (c"color", 4, offset_of!(Vertex, color)),
298312 (c"local", 2, offset_of!(Vertex, local)),
299313 (c"shape", 4, offset_of!(Vertex, shape)),
300314 (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)),
301317];
302318
303319impl Renderer {
304320 /// Draws with the OpenGL context current on this thread, which must stay current
305321 /// whenever the renderer or a `Target` is used.
306322 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];
332327 let mut max_texture = 0;
333328 unsafe {
334329 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 );
335339 glGetIntegerv(MAX_TEXTURE_SIZE, &mut max_texture);
336340 }
337341 Ok(Self::with_gpu(Gpu {
338 program,
342 program: draw,
339343 buffer,
344 translucent,
345 triangle,
340346 atlas: Image::new([ATLAS_SIZE; 2], NEAREST, None),
341347 max_texture: max_texture as u32,
348 groups: Vec::new(),
342349 }))
343350 }
344351
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
345382 /// The widest and tallest texture the driver takes, in pixels.
346383 pub fn max_texture_dimension(&self) -> u32 {
347384 self.gpu.max_texture
......@@ -368,17 +405,68 @@ impl Renderer {
368405 Image::new(image.size, LINEAR, Some(image.pixels()))
369406 }
370407
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);
374435 let gpu = &self.gpu;
375 unsafe {
436 let begin = |target: &Target, clear: [f32; 4]| unsafe {
376437 glBindFramebufferEXT(FRAMEBUFFER, target.framebuffer);
377 glEnable(FRAMEBUFFER_SRGB);
378 glViewport(0, 0, width, height);
379438 glDisable(SCISSOR_TEST);
380439 glClearColor(clear[0], clear[1], clear[2], clear[3]);
381440 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);
382470 glUseProgram(gpu.program);
383471 glUniform1i(glGetUniformLocation(gpu.program, c"atlas".as_ptr()), 0);
384472 glActiveTexture(TEXTURE0);
......@@ -401,32 +489,26 @@ impl Renderer {
401489 );
402490 }
403491 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;
422502 glBlendFuncSeparate(src_rgb, dst_rgb, src_alpha, dst_alpha);
423 glBindTexture(TEXTURE_2D, texture.name);
503 glBindTexture(TEXTURE_2D, picture.texture.name);
424504 glDrawArrays(
425505 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,
428508 );
509 next = group.batches.end;
429510 }
511 draw(&self.batches[next..]);
430512 glDisable(SCISSOR_TEST);
431513 glDisable(BLEND);
432514 glBindFramebufferEXT(FRAMEBUFFER, 0);
......@@ -434,6 +516,33 @@ impl Renderer {
434516 }
435517}
436518
519/// A program from `source`'s two stages, its attributes bound in order.
520unsafe 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
437546unsafe fn compile(kind: GLenum, source: &str) -> Result<GLuint, String> {
438547 unsafe {
439548 let shader = glCreateShader(kind);
......@@ -444,7 +553,7 @@ unsafe fn compile(kind: GLenum, source: &str) -> Result<GLuint, String> {
444553 glGetShaderiv(shader, COMPILE_STATUS, &mut compiled);
445554 if compiled == 0 {
446555 return Err(format!(
447 "Compiling the draw shader failed: {}",
556 "Compiling a shader failed: {}",
448557 log(shader, glGetShaderiv, glGetShaderInfoLog)
449558 ));
450559 }
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
4attribute vec2 position;
5
6void main() {
7 gl_Position = vec4(position, 0.0, 1.0);
8}
9#else
10uniform sampler2D frame;
11uniform vec2 size;
12
13vec3 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
18void 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;
66
77pub(super) type Image = wgpu::BindGroup;
88
9/// Whether an offscreen picture's rows run bottom first.
10pub(super) const FLIPPED: bool = false;
11
912pub(super) struct Gpu {
1013 pipeline: wgpu::RenderPipeline,
1114 image_pipeline: wgpu::RenderPipeline,
......@@ -14,6 +17,10 @@ pub(super) struct Gpu {
1417 bind_group: wgpu::BindGroup,
1518 image_sampler: wgpu::Sampler,
1619 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],
1724}
1825
1926impl Renderer {
......@@ -56,7 +63,7 @@ impl Renderer {
5663 buffers: &[Some(wgpu::VertexBufferLayout {
5764 array_stride: size_of::<Vertex>() as u64,
5865 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],
6067 })],
6168 },
6269 fragment: Some(wgpu::FragmentState {
......@@ -148,6 +155,9 @@ impl Renderer {
148155 bind_group,
149156 image_sampler,
150157 vertex_buffer,
158 format,
159 groups: Vec::new(),
160 groups_size: [0; 2],
151161 },
152162 )
153163 }
......@@ -225,18 +235,76 @@ impl Renderer {
225235 })
226236 }
227237
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);
230284 let gpu = &self.gpu;
231285 self.queue
232286 .write_buffer(&gpu.vertex_buffer, 0, bytemuck::cast_slice(&self.vertices));
233287 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]| {
235303 let [r, g, b, a] = clear.map(f64::from);
236304 let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
237305 label: Some("Draw"),
238306 color_attachments: &[Some(wgpu::RenderPassColorAttachment {
239 view: target,
307 view,
240308 depth_slice: None,
241309 resolve_target: None,
242310 ops: wgpu::Operations {
......@@ -247,19 +315,24 @@ impl Renderer {
247315 ..Default::default()
248316 });
249317 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;
262333 }
334 draw(&mut pass, &self.batches[next..]);
335 drop(pass);
263336 self.queue.submit([encoder.finish()]);
264337 }
265338}
crates/notebook/examples/keystroke_probe.rs+1
......@@ -136,6 +136,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
136136 created: None,
137137 margin_origin: [0.0; 2],
138138 color: None,
139 rule_lines: None,
139140 objects: vec![PageObject::Outline(Outline {
140141 id: new_id()?,
141142 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>> {
4545 created: None,
4646 margin_origin: [0.0; 2],
4747 color: None,
48 rule_lines: None,
4849 objects: vec![PageObject::Outline(Outline {
4950 id: new_id()?,
5051 title: false,
crates/notebook/examples/save_probe.rs+1
......@@ -51,6 +51,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
5151 created: None,
5252 margin_origin: [0.0; 2],
5353 color: None,
54 rule_lines: None,
5455 objects: vec![PageObject::Outline(Outline {
5556 id: new_id()?,
5657 title: false,
crates/notebook/src/merge.rs+10-6
......@@ -153,7 +153,7 @@ fn named(op: &PageOp) -> Vec<ExGuid> {
153153 | PageOp::Equation { text, .. }
154154 | PageOp::Split { text, .. } => vec![*text],
155155 PageOp::Date { fields, .. } => fields.iter().map(|(text, _)| *text).collect(),
156 PageOp::Color(_) => Vec::new(),
156 PageOp::Color(_) | PageOp::RuleLines(_) => Vec::new(),
157157 PageOp::Join { left, right } => vec![*left, *right],
158158 PageOp::Insert { container, .. } => vec![*container],
159159 PageOp::Move { object, .. }
......@@ -655,8 +655,11 @@ impl Diff {
655655 self.region(text)?;
656656 }
657657 }
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(_) => {}
660663 PageOp::Move {
661664 object,
662665 parent,
......@@ -692,9 +695,10 @@ impl Diff {
692695 // What the edit sets, as each side has it.
693696 let part = |index: &Index| {
694697 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),
698702 (Own::Outline { layout, .. }, OutlineEdit::Width { .. }) => (
699703 layout.max_width,
700704 layout.reserved_width,
crates/onestore/README.md+3-2
......@@ -46,7 +46,7 @@ harness also accepts `--client-profile release`.
4646| `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 |
4747| `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 |
4848| `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` |
5050| `protected::UnlockedSection` (optional feature) | Own decoded buffers for explicit known-password inspection; clear those buffers on drop; derived document strings/exports remain caller-owned |
5151| `create_section` | Create one page containing one plain-text paragraph and an author, including Unicode |
5252| `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
9191identity and creation time, as a page moved to the recycle bin keeps them. The page has
9292no body outlines or applied template; `create_empty_section` makes a section for such
9393pages, and `PageOp::Color` sets or clears a page's colour (`0x14001d2a` on the page node,
94absent for "No color").
94absent for "No color"), and `PageOp::RuleLines` its rule lines (six properties on the page
95node, absent for None; [rule-lines](../../corpus/rule-lines/README.md)).
9596Retain the intent to preserve its page, title and space identities; existing
9697identities require reconciliation before retry. Body insertion and title edits
9798use 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> {
638638 }
639639 Ok(BTreeMap::from([(node, object)]))
640640 }),
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 }),
641661 PageOp::Insert {
642662 container,
643663 before,
crates/onestore/src/op/lower.rs+23-2
......@@ -45,6 +45,7 @@ pub fn lower(
4545 created: None,
4646 margin_origin: [0.0; 2],
4747 color: None,
48 rule_lines: None,
4849 objects: vec![PageObject::Outline(Outline {
4950 id: container,
5051 title: false,
......@@ -1424,8 +1425,25 @@ impl Lowering {
14241425 }
14251426
14261427 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 }
14291447 }
14301448 let mut add = Vec::new();
14311449 for stroke in &ink.strokes {
......@@ -1569,6 +1587,9 @@ impl Lowering {
15691587 if before.color != after.color {
15701588 self.emit(PageOp::Color(after.color))?;
15711589 }
1590 if before.rule_lines != after.rule_lines {
1591 self.emit(PageOp::RuleLines(after.rule_lines))?;
1592 }
15721593 if before.created == after.created && shown {
15731594 return Ok(());
15741595 }
crates/onestore/src/op/mod.rs+2
......@@ -88,6 +88,8 @@ pub enum PageOp {
8888 },
8989 /// The page's colour (View, Page Color), COLORREF; `None` is OneNote's "No color".
9090 Color(Option<u32>),
91 /// The page's rule lines (View, Rule Lines); `None` is OneNote's "None".
92 RuleLines(Option<crate::page::RuleLines>),
9193 /// Inserts paragraphs before a direct child of `container`, or last. Children follow
9294 /// their parents in `paragraphs`; levels are absolute. Text keeps its spans' formats;
9395 /// 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> {
627627 page.created = Some(*created);
628628 }
629629 PageOp::Color(color) => page.color = *color,
630 PageOp::RuleLines(lines) => page.rule_lines = *lines,
630631 PageOp::Text { text, range, with } => {
631632 let object = text_mut(page, *text)?;
632633 object.text = replace_text(&object.text, range.clone(), with)?;
......@@ -923,9 +924,12 @@ fn interpret(page: &mut Page, op: &PageOp) -> Result<(), Error> {
923924 PageOp::Outline { object, edit } => match edit {
924925 OutlineEdit::Collapsed(value) => paragraph_mut(page, *object)?.collapsed = *value,
925926 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));
929933 retitle(page, None);
930934 }
931935 OutlineEdit::Width { points, user_set } => {
crates/onestore/src/op/tests.rs+190-2
......@@ -8,7 +8,8 @@ use crate::{
88 document::{Document, Format, Kind, Layout},
99 page::{
1010 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,
1213 },
1314 write::GUIDS,
1415};
......@@ -352,11 +353,12 @@ pub(super) enum Family {
352353 AttachmentRename,
353354 NewInk,
354355 InkStrokes,
356 InkPosition,
355357 Link,
356358 Equation,
357359}
358360
359pub(super) const FAMILIES: [Family; 33] = [
361pub(super) const FAMILIES: [Family; 34] = [
360362 Family::Text,
361363 Family::Format,
362364 Family::Insert,
......@@ -388,6 +390,7 @@ pub(super) const FAMILIES: [Family; 33] = [
388390 Family::AttachmentRename,
389391 Family::NewInk,
390392 Family::InkStrokes,
393 Family::InkPosition,
391394 Family::Link,
392395 Family::Equation,
393396];
......@@ -1223,6 +1226,17 @@ pub(super) fn mutate(page: &mut Page, rng: &mut Rng, family: Family) -> bool {
12231226 }
12241227 true
12251228 }
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 }
12261240 }
12271241}
12281242
......@@ -2769,3 +2783,177 @@ fn a_page_date_stores_what_onenote_stores() {
27692783 .unwrap();
27702784 }
27712785}
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.
2789const 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.
2792fn 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]
2816fn 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};
1313#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
1414#[serde(deny_unknown_fields)]
1515pub 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.
1718 /// Refreshes automatic page titles when the leading outline changes.
1819 Position { x: f32, y: f32 },
1920 /// Changes an ordinary outline's maximum width in points, at least 36.
......@@ -63,7 +64,17 @@ impl OutlineEdit {
6364 return Err(invalid("Select a paragraph for its saved expansion state"));
6465 }
6566 }
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 { .. } => {
6778 if !matches!(node.kind, Kind::Outline { .. })
6879 || parents.get(&object).map(Vec::as_slice) != Some(pages.as_slice())
6980 || !view.nodes[page].children.contains(&object)
crates/onestore/src/page/ink.rs+2-1
......@@ -27,6 +27,7 @@ const DIMENSION_Y: [u8; 16] = [
2727#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
2828pub struct Ink {
2929 pub id: ExGuid,
30 /// A drawing's `x` and `y` offset every stroke point, as OneNote moves ink.
3031 pub layout: Layout,
3132 pub strokes: Vec<InkStroke>,
3233 /// Nested ink containers, as newer OneNote versions group handwriting.
......@@ -36,7 +37,7 @@ pub struct Ink {
3637#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
3738pub struct InkStroke {
3839 pub id: ExGuid,
39 /// Absolute page coordinates in points.
40 /// Page coordinates in points, before the drawing's offset.
4041 pub points: Vec<[f32; 2]>,
4142 /// Pen width and height in points.
4243 pub width: f32,
crates/onestore/src/page/mod.rs+94
......@@ -56,6 +56,94 @@ pub use text::Paragraph;
5656/// MS-ONE, observed in OneNote 2010's pages (`corpus/notebook-management/native/page-color`).
5757pub(crate) const PAGE_COLOR: u32 = 0x14001d2a;
5858
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.
62pub(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];
70const HORIZONTAL_KIND: u32 = 0x14001cd3;
71const HORIZONTAL_SPACING: u32 = 0x14001cd4;
72const HORIZONTAL_COLOR: u32 = 0x14001cd5;
73const VERTICAL_KIND: u32 = 0x14001cd6;
74const VERTICAL_SPACING: u32 = 0x14001cd7;
75const 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)]
81pub 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)]
89pub 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
96impl 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
59147/// The role of a title-outline paragraph that displays the page's creation date or time.
60148#[derive(Clone, Copy, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
61149pub enum DateField {
......@@ -74,6 +162,8 @@ pub struct Page {
74162 /// The page's colour (View, Page Color), COLORREF; `None` is OneNote's "No color".
75163 #[serde(default)]
76164 pub color: Option<u32>,
165 #[serde(default)]
166 pub rule_lines: Option<RuleLines>,
77167 pub objects: Vec<PageObject>,
78168 pub definitions: BTreeMap<ExGuid, Definition>,
79169}
......@@ -617,6 +707,10 @@ impl Page {
617707 _ => Err(invalid("The page color is damaged")),
618708 })
619709 .transpose()?,
710 rule_lines: root
711 .extra
712 .first()
713 .and_then(|fields| RuleLines::read(fields)),
620714 objects: Vec::new(),
621715 definitions: BTreeMap::new(),
622716 };
crates/snowbound/Cargo.toml+1-1
......@@ -29,7 +29,7 @@ accesskit_winit = { version = "0.34.0", default-features = false, features = ["r
2929[target.'cfg(target_os = "macos")'.dependencies]
3030libc = "0.2"
3131objc2 = "0.5.2"
32objc2-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"] }
32objc2-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"] }
3333objc2-foundation = { version = "0.2.2", default-features = false, features = ["NSString", "NSThread", "NSAttributedString", "NSDate", "NSCalendar", "NSDateFormatter", "NSGeometry", "NSLocale", "NSPathUtilities", "NSTimeZone", "NSURL", "NSArray"] }
3434
3535[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
6use 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};
14use objc2_foundation::{MainThreadMarker, NSPoint, NSRect, NSSize};
15use std::{cell::Cell, collections::HashMap};
16use ui::{Axis, PaintedScroller, Scroller, ScrollerPart};
17use 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.
24pub(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.
34pub 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
60thread_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.
66pub fn set_window_drags(drags: bool) {
67 DRAGS.set(drags);
68}
69
70/// `-mouseDownCanMoveWindow` for the content view of a textured window.
71pub 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.
77pub fn move_cursor() -> CursorIcon {
78 if before_lion() {
79 CursorIcon::Grab
80 } else {
81 CursorIcon::Move
82 }
83}
84
85/// NSScrollerKnob, NSScrollerKnobSlot, NSScrollerDecrementLine, NSScrollerIncrementLine.
86const KNOB: usize = 2;
87const SLOT: usize = 6;
88const DECREMENT_LINE: usize = 4;
89const INCREMENT_LINE: usize = 5;
90
91extern "C" fn yes(_: &AnyObject, _: Sel) -> Bool {
92 Bool::YES
93}
94
95/// AppKit's scrollers, on systems whose scrollers sit beside the content.
96pub 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
166unsafe 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.
232unsafe 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.
266fn 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.
277pub 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");
5353pub const FIND_TAGS: &[&str] = art!("icons/find-tags");
5454pub const FORMAT_PAINTER: &[&str] = art!("icons/format-painter");
5555pub const FORWARD: &[&str] = art!("icons/forward");
56pub const FULL_PAGE_VIEW: &[&str] = art!("icons/full-page-view");
5756pub const INSERT_SPACE: &[&str] = art!("icons/insert-space");
5857pub const PAGE_COLOR: &[&str] = art!("icons/page-color");
5958pub const PASTE: &[&str] = art!("icons/paste");
6059pub const RECORD_AUDIO: &[&str] = art!("icons/record-audio");
6160pub const RECORD_VIDEO: &[&str] = art!("icons/record-video");
62pub const RULE_LINES: &[&str] = art!("icons/rule-lines");
6361pub const SCREEN_CLIPPING: &[&str] = art!("icons/screen-clipping");
6462pub const SPELLING: &[&str] = art!("icons/spelling");
6563pub const SYMBOL: &[&str] = art!("icons/symbol");
64pub const SYNC_BUSY: &[&str] = art!("icons/sync-busy");
65pub const SYNC_DONE: &[&str] = art!("icons/sync-done");
66pub const SYNC_ERROR: &[&str] = art!("icons/sync-error");
67pub const SYNC_OFFLINE: &[&str] = art!("icons/sync-offline");
6668
6769pub const TAG_REMEMBER: &[&str] = art!("tags/remember");
6870pub 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
4use crate::{
5 commands::Choice,
6 templates::{TILE, Thumbnails},
7};
8use canvas::{
9 gpu::{Paper, colorref},
10 template::{PAGE_COLORS, RULE_LINES},
11};
12use onestore::page::{RuleLines, VerticalRule};
13use ui::{Anchor, Flags, Id, Spec, Ui, fill, px};
14
15const COLUMNS: usize = 9;
16const SWATCH: [f32; 2] = [40.0, 32.0];
17const RULES: f32 = 40.0;
18/// Points of page per point of a rule-line thumbnail.
19const SHRINK: f32 = 4.0;
20/// The art offered after None.
21const 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)]
26pub 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.
154fn 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 {
2929 SelectAll,
3030 Find,
3131 Search,
32 SearchResults,
33 CommandPalette,
3234 Back,
3335 Forward,
3436 ZoomIn,
......@@ -63,6 +65,7 @@ pub enum Id {
6365 ClearFormatting,
6466 Tag(NoteTag),
6567 RemoveTags,
68 FindTags,
6669 Help,
6770}
6871
......@@ -81,6 +84,10 @@ pub enum Choice {
8184 Table([usize; 2]),
8285 /// An index into `PAGE_COLORS`, or `None` for no colour.
8386 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>),
8491}
8592
8693#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
......@@ -225,6 +232,19 @@ pub const COMMANDS: &[Command] = &[
225232 row!(Id::SelectAll, "Select All", &[cmd('a')], &[cmd('a')]),
226233 row!(Id::Find, "Find on This Page", &[cmd('f')], &[cmd('f')]),
227234 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 ),
228248 // Option-Command-arrows move the caret; Xcode goes back with Control-Command.
229249 row!(
230250 Id::Back,
......@@ -252,7 +272,7 @@ pub const COMMANDS: &[Command] = &[
252272 &[cmd('0')],
253273 &[cmd('0').option()]
254274 ),
255 row!(Id::Sidebar, "Sidebar", &[cmd('s').control()], NONE),
275 row!(Id::Sidebar, "Notebook List", &[cmd('s').control()], NONE),
256276 row!(Id::PageList, "Page List", NONE, NONE),
257277 row!(Id::DarkPages, "Dark Pages", NONE, NONE),
258278 // AppKit's Enter Full Screen takes Control-Command-F.
......@@ -393,6 +413,7 @@ pub const COMMANDS: &[Command] = &[
393413 &[cmd('0').control()],
394414 &[cmd('0')]
395415 ),
416 row!(Id::FindTags, "Find Tags", NONE, NONE),
396417 row!(Id::Help, "Snowbound Help", NONE, NONE),
397418];
398419
......@@ -442,7 +463,12 @@ pub fn title(choice: Choice) -> String {
442463 .iter()
443464 .find(|(listed, _)| *listed == color)
444465 .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(_) => {
446472 unreachable!("Only the toolbar offers these")
447473 }
448474 }
......@@ -626,10 +652,12 @@ impl State {
626652 Choice::Size(size) => return checked(format.font_size == Some(*size)),
627653 Choice::Highlight(_) | Choice::Color(_) | Choice::List(_) => return enabled(text),
628654 Choice::Table(_) => Id::Table,
629 Choice::PageColor(_) => Id::PageColor,
655 Choice::PageColor(_) | Choice::RuleLines(_) | Choice::Art(_) => Id::PageColor,
630656 };
631657 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 }
633661 Id::CloseNotebook | Id::ShowNotebook => enabled(!modal && self.notebook().is_some()),
634662 Id::NewSection | Id::NewSectionGroup => enabled(
635663 !modal
......@@ -669,6 +697,14 @@ impl State {
669697 enabled: !modal && session.is_some(),
670698 checked: self.pages_open,
671699 },
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 },
672708 Id::DarkPages => Status {
673709 enabled: !modal,
674710 checked: !self.light_pages,
......@@ -679,8 +715,12 @@ impl State {
679715 },
680716 Id::PageColor => enabled(writable && session.is_some()),
681717 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 },
684724 Id::Table
685725 | Id::Picture
686726 | Id::Symbol
......@@ -776,7 +816,14 @@ impl State {
776816 }
777817 Choice::PageColor(color) => {
778818 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);
780827 }
781828 };
782829 let field = self.ui.focused().filter(|focus| *focus != page());
......@@ -860,6 +907,14 @@ impl State {
860907 self.start_search();
861908 return Ok(());
862909 }
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 }
863918 Id::Back | Id::Forward => {
864919 self.travel(id == Id::Forward);
865920 return Ok(());
......@@ -917,7 +972,12 @@ impl State {
917972 self.save_settings();
918973 return Ok(());
919974 }
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);
921981 return Ok(());
922982 }
923983 Id::Link => {
......@@ -1125,8 +1185,8 @@ mod tests {
11251185 fn toolbar_buttons_run_commands() {
11261186 let source = include_str!("main.rs");
11271187 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");
11301190 let body = &source[start..];
11311191 let body = &body[..body[1..]
11321192 .find("\n fn ")
crates/snowbound/src/conflict_render.rs+1
......@@ -133,6 +133,7 @@ fn paint(
133133 ],
134134 clip: Some(rect.map(|value| value * SCALE)),
135135 backdrop: Some(ui.theme.paper),
136 round: None,
136137 motion: None,
137138 primitives: &primitives,
138139 },
crates/snowbound/src/library.rs+48
......@@ -288,6 +288,17 @@ impl Library {
288288 Ok(section)
289289 }
290290
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
291302 /// How the notebook's files are reached.
292303 pub fn transport(&self) -> String {
293304 match (&self.server, &self.notice) {
......@@ -584,6 +595,43 @@ mod tests {
584595 let _ = std::fs::remove_dir_all(&cache);
585596 }
586597
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
587635 #[test]
588636 fn mounts_name_their_server_share_account_and_folder() {
589637 assert_eq!(
crates/snowbound/src/linux.rs+145-52
......@@ -17,7 +17,7 @@ use ui::{Flags, Spec, Ui, px};
1717use winit::{
1818 error::EventLoopError,
1919 event_loop::{EventLoop, EventLoopProxy},
20 platform::wayland::WindowAttributesExtWayland,
20 platform::wayland::{EventLoopExtWayland, WindowAttributesExtWayland},
2121 window::{Icon, ResizeDirection, Theme, Window, WindowAttributes},
2222};
2323
......@@ -48,25 +48,34 @@ pub fn event_loop(_headless: bool) -> Result<EventLoop<crate::UserEvent>, EventL
4848 if desktop() == Desktop::Kde {
4949 follow_breeze_radius(event_loop.create_proxy());
5050 }
51 let libadwaita = desktop() == Desktop::Gnome && event_loop.is_wayland();
52 sctk_adwaita::LIBADWAITA_EDGE.store(libadwaita, Ordering::Relaxed);
5153 Ok(event_loop)
5254}
5355
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.
5659pub 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() {
6361 return 0.0;
6462 }
63 if cuts_corners() {
64 return sctk_adwaita::WINDOW_RADIUS;
65 }
6566 // Breeze snaps its radius to the device pixel grid.
67 let units = f32::from_bits(BREEZE_RADIUS.load(Ordering::Relaxed));
6668 let scale = window.scale_factor() as f32;
6769 (units * scale).round() / scale
6870}
6971
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.
75pub fn cuts_corners() -> bool {
76 sctk_adwaita::LIBADWAITA_EDGE.load(Ordering::Relaxed)
77}
78
7079/// Reads Breeze's corner radius now and again whenever KWin is told to reload its
7180/// configuration, as Breeze's and the border size's settings pages do, redrawing then.
7281fn follow_breeze_radius(proxy: EventLoopProxy<crate::UserEvent>) {
......@@ -141,8 +150,12 @@ pub fn window_attributes() -> WindowAttributes {
141150 Window::default_attributes()
142151 .with_name(APP_ID, APP_ID)
143152 .with_window_icon(icon)
153 .with_transparent(cuts_corners())
144154}
145155
156/// The window manager or the frame draws the title bar.
157pub fn install_title_bar(_: &Window) {}
158
146159/// Wayland's clipboard through the window's own connection, as not every compositor offers
147160/// a clipboard to clients without a window; X11's otherwise.
148161pub enum Clipboard {
......@@ -180,10 +193,11 @@ impl Clipboard {
180193 }
181194}
182195
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.
185199pub fn system_titlebar(window: &Window) -> bool {
186 window.is_decorated()
200 window.is_decorated() && !sctk_adwaita::FRAMED.load(Ordering::Relaxed)
187201}
188202
189203/// Window managers show no document of a window's.
......@@ -463,41 +477,63 @@ fn adwaita_titlebar(appearance: Theme) -> [[u8; 3]; 2] {
463477 })
464478}
465479
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.
467483pub 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 }
501537 }
502538}
503539
......@@ -539,6 +575,21 @@ pub fn zoom(window: &Window) {
539575/// Winit delivers typed text with its key events on Linux.
540576pub fn install_text_input(_: &Window) {}
541577
578pub fn move_cursor() -> winit::window::CursorIcon {
579 winit::window::CursorIcon::Move
580}
581
582/// Window drags are the app's own.
583pub fn set_window_drags(_: bool) {}
584
585/// Scrollbars overlay the content.
586pub fn scrollers() -> Option<ui::Scrollers> {
587 None
588}
589
590/// Resizing takes the window's edges.
591pub fn resize_grip(_: &mut Ui, _: &Window, _: [f32; 2]) {}
592
542593/// Options opens from the sidebar's footer and Ctrl+Comma, as Linux apps have no shared menu.
543594pub fn install_menu() {}
544595
......@@ -639,6 +690,45 @@ fn portal_setting(namespace: &str, key: &str) -> Option<zbus::zvariant::OwnedVal
639690 value.try_into_owned().ok()
640691}
641692
693pub 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`.
697pub 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.
718fn 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
642732pub fn cache_dir() -> Option<PathBuf> {
643733 xdg_dir("XDG_CACHE_HOME", ".cache")
644734}
......@@ -727,12 +817,6 @@ fn xdg_base(variable: &str, fallback: &str) -> Option<PathBuf> {
727817 .or_else(|| Some(PathBuf::from(std::env::var_os("HOME")?).join(fallback)))
728818}
729819
730/// The application's icon from the icon theme, the smallest at least `pixels` square.
731pub 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
736820/// Looks beside the executable first, as the release archive installs it, then in the
737821/// XDG data directories.
738822fn icon_pixels(pixels: u32) -> Option<(u32, Vec<u8>)> {
......@@ -1225,6 +1309,15 @@ mod tests {
12251309 assert_eq!(dark.motion, ui::PopupMotion::Cut);
12261310 }
12271311
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
12281321 #[test]
12291322 fn kconfig_colours_read_as_channels_or_hex() {
12301323 assert_eq!(kde_color("222,224,226"), Some([222, 224, 226]));
crates/snowbound/src/macos.rs+106-16
......@@ -29,6 +29,8 @@ use winit::{
2929 window::{Window, WindowAttributes},
3030};
3131
32pub use crate::aqua::{move_cursor, resize_grip, scrollers, set_window_drags};
33
3234static QUIT: OnceLock<EventLoopProxy<crate::UserEvent>> = OnceLock::new();
3335static INPUT_CLASS: OnceLock<&'static AnyClass> = OnceLock::new();
3436static BACKDROP_CLASS: OnceLock<&'static AnyClass> = OnceLock::new();
......@@ -86,8 +88,20 @@ pub const LEADING: f32 = 78.0;
8688/// How far AppKit rounds a window's corners.
8789pub const CORNER_RADIUS: f32 = 10.0;
8890
91/// How far the window's corners round: 10.6 rounds a textured window's bottom corners
92/// by about 3.5 pixels.
8993pub 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.
103pub fn cuts_corners() -> bool {
104 crate::aqua::before_lion()
91105}
92106
93107/// A transparent title bar over the content, where the window draws its own.
......@@ -97,6 +111,57 @@ pub fn window_attributes() -> WindowAttributes {
97111 .with_fullsize_content_view(true)
98112}
99113
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.
117pub 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.
145pub 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
100165pub struct Clipboard(arboard::Clipboard);
101166
102167impl Clipboard {
......@@ -189,9 +254,9 @@ unsafe extern "C" fn hit_nothing(_: &AnyObject, _: Sel, _: NSPoint) -> *mut AnyO
189254/// through it to the view it lies in.
190255pub fn install_backdrop(window: &Window) -> bool {
191256 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.
193258 let Some(effect) = AnyClass::get("NSVisualEffectView") else {
194 return false;
259 return crate::aqua::textured(window, crate::TITLE);
195260 };
196261 let RawWindowHandle::AppKit(handle) =
197262 window.window_handle().expect("Live AppKit window").as_raw()
......@@ -230,6 +295,18 @@ pub fn resize_direction(_: &Window, _: [f32; 2]) -> Option<winit::window::Resize
230295 None
231296}
232297
298pub const SHOW_FILE: &str = "Show in Finder";
299
300/// Opens a Finder window with `file` selected.
301pub 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
233310pub fn cache_dir() -> Option<std::path::PathBuf> {
234311 Some(std::path::PathBuf::from(std::env::var_os("HOME")?).join("Library/Caches/snowbound"))
235312}
......@@ -453,6 +530,12 @@ pub fn install_text_input(window: &Window) {
453530 sel!(insertText:replacementRange:),
454531 insert_text as unsafe extern "C" fn(_, _, _, _),
455532 );
533 if crate::aqua::before_lion() {
534 class.add_method(
535 sel!(mouseDownCanMoveWindow),
536 crate::aqua::window_drags as extern "C" fn(_, _) -> _,
537 );
538 }
456539 class.register()
457540 });
458541 assert_eq!(class.superclass(), Some(view.class()));
......@@ -676,11 +759,6 @@ pub fn commit_presentation(window: &Window) {
676759 }
677760}
678761
679/// None: AppKit's title shows the document's icon in its place.
680pub fn app_icon(_: u32) -> Option<draw::RasterImage> {
681 None
682}
683
684762/// The insertion point's colour and selected text's fill with and without keyboard focus,
685763/// linear RGBA, in `window`'s appearance.
686764pub fn text_colors(window: &Window) -> [[f32; 4]; 3] {
......@@ -770,17 +848,29 @@ declare_class!(
770848 objc2::runtime::Bool::new(status.enabled)
771849 }
772850
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.
774853 #[method(sendEvent:)]
775854 fn send_event(&self, event: &NSEvent) {
776855 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 }
784874 }
785875 }
786876 }
crates/snowbound/src/main.rs+832-599
......@@ -1,4 +1,7 @@
1#[cfg(target_os = "macos")]
2mod aqua;
13mod art;
4mod background;
25mod commands;
36#[cfg(test)]
47mod conflict_render;
......@@ -12,6 +15,8 @@ mod menubar;
1215mod menus;
1316mod navigation;
1417mod options;
18mod palette;
19mod pane;
1520#[cfg_attr(target_os = "linux", path = "linux.rs")]
1621#[cfg_attr(target_os = "macos", path = "macos.rs")]
1722mod platform;
......@@ -36,7 +41,6 @@ use canvas::{
3641use canvas::{
3742 gpu::{colorref, tag_sources},
3843 outline::TagIcon,
39 template::PAGE_COLORS,
4044};
4145use draw::Renderer;
4246use library::Library;
......@@ -61,8 +65,12 @@ use winit::{
6165 window::{CursorIcon, Window, WindowId},
6266};
6367
64/// Height of the title bar, which holds the window's controls.
65const 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.
70const TITLE: f32 = 38.0;
71/// Space between toolbar groups, and the toolbar's margin at an edge without window controls.
72const GAP: f32 = 6.0;
73const EDGE: f32 = 8.0;
6674const TAB_ROW: f32 = 28.0;
6775/// Width of the section colour around the page.
6876const FRAME: f32 = 6.0;
......@@ -185,11 +193,13 @@ fn cursor_icon(cursor: Cursor) -> CursorIcon {
185193 Cursor::Default => CursorIcon::Default,
186194 Cursor::Text => CursorIcon::Text,
187195 Cursor::Pointer => CursorIcon::Pointer,
188 Cursor::Move => CursorIcon::Move,
196 Cursor::Move => platform::move_cursor(),
189197 Cursor::EwResize => CursorIcon::EwResize,
190198 Cursor::NsResize => CursorIcon::NsResize,
191199 Cursor::NwseResize => CursorIcon::NwseResize,
192200 Cursor::NeswResize => CursorIcon::NeswResize,
201 Cursor::RowResize => CursorIcon::RowResize,
202 Cursor::ColResize => CursorIcon::ColResize,
193203 }
194204}
195205
......@@ -489,6 +499,7 @@ struct State {
489499 last_pages: HashMap<String, ExGuid>,
490500 /// Pages visited, for Back and Forward.
491501 trail: navigation::Trail,
502 swipe: navigation::Swipe,
492503 /// Asks for a frame when a worker thread finishes something the page shows.
493504 redraw: std::task::Waker,
494505 surface: surface::Surface,
......@@ -547,8 +558,6 @@ struct State {
547558 recent_fonts: Vec<String>,
548559 /// What the toolbar's buttons apply from their menus' last picks.
549560 toolbar: settings::Toolbar,
550 /// The application's icon at the display's scale, for the title bar.
551 app_icon: Option<draw::RasterImage>,
552561 commands: Vec<Command>,
553562 /// The page changed during this frame, to be saved and announced after it.
554563 changed: bool,
......@@ -567,6 +576,9 @@ struct State {
567576 strip_held: bool,
568577 /// Where a replay asked the next frame to be written.
569578 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)>,
570582 initial: Vec<(onestore::ExGuid, TextDocument)>,
571583 initial_layouts: Vec<(onestore::ExGuid, onestore::document::Layout)>,
572584 initial_date: Option<u64>,
......@@ -590,6 +602,10 @@ fn frame() -> Id {
590602 Id::ROOT.child("frame")
591603}
592604
605fn sections() -> Id {
606 Id::ROOT.child("sections")
607}
608
593609impl State {
594610 async fn new(
595611 event_loop: &ActiveEventLoop,
......@@ -624,6 +640,7 @@ impl State {
624640 .with_min_inner_size(LogicalSize::new(600.0, 400.0)),
625641 )?,
626642 );
643 platform::install_title_bar(&window);
627644 platform::install_text_input(&window);
628645 // Beneath the surface's layer, which is added over it.
629646 let backdrop = visible && platform::install_backdrop(&window);
......@@ -743,6 +760,7 @@ impl State {
743760 let titlebar = platform::titlebar(appearance).unwrap_or([ui.theme.strip; 2]);
744761 // A window shown but never focused hears no focus event; a hidden one draws as focused.
745762 ui.window_focused = !visible || window.has_focus();
763 ui.scrollers = platform::scrollers();
746764 ui.set_focus(Some(page()));
747765 for family in FONTS {
748766 for (face, _) in engine.substitute(family).map_or(&[][..], |s| &s.faces) {
......@@ -806,7 +824,6 @@ impl State {
806824 fonts,
807825 recent_fonts: stored.recent_fonts,
808826 toolbar: stored.toolbar,
809 app_icon: platform::app_icon((16.0 * dpr).round() as u32),
810827 commands: Vec::new(),
811828 changed: false,
812829 moved: false,
......@@ -816,6 +833,7 @@ impl State {
816833 strip_press: None,
817834 strip_held: false,
818835 snapshot: None,
836 frames: std::env::var_os("SNOWBOUND_FRAMES").map(|dir| (dir.into(), Instant::now())),
819837 initial,
820838 initial_date,
821839 initial_layouts,
......@@ -829,6 +847,7 @@ impl State {
829847 places: HashMap::new(),
830848 last_pages: HashMap::new(),
831849 trail: navigation::Trail::default(),
850 swipe: navigation::Swipe::default(),
832851 access_adapter,
833852 accessibility: accessibility::Accessibility::default(),
834853 };
......@@ -903,10 +922,32 @@ impl State {
903922 let (section, open_tab, open_page) = self.build()?;
904923 self.options_dialog();
905924 self.link_dialog()?;
925 self.palette();
906926 self.sync_popup()?;
907927 self.text_menu()?;
928 platform::resize_grip(&mut self.ui, &self.window, size);
908929 self.ui.end();
909930 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 }
910951 if let Some(rect) = self.ui.rect(page()) {
911952 let size = [
912953 ((rect[2] - rect[0]) * scale).round() as u32,
......@@ -978,15 +1019,13 @@ impl State {
9781019 // The page, and the open page's tab joined to it, take the page's colour.
9791020 let mut theme = self.ui.theme.clone();
9801021 theme.paper = self.paper().color;
981 if !platform::system_titlebar(&self.window) {
982 self.title_bar(&theme);
983 }
9841022 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 {
9861026 self.welcome(&theme);
9871027 return Ok((section, Id::ROOT, None));
9881028 }
989 self.toolbar(&theme)?;
9901029 self.ui.open(
9911030 "body",
9921031 Spec {
......@@ -1017,7 +1056,19 @@ impl State {
10171056 ..Spec::default()
10181057 },
10191058 );
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 }
10211072 self.ui.leaf(
10221073 "corner",
10231074 Spec {
......@@ -1025,7 +1076,7 @@ impl State {
10251076 ..Spec::default()
10261077 },
10271078 );
1028 let row = self.ui.id("sections");
1079 let row = sections();
10291080 let (clicked, open_tab) = match &self.session {
10301081 Some(session) => {
10311082 // A tab being renamed takes the name typed, which its field covers.
......@@ -1054,13 +1105,14 @@ impl State {
10541105 open: open_tab,
10551106 } = ui::shell::section_tabs(
10561107 &mut self.ui,
1057 "sections",
1108 row,
10581109 &tabs,
10591110 session.tab,
10601111 lit,
10611112 dragged,
10621113 &section,
10631114 TAB_ROW,
1115 theme.strip,
10641116 );
10651117 self.drag_tabs(held, slot, settled, row);
10661118 let Some(session) = &self.session else {
......@@ -1100,19 +1152,27 @@ impl State {
11001152 let shown = if self.temporary { &tabs[..] } else { &[] };
11011153 let open_tab = ui::shell::section_tabs(
11021154 &mut self.ui,
1103 "sections",
1155 row,
11041156 shown,
11051157 0,
11061158 None,
11071159 None,
11081160 &section,
11091161 TAB_ROW,
1162 theme.strip,
11101163 )
11111164 .open;
11121165 (None, open_tab)
11131166 }
11141167 };
11151168 self.commands.extend(clicked);
1169 self.ui.leaf(
1170 "space",
1171 Spec {
1172 size: [fill(), px(1.0)],
1173 ..Spec::default()
1174 },
1175 );
11161176 if self.session.is_some() {
11171177 self.page_tools(&theme);
11181178 }
......@@ -1153,6 +1213,7 @@ impl State {
11531213 self.ui.close();
11541214 self.ui.close();
11551215 self.ui.close();
1216 self.sidebar_button(&theme, height);
11561217 self.ui.close();
11571218 self.context_menu();
11581219 return Ok((section, open_tab, None));
......@@ -1195,6 +1256,8 @@ impl State {
11951256 let open_page = self.page_list(&theme, &section, row);
11961257 self.ui.close();
11971258 self.ui.close();
1259 self.task_pane(&theme);
1260 self.sidebar_button(&theme, height);
11981261 self.ui.close();
11991262 self.context_menu();
12001263 // No tab stands on the frame in Full Page View.
......@@ -1291,10 +1354,16 @@ impl State {
12911354 |_| strip,
12921355 );
12931356 }
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]]);
12941362 let [foot, toe] = self
12951363 .ui
12961364 .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));
12981367 // The border runs down both sides to the window's bottom, round the bottom corner
12991368 // beside the sidebar.
13001369 self.ui.border(
......@@ -1318,62 +1387,75 @@ impl State {
13181387 );
13191388 }
13201389
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()
13311405 },
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 };
13331418 self.ui.leaf(
1334 "lights",
1419 "lead",
13351420 Spec {
1336 size: [px(platform::LEADING), px(1.0)],
1421 size: [px(lead - GAP), px(1.0)],
13371422 ..Spec::default()
13381423 },
13391424 );
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 {
13421428 self.ui.leaf(
1343 "notebook",
1429 "space",
13441430 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)],
13521432 ..Spec::default()
13531433 },
13541434 );
13551435 }
1436 if title {
1437 platform::window_controls(&mut self.ui, &self.window);
1438 }
13561439 self.ui.leaf(
1357 "space",
1440 "trail",
13581441 Spec {
1359 size: [fill(), px(1.0)],
1442 size: [px(EDGE - GAP), px(1.0)],
13601443 ..Spec::default()
13611444 },
13621445 );
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);
13671446 self.ui.close();
1447 Ok(())
13681448 }
13691449
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};
13731454 use canvas::editor::{
13741455 Alignment, BULLET_LIBRARY, ListStyle, NUMBER_LIBRARY, NoteTag, Toggle,
13751456 };
1376 use commands::{Choice, Id as Cmd, shortcut};
1457 use commands::{Choice, Id as Cmd};
1458 use ui::shell::TOOL;
13771459 // A focused picture has no text to show a format for.
13781460 let state = self.format_state();
13791461 let statuses: Vec<_> = commands::COMMANDS
......@@ -1391,52 +1473,39 @@ impl State {
13911473 .find(|(listed, _)| *listed == id)
13921474 .map_or_else(commands::Status::default, |(_, status)| *status)
13931475 };
1476 let status_of = &status_of;
13941477 let fonts = &self.fonts;
13951478 let recent_fonts = &self.recent_fonts;
13961479 let pens = &self.toolbar;
13971480 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);
14021483 let ui = &mut self.ui;
14031484 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)
14141500 },
14151501 );
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;
14401509 let paste = ui.id("paste");
14411510 if !status_of(Cmd::Paste).enabled {
14421511 ui::shell::unavailable(ui, "paste", art::PASTE, text, true);
......@@ -1474,138 +1543,251 @@ impl State {
14741543 if ui::popup::menu(ui, toolbar_popup("paste"), anchor, &items, None).is_some() {
14751544 choice = Some(Choice::Command(Cmd::Paste));
14761545 }
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);
14861548 }
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);
14901563 let font = state.font.clone().unwrap_or_default();
14911564 let size = state
14921565 .font_size
14931566 .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
15151704 .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 });
15181714 }
15191715 }
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(
15741717 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,
15931728 )
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);
15971779 }
1598 tip(ui, Cmd::Numbering);
1780 let [bullets, numbering] =
1781 ["bullets", "numbering"].map(|part| ui.rect(ui.id(part)).unwrap_or_default());
15991782 let current = |bullet| match state.list {
16001783 Some(ListStyle::Bullet(place)) if bullet => Some(place),
16011784 Some(ListStyle::Number(place)) if !bullet => Some(place),
16021785 _ => None,
16031786 };
1604 let anchor = ui::Anchor::Below(ui.rect(bullets).unwrap_or_default());
16051787 if let Some(place) = list_gallery(
16061788 ui,
16071789 toolbar_popup("bullets"),
1608 anchor,
1790 ui::Anchor::Below(bullets),
16091791 ["Recently Used Bullets", "Bullet Library"],
16101792 (&pens.bullets, BULLET_LIBRARY.len()),
16111793 current(true),
......@@ -1627,11 +1809,10 @@ impl State {
16271809 ) {
16281810 choice = Some(Choice::List(place.map(ListStyle::Bullet)));
16291811 }
1630 let anchor = ui::Anchor::Below(ui.rect(numbering).unwrap_or_default());
16311812 if let Some(place) = list_gallery(
16321813 ui,
16331814 toolbar_popup("numbering"),
1634 anchor,
1815 ui::Anchor::Below(numbering),
16351816 ["Recently Used Number Formats", "Numbering Library"],
16361817 (&pens.numbering, NUMBER_LIBRARY.len()),
16371818 current(false),
......@@ -1673,234 +1854,68 @@ impl State {
16731854 ) {
16741855 choice = Some(Choice::List(place.map(ListStyle::Number)));
16751856 }
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),
18051869 ];
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);
18291894 }
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;
19041919 if status_of(Cmd::Table).enabled {
19051920 let anchor =
19061921 ui::shell::menu_button(ui, "table", art::TABLE, toolbar_popup("table"));
......@@ -1913,30 +1928,20 @@ impl State {
19131928 } else {
19141929 ui::shell::unavailable(ui, "table", art::TABLE, text, true);
19151930 }
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);
19371933 }
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);
19401945 ui.leaf(
19411946 "space",
19421947 Spec {
......@@ -1944,13 +1949,35 @@ impl State {
19441949 ..Spec::default()
19451950 },
19461951 );
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();
19491964 }
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;
19541981 if status_of(Cmd::PageColor).enabled {
19551982 let anchor = ui::shell::menu_button(
19561983 ui,
......@@ -1959,83 +1986,28 @@ impl State {
19591986 toolbar_popup("page color"),
19601987 );
19611988 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(
19671990 ui,
19681991 toolbar_popup("page color"),
19691992 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());
19792002 } else {
19802003 ui::shell::unavailable(ui, "page color", art::PAGE_COLOR, text, true);
19812004 }
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);
20342006 let level = ui.leaf(
20352007 "level",
20362008 Spec {
20372009 flags: Flags::CLICKABLE,
2038 size: [px(44.0), px(ui::shell::TOOL)],
2010 size: [px(44.0), px(TOOL)],
20392011 text: Some(&label),
20402012 hover_fill: Some(theme.hover()),
20412013 radius: 4.0,
......@@ -2047,51 +2019,76 @@ impl State {
20472019 choice = Some(Choice::Command(Cmd::ActualSize));
20482020 }
20492021 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);
20622039 if let Some(choice) = choice {
20632040 self.choose(choice);
20642041 }
2065 Ok(())
20662042 }
20672043
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.
20692047 fn page_tools(&mut self, theme: &Theme) {
2048 let finding = self.search.finding;
20702049 self.ui.open(
20712050 "tools",
20722051 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),
20762053 ..Spec::default()
20772054 },
20782055 );
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();
20932091 }
2094 tip(&mut self.ui, commands::Id::PageList);
20952092 self.ui.close();
20962093 }
20972094
......@@ -2941,6 +2938,17 @@ impl State {
29412938 if let Some(path) = self.snapshot.take() {
29422939 self.snapshot(&path)?;
29432940 }
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 }
29442952 if self.occluded {
29452953 return Ok(());
29462954 }
......@@ -3006,6 +3014,7 @@ impl State {
30063014 ],
30073015 clip: Some(rect.map(|value| value * scale)),
30083016 backdrop: Some(paper.color),
3017 round: None,
30093018 motion: None,
30103019 primitives: &page_primitives,
30113020 },
......@@ -3022,25 +3031,48 @@ impl State {
30223031
30233032 /// Writes the frame to a PNG, so a covered window can be reviewed.
30243033 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>> {
30263040 let offscreen = self.surface.offscreen(&self.renderer)?;
30273041 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)
30363043 }
30373044
30383045 /// Queues input for the next frame. A press on the strip moves the window: at once on
30393046 /// macOS, while AppKit still holds the press, and elsewhere once the pointer moves, as
30403047 /// 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
30413072 fn input(&mut self, event: ui::Event) {
30423073 if let ui::Event::PointerMoved(point) = event {
30433074 self.pointer = point;
3075 platform::set_window_drags(self.ui.box_at(point) == Some(strip()));
30443076 if std::mem::take(&mut self.strip_held) {
30453077 let _ = self.window.drag_window();
30463078 }
......@@ -3658,7 +3690,14 @@ fn list_gallery(
36583690 size: [f32; 2],
36593691 preview: impl Fn(&mut Ui, usize),
36603692) -> 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 });
36623701 let place = |index: usize| match index.checked_sub(recent.len()) {
36633702 None => Some(recent[index]),
36643703 Some(at) => at.checked_sub(1),
......@@ -3668,9 +3707,7 @@ fn list_gallery(
36683707 id,
36693708 anchor,
36703709 &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(),
36743711 |ui, index| match place(index) {
36753712 Some(place) => preview(ui, place),
36763713 None => {
......@@ -3689,44 +3726,161 @@ fn list_gallery(
36893726 Some(place(chosen))
36903727}
36913728
3692/// A toolbar group's rows, stacked.
3693fn 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.
3731fn 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> {
36943738 ui.open(
36953739 part,
36963740 Spec {
3697 axis: Axis::Y,
3698 gap: 2.0,
3741 fold: Some(priority),
36993742 ..Spec::default()
37003743 },
37013744 );
3702 rows(ui);
3703 ui.close();
3704}
3705
3706fn row(ui: &mut Ui, part: usize, buttons: impl FnOnce(&mut Ui)) {
37073745 ui.open(
3708 part,
3746 "full",
37093747 Spec {
37103748 gap: 1.0,
37113749 ..Spec::default()
37123750 },
37133751 );
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();
37153757 ui.close();
3758 choice
37163759}
37173760
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.
3722fn tool(
3761/// A row of a toolbar menu.
3762#[derive(Clone, Copy)]
3763enum 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.
3776enum 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.
3784fn dropdown(
37233785 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,
37293790) -> 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.
3873fn 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 };
37303884 if !status.enabled {
37313885 ui::shell::unavailable(ui, part, icon, tint, false);
37323886 return None;
......@@ -3736,6 +3890,57 @@ fn tool(
37363890 clicked.then_some(commands::Choice::Command(id))
37373891}
37383892
3893/// The artwork the toolbar and its menus show for command `id`.
3894fn 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
37393944/// The artwork the toolbar shows for `tag`.
37403945fn tag_icon(tag: canvas::editor::NoteTag) -> &'static [&'static str] {
37413946 use canvas::editor::NoteTag;
......@@ -3751,18 +3956,21 @@ fn toolbar_popup(name: &str) -> Id {
37513956 Id::ROOT.child(("popup", name))
37523957}
37533958
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.
37543961fn tip(ui: &mut Ui, id: commands::Id) {
37553962 let title = commands::command(id).title.trim_end_matches('…');
37563963 ui::popup::tooltip(ui, title, &commands::shortcut(id), None);
37573964}
37583965
3759/// The line between two toolbar groups.
3760fn 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.
3967fn divider(ui: &mut Ui, theme: &Theme) {
37613968 ui.leaf(
3762 ("divider", part),
3969 "divider",
37633970 Spec {
3764 size: [px(1.0), px(2.0 * ui::shell::TOOL + 2.0)],
3971 size: [px(GAP), px(ui::shell::TOOL)],
37653972 fill: Some(theme.chip),
3973 inset: [0.0, 0.0, GAP - 1.0, 0.0],
37663974 ..Spec::default()
37673975 },
37683976 );
......@@ -4054,9 +4262,8 @@ impl ApplicationHandler<UserEvent> for App {
40544262 state.set_appearance(state.color_scheme.theme().unwrap_or(appearance));
40554263 state.window.request_redraw();
40564264 }
4057 WindowEvent::ScaleFactorChanged { scale_factor, .. } => {
4265 WindowEvent::ScaleFactorChanged { .. } => {
40584266 state.renderer.clear_glyph_cache();
4059 state.app_icon = platform::app_icon((16.0 * scale_factor).round() as u32);
40604267 state.window.request_redraw();
40614268 }
40624269 WindowEvent::Focused(focused) => {
......@@ -4090,7 +4297,10 @@ impl ApplicationHandler<UserEvent> for App {
40904297 pressed: pressed == ElementState::Pressed,
40914298 at: Instant::now(),
40924299 }),
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 }
40944304 state.input(ui::Event::Wheel(match delta {
40954305 MouseScrollDelta::LineDelta(x, y) => [x * 32.0, y * 32.0],
40964306 MouseScrollDelta::PixelDelta(p) => [p.x as f32 / scale, p.y as f32 / scale],
......@@ -4105,6 +4315,13 @@ impl ApplicationHandler<UserEvent> for App {
41054315 })
41064316 }
41074317 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 }
41084325 _ => {}
41094326 }
41104327 Ok(())
......@@ -4137,6 +4354,16 @@ impl ApplicationHandler<UserEvent> for App {
41374354 }
41384355}
41394356
4357fn 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
41404367/// Feeds a development script to the window from another thread, one command per line
41414368/// in logical pixels: `move X Y`, `press [right]`, `release [right]`, `wheel DX DY`, `key NAME`, `type
41424369/// TEXT`, `modifiers [shift] [command]`, `wait MILLISECONDS`, `snapshot PNG_PATH`,
......@@ -4214,9 +4441,15 @@ fn replay(script: String, proxy: EventLoopProxy<UserEvent>) -> Result<(), Box<dy
42144441 let _ = proxy.send_event(UserEvent::Replay(replay));
42154442 }
42164443 Err(duration) => {
4444 // Ticks keep a 60 Hz display's pace, dropping those a slow frame missed.
42174445 let start = Instant::now();
4446 let period = std::time::Duration::from_micros(16_667);
4447 let mut tick = start;
42184448 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());
42204453 let _ = proxy.send_event(UserEvent::Replay(Replay::Tick));
42214454 }
42224455 }
crates/snowbound/src/manage.rs+142-51
......@@ -2,6 +2,7 @@
22//! structure through `notebook::session::Notebook`, pages as ops on the open section.
33
44use crate::{Command, Library, Loaded, State, notify, platform, read_session};
5use canvas::template::Template;
56use notebook::session::Notebook;
67use onestore::{
78 ExGuid, PageCreation, PageEdit,
......@@ -133,13 +134,28 @@ impl State {
133134 self.edit_page(ops)
134135 }
135136
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>> {
141143 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(())
143159 }
144160
145161 /// Applies `ops` to the open page as one edit and shows the result.
......@@ -429,60 +445,26 @@ impl State {
429445 }
430446}
431447
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.
434450pub fn template_ops(
435451 page: &Page,
436452 choice: crate::templates::Choice,
437453) -> Result<Vec<PageOp>, Box<dyn Error>> {
438454 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 };
447463 match choice {
448464 Choice::Template(name) => {
449 let template = canvas::template::find(name).ok_or("That template is not available")?;
450465 if page.color.is_some() {
451466 ops.push(PageOp::Color(None));
452467 }
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 }
486468 // The one template with content worth keeping.
487469 if name == "Informal Meeting Notes" {
488470 ops.extend(onestore::op::lower_page(
......@@ -494,7 +476,54 @@ pub fn template_ops(
494476 Choice::Color(index) => {
495477 ops.push(PageOp::Color(Some(canvas::template::PAGE_COLORS[index].1)))
496478 }
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.
486pub 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 });
498527 }
499528 Ok(ops)
500529}
......@@ -842,4 +871,66 @@ mod tests {
842871 "New Section Group 2"
843872 );
844873 }
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 }
845936}
crates/snowbound/src/menubar.rs+4
......@@ -79,6 +79,7 @@ const MENUS: &[Item] = &[
7979 S,
8080 C(Id::Find),
8181 C(Id::Search),
82 C(Id::SearchResults),
8283 S,
8384 Item::System("Start Dictation…", "startDictation:", None),
8485 Item::System(
......@@ -92,6 +93,8 @@ const MENUS: &[Item] = &[
9293 Item::Menu(
9394 "View",
9495 &[
96 C(Id::CommandPalette),
97 S,
9598 C(Id::Back),
9699 C(Id::Forward),
97100 S,
......@@ -192,6 +195,7 @@ const MENUS: &[Item] = &[
192195 ),
193196 S,
194197 C(Id::RemoveTags),
198 C(Id::FindTags),
195199 ],
196200 ),
197201 Item::Menu(
crates/snowbound/src/navigation.rs+68
......@@ -2,6 +2,8 @@
22//! 2010's Quick Access Toolbar offers them.
33
44use super::*;
5use std::time::Duration;
6use winit::event::TouchPhase;
57
68/// A page shown: its notebook's location, its section's catalog path and the page.
79#[derive(Clone, Debug, PartialEq)]
......@@ -107,6 +109,50 @@ impl State {
107109 }
108110}
109111
112/// A two-finger horizontal trackpad swipe, which travels Back or Forward as macOS's
113/// "swipe between pages" does.
114#[derive(Default)]
115pub struct Swipe {
116 travelled: Option<[f32; 2]>,
117 ended: Option<Instant>,
118}
119
120impl 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
110156#[cfg(test)]
111157mod tests {
112158 use super::*;
......@@ -122,6 +168,28 @@ mod tests {
122168 }
123169 }
124170
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
125193 #[test]
126194 fn back_and_forward_retrace_visits_across_sections() {
127195 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
4use crate::{
5 Command, Library, State,
6 commands::{self, COMMANDS, Choice},
7 library,
8};
9use onestore::ExGuid;
10use std::sync::Arc;
11use ui::{Id, popup::Item};
12
13pub(crate) fn id() -> Id {
14 Id::ROOT.child("palette")
15}
16
17enum 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.
24struct Row {
25 text: String,
26 after: String,
27 disabled: bool,
28 target: Option<Target>,
29}
30
31impl 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
4use crate::search::{Scope, marked};
5use crate::{State, Theme, art, page};
6use canvas::gpu::tag_sources;
7use canvas::outline::TagIcon;
8use canvas::search::{Found, Tagged};
9use std::{error::Error, sync::atomic::Ordering};
10use ui::{Anchor, Axis, Flags, Id, Spec, Ui, fill, fit, px};
11use winit::keyboard::NamedKey;
12
13/// The pane's width, and its lists' rows and group headings.
14const WIDTH: f32 = 260.0;
15const RESULT: f32 = 40.0;
16const TAGGED: f32 = 24.0;
17const HEADING: f32 = 22.0;
18/// A control's width inside the pane.
19const INSIDE: f32 = WIDTH - 2.0 * PAD;
20const PAD: f32 = 8.0;
21
22#[derive(Clone, Copy, Debug, PartialEq)]
23pub 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)]
36pub enum Sort {
37 Section,
38 Title,
39 Date,
40}
41
42impl 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)]
56pub enum Group {
57 Tag,
58 Section,
59 Title,
60 Date,
61 Text,
62}
63
64impl 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)]
86pub enum TagScope {
87 PageGroup,
88 Section,
89 SectionGroup,
90 Notebook,
91 All,
92 Today,
93 Yesterday,
94 ThisWeek,
95 LastWeek,
96 Older,
97}
98
99impl 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`.
158fn 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.
170fn 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
182const 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.
198struct Grouped {
199 count: usize,
200 /// The rows starting groups, in order.
201 starts: Vec<usize>,
202}
203
204impl 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.
223fn 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`.
243fn 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
248fn 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.
258fn 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.
296fn 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.
325pub(crate) fn field() -> Id {
326 Id::ROOT.child("pane-field")
327}
328
329fn 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.
334fn 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
352fn 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.
365fn 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
381impl 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.
787fn 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.
854fn 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)]
888mod 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;
99/// drawn at `SCALE` pixels per point or fewer where the GPU caps textures.
1010const SIZE: [f32; 2] = [1440.0, 900.0];
1111const 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.
1413#[cfg(target_os = "macos")]
15const 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]),
14const 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]),
1918];
2019
2120impl State {
......@@ -32,7 +31,6 @@ impl State {
3231 let scale = SCALE.min(most / size[0].max(size[1]));
3332 self.surface.size = size.map(|side| (side * scale).max(1.0) as u32);
3433 self.renderer.clear_glyph_cache();
35 self.app_icon = platform::app_icon((16.0 * scale) as u32);
3634 let response = self.view.scale_factor_changed(scale)?;
3735 self.respond(response);
3836 self.ui.set_focus(None);
......@@ -66,16 +64,26 @@ impl State {
6664 let path = PathBuf::from(format!("{}-{name}.png", prefix.display()));
6765 self.snapshot(&path)?;
6866 #[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 )?;
7073 }
7174 Ok(())
7275 }
7376}
7477
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.
7780#[cfg(target_os = "macos")]
78fn window_frame(path: &Path, appearance: Appearance, scale: f32) -> Result<(), Box<dyn Error>> {
81fn window_frame(
82 path: &Path,
83 appearance: Appearance,
84 scale: f32,
85 lights: [[f32; 2]; 3],
86) -> Result<(), Box<dyn Error>> {
7987 let mut reader =
8088 png::Decoder::new(std::io::BufReader::new(std::fs::File::open(path)?)).read_info()?;
8189 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
98106 for (index, pixel) in pixels.chunks_exact_mut(4).enumerate() {
99107 let x = (index as u32 % info.width) as f32 + 0.5;
100108 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);
103111 blend(
104112 pixel,
105113 [rim[0], rim[1], rim[2], 255],
crates/snowbound/src/search.rs+95-44
......@@ -2,8 +2,9 @@
22//! results dropping down from it, and Find on This Page. The index is kept on a thread of
33//! its own, from the section files and the open section's replica.
44
5use crate::pane::Pane;
56use crate::{Command, Library, State, Theme, art, commands, lap, page};
6use canvas::search::{Entry, Found, Index, Query, page_matches};
7use canvas::search::{Entry, Found, Index, PageMatch, Query, page_matches, paragraph_match};
78use notebook::Replica;
89use onestore::ExGuid;
910use std::{
......@@ -31,9 +32,9 @@ pub enum Scope {
3132}
3233
3334impl 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];
3536
36 fn name(self) -> &'static str {
37 pub(crate) fn name(self) -> &'static str {
3738 match self {
3839 Scope::Section => "This Section",
3940 Scope::Group => "This Section Group",
......@@ -52,7 +53,7 @@ const PLACE: f32 = 150.0;
5253/// How long typing pauses before the index reads the pages it changed.
5354const SETTLE: Duration = Duration::from_millis(400);
5455
55fn results() -> Id {
56pub(crate) fn results() -> Id {
5657 Id::ROOT.child("results")
5758}
5859
......@@ -71,7 +72,12 @@ fn find_field() -> Id {
7172
7273/// Whether `focus` is one of the box's fields, which take typed text.
7374pub 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)
7581}
7682
7783/// Work for the index thread.
......@@ -97,26 +103,27 @@ pub struct Search {
97103 /// Scope searches start in, as "Set This Scope as Default" leaves it.
98104 pub default: Scope,
99105 /// Find on This Page is open, at the match selected.
100 finding: bool,
106 pub(crate) finding: bool,
101107 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>>,
105111 /// Counts the index's changes, so results are made again when it changes.
106112 version: Arc<AtomicU64>,
107 busy: Arc<AtomicBool>,
113 pub(crate) busy: Arc<AtomicBool>,
108114 jobs: mpsc::Sender<Job>,
109 found: Vec<Found>,
115 pub(crate) found: Vec<Found>,
110116 found_for: Option<(Query, Scope, u64, String)>,
111 selected: Option<u64>,
117 pub(crate) selected: Option<u64>,
112118 /// The notebooks and open section the index was last brought to, by identity.
113119 synced: Vec<usize>,
114120 /// The results drop down, until chosen from or dismissed.
115 open: bool,
121 pub(crate) open: bool,
116122 /// Where the scope menu opens.
117123 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>,
120127}
121128
122129impl Search {
......@@ -148,6 +155,7 @@ impl Search {
148155 open: false,
149156 anchor: None,
150157 claim: false,
158 pane: None,
151159 }
152160 }
153161
......@@ -188,7 +196,7 @@ fn stamp(file: &Path) -> Stamp {
188196}
189197
190198/// Now in Time32, seconds since 1980, as page modification times are kept.
191fn now() -> u64 {
199pub(crate) fn now() -> u64 {
192200 SystemTime::now()
193201 .duration_since(SystemTime::UNIX_EPOCH)
194202 .map_or(0, |since| since.as_secs().saturating_sub(315_532_800))
......@@ -395,7 +403,13 @@ fn reread(
395403}
396404
397405/// A text box's label with `hits`, byte ranges of `text`, marked in OneNote's yellow.
398fn marked(ui: &mut Ui, part: &str, text: &str, hits: &[std::ops::Range<usize>], spec: Spec) {
406pub(crate) fn marked(
407 ui: &mut Ui,
408 part: &str,
409 text: &str,
410 hits: &[std::ops::Range<usize>],
411 spec: Spec,
412) {
399413 let height = match spec.size[1].size {
400414 ui::Size::Pixels(height) => height,
401415 _ => 0.0,
......@@ -481,7 +495,7 @@ impl State {
481495 }
482496
483497 /// 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 {
485499 let Some(session) = &self.session else {
486500 return scope == Scope::All;
487501 };
......@@ -499,7 +513,7 @@ impl State {
499513 }
500514
501515 /// 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>> {
503517 let query = Query::new(&self.search.query);
504518 let version = self.search.version.load(Ordering::Relaxed);
505519 let open = self
......@@ -538,33 +552,37 @@ impl State {
538552 }
539553
540554 /// 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.
542556 pub(crate) fn refind(&mut self, select: bool) -> Result<(), Box<dyn Error>> {
543557 self.view.found = page_matches(&self.view.editor, &self.search.shown());
544558 if !select {
545559 return Ok(());
546560 }
547561 let space = self.session.as_ref().map(|session| session.space);
548 let at = if self.search.finding {
562 let target = if self.search.finding {
549563 self.search.current = match self.search.current {
550564 Some(at) if at < self.view.found.len() => Some(at),
551565 _ => (!self.view.found.is_empty()).then_some(0),
552566 };
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 {
555573 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 }
557578 } else {
558579 None
559580 };
560 match at {
561 Some(at) => self.select_match(at),
562 None => Ok(()),
563 }
581 self.select(target)
564582 }
565583
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 {
568586 return Ok(());
569587 };
570588 self.view.editor.focus_outline(outline)?;
......@@ -598,26 +616,38 @@ impl State {
598616 }
599617
600618 /// 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) {
603621 self.search.open = false;
604 self.search.query.clear();
605622 self.search.finding = false;
606623 self.search.current = None;
607 self.search.scope = self.search.default;
608 self.view.found.clear();
609624 self.ui.close_popup(results());
610625 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 }
611631 }
612632
613633 /// Opens a result's page, whose first match is selected once it shows.
614634 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));
621651 let open = self.session.as_ref().map(|session| {
622652 (
623653 session.library.key(&session.tabs[session.tab].path),
......@@ -633,9 +663,9 @@ impl State {
633663 .iter()
634664 .find(|library| library.location == location)
635665 .ok_or("The notebook is no longer open")?;
636 self.last_pages.insert(section, space);
637666 self.commands
638667 .push(Command::OpenSection(Arc::clone(library), path.to_owned()));
668 self.last_pages.insert(section, space);
639669 }
640670 }
641671 Ok(())
......@@ -984,8 +1014,29 @@ impl State {
9841014 .or_else(|| entered.then_some(0))
9851015 });
9861016 }
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;
9871036 self.ui.close();
988 if let Some(index) = chosen {
1037 if pane {
1038 self.open_search_pane();
1039 } else if let Some(index) = chosen {
9891040 self.open_result(index)?;
9901041 self.search.open = false;
9911042 self.ui.close_popup(results());
......@@ -1084,7 +1135,7 @@ impl State {
10841135 (at + count - 1) % count
10851136 };
10861137 self.search.current = Some(next);
1087 self.select_match(next)?;
1138 self.select(self.view.found.get(next).copied())?;
10881139 }
10891140 Ok(())
10901141 }
crates/snowbound/src/sidebar.rs+6-15
......@@ -139,7 +139,7 @@ fn sidebar(ui: &mut Ui, tree: &mut Tree, notebooks: &[Arc<Library>], header: f32
139139 ..Spec::default()
140140 },
141141 );
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.
143143 ui.leaf(
144144 "toggle",
145145 Spec {
......@@ -710,26 +710,17 @@ impl crate::State {
710710 /// The sidebar's button in its square at the window's edge, and the room the section
711711 /// tabs, standing `width` from that edge, leave it; the button stays put as the sidebar
712712 /// 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) {
714716 if self.temporary {
715717 return;
716718 }
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 );
727719 self.ui.open(
728720 "toggle",
729721 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)],
733724 pad: [(RAIL - ui::shell::TOOL) / 2.0; 2],
734725 ..Spec::default()
735726 },
crates/snowbound/src/surface.rs+16-12
......@@ -58,6 +58,12 @@ impl Surface {
5858 config.view_formats.push(window_format);
5959 draw::Translucent::new(&device, config.format, window_format)
6060 });
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 }
6167 surface.configure(&device, &config);
6268 eprintln!("Canvas GPU: {:?}", adapter.get_info());
6369 let renderer = Renderer::new(device, queue, config.format);
......@@ -205,17 +211,15 @@ impl Surface {
205211 self.config.format,
206212 wgpu::TextureFormat::Bgra8Unorm | wgpu::TextureFormat::Bgra8UnormSrgb
207213 );
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)
220224 }
221225}
crates/snowbound/src/surface_gl.rs+18-5
......@@ -18,6 +18,9 @@ pub struct Surface {
1818 /// Device pixels frames and snapshots are drawn at; `configure` gives the window it.
1919 pub size: [u32; 2],
2020 context: Retained<AnyObject>,
21 /// Frames leave the window's textured backdrop showing through their transparent
22 /// pixels.
23 translucent: bool,
2124 /// The frame target, kept between frames.
2225 target: Option<Target>,
2326}
......@@ -29,10 +32,10 @@ pub struct Frame {
2932pub type Offscreen = Frame;
3033
3134impl Surface {
32 /// No backdrop shows through an OpenGL view.
35 /// With `backdrop`, the surface is transparent where frames are.
3336 pub async fn new(
3437 window: Arc<Window>,
35 _backdrop: bool,
38 backdrop: bool,
3639 ) -> Result<(Self, Renderer), Box<dyn Error>> {
3740 let RawWindowHandle::AppKit(handle) = window.window_handle()?.as_raw() else {
3841 unreachable!()
......@@ -59,6 +62,11 @@ impl Surface {
5962 // NSOpenGLCPSwapInterval: present on the display's refresh.
6063 let interval: i32 = 1;
6164 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 }
6270 context
6371 };
6472 let renderer = Renderer::new()?;
......@@ -68,6 +76,7 @@ impl Surface {
6876 Self {
6977 size: [size.width, size.height],
7078 context,
79 translucent: backdrop,
7180 target: None,
7281 },
7382 renderer,
......@@ -75,7 +84,7 @@ impl Surface {
7584 }
7685
7786 pub fn translucent(&self) -> bool {
78 false
87 self.translucent
7988 }
8089
8190 pub fn configure(&mut self, _: &Renderer) {
......@@ -94,8 +103,12 @@ impl Surface {
94103 Ok(Some(Frame { target }))
95104 }
96105
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 }
99112 let _: () = unsafe { msg_send![&self.context, flushBuffer] };
100113 self.target = Some(frame.target);
101114 }
crates/snowbound/src/sync.rs+73-22
......@@ -1,10 +1,12 @@
11//! The sync status at the top right, and the popup it opens: OneNote's Shared Notebook
22//! Synchronization for the open section.
33
4use crate::{Session, State, filetime, library, platform};
4use crate::{Session, State, art, filetime, library, platform};
55use notebook::session::SyncStatus;
66use std::{error::Error, io::ErrorKind};
7use ui::{Anchor, Axis, Flags, Id, Overflow, Spec, Theme, Ui, children, fill, fit, px};
7use ui::{
8 Anchor, Axis, Flags, Id, Overflow, Spec, Theme, Ui, children, fill, fit, px, shell::TOOL,
9};
810
911const WIDTH: f32 = 380.0;
1012
......@@ -16,24 +18,27 @@ fn button() -> Id {
1618 Id::ROOT.child("sync-button")
1719}
1820
19/// The status's label, and what the reader can do about an error.
20fn describe(sync: &SyncStatus) -> (&'static str, Option<&'static str>) {
21/// The status's label and icon, and what the reader can do about an error.
22fn describe(sync: &SyncStatus) -> (&'static str, &'static [&'static str], Option<&'static str>) {
2123 use ErrorKind::*;
2224 if library::offline() {
2325 return (
2426 "Working offline",
27 art::SYNC_OFFLINE,
2528 Some("Changes stay on this computer until you sync."),
2629 );
2730 }
2831 match sync.error.as_ref().map(std::io::Error::kind) {
2932 Some(PermissionDenied | ReadOnlyFilesystem) => (
3033 "Unable to sync",
34 art::SYNC_ERROR,
3135 Some(
3236 "You can’t change this notebook where it’s stored. Changes stay on this computer.",
3337 ),
3438 ),
3539 Some(WouldBlock | ResourceBusy) => (
3640 "Section in use",
41 art::SYNC_BUSY,
3742 Some("Someone else is saving this section. Sync continues when they finish."),
3843 ),
3944 Some(
......@@ -42,11 +47,12 @@ fn describe(sync: &SyncStatus) -> (&'static str, Option<&'static str>) {
4247 | AddrNotAvailable,
4348 ) => (
4449 "Not connected",
50 art::SYNC_OFFLINE,
4551 Some("Changes stay on this computer and sync when the notebook is back."),
4652 ),
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),
5056 }
5157}
5258
......@@ -65,25 +71,47 @@ fn when(time: u64) -> String {
6571 }
6672}
6773
68/// The status beside the window's controls, which opens the popup.
69pub(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.
76pub(crate) fn control(ui: &mut Ui, session: &Session, theme: &Theme, compact: bool) {
7077 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 };
7193 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 }
83108 },
84109 );
85110 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 {
87115 if ui.popup_open(id()) {
88116 ui.close_popup(id());
89117 } else {
......@@ -107,7 +135,7 @@ impl State {
107135 session.sync = session.section.sync_status()?;
108136 let theme = ui.theme.clone();
109137 let offline = library::offline();
110 let (progress, advice) = describe(&session.sync);
138 let (progress, _, advice) = describe(&session.sync);
111139 ui.open_as(
112140 id(),
113141 Spec {
......@@ -209,6 +237,26 @@ impl State {
209237 ..Spec::default()
210238 },
211239 );
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 };
212260 let now = ui::button(ui, "sync-now", "Sync Now").clicked;
213261 ui.close();
214262 ui.close();
......@@ -219,6 +267,9 @@ impl State {
219267 if now {
220268 session.section.wake();
221269 }
270 if let Some(file) = file.filter(|_| show) {
271 platform::show_file(&file);
272 }
222273 Ok(())
223274 }
224275}
crates/snowbound/src/templates.rs+2-2
......@@ -12,7 +12,7 @@ use ui::{Axis, Flags, Spec, Theme, Ui, fill, px};
1212
1313/// What a thumbnail shows: the top of a letter page, in points from the page origin.
1414const REGION: [f32; 4] = [-72.0, -40.0, 540.0, 419.0];
15const TILE: [f32; 2] = [112.0, 84.0];
15pub(crate) const TILE: [f32; 2] = [112.0, 84.0];
1616const SWATCH: f32 = 24.0;
1717const LABEL: f32 = 22.0;
1818const GAP: f32 = 12.0;
......@@ -59,7 +59,7 @@ pub struct Thumbnails(HashMap<(&'static str, bool), Art>);
5959
6060impl Thumbnails {
6161 /// `template`'s art and where it lies in a `TILE`, drawn at `scale` for `paper`.
62 fn art(
62 pub(crate) fn art(
6363 &mut self,
6464 template: &'static Template,
6565 paper: Paper,
crates/ui/examples/popups.rs+1-1
......@@ -761,7 +761,7 @@ fn palette(scene: &mut Scene) {
761761 });
762762 let id = Id::ROOT.child("palette");
763763 scene.open(id, typed("ne"), |ui, _| {
764 ui::popup::palette(ui, id, &commands);
764 ui::popup::palette(ui, id, &commands, "Search commands");
765765 });
766766}
767767
crates/ui/src/layout.rs+112-18
......@@ -2,8 +2,9 @@ use crate::{Anchor, Axis, Built, Flags, ICON, ICON_GAP, Id, Overflow, Size, Stat
22use std::collections::HashMap;
33
44/// 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
78/// solved width wrap or shorten before heights are solved. Boxes are in build order, so
89/// index order is pre-order.
910pub(crate) fn solve(
......@@ -13,6 +14,12 @@ pub(crate) fn solve(
1314 texts: &mut Texts,
1415 frame: u64,
1516) {
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 }
1623 for axis in 0..2 {
1724 if axis == 1 {
1825 fit_labels(nodes, texts, frame);
......@@ -48,7 +55,7 @@ pub(crate) fn solve(
4855 while ancestor != 0 && nodes[ancestor].size[axis].size == Size::Children {
4956 ancestor = nodes[ancestor].parent;
5057 }
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];
5259 nodes[index].computed[axis] = room.max(0.0) * fraction;
5360 }
5461 }
......@@ -66,22 +73,48 @@ pub(crate) fn solve(
6673 let room = (node.computed[axis] - 2.0 * node.pad[axis]).max(0.0);
6774 let children: Vec<_> = in_flow(nodes, index).collect();
6875 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
71100 .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);
82113 }
83114 } else {
84115 for child in children {
116 let room =
117 (across(nodes, index, child, axis) - 2.0 * nodes[index].pad[axis]).max(0.0);
85118 let over = nodes[child].computed[axis] - room;
86119 if over > 0.0 {
87120 nodes[child].computed[axis] -=
......@@ -93,6 +126,13 @@ pub(crate) fn solve(
93126 let window = nodes[0].computed[axis];
94127 for index in 0..nodes.len() {
95128 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 };
96136 for child in nodes[index].children.clone() {
97137 nodes[child].relative[axis] = if let Some(anchor) = nodes[child].anchor {
98138 let shown = nodes[child].computed[axis];
......@@ -103,6 +143,8 @@ pub(crate) fn solve(
103143 anchor.place(axis, size, shown, window)
104144 } else if nodes[child].flags.contains(Flags::FLOAT) {
105145 nodes[child].position[axis]
146 } else if nodes[child].hidden {
147 nodes[index].pad[axis]
106148 } else if along(&nodes[index], axis) {
107149 let at = cursor;
108150 cursor += nodes[child].computed[axis] + nodes[index].gap;
......@@ -110,6 +152,9 @@ pub(crate) fn solve(
110152 } else {
111153 nodes[index].pad[axis]
112154 };
155 if !nodes[child].flags.contains(Flags::STILL) {
156 nodes[child].relative[axis] += shift;
157 }
113158 }
114159 if axis == 1 {
115160 nodes[index].content = flow(nodes, index, axis) + 2.0 * nodes[index].pad[axis];
......@@ -124,7 +169,13 @@ pub(crate) fn solve(
124169 snap(nodes[0].computed[1]),
125170 ];
126171 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];
128179 let scroll =
129180 if parent.flags.contains(Flags::SCROLL) && !nodes[index].flags.contains(Flags::FLOAT) {
130181 states.get(&parent.id).map_or(0.0, |state| state.scroll)
......@@ -143,17 +194,60 @@ pub(crate) fn solve(
143194 }
144195}
145196
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.
199fn 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.
213fn 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
146228fn along(node: &Built, axis: usize) -> bool {
147229 (node.axis == Axis::Y) == (axis == 1)
148230}
149231
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.
150234fn 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])
152244}
153245
154246fn in_flow(nodes: &[Built], index: usize) -> impl Iterator<Item = usize> + '_ {
155247 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
157251 })
158252}
159253
crates/ui/src/lib.rs+169-26
......@@ -13,7 +13,10 @@ mod widgets;
1313
1414pub use list::{List, Row, Rows, list};
1515pub use theme::{Menu, PopupMotion, Section, Shades, Shadow, Theme};
16pub use widgets::{button, check_box, edit_key, edit_modifiers, scrollbar, text_field};
16pub use widgets::{
17 PaintedScroller, Scroller, ScrollerPart, Scrollers, button, check_box, edit_key,
18 edit_modifiers, scrollbar, text_field,
19};
1720
1821use draw::{
1922 PathStyle, Primitive, RasterImage, Stroke,
......@@ -38,7 +41,7 @@ use winit::{
3841const HALF_LIFE: f32 = 0.03;
3942/// How far a box's shadow spreads, in logical pixels, and how far below the box it
4043/// falls; a popup's, floating higher, spreads and falls further.
41const SHADOW: [f32; 2] = [3.0, 0.0];
44pub const SHADOW: [f32; 2] = [3.0, 0.0];
4245const POPUP_SHADOW: [f32; 2] = [12.0, 4.0];
4346/// Logical size of a box's icon, and its distance from the label.
4447const ICON: f32 = 16.0;
......@@ -93,6 +96,13 @@ impl Default for Extent {
9396 }
9497}
9598
99/// Logical pixels, as `px`.
100impl From<f32> for Extent {
101 fn from(pixels: f32) -> Self {
102 px(pixels)
103 }
104}
105
96106pub fn px(pixels: f32) -> Extent {
97107 Extent {
98108 size: Size::Pixels(pixels),
......@@ -202,6 +212,9 @@ pub enum Anchor {
202212 Point([f32; 2]),
203213 /// Centred in the window over the interface, which dims, as a dialog opens.
204214 Dialog,
215 /// Centred across the window near its top, swinging in as a dialog does, as a command
216 /// palette opens.
217 Top,
205218}
206219
207220impl Anchor {
......@@ -215,6 +228,8 @@ impl Anchor {
215228 Anchor::Over(rect) => (rect, None),
216229 Anchor::Point([x, y]) => ([x, y, x, y], Some(1)),
217230 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,
218233 };
219234 let [low, high] = [rect[axis], rect[axis + 2]];
220235 let (first, second) = if along == Some(axis) {
......@@ -231,31 +246,37 @@ impl Anchor {
231246 }
232247 }
233248
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.
237252 fn motion(self, rect: [f32; 4], open: f32, grows: bool) -> Motion {
238253 let pivot = match self {
239254 Anchor::Below(anchor) => [anchor[0], anchor[3]],
240255 Anchor::Right(anchor) => [anchor[2], anchor[1]],
241256 Anchor::Over(anchor) => [anchor[0], anchor[1]],
242257 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]],
244259 };
245260 let (from, tilt) = match self {
246261 _ if !grows => (1.0, 0.0),
247 Anchor::Dialog => (0.95, 0.4),
262 Anchor::Dialog | Anchor::Top => (0.95, 0.2),
248263 // Over a box, the popup widens out of it in layout instead of growing.
249264 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
252276 };
253277 Motion {
254278 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,
259280 tilt: tilt * (1.0 - open),
260281 opacity: open,
261282 }
......@@ -263,7 +284,7 @@ impl Anchor {
263284
264285 /// Seconds the popup takes to open and to close.
265286 fn durations(self) -> [f32; 2] {
266 if self == Anchor::Dialog {
287 if matches!(self, Anchor::Dialog | Anchor::Top) {
267288 DIALOG
268289 } else {
269290 POPUP
......@@ -332,6 +353,16 @@ pub struct Spec<'a> {
332353 /// Floats the box over all others beside a rectangle in the window, as a popup
333354 /// `Ui::open_popup` opened; boxes beneath take no input while one is open.
334355 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],
335366}
336367
337368/// Input the host forwards; positions and wheel distances are logical pixels.
......@@ -387,6 +418,8 @@ pub enum Layer<'a> {
387418/// opening or closing.
388419pub struct Primitives<'a> {
389420 pub clip: Option<[f32; 4]>,
421 /// A rounded rectangle and its corner radius the primitives paint only inside.
422 round: Option<([f32; 4], f32)>,
390423 motion: Option<Motion>,
391424 pub primitives: Vec<Primitive<'a>>,
392425}
......@@ -411,14 +444,18 @@ impl Primitives<'_> {
411444 pivot[axis] + (point[axis] - pivot[axis]) * zoom
412445 }))
413446 };
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 };
414451 draw::Layer {
415452 scale: scale * zoom,
416453 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),
421455 backdrop: None,
456 round: self
457 .round
458 .map(|(rect, radius)| (moved_rect(rect), radius * scale * zoom)),
422459 motion: self.motion.map(|_| draw::Motion {
423460 opacity,
424461 tilt,
......@@ -462,6 +499,11 @@ struct Built {
462499 motion: Option<Motion>,
463500 /// Rectangles relative to the box, painted over its fill.
464501 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,
465507 computed: [f32; 2],
466508 relative: [f32; 2],
467509 rect: [f32; 4],
......@@ -486,6 +528,8 @@ struct State {
486528 select_all: bool,
487529 /// Where a dragged scrollbar thumb was taken, from its start.
488530 grab: f32,
531 /// A system scroller's part held down, and when a held arrow or track next repeats.
532 held: Option<(ScrollerPart, Instant)>,
489533 /// An animated value and its target, for `Ui::animate`.
490534 tween: Option<[f32; 2]>,
491535}
......@@ -531,6 +575,8 @@ struct Hit {
531575#[derive(Clone)]
532576enum Display {
533577 Clip(Option<[f32; 4]>),
578 /// A rounded rectangle and its corner radius, clipping what follows.
579 Round(Option<([f32; 4], f32)>),
534580 Motion(Option<Motion>),
535581 Rect {
536582 rect: [f32; 4],
......@@ -582,6 +628,9 @@ pub struct Ui {
582628 /// Whether the window has keyboard focus; without it a field hides its caret and dims
583629 /// its selection.
584630 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>,
585634 frame: u64,
586635 now: Instant,
587636 scale: f32,
......@@ -608,6 +657,8 @@ pub struct Ui {
608657 modifiers: ModifiersState,
609658 clicks: Clicks,
610659 display: Vec<Display>,
660 /// The painting of what holds still in the popup being painted, which goes over it.
661 still: Vec<Display>,
611662 /// Where the popups' painting starts in `display`, for edges drawn beneath them.
612663 popups_painted: usize,
613664 /// Each popup painted, its anchor and rectangle, and its painting's place after
......@@ -634,6 +685,7 @@ impl Ui {
634685 theme,
635686 icon_palette: draw::Palette::default(),
636687 window_focused: true,
688 scrollers: None,
637689 frame: 0,
638690 now: Instant::now(),
639691 scale: 1.0,
......@@ -656,6 +708,7 @@ impl Ui {
656708 modifiers: ModifiersState::empty(),
657709 clicks: Clicks::new(double_click),
658710 display: Vec::new(),
711 still: Vec::new(),
659712 popups_painted: 0,
660713 painted: Vec::new(),
661714 closing: Vec::new(),
......@@ -1188,6 +1241,15 @@ impl Ui {
11881241 }
11891242 let from = self.display.len() - self.popups_painted;
11901243 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 }
11911253 painted.push((
11921254 id,
11931255 anchor,
......@@ -1279,9 +1341,10 @@ impl Ui {
12791341 })
12801342 }
12811343
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.
12831346 fn popup_motion(&self, anchor: Anchor) -> PopupMotion {
1284 if anchor == Anchor::Dialog {
1347 if matches!(anchor, Anchor::Dialog | Anchor::Top) {
12851348 PopupMotion::Grow
12861349 } else {
12871350 self.theme.menu().motion
......@@ -1311,6 +1374,18 @@ impl Ui {
13111374 }
13121375
13131376 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>) {
13141389 let node = &self.nodes[index];
13151390 // The motion the box and its children paint with, where it changes it.
13161391 let own = if node.flags.contains(Flags::STILL) {
......@@ -1485,7 +1560,9 @@ impl Ui {
14851560 });
14861561 }
14871562 }
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() {
14891566 Some(visible.unwrap_or([rect[0], rect[1], rect[0], rect[1]]))
14901567 } else {
14911568 clip
......@@ -1493,11 +1570,18 @@ impl Ui {
14931570 if inner_clip != clip {
14941571 self.display.push(Display::Clip(inner_clip));
14951572 }
1573 if round.is_some() {
1574 self.display.push(Display::Round(round));
1575 }
14961576 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 {
14981578 self.paint(child, inner_clip, own.unwrap_or(motion));
14991579 }
15001580 }
1581 if round.is_some() {
1582 self.display.push(Display::Round(None));
1583 }
1584 self.fade(index);
15011585 if inner_clip != clip {
15021586 self.display.push(Display::Clip(clip));
15031587 }
......@@ -1506,21 +1590,69 @@ impl Ui {
15061590 }
15071591 }
15081592
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
15091638 /// This frame's painting in order, in logical pixels.
15101639 pub fn layers(&self) -> Vec<Layer<'_>> {
15111640 let mut layers = Vec::new();
15121641 let mut clip = None;
1642 let mut round = None;
15131643 let mut motion = None;
15141644 let mut primitives = Vec::new();
15151645 fn flush<'a>(
15161646 layers: &mut Vec<Layer<'a>>,
15171647 clip: Option<[f32; 4]>,
1648 round: Option<([f32; 4], f32)>,
15181649 motion: Option<Motion>,
15191650 primitives: &mut Vec<Primitive<'a>>,
15201651 ) {
15211652 if !primitives.is_empty() {
15221653 layers.push(Layer::Primitives(Primitives {
15231654 clip,
1655 round,
15241656 motion,
15251657 primitives: std::mem::take(primitives),
15261658 }));
......@@ -1529,11 +1661,15 @@ impl Ui {
15291661 for item in &self.display {
15301662 match item {
15311663 Display::Clip(next) => {
1532 flush(&mut layers, clip, motion, &mut primitives);
1664 flush(&mut layers, clip, round, motion, &mut primitives);
15331665 clip = *next;
15341666 }
1667 Display::Round(next) => {
1668 flush(&mut layers, clip, round, motion, &mut primitives);
1669 round = *next;
1670 }
15351671 Display::Motion(next) => {
1536 flush(&mut layers, clip, motion, &mut primitives);
1672 flush(&mut layers, clip, round, motion, &mut primitives);
15371673 motion = *next;
15381674 }
15391675 Display::Rect {
......@@ -1617,7 +1753,7 @@ impl Ui {
16171753 ink: *color,
16181754 }),
16191755 Display::Custom { id, rect } => {
1620 flush(&mut layers, clip, motion, &mut primitives);
1756 flush(&mut layers, clip, round, motion, &mut primitives);
16211757 layers.push(Layer::Custom {
16221758 id: *id,
16231759 rect: *rect,
......@@ -1625,7 +1761,7 @@ impl Ui {
16251761 }
16261762 }
16271763 }
1628 flush(&mut layers, clip, motion, &mut primitives);
1764 flush(&mut layers, clip, round, motion, &mut primitives);
16291765 layers
16301766 }
16311767
......@@ -1648,6 +1784,10 @@ impl Ui {
16481784 pub(crate) fn grab(&mut self, id: Id) -> &mut f32 {
16491785 &mut self.states.entry(id).or_default().grab
16501786 }
1787
1788 pub(crate) fn held(&mut self, id: Id) -> &mut Option<(ScrollerPart, Instant)> {
1789 &mut self.states.entry(id).or_default().held
1790 }
16511791}
16521792
16531793impl Built {
......@@ -1699,6 +1839,9 @@ impl Built {
16991839 open: 1.0,
17001840 motion: None,
17011841 marks: Vec::new(),
1842 fold: spec.fold,
1843 fade: spec.fade,
1844 hidden: false,
17021845 computed: [0.0; 2],
17031846 relative: [0.0; 2],
17041847 rect: [0.0; 4],
crates/ui/src/list.rs+5-5
......@@ -189,18 +189,18 @@ pub fn list<R: Rows>(
189189 let scroll = state.scroll;
190190 ui.animating |= scroll != state.target;
191191
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 };
192195 ui.open_as(
193196 id,
194197 Spec {
195198 flags: spec.flags | Flags::SCROLL | Flags::CLIP,
199 pad: [spec.pad[0] + gutter, spec.pad[1]],
196200 ..spec
197201 },
198202 );
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();
204204 let in_view = row_at(scroll)..(row_at(scroll + view) + 1).min(len);
205205 let mut shown = Vec::with_capacity(in_view.len());
206206 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>)
209209}
210210
211211/// 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.
214pub 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.
214pub fn palette(ui: &mut Ui, id: Id, items: &[Item], placeholder: &str) -> Option<usize> {
215215 if !ui.popup_open(id) {
216216 return None;
217217 }
218218 let window = ui.rect(Id::ROOT).unwrap_or_default();
219219 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)
230221}
231222
232223fn choose(
......@@ -694,26 +685,42 @@ pub fn table_picker(ui: &mut Ui, id: Id, anchor: Anchor, size: [usize; 2]) -> Op
694685 None
695686}
696687
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)]
690pub 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.
702700pub fn gallery(
703701 ui: &mut Ui,
704702 id: Id,
705703 anchor: Anchor,
706 groups: &[(&str, usize)],
707 columns: usize,
708 size: [f32; 2],
709 current: Option<usize>,
704 groups: &[Group],
705 current: &[usize],
710706 mut cell: impl FnMut(&mut Ui, usize),
711707) -> Option<usize> {
712708 if !ui.popup_open(id) {
713709 return None;
714710 }
715711 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 };
717724 let keys = navigation(
718725 ui,
719726 &[id],
......@@ -737,7 +744,7 @@ pub fn gallery(
737744 }
738745 }
739746 for key in keys {
740 let at = highlight.or(current).unwrap_or(0);
747 let at = highlight.or(current.first().copied()).unwrap_or(0);
741748 highlight = Some(match key {
742749 NamedKey::Enter => {
743750 chosen = chosen.or(highlight);
......@@ -745,8 +752,8 @@ pub fn gallery(
745752 }
746753 NamedKey::ArrowLeft => at.saturating_sub(1),
747754 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),
750757 });
751758 }
752759 if let Some(index) = chosen {
......@@ -754,19 +761,18 @@ pub fn gallery(
754761 return Some(index);
755762 }
756763 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);
763769 let mut index = 0;
764 for (group, (heading, cells)) in groups.iter().enumerate() {
770 for (number, group) in groups.iter().enumerate() {
765771 ui.leaf(
766 ("heading", group),
772 ("heading", number),
767773 Spec {
768774 size: [fill(), px(MENU_ROW)],
769 text: Some(heading),
775 text: Some(group.heading),
770776 font_size: Some(theme.font_size - 2.0),
771777 bold: true,
772778 color: Some(theme.text_dim),
......@@ -774,23 +780,23 @@ pub fn gallery(
774780 ..Spec::default()
775781 },
776782 );
777 for row in 0..cells.div_ceil(columns) {
783 for row in 0..group.cells.div_ceil(group.columns) {
778784 ui.open(
779 ("row", group, row),
785 ("row", number, row),
780786 Spec {
781 size: [fill(), px(size[1])],
787 size: [fill(), px(group.size[1])],
782788 ..Spec::default()
783789 },
784790 );
785 for _ in 0..columns.min(cells - row * columns) {
791 for _ in 0..group.columns.min(group.cells - row * group.columns) {
786792 ui.open_as(
787793 cell_id(index),
788794 Spec {
789795 flags: Flags::CLICKABLE,
790796 axis: Axis::Y,
791 size: [px(size[0]), px(size[1])],
797 size: [px(group.size[0]), px(group.size[1])],
792798 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),
794800 radius: 4.0,
795801 pad: [PAD, PAD],
796802 ..Spec::default()
crates/ui/src/shell.rs+71-24
......@@ -4,7 +4,8 @@
44use draw::{PathStyle, edit::SelectionUnit};
55
66use 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,
89};
910use std::hash::Hash;
1011
......@@ -18,34 +19,34 @@ pub const TOOL: f32 = 22.0;
1819const ARROW: f32 = 10.0;
1920/// Where a section tab's label starts inside it.
2021pub 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.
24const EDGE: f32 = 20.0;
2125
2226/// Section tabs in a row `height` tall, as OneNote draws them: each leans over the next
2327/// at 45°, and the open one lies on top in `section`'s colours, rising to meet the frame
2428/// below as its outline and shadow fade in. `tabs` are names and section colours; `lit`
2529/// shows as hovered, as a tab something is dragged onto. A `dragged` tab follows the
2630/// 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.
2837#[allow(clippy::too_many_arguments)]
2938pub fn section_tabs(
3039 ui: &mut Ui,
31 part: impl Hash,
40 row: Id,
3241 tabs: &[(&str, [f32; 4])],
3342 active: usize,
3443 lit: Option<usize>,
3544 dragged: Option<Dragged>,
3645 section: &Section,
3746 height: f32,
47 fill: [f32; 4],
3848) -> Tabs {
3949 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];
4950 // Room for the first tab's outline and shadow, which the row would clip.
5051 let mut left = crate::SHADOW[0];
5152 let placed: Vec<_> = tabs
......@@ -56,14 +57,65 @@ pub fn section_tabs(
5657 (left - width, width)
5758 })
5859 .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];
59109 let (mut clicked, mut context, mut held, mut renamed) = (None, None, None, None);
60110 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);
61113 let slot = dragged.and_then(|dragged| {
62114 let width = placed[dragged.index].1;
63115 Some(crate::drop_slot(
64116 &spans,
65117 dragged.index,
66 dragged.start? + width / 2.0,
118 start? + width / 2.0,
67119 ))
68120 });
69121 let lifted = dragged.map(|dragged| dragged.index);
......@@ -79,14 +131,9 @@ pub fn section_tabs(
79131 let (x, width) = placed[index];
80132 // Keyed by name, so a tab eases from where it stood when the tabs are reordered.
81133 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(
90137 key,
91138 x + crate::slide(index, dragged.index, slot, placed[dragged.index].1),
92139 ),
......@@ -110,7 +157,7 @@ pub fn section_tabs(
110157 Spec {
111158 flags: Flags::CLICKABLE | Flags::FLOAT,
112159 size: [px(width), px(tall)],
113 position: [x, height - tall],
160 position: [x - offset, height - tall],
114161 text: Some(name),
115162 color: Some(mix(mix(theme.ink, fill, 0.2), theme.ink, open)),
116163 fill: Some(if lit == Some(index) {
......@@ -409,13 +456,13 @@ pub fn menu_button(
409456}
410457
411458/// A drop-down box `width` wide showing `text`.
412pub fn combo(ui: &mut Ui, part: impl Hash, text: &str, width: f32) -> Signal {
459pub fn combo(ui: &mut Ui, part: impl Hash, text: &str, width: impl Into<Extent>) -> Signal {
413460 let theme = ui.theme.clone();
414461 let id = ui.open(
415462 part,
416463 Spec {
417464 flags: Flags::CLICKABLE,
418 size: [px(width), px(TOOL)],
465 size: [width.into(), px(TOOL)],
419466 fill: Some(theme.base),
420467 hover_border: Some(theme.accent),
421468 border: Some(theme.chip),
crates/ui/src/tests.rs+340-30
......@@ -127,6 +127,146 @@ fn children_sum_with_gaps_and_padding() {
127127 assert_eq!(ui.rect(second.unwrap()), Some([4.0, 18.0, 34.0, 38.0]));
128128}
129129
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.
133fn 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]
193fn 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]
208fn 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]
219fn 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]
257fn 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
130270fn button_frame(ui: &mut Ui) -> Signal {
131271 let mut signal = Signal::default();
132272 frame(ui, |ui| {
......@@ -709,6 +849,86 @@ fn scrollbar_thumbs_track_the_offset_and_drags_reach_both_ends() {
709849 assert_eq!(offset, 0.0);
710850}
711851
852#[test]
853fn 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
712932fn items() -> Vec<popup::Item<'static>> {
713933 let item = |text| popup::Item {
714934 text,
......@@ -1071,13 +1291,15 @@ fn nothing_matching_leaves_nothing_to_choose() {
10711291}
10721292
10731293#[test]
1074fn the_palette_centres_across_the_window_and_chooses_the_best_match() {
1294fn the_palette_centres_near_the_windows_top_and_chooses_the_best_match() {
10751295 let mut ui = Ui::new(Theme::dark(), DOUBLE_CLICK);
10761296 let palette = Id::ROOT.child("palette");
10771297 let commands = items();
10781298 let build = |ui: &mut Ui| {
10791299 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 });
10811303 chosen
10821304 };
10831305 build(&mut ui);
......@@ -1087,6 +1309,7 @@ fn the_palette_centres_across_the_window_and_chooses_the_best_match() {
10871309 }
10881310 let rect = ui.rect(palette).unwrap();
10891311 assert_eq!(rect[0] + rect[2], 400.0, "centred");
1312 assert_eq!(rect[1], 300.0 / 8.0, "an eighth down");
10901313 ui.event(typed("a"));
10911314 ui.event(key(NamedKey::Enter));
10921315 assert_eq!(
......@@ -1748,7 +1971,7 @@ fn popups_ease_open_and_closed_then_stop_asking_for_frames() {
17481971 build(ui);
17491972 });
17501973 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);
17521975 assert_eq!(
17531976 first.pivot,
17541977 [200.0, 125.0],
......@@ -1895,7 +2118,7 @@ fn every_control_in_an_open_dialog_takes_clicks_where_it_rests() {
18952118}
18962119
18972120#[test]
1898fn a_combo_s_field_holds_its_place_as_its_list_opens_and_its_rows_take_clicks() {
2121fn a_combo_s_field_widens_over_rows_laid_out_where_they_end_and_its_rows_take_clicks() {
18992122 let mut ui = Ui::new(Theme::light(), DOUBLE_CLICK);
19002123 let fonts = fonts();
19012124 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()
19152138 let [box_rect, first] = [ui.rect(combo).unwrap(), ui.rect(field).unwrap()];
19162139 assert_eq!(first[..2], box_rect[..2], "the field starts on the box");
19172140 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
19202150 .iter()
19212151 .filter_map(|layer| match layer {
1922 Layer::Primitives(layer) => Some(layer.motion.is_some()),
2152 Layer::Primitives(layer) => Some(layer),
19232153 Layer::Custom { .. } => None,
19242154 })
2155 .skip_while(|layer| layer.motion.is_none())
19252156 .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 );
19272165 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"
19302171 );
2172 drop(layers);
19312173 for _ in 0..20 {
19322174 build(&mut ui);
19332175 }
19342176 assert!(!ui.wants_frame());
19352177 let [panel, last] = [ui.rect(menu_id()).unwrap(), ui.rect(field).unwrap()];
19362178 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");
19372180 click_box(&mut ui, menu_id().child("rows").child(3_u64));
19382181 assert_eq!(build(&mut ui), Some(3));
19392182}
......@@ -1955,6 +2198,8 @@ fn a_popup_over_a_box_by_the_window_s_edge_widens_away_from_it() {
19552198 let edges =
19562199 |ui: &Ui| [ui.rect(field).unwrap(), ui.rect(menu_id()).unwrap()].map(|rect| rect[2]);
19572200 let first = ui.rect(field).unwrap();
2201 let row = menu_id().child("rows").child(3_u64);
2202 let opening = ui.rect(row).unwrap();
19582203 assert_eq!(
19592204 edges(&ui),
19602205 [390.0, 394.0],
......@@ -1969,6 +2214,7 @@ fn a_popup_over_a_box_by_the_window_s_edge_widens_away_from_it() {
19692214 ui.rect(field).unwrap()[0] < first[0] - 30.0,
19702215 "the field widens leftward"
19712216 );
2217 assert_eq!(ui.rect(row), Some(opening), "the rows never move");
19722218}
19732219
19742220fn built(ui: &Ui, id: Id) -> &Built {
......@@ -2113,29 +2359,25 @@ fn a_menu_button_opens_its_menu_with_the_icons_under_its_own() {
21132359fn galleries_choose_across_their_groups_by_keys_and_clicks() {
21142360 let mut ui = Ui::new(Theme::dark(), DOUBLE_CLICK);
21152361 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 });
21172368 let build = |ui: &mut Ui| {
21182369 let mut chosen = None;
21192370 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 });
21392381 });
21402382 chosen
21412383 };
......@@ -2206,3 +2448,71 @@ fn desktop_menus_cut_or_fade_without_growing() {
22062448 assert!((closing.opacity - (1.0 - 0.096 / 0.6_f32).powi(4)).abs() < 0.01);
22072449 assert!(ui.wants_frame());
22082450}
2451
2452/// A row 200 wide of twelve section tabs with `active` open; returns the row's id.
2453fn 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.
2479fn 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]
2488fn 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]
2502fn 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 @@
11use crate::{Axis, Event, Flags, Id, Signal, Spec, Ui, fit, px};
2use draw::RasterImage;
23use draw::edit::{self, Command, Movement};
34use parley::{
45 Affinity,
56 editing::{Cursor, Selection},
67};
7use std::hash::Hash;
8use std::{hash::Hash, time::Duration};
89use winit::{
910 event::Ime,
1011 keyboard::{Key, ModifiersState, NamedKey},
......@@ -75,9 +76,56 @@ pub fn check_box(ui: &mut Ui, part: impl Hash, label: &str, checked: bool) -> Si
7576 signal
7677}
7778
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.
80pub 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.
87pub 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)]
102pub 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)]
112pub 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.
122const REPEAT_DELAY: Duration = Duration::from_millis(300);
123const 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.
81129pub fn scrollbar(
82130 ui: &mut Ui,
83131 part: impl Hash,
......@@ -87,6 +135,9 @@ pub fn scrollbar(
87135 view: f32,
88136 color: [f32; 4],
89137) -> Option<f32> {
138 if ui.scrollers.is_some() {
139 return system_scrollbar(ui, part, axis, offset, range, view);
140 }
90141 let along = usize::from(axis == Axis::Y);
91142 let rect = ui.rect(ui.current())?;
92143 let length = rect[along + 2] - rect[along];
......@@ -133,6 +184,115 @@ pub fn scrollbar(
133184 chosen
134185}
135186
187fn 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
136296/// A single-line field editing `text`, showing `placeholder` while empty. Presses, drags
137297/// and keys select and edit as on the page: a double press selects a word, a triple press
138298/// everything, and dragging extends by that unit.
platform/snow-leopard/console/input.c+42-10
......@@ -1,5 +1,7 @@
11// 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]`.
35// Points are the display's, from its top left.
46#include <ApplicationServices/ApplicationServices.h>
57#include <stdlib.h>
......@@ -12,8 +14,15 @@ static void post(CGEventRef event) {
1214 usleep(20000);
1315}
1416
15static void mouse(CGEventType type, CGPoint at) {
16 post(CGEventCreateMouseEvent(NULL, type, at, kCGMouseButtonLeft));
17static 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
23static void click(CGPoint at, int clicks) {
24 mouse(kCGEventLeftMouseDown, at, clicks);
25 mouse(kCGEventLeftMouseUp, at, clicks);
1726}
1827
1928static 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
2635}
2736
2837int 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")) {
3054 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);
3459 } else if (argc == 3 && !strcmp(argv[1], "type")) {
3560 CFStringRef text = CFStringCreateWithCString(NULL, argv[2], kCFStringEncodingUTF8);
3661 for (CFIndex i = 0; i < CFStringGetLength(text); i++) {
......@@ -38,10 +63,17 @@ int main(int argc, char **argv) {
3863 key(0, 0, &c, 1);
3964 }
4065 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);
4375 } 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");
4577 return 2;
4678 }
4779 return 0;
platform/snow-leopard/remote.sh+3-2
......@@ -9,7 +9,8 @@
99# remote.sh stop NAME end what start started (a bundle by its executable)
1010# remote.sh screen LOCAL.png wake the display and capture it (screencapture
1111# 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]...
1314# remote.sh selectors BINARY selector-like names BINARY holds that nothing on
1415# 10.6 implements
1516# remote.sh mirror PATH... copy repo paths to the same absolute paths there, so
......@@ -119,7 +120,7 @@ sh)
119120 ssh "$host" "$@"
120121 ;;
121122*)
122 sed -n '2,20s/^# \{0,1\}//p' "$0" >&2
123 sed -n '2,21s/^# \{0,1\}//p' "$0" >&2
123124 exit 2
124125 ;;
125126esac
platform/snow-leopard/rt/appkit.c-1
......@@ -112,7 +112,6 @@ __attribute__((constructor)) static void polyfill(void) {
112112 add("NSWindow", 0, "setTabbingIdentifier:", ignore, "v@:@");
113113 add("NSWindow", 1, "setAllowsAutomaticWindowTabbing:", ignore, "v@:c");
114114 add("NSWindow", 0, "setTitlebarAppearsTransparent:", ignore, "v@:c");
115 add("NSWindow", 0, "setTitleVisibility:", ignore, "v@:q");
116115 add("NSView", 0, "setWantsBestResolutionOpenGLSurface:", ignore, "v@:c");
117116 add("NSView", 0, "setAccessibilityLabel:", set_accessibility_label, "v@:@");
118117 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
1010create and resize as you would expect. In addition, Snowbound supports
1111pen and drawing tools [(WIP)](https://shale.paperclover.net/snowbound/issues/21), recording audio and video
1212[(WIP)](https://shale.paperclover.net/snowbound/issues/36), revision
13history[(WIP)](https://shale.paperclover.net/snowbound/issues/37), multi-machine
14live collaboration, and much more.
13history, multi-machine live collaboration, and much more.
1514
1615<!-- Regenerate from the sample notebook (edit it freely in OneNote or Snowbound):
1716python3 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
2322
2423Snowbound implements the file and sync protocol used in 2010 Microsoft OneNote,
2524so notebooks are fully compatible, including collaboration features. It supports
26OS X 10.6 Snow Leopard
27[(WIP)](https://shale.paperclover.net/snowbound/issues/13), all the way to
28modern macOS, Linux, Windows
25OS X 10.6 Snow Leopard, all the way to modern macOS, Linux, Windows
2926[(WIP)](https://shale.paperclover.net/snowbound/issues/11), and fully capable
3027iOS[(WIP)](https://shale.paperclover.net/snowbound/issues/36) and
3128Android[(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
4949toolchains, `cargo install cargo-fuzz`, and `build-essential pkg-config
5050libfontconfig-dev`; copy the checkout and run the script under `tmux`.
5151
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
56hidden-window replay (`SNOWBOUND_REPLAY` plus `--screenshot`, see
57`tools/canvas/README.md`) this records an animation at its real pace without
58touching the screen: waits tick at 60 Hz, and the app draws as fast as it can
59while it animates. Point it at a copy of a notebook, end the script with a
60short `wait` so the last frames finish writing, and assemble strips or GIFs
61with `ffmpeg`.
62
5263## Private and native verification
5364
5465Private notebooks stay outside versioned fixtures. Materialize a copy before
tools/test_page_background.py created+42
......@@ -0,0 +1,42 @@
1from pathlib import Path
2import runpy
3import shutil
4from tempfile import TemporaryDirectory
5import unittest
6import xml.etree.ElementTree as ET
7
8from native_xml import ns
9
10ROOT = Path(__file__).resolve().parent.parent
11FIXTURE = ROOT / 'corpus/page-background'
12compare = runpy.run_path(str(ROOT / 'tools/verify-document.py'))['compare']
13
14
15class 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
41if __name__ == '__main__':
42 unittest.main()
tools/test_rule_lines.py created+83
......@@ -0,0 +1,83 @@
1from pathlib import Path
2import runpy
3import shutil
4from tempfile import TemporaryDirectory
5import unittest
6import xml.etree.ElementTree as ET
7
8from native_xml import ns
9
10ROOT = Path(__file__).resolve().parent.parent
11FIXTURE = ROOT / 'corpus/rule-lines'
12compare = runpy.run_path(str(ROOT / 'tools/verify-document.py'))['compare']
13
14BLUE = '#CAEBFD'
15MARGIN = ('Margin', '#FF5050', None)
16
17
18def 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
36def ruled(spacing, color=BLUE):
37 return ('true', [('Horizontal', color, spacing), MARGIN])
38
39
40def grid(spacing, color=BLUE):
41 return ('true', [('Horizontal', color, spacing), ('Vertical', color, spacing)])
42
43
44NONE = ('false', [])
45
46
47class 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
82if __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
66`[patch.crates-io]` here.
77
88`Cargo.toml` gains a `[lints.rust]` table silencing a lint newer than the
9release, since Cargo does not cap a path dependency's warnings. The one source
9release, since Cargo does not cap a path dependency's warnings. The first source
1010change drops the 1 px border line under the header bar, so the title
1111bar flows into Snowbound's toolbar as it does in a GTK 4 app:
1212
......@@ -28,7 +28,31 @@ bar flows into Snowbound's toolbar as it does in a GTK 4 app:
2828 }
2929```
3030
31The second change serves GNOME, where Snowbound cuts its window's bottom
32corners as libadwaita does. With `LIBADWAITA_EDGE` set, the frame draws
33libadwaita 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
35and not with their 1 px ring, and the 7 % white outline 1 px inside the
36edge. The header keeps the body's width, the sides overlap the body by the
37outline and the bottom by the radius, so the frame paints the outline and the
38bottom corners' shadow over the application's pixels.
39
40The third change makes the header bar zero height, so Snowbound's toolbar row
41is 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
48Around it: `FRAMED` is set once a frame is made, which tells Snowbound to draw
49the title bar and the window's buttons itself; the empty header part is not
50drawn; 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
52radius, as the bottom reaches up, owning them from the sides, since Snowbound
53cuts those corners too.
54
3155To bump: copy the new release's `src/`, `Cargo.toml`, `LICENSE`, `README.md`
32and `CHANGELOG.md` over this folder, reapply the diff, and update the version
56and `CHANGELOG.md` over this folder, reapply the three changes, and update the version
3357above. The patch applies only while the version here satisfies winit's
3458requirement; `cargo tree -i sctk-adwaita` shows the path source when it does.
vendor/sctk-adwaita/src/lib.rs+200-11
......@@ -1,6 +1,7 @@
11use std::error::Error;
22use std::mem;
33use std::num::NonZeroU32;
4use std::sync::atomic::{AtomicBool, Ordering};
45use std::sync::Arc;
56use std::time::Duration;
67
......@@ -49,6 +50,42 @@ use wl_typed::WlTyped;
4950/// XXX this is not result, so `must_use` when needed.
5051type SkiaResult = Option<()>;
5152
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.
56pub static LIBADWAITA_EDGE: AtomicBool = AtomicBool::new(false);
57
58/// libadwaita's `--window-radius`, in logical pixels.
59pub 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.
63const 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.
79const 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.
83pub static FRAMED: AtomicBool = AtomicBool::new(false);
84
85fn libadwaita_edge() -> bool {
86 LIBADWAITA_EDGE.load(Ordering::Relaxed)
87}
88
5289/// A simple set of decorations
5390#[derive(Debug)]
5491pub struct AdwaitaFrame<State> {
......@@ -106,6 +143,7 @@ where
106143 let base_surface = WlTyped::wrap::<State>(base_surface.wl_surface().clone());
107144
108145 let pool = SlotPool::new(1, shm)?;
146 FRAMED.store(true, Ordering::Relaxed);
109147
110148 let decorations = Some(DecorationParts::new(
111149 &base_surface,
......@@ -226,12 +264,16 @@ where
226264 true
227265 };
228266 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 );
229272
230273 // 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 }) {
235277 let scale = self.scale_factor;
236278
237279 let mut rect = part.surface_rect;
......@@ -240,10 +282,28 @@ where
240282 // start. To achieve that, we enlargen the width of the header by
241283 // 2 * `VISIBLE_BORDER_SIZE`, and move `x` by `VISIBLE_BORDER_SIZE`
242284 // to the left.
243 if idx == DecorationParts::HEADER && draw_borders {
285 if idx == DecorationParts::HEADER && draw_borders && !libadwaita {
244286 rect.width += 2 * VISIBLE_BORDER_SIZE;
245287 rect.x -= VISIBLE_BORDER_SIZE as i32;
246288 }
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 }
247307
248308 rect.width *= scale;
249309 rect.height *= scale;
......@@ -265,7 +325,23 @@ where
265325 // do invisible borders to enlarge the input zone.
266326 pixmap.fill(Color::TRANSPARENT);
267327
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) {
269345 self.shadow.draw(
270346 &mut pixmap,
271347 scale,
......@@ -292,6 +368,7 @@ where
292368 self.mouse.location,
293369 );
294370 }
371 _ if edge => {}
295372 border => {
296373 // The visible border is one pt.
297374 let visible_border_size = VISIBLE_BORDER_SIZE * scale;
......@@ -318,8 +395,16 @@ where
318395 (rect.height - y) as f32,
319396 )
320397 }
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 }
323408 DecorationParts::BOTTOM => {
324409 let x = (rect.x.unsigned_abs() * scale) - visible_border_size;
325410 Rect::from_xywh(
......@@ -339,6 +424,10 @@ where
339424 }
340425 };
341426
427 if edge {
428 edge_layer(&mut pixmap, true);
429 }
430
342431 if should_sync {
343432 part.subsurface.set_sync();
344433 } else {
......@@ -359,8 +448,13 @@ where
359448 if let Some(input_rect) = part.input_rect {
360449 let input_region = Region::new(&*self.compositor).ok()?;
361450 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,
364458 input_rect.width as i32,
365459 input_rect.height as i32,
366460 );
......@@ -685,6 +779,8 @@ fn draw_headerbar_bg(
685779
686780 let radius = if state.intersects(WindowState::MAXIMIZED | WindowState::TILED) {
687781 0.
782 } else if libadwaita_edge() {
783 WINDOW_RADIUS * scale
688784 } else {
689785 CORNER_RADIUS as f32 * scale
690786 };
......@@ -702,6 +798,99 @@ fn draw_headerbar_bg(
702798 Some(())
703799}
704800
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.
805fn 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).
881fn 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
705894fn rounded_headerbar_shape(x: f32, y: f32, width: f32, height: f32, radius: f32) -> Option<Path> {
706895 // https://stackoverflow.com/a/27863181
707896 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)
765954
766955// returns horizontal margin, logical points
767956fn 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() {
769958 0.
770959 } else {
771960 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;
66// https://gitlab.gnome.org/GNOME/gtk/-/blob/1bf88f1d81043fd99740e2f91e56ade7ede7303b/gtk/gtkwindow.c#L166
77pub(crate) const RESIZE_HANDLE_CORNER_SIZE: u32 = 24;
88pub(crate) const BORDER_SIZE: u32 = crate::shadow::SHADOW_SIZE + VISIBLE_BORDER_SIZE;
9pub(crate) const HEADER_SIZE: u32 = 35;
9pub(crate) const HEADER_SIZE: u32 = 0;
1010pub(crate) const CORNER_RADIUS: u32 = 10;
1111pub(crate) const VISIBLE_BORDER_SIZE: u32 = 1;
1212