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....@@ -29,8 +29,13 @@ keyboard, the toolbar and the macOS menu bar all run commands from it.
2929
30### macOS30### macOS
3131
32- The title bar is transparent. The app draws its own around AppKit's traffic32- The toolbar's row is the title bar. An empty `NSToolbar`, unified compact
33 lights, as part of the same frame as the rest of the chrome. Under the whole33 from macOS 11, with the title hidden makes the title bar the row's height,
34 and AppKit places the traffic lights in it; a press in the row's gaps drags
35 the window. The title is still set, for the Window menu, Mission Control and
36 VoiceOver. `--screenshot` paints the lights where the hidden window's AppKit
37 put them. The app draws the row as part of the same frame as the rest of the
38 chrome. Under the whole
34 window lies AppKit's title bar material, an `NSVisualEffectView`, and the39 window lies AppKit's title bar material, an `NSVisualEffectView`, and the
35 chrome is drawn transparent over it, so the title bar, toolbar, tab row and40 chrome is drawn transparent over it, so the title bar, toolbar, tab row and
36 sidebar take the system's desktop tint, appearance and focus state exactly.41 sidebar take the system's desktop tint, appearance and focus state exactly.
...@@ -52,9 +57,14 @@ keyboard, the toolbar and the macOS menu bar all run commands from it....@@ -52,9 +57,14 @@ keyboard, the toolbar and the macOS menu bar all run commands from it.
52### Linux57### Linux
5358
54- X11 and Wayland. Title bars come from the window manager, or on Wayland from59- 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-side60 the compositor where it offers server-side decorations, and the toolbar's
56 frame otherwise. The app draws its own window controls only where no frame61 row lies beneath them, as in Dolphin and Kate. Elsewhere on Wayland, as on
57 could be made.62 GNOME, the row is the title bar, as a GTK 4 header bar holding tools: winit's
63 Adwaita frame keeps its shadow, corners and resize edges but has no header,
64 and the row ends in the window's buttons in the order GNOME's `button-layout`
65 lists them. Under that frame on GNOME the window erases its bottom
66 corners to transparent pixels and the frame draws libadwaita's window edge
67 round it: radius, shadows and outline. KWin rounds Breeze's corners itself.
58- The toolbar and the rest of the chrome continue the title bar's fill,68- The toolbar and the rest of the chrome continue the title bar's fill,
59 focused and not: on KDE the colour scheme's header colours from69 focused and not: on KDE the colour scheme's header colours from
60 `kdeglobals`, as KWin paints its title bars, and on GNOME winit's Adwaita70 `kdeglobals`, as KWin paints its title bars, and on GNOME winit's Adwaita
arc/ui.md+20-5
...@@ -23,9 +23,10 @@ quickly and never feel like they're waiting on an animation. Animated values...@@ -23,9 +23,10 @@ quickly and never feel like they're waiting on an animation. Animated values
23ease exponentially toward their targets with a short half-life. That is23ease exponentially toward their targets with a short half-life. That is
24frame-rate independent, a retargeted animation continues smoothly from where24frame-rate independent, a retargeted animation continues smoothly from where
25it is, and it settles fast. Popups open and close on short timed curves,25it 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 it26slow enough to follow: a menu swings out of the pointer or its button as it
27fades in, a combo's field widens into its list, and a dialog swings up into27fades 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 instead28place 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
29look and move as the desktop's own (see [platforms](platforms.md)).30look and move as the desktop's own (see [platforms](platforms.md)).
3031
31## Why not native widgets32## Why not native widgets
...@@ -62,7 +63,7 @@ frame N...@@ -62,7 +63,7 @@ frame N
62 route input ─── against frame N-1's layout (hover, press, focus, drags, wheel)63 route input ─── against frame N-1's layout (hover, press, focus, drags, wheel)
63 build ───────── app code declares boxes; each reads its Signal (clicked, dragging, events…)64 build ───────── app code declares boxes; each reads its Signal (clicked, dragging, events…)
64 solve layout ── per axis: pixels · label size · fraction of an ancestor · sum of children65 solve layout ── per axis: pixels · label size · fraction of an ancestor · sum of children
65 overflow is shared out by each box's strictness66 overflow taken from space, then by folding groups, then by strictness
66 paint ───────── Layers: primitives under a clip, or a Custom box the host paints67 paint ───────── Layers: primitives under a clip, or a Custom box the host paints
67 after paint ─── work the frame asked for (page requests, saving) runs, then asks for a frame68 after paint ─── work the frame asked for (page requests, saving) runs, then asks for a frame
68```69```
...@@ -75,8 +76,17 @@ frame N...@@ -75,8 +76,17 @@ frame N
75 it is being declared. It sounds odd and is invisible in practice.76 it is being declared. It sounds odd and is invisible in practice.
76- **Layout is solved after building**, per axis. A box can be sized in fixed77- **Layout is solved after building**, per axis. A box can be sized in fixed
77 pixels, by its label, as a fraction of an ancestor, or by its children. When78 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*. That79 siblings overflow, space sized from an ancestor gives way first. Then a row
79 one knob covers most of what flexbox is usually needed for.80 *folds* its groups, boxes with a full and a folded form (`Spec::fold`), by
81 priority. Only then do boxes give up room by their *strictness*, the least
82 strict first. That one knob covers most of what flexbox is usually needed
83 for.
84- **The toolbar is one row that never overflows.** Both forms of every group
85 are built each frame and the solver picks, so a group folds in the frame the
86 window narrows, with no widths remembered or worked out by the app. A folded
87 group is a dropdown of the same command-table rows, and a gallery it lists
88 opens under it, since a folded-away box takes its group's rectangle. The
89 section tabs scroll sideways past the row's room, fading out where cut.
80- **Nothing runs while nothing changes.** The kit reports whether it wants90- **Nothing runs while nothing changes.** The kit reports whether it wants
81 another frame (queued input, an animation still settling) and when a timed91 another frame (queued input, an animation still settling) and when a timed
82 change such as a caret blink is due. The app sleeps in between.92 change such as a caret blink is due. The app sleeps in between.
...@@ -113,6 +123,11 @@ rounded or gradient rectangles, pen strokes and raster images....@@ -113,6 +123,11 @@ rounded or gradient rectangles, pen strokes and raster images.
113 ordinary ones.123 ordinary ones.
114- **Batches** merge consecutive primitives that share a texture and clip,124- **Batches** merge consecutive primitives that share a texture and clip,
115 without reordering paint.125 without reordering paint.
126- **A popup in motion is one picture.** Layers sharing a motion draw
127 offscreen together, then fade and lean back as a whole, so nothing beneath
128 or within shows through a row. A popup's contents clip to its rounded
129 outline, and one opening over a combo lays its rows out at their final
130 width while only the outline widens.
116- **Caches are bounded.** The glyph atlas and image cache have fixed budgets.131- **Caches are bounded.** The glyph atlas and image cache have fixed budgets.
117 Eviction keeps everything the current frame needs and rebuilds after132 Eviction keeps everything the current frame needs and rebuilds after
118 pressure. Images are filtered in linear light with premultiplied alpha.133 pressure. Images are filtered in linear light with premultiplied alpha.
corpus/page-background/README.md created+13
...@@ -0,0 +1,13 @@
1# Page background
2
3`candidate` is `backgrounds_go_on_and_off_pages_that_have_content` in
4`crates/snowbound/src/manage.rs` (`SNOWBOUND_BACKGROUND_EXPORT`), on a copy of
5`rule-lines/native/notebook`: the Page Color menu's ops give the ruled "Standard" page
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
178zGz+ua4sH/BXq/vu7mlT4jSrtMypdaXbXt5Z3U0Yae0Zmhb+6WXaf0rGdGFScaklrHb56AW62A4v
1794T/8i3df9hC4/wDQ68jK/wCC/wDFL8zz8D/DfqztK9c9AKAKd45hmjkH0NZS0dyZOxaRg6hh0NaJ
1803KBmCKSegobsBWs5DNLI59gKzg7tslO5brUoKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAzdNP2
181a9vrQ9n+0R+6vkn/AMeDfmPWuKh7lSdLzuvR/wDBuaS1SkaVdpmFABQBxfwn/wCRbuv+whcf+h15
182GV/wX/il+Z5+B/hv1Z2IlQnG4Z9M1610egOpgR3EPnx7e/UVMldCauMswyxbWGCDilDYFsF8SLc4
1837kUT2CWwtnD5UPPU8miCsgSsierGFABQAUAFABQAUAFABQAUAFABQAUAFABQAUAUNTjeJoryFS8k
184GdyKMl4z94AdzwCPcY71yV4uLVaCu4/iuv8AmvS3U0i7+6y5FKk8SSRsHjcBlZTkEHoa6YyU0pRd
1850zNq2jH1QBQBxfwn/wCRbuv+whcf+h15GV/wX/il+Z5+B/hv1Z1txarPz0b1r1JRud7VyCNLi3bg
186b19M1CUokpNF3Py5x+FaljRIpUEEYPSi6AXKv6GjRgKTgUwKkl/822Ndx9TWTn2J5uxYh37cyYye
187w7Vcb9SiSqAKACgAoAKACgAoAKACgAoAKACgAoAKACgDPeCXTpGltk823YlpLYdQTyWTtz3Xv1GD
188nPG4Tovmpq8eq/Vfquu6s730upaMs2t5Fexb4X3AHBBBBU+hB5B9jXRTqRqrmg/6810IacdyetBH
189F/Cf/kW7r/sIXH/odeRlf8F/4pfmefgf4b9WdpXrnoBQAlAFe3jaHfjlTyPr6VlFNBawLBI775G5
190HRR0o5W9WJEroJ48HIB9Ku3MhtBFbpD91efWhRSFaxJVDCgAoAKACgAoAKACgAoAKACgAoAKACgA
191oAKACgCnd6alxL50bNb3QGBPF1I9GHRh7H8MVzVKCm+eLtLuv17r1+RalbToQ/2jNY8X8WEH/LzA
192CYz7kclP1HvWft50tK607rb5rdfivMfKpfCc58JWD+Gbkggg39wQR3+euTK3eg2v5n+Z5eC/hv1Z
1932tewegRSzqh27vm7AVDlYBXZyn7vGT69qbba0AZDb7Ad7FmbqaSjbcENt2aMOj87eh9RSi7aMSJH
194kFvGC2T9Kd+VDbI1v4ie4+opc6J5kTq4cZUgj2q07lDqYBQAUAFABQAUAFABQAUAFABQAUAFABQA
195UAFABQAUAcV8Jhjw1c/9f9x/6HXkZX/Bf+KX5nn4L+G/VnX3DOF2xglj39K9STeyO9kdvaeW29zu
196f+VKMbasSVidyEBbOMcmqehQBmYZAU575ouwELBXAZhuPQUddQHkZGDVAV3sY2PGV+lZuCJcUwis
197/JbKyMKFC3UaVizWgwoAKACgAoAKACgAoAKACgAoAKACgAoAKAIJL6CK7itXlVbiZWaOMnlwuM4+
198mRWbqQjNU29Xt8gJ60AKAOL+E/8AyLd1/wBhC4/9DryMr/gv/FL8zz8D/DfqztK9c9AQnAoApXzk
199Io7tyaxmTN6Fm1/490+laR2GtiO9hMseR95amauKSuOtJvOi5+8ODTg7oad0T1YwoAKACgAoAKAC
200gAoAKACgAoAKACgAoAKACgAoA4f4s+HLvW/D8d3pvmDU9Ok+0QmIkOR/EFx36H8K8PNsNOtRVSl8
201UHdW387fn8jlxNOU6f7v4lqix8MPGreM/D/mXGBqFs3lXAAxuOOGx2z/ADBrTLMa8ZR974o7/o/m
202GGre3p8z32fqdLdXbiUW9uA9wRklvuxL/eb+g6n2GSPQqVHf2dP4vwXm/wBF18ldrsS6vY5T4SDb
2034ZuASWIv7j5j1Pz15+Vq1B/4n+Z5uC/hv1Z21ewegMb5mA/E1L10AqXyGSaNR34rOauyJK9i4q7F
204AHbitUrFjqYDFjCMxHG7rSSs7gPpgFABQAUAFABQAUAFABQAUAFABQAUAFABQAUAFAHlvw18dtq3
205jPxDps8m5Jrh57XJ7KdpUf8AAQp/A18rlmNdTE1acnpJtr+vT8jho4n2tWpTfR6em39ep20WlWWl
206apdPpdrHFqF4Fa4dc7FAJw7LnGTk4xgt+BI9j2VOjVl7CNpy37Jd2v6b+Ta9CMEry/pmraWiWcWx
207SzEnc7vyzt3JPr/+oYArtp01TVl9/Vvu/wCvJaCbucl8J/8AkW7r/sIXH/odeblf8F/4pfmedgf4
208b9WdmTtGfSvX2PQEQYGT1PNJABUFg2ORRbqA6mAUAFABQAUAFABQAUAFABQAUAFABQAUAFABQAUA
209FABQA2TPltjrg0pbMEfJvhnVptF8YWd7G2JEucEvnGCcHP4E1+VYerKjVjUjumfKUJ+zxt31bX3s
210+rLO0WziKgl3Y75JG+87ep/QewAA4Ar9QpU1SjbdvVvu+/8AWy0Wh9a3csVsScX8J/8AkW7r/sIX
211H/odeRlf8F/4pfmefgf4b9Wdi3zMB6cmvVersegPqgCgAoAKACgAoAKACgAoAKACgAoAKACgAoAK
212ACgAoAKACgAoAgvLtLK3aV8kDACr1Yk4Cj3J4rKpUVKLlL+vL5jS5nY+V/FuhXGheMrmzljCO04k
213RVORtc5AB79cfhX5hiaM6FWVOa1/zPlcTS9njbLZtNfNn1bGCI1B6gCv1NbI+pY6mBxXwnOPDV1/
2141/3H/odeRln8CX+KX5nn4H+G/VnZoOMnqea9ZHoDqYBQAUAFABQAUAFABQAUAFABQAUAFABQAUAF
215ABQAUAFABQAUAZbSpc6i80rqlrZHYCxwDKRyfwBAHuzelcHMqlVyk/dhp/28/wDJaLzb7Gnwxt3/
216ACM/xN4EsPE+q6XqE+UubGVX3AZ8xAc7D7Z5z9fWs8VgKeKqQqy0cX967HNOlCpKMpLWOx0lemah
217QBxPwpGfDlyO39oXGf8AvuvHyzWi1/el+ZwYH+G/VnbV7B3hQAUAFABQAUAFABQAUAFABQAUAFAB
218QAUAFABQAUAFABQAUAFAHiX/AAkVx4y+IGl6BZTM+k2Fx500g4+0Oh3tI3sW6D3r4ZVpYvFU8NB3
219hF39Wndt+r2POliHXxfs4fDHf5dPkz22vuT0RCQoyTgepobsAUAcZ8J/+Rbuv+whcf8AodeRlf8A
220Bf8Ail+Z5+B/hv1Z2leuegFABQAUAFABQAUAFABQAUAFABQAUAFABQAUAFABQAUAFABQAUAcB8Kf
221hwfBllJd3uG1W5UBwDkRJ12g9z3J/wAOfAyvLvqkfaVPjf4Lt/mcOEw31eLvrJ7nf1753EN3aQ31
222tLb3EaywyqVeNxkMD2qJwjUi4TV0wPMn8K+LvA2q+d4euW1jR2bJ0+5lG5R/dG79CD9RXy7wmNwF
223Tmwr54dm/wCvvRw+zr053py5o9nv8mbnwiYv4XnYqUJvrglT1Hz9K9PKneg3/ef5k4L+G/Vnb17B
2246AUAFABQAUAFABQAUAFABQAUAFABQAUAFABQAUAFABQAUAFABQBz8vi+zu7FrjR7i31YxyIjxW8q
225knccKM5wCTjr715c8fBw58O1OzSaXnovxKhaezv6HDXfxA8etfNHb+EjEgPCPG78f7wIFeNLMMxc
2267Ro2+Tf4nmyrYnmtGlp6o77wvf61qFkX1rTIdNm4wkc/mZ+oxx+Zr3sJUxNSN8RBRfr+nT7zui24
2273krP7zbrvKOL+E//ACLd1/2ELj/0OvIyv+C/8UvzPPwP8N+rO0r1z0AoAKACgAoAKACgAoAKACgA
228oAKACgAoAKACgAoAKACgAoAKACgDAgbT5riztLMQW1orGZEQBBMy9Ai8ZAJBLdOAOeceTF0ak4U6
229VlFa9rtdu6W7fpa+ttbci8zfr1jIKACgDi/hP/yLd1/2ELj/ANDryMr/AIL/AMUvzPPwP8N+rO0r
2301z0AoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgDzb44aFc33h+21SzLi402QyE
231xnDBD1YH2IB/Ovm87oSnSjWhvD8n1+RwY2E5UuanvHUufCTx4/jDR3gvGB1Kzwsjf89FPR/r2P8A
2329etspxzxVNwqfFH8V3/zHg8R9Yp3e63O9r3juCgDi/hP/wAi3df9hC4/9DryMr/gv/FL8zz8D/Df
233qztK9c9AKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAbJGsqMjqHRgQysMgj0N
234JpSVmB5tpHw8n8E/EGHUNKDPot4HimiHJt8jI+q7gMHqOh9T8zSy+eCxsalHWDun5f8AAvbX7+74
235qWGVGs5w2a1Xmel19OdoUAcX8J/+Rbuv+whcf+h15GV/wX/il+Z5+B/hv1Z2leuegFABQAUAFABQ
236AUAFABQAUAFABQAUAFABQAUAFABQAUAFABQAUAFABQAUAFAHF/Cf/kW7r/sIXH/odeRlf8F/4pfm
237efgf4b9WdmTgV656AUALQAUAFABQAUAFABQAUAFABQAUAFABQAUAFABQAUAFABQAUAMeVI3RWdVZ
238zhFJwWOM4HrwCfwqXJJpN7gPqgCgAoA4v4T/APIt3X/YQuP/AEOvIyv+C/8AFL8zz8D/AA36s6e/
239nx+7H416M30O2T6FwdBWyLFoAhln8uWNO7Hn6VDlZ2FcmqxhQAUAFABQAUAFABQAUAFABQAUAFAB
240QAUAFABQAUAeafHW+udL0PSLy0dop4L9XWRf4SEevmc8lKFOnOLs1L9GcGOqSpUlOG6a/U6/wZ4m
241i8XeHbXUo8K7jbLGP4JB94f1+hFexgsSsXQVXr19TqpVFVgpx6m5XcahQBxPwpbZ4Zuz6X9z/wCh
24214+WO1CX+KX5nn4H+G/Vm7kyyZPVjXdudW7Niuo2CgDLeTzLnd/tcVzN3dzK/vGrXSahQAUAFABQ
243AUAFABQAUAFABQAUAFABQAUAFABQAUAcb8WfD7+IfBN5HCpe4tyLiNR1JXqP++S1eNm1B18K+XeO
244v+f4HNiaXtqMoLc4b9nfVH83V9OJJTalwo9D91v/AGX8q8XIKr550ujV/wBP8jzcrm3CUH0f5/8A
245DHtdfZnthQBw3wx/5FO9/wCv+4/9Drxsu/3eX+J/medgv4T9WdNYxb5s9l5r0Yq7OyKNOug1Gv8A
246cb6UnsBlW6750HvXOldmMdzXrpNgoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKAEoA5nQfAN
247h4b8Sahq1lmIXkYU2wHyoc5Yj2PHHbn6V5eHy+nhsRKtT2a27d/+GMYUYU5SnFay3Onr1DYKAOJ+
248FK7/AAzdqe9/cj/x+vHyxXoS/wAUvzPPwX8N+rOyhiEKbR+frXrpWPQSsPzzTAKAK9ta+S7MfoPp
249WcY2YkrE7MEUknAFW3YYkUnmoGAwDQndXAfTAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACg
250AoAQkKCScAdzRsBxXwkYP4ZuWUgqb+4II7jfXj5W06Da/mf5nn4L+G/VnbV7B6BTaYpf47HC1le0
251ib62LlalCE4GTQBmXE5uJMD7vQCueT5jJu7saMaeXGq+grdKyNR9MAoAKACgAoAKACgAoAKACgAo
252AKACgAoAKACgAoAKAKupmMafcCWZrdGQp5sZIZSeBtxznJ4x3xWFdxVOXM7Lv117efbzHFNvQ8Xs
253PitqGiau2keLYZLmG3fYzooVz6M69HGOcDHXvxXxdPMqtOfscam0t+/z7+n56HlLHvD1XSrL5rt/
254XY7v4QzJceFZpYzuR76dlPqC+RX0eVNSoNr+Z/maYF3pP1Z29ewegZt4uy6z64Nc8tzOW5oiug0K
2559/Jshx/eOKzm9CZOyK9jBvfefujp7mogr6kxXU0a3NAoAKACgAoAKACgAoAKACgAoAKACgAoAKAC
256gAoAKAI5547aF5ZWCRoMkmonONOLlJ6IaV3ZFS1t3upVu7pdrDPkwH/lkDxk/wC2R1PYHA7luenC
257VSXtai9F2/4L69tl1bpu3uo8++Mvw6fxDbDWNOi36hbriWJBzMg9PVh+o+grxM3y91l7ekveW67r
258/NfkeTjsL9YjzR+Jfj5D/gpcGDwXB/d86TI/GtcnlbDL1ZGXvlor1Z6YrBlBHQ19DuesRTwLNtJ4
259KnrUyjcVrkm9f7w/OndDsMmgE+3J4HYd6TjzCauSKoUYAwB2qthjZJViXLHFJtINiq+ojPyoT9az
260dQjmRYgeSRcuoUdh3q4tvcpEtUMKACgAoAKACgAoAKACgAoAKACgAoAKACgDNh/4mt15x/49IHIi
261XtI44L/Qcge+T6GuKP8AtE+f7MdvN9/RbLz17Gj9xW6s0q7TMKAOD+F9os3hu5IO0i/uAPT75rxc
262tjei/wDE/wAzzcEr036s7W3RoY9jYJ7Yr1leOh6KViXZ68n3q7dxkV3J5UOR16VMnZCbsPUZUMvG
263RnFNd0MeDkVSdwKcllJM+5nH4dqycG9yXG5LDZpFz95vU1SgkNJIsVYwoAKACgAoAKACgAoAKACg
264AoAKACgAoAKAM/VpXKRWsTFZrltgZeqJ1ZvwHA9yK5MTJ2VKD1lp6Lq/u/Fo0gur6F2KJIIkjjUJ
265GgCqq9AB0FdMYqCUYqyRm3fVjbi6htULzSpCg/ikYKP1pSnGCvJ2CxmnxdoYk2HWLHd6faE/xrl+
266u4W9vaR+9CutrmB8JGD+GbllIZTf3BBHQjfXJlbvQbX8z/M4MD/Dfqzs1PVia9Zdz0BjXcSn74/C
267lzoV0MuQLmD92d2D2pS95aA9UTouxFHoMVa0QxF4dvwNJbgPqgCgAoAKACgAoAKACgAoAKACgAoA
268KACgAoAKACgDNtf9K1q7m/htlFunsSA7n8coP+A1xU/3mInP+X3V+b/T7jR6RS76mlXaZmH4o8Ga
269T4vtxHqVsJGUYSZPlkT6H+h4rgxWBoYxfvVr36ozqU41Y8s1dHlurfs8TrITpmqxvH2S6Qgj8Vzn
2708hXzVXIaif7qaa89PyuePPKoP4JW9dTq/g1YyW3hERmQKYbqaNtvchsV62UwccPbs2dOXx5aVvNn
271oCDKfnXuLY9Nkb2kbZ+QZpOCFZCCVIXjjVQA36Uk0rJBtoNlsvMYt5jfjScL9RNXH28HlZO5jn1p
272xjbUq1h08bSJhWKmqkrgzOdZoTzuHvmsHdGT5kSRX7p975x+tUptDUu5eimWZcqfwrZNMtO5JTGF
273ABQAUAFABQAUAFABQAUAFABQAUAZvh/59NE3e4kkmz6hmJH6YH4VxYPWlz/zNv73p+FjSp8VuxoS
274P5aMxBIUE4UZP5V2N2VzM4m5+M3ha0uWglup0kQlWDWsgKkdiCM14jzrCJ2bf3M5Z4qhTlyylZ/P
275/I2tC8c6F4kcR6fqUM0x58kkq/8A3ycGu2hj8NiXanPXts/xNoVYVPgkmZPwn/5Fu6/7CFx/6HXP
276lf8ABf8Ail+Zx4H+G/VnZ1656AtAFSeItdREd+9ZSXvCa1RZ2+pJrS3cYpOBk8CmAA5FABQA0xIf
2774V/KlZAKqKnRQPoKEkgHUwCgAoAKACgAoAKACgAoAKACgAoAKAKWiJ5WjWCf3beMf+OiuXCrlw9N
278eS/IuesmXa6iDnvEvgLRPFgzqFkrTDpPGdkg/EdfxzXnYnL8Pitakde60f8AXqZVKNOsrVFc5GL4
279A6JDdLMmoaiu1gwCugIPsdua8pZDRTvzy/D/ACOOOX0IS5o3+82PhIoi8LzrknF/cDLHJPz967sr
28092g1/ef5jwP8N+rO26V7B6BGk6SOVU5x3qVJN2C5JVAAYEkdx1ouAjLuUj1GKTVwM2OZ7WQqegPI
281NYJuLMr8rsaSMHUMOhrdO5qOpgFABQAUAFABQAUAFABQAUAFABQAUAFABQBS0R/N0awf+9bxn/x0
282Vy4V82HpvyX5Fz0ky7XUQFABQBxPwpGfDN2P+n+4/wDQ68fLdaEv8UvzPPwP8N+rOmvLrf8AIvA7
28316MpX2O2T6EthFsjLn+Lp9KqC6jirItVqUZ9rKTdn/azWEX7xCepo1uWVru184bl++P1rOUb6ktX
284I7CQgtG3BHODSg+go9i7WpYUAFABQAUAFABQAUAFABQAUAFABQAUAFAGb4f+TThB3t5Hhx6BWIH6
285YP41xYPSlyfytr7np+FjSp8V+5pV2mYUAFAHDfC+TyvCt43f7fcY/wC+68bLXahL/FL8zzsE7Un6
286s6e1tTK25+FHb1r0Yxudqj1ZogYFdBoBoAzbVf8ASwPQmueO5nFamnXQaBQAwxqXDY+Yd6VtbgPp
287gFABQAUAFABQAUAFABQAUAFABQAUAFABQBm23+i61dw/w3Ki4T3IAR/ywh/4FXFT/d4icP5veXy0
288f6feaPWKfbQ0q7TMKACgDifhSgfw3c5Gcahcdf8Afrx8sV6L/wAUvzPPwX8N+rO06P8AUV62zPQH
289VQBQBBFBsuJH9elQo2dxW6k9WMKACgAoAKACgBCdoyego2AAdwBHQ0bgLQAUAFABQAUAFABQAUAF
290ABQB5pBoXirwlrkHkyya9oaSFk82QG4hQ8MvP3uOw9AeOlfKPD43BVYuF6kE++qXVa+Xy0vpsZUl
291UhNxbvF991/mvxPSIpUniSSNg8bgMrL0IPQ19TGSmlKLumbNW0Y+qEFAHF/Cf/kW7r/sIXH/AKHX
292kZX/AAX/AIpfmefgf4b9Wdk/3c+nNesz0BaYC0AFABQAUAFABQBXluDBMA3+rbv6Vm5crE3YnByM
293itBle+fbBj+8cVnN6Ey0RJbHMCfSqjsUiWqAKACgAoAKACgAoAKACgAoAKAM2H/iVXfkni0uGJiP
294aNzyU+h5I98j0FcUf9nnyfZlt5Pt89153XY0fvq/VGlXaZhQBxfwn/5Fu6/7CFx/6HXkZX/Bf+KX
2955nn4H+G/VnaV656AxOMr6dPpUrTQY+qEFABQAUAFABQBFPCJ02nr2PpUyVxNXG2qPGhR+x4PtSim
296tGC0FuoTPHgdQaJK6Bq5Iq7FAHQVaVhkFxeCI7V+Z/5VnKdtiXKxJAH2ZkPzHt6VUb9SkS1QBQAU
297AFABQAUAFABQAUARXNsl3bvDICUcYODgj3B7H3rOpCNSLhLZjTs7or6dcyMZLa4IN1Bjc2MeYp+6
2984+uDn0INY0Zyd6dT4o/iuj+f53Kkuq2LtdRBxfwn/wCRbuv+whcf+h15GV/wX/il+Z5+B/hv1Z2l
299euegNYdx1FJgRzljFujOCOamW10DIIdQzxIMe4qVPuQpdy2rBxkHI9RWt7ljqACgAoATPNAATgZo
300egENtMZIskE8nms4y0BakpOV+UjPY1e60AghsxG25jvb1qIwtuSlYs1oUFABQAUAFABQAUAFABQA
301UAFAFHUbeQ+Xc24zcwZwv/PRT95PxwMe4FcteEtKlP4o/iuq+fTzsXF9HsWba4ju4EmiO6NxkGt4
302TjUipx2ZLVnZnIfCf/kW7r/sIXH/AKHXl5X/AAX/AIpfmedgf4b9WdpXrnoBQA0jByPxHrSsBm3U
303HlvuX7h/T2rCSsZSVhsFw0DccjuKSdhJ2NSNxIgYdDXQnc2Bs7flxn3od+gFVkumP3gv0rO0mTqT
304xqyH5m3Njr0qldPUroMZ5i4/dDAPrSbk+gailSQUicIV6jFFuiAWFXQsGYN36U4prcBHvIozgtk+
3051DmkK6Q6O5jkOAwz6GmpJgnclqhhQAUAFABQAUAFABQAlAFK41zTrN9k9/awt/dkmVT+prnliaMH
306aU0vmg2C21vTrxtsF/bTN6RzKx/Q0QxNGekZp/NBuR/8gy/9LS6b8I5T/Rv/AEL/AHqy/gVf7svw
307l/wfz9TT4l5r8jnfhP8A8i3df9hC4/8AQ65cr/gv/FL8zy8D/DfqztK9c9AKACgBkkYcHjr1HrUt
308XAoS2TLkp8y+ncVi49jNx7D7ByjmNgRn19aqDs7DjpoX62LCgCJ5V8xUB+f2qG1ewXH7x34Poaq/
309cCGJdk0rdd2MYqFo2FiZRjr1NWkA14UkPzKD70nFMLAsEadEA/ChRSAkqgCgAoAKACgAoAKACgD5
310N1vx5r3iFm+26nOyH/llG2xP++RgV+W1sXXxH8Wbf5fdsfHVMdXqfat6aGCeTnvXHaxwtt7gpKsC
311CQR0I7UDUnHVM6LR/iBrmixNBHfST2rDBt7hi6/h3U+hBFdlPF1qcXCMnZ9On/A+Vj0aGY16LTvf
3121PcPgrctd+DPOf78l1M5x0yWzX2+TycsNd73Z7mAfNRv5s76vbPRCgAoAKAGsMHI/EUn3AUc80wF
313oAr3k/kx8febpUTdiW7EVjARmRup6ZqYLqEVbVlmd/LiZvT1q5OyK2FicSRqw7007oNyveB4mEqE
314gdxWcrrVEyutUS21wJ19GHUVUZXGncmqxhQAUAFABQAUAFABQAUAfF9fkR+fhQAUAdN4H8A6h431
315ARwKYbND++u2HyoPQere38q78HgquMnyw26vov8Ag+R6GFwcsQ7vSPf/ACPcfg7braeEpIEzsivZ
3160XPXAbFfa5THkw7iujf5nv4BWpNLuzua9k9EKACgAoAKAGD5Wx2PSltoMfTEQvCpfzG+YjoDUNdQ
317sSqNqgVSVgI7ld8Dj2pSV0J6iWq7bdPpRHYFsSkZFUMgFr5UoeM49VrPls7oVrFitBhQAUAFABQA
318UAFABQAUAfP91+z7rsZ/cXtjMP8AaZ0/9lNfByyPFL4XF/N/5Hzzyl/Zn+A22/Z+1+Rv313YQj1D
319u3/sopRyPFPdpfN/5CWUy6zX3HW+H/gDpdi6y6pdyaiw58pB5ceffnJ/MV6lDIqcda8ubyWi/wA/
320yO6ll1GnrL3n5/5Hp1lY2+m2sdvawx28EYwscahVH4V9LTpwpRUIKyR6m2hyXwn/AORbuv8AsIXH
321/odeZlf8F/4pfmefgf4b9WdpXrnoBQAUAFABQA1hkUmrgKp3DNCdwEblgPxpPsMdVCEoAOlAC0AF
322ABQAUAFABQAUAFABQAUAFABQAUAFABQBxfwn/wCRbuv+whcf+h15GV/wX/il+Z5+B/hv1Z2leueg
323FABQAUAFABQAwfK/1qdmAq8sT+FNdwHUwCgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoA4v4
324T/8AIt3X/YQuP/Q68jK/4L/xS/M8/A/w36s7SvXPQCgAoAjlnSEfMfwqXJIG7DLe6FwzADGPWlGX
325MJO5PVjGuPlyOo5pPYECjCihbAOpgFABQAUAFABQAUAFABQAUAFABQAUAFABQAUAFABQBxfwn/5F
326u6/7CFx/6HXkZX/Bf+KX5nn4H+G/VnZswUZJAHvXrt2PQK0l+i/dBY/pWbmuhLkkLNdGKFTgb2HT
3270ocrIbdjOZi7ZJyT3rExbuXdOXCu3rxWtM0jsXa1LCgBkf3cenFJAPpgFABQAUAFABQAUAFABQAU
328AFABQAUAFABQAUAFAFHV9asdBs2utQuo7WBf4pD19gOpPsKwrV6eHjz1ZWQpSUVeTsji/hVqIfwr
329JJDykt5PIrEdi/FeTllS9BtdW/zPNwUv3Tt3Z1LyNIcsSfrXp3udjdwiXc4B6d/pQNK7FlkMshY9
3306G7ibuNRDIwVRkmjcErmtDEIYwo7V0pWRvsSUwCgBi8O350luwH0wCgAoAKACgAoAKACgAoAKACg
331AoAKACgAoAKAPP8A4gfFux8I+ZZ2gW+1UcGMH5Ij/tn19h+leBj82hhb06fvT/Bev+Rw4nGQwytv
332Lt/meA6/4k1HxPem61K6e4k7A8Kg9FHQCvh61epiJc9WV2fLV8RUxDvN/Loe2fBz/kSIP+u0n86+
3330yn/AHVerPewH8BfM7evYPRJFG2It/e+UUy1ohIoWmbCjPvQlcSVzSt7YQL6sepreMbGqViarGFA
334BQAwf6w/QVPUB9UAUAFABQAUAFABQAUAFABQAUAFABQAUAFAHkfxW+LR0xpdG0SX/Sxlbi7Q/wCq
3359VX/AGvU9vr0+TzTNeS9DDvXq+3kvPz6eu3kY3G+x/d0/i/L/gnhjMXYsxLMTkk8kmvjj5dtyd2J
336QI9++Dn/ACJEH/XaT+dfdZT/ALqvVn0+A/gL5ncAFiAOpr2T0tzSFmpVA2SFHT3rbkNrEyqEGAAB
3377VaVhjqYBQAUAFADP+Wn4UuoD6YBQAUAFABQAUAFABQAUAFABQAUAFABQB518X/iEfCumjT7GTbq
338l0p+ZesKdN31PQfia+dzbHvDx9jTfvP8F/m+h5+NxP1eGnxPb/M+dCSxJJJJ6k18IfINtu7CgQUA
339fQHwgTZ4It/eRz+tfd5UrYVerPqMB/AR6BYQbm8w9B0+te3BX1PUiupoVuaBQAUAFABQAUAMH+sP
3400FT1AfVAFABQAUAFABQAUAFABQAUAFABQAUAFAHyB4i1248S61d6jck+ZO5bbnhV7KPYDivyitVl
341XqSqz3Z8RiKzr1HN/wBIzqxOYKACgD6J+EcBfwdZKO5Y199la/2aKPq8Ar0UeiogjQKOgr3krHqj
342qYBQAUAFABQAUAMX77fgKlbsB9UAUAFABQAUAFABQAUAFABQAUAFABQAUAfF9fkR+fhQAUAWNO06
34341a+hs7SJpriZtqRr1Jq4QlUkoQV2zWnTlVkoQ3Z9NfC+wbTfCkNvJjzYnaNivTI4r9Gy2Dp4dRe
3446PrsHHkpcvY66vUO0KACgAoAKACgAoAan8X1qUA6qAKACgAoAKACgAoAKACgAoAKACgAoAKAPi+v
345yI/PwoA0dB8PX/ibUEs9Ot2uJm64+6g9WPYVtRo1K81Tpq7Omhh6mIlywR9G/D34bWfge18w7bnU
3465FxLckfd/wBlPQfzr7/L8uhg1zPWb6/oj6zDYaGGjaO/Vmv4TTy9NlH/AE8SH/x6u3Cq0H6sugrR
347+bNuus6AoAKACgAoAKACgBqjaKSVgHUwCgAoAKACgAoAKACgAoAKACgAoAKACgD440/S7zVrgQWV
348rNdTH+CFCx/SvySEJVJcsFd+R8NToVK3wRuem+FPgNqF+Vm1uYafD18iMhpW/HoP1r6HC5JWq613
349yr8f8kezQyvrWfyX+Z7PoHhvTvDFiLXTbVLaIdSOWc+rHqT9a+ww+GpYWPJSVv19T3YQjTXLBWRp
35011FmZ4fj8uycf9NnP61z0FaPzZjSVomnXQbBQAUAFABQAUAFABQAUAFABQAUAFABQAUAFABQAUAF
351ABQAUAFAEFpY29hEIraCK3jHRIkCj8hWcKcKS5YJJeQE9aAFABQBT0xPLt2H+2x/WsqasiIaIuVq
352WFABQAUAFABQAUAFABQAUAFABQAUAFABQAUAFABQAUAFABQAUAFABQAUAFAEcKeWpHuTSSsJKxJT
353GFABQAUAFABQAUAFABQAUAFABQAUAFABQAUAFABQAUAFABQAUAf/2Q==</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;...@@ -31,6 +31,7 @@ mod format;
31mod link;31mod link;
32mod ops;32mod ops;
33pub(crate) mod page;33pub(crate) mod page;
34mod space;
34mod table;35mod table;
35pub use format::{36pub use format::{
36 Alignment, BULLET_LIBRARY, FormatState, Formatting, ListStyle, NUMBER_LIBRARY, NoteTag, Toggle,37 Alignment, BULLET_LIBRARY, FormatState, Formatting, ListStyle, NUMBER_LIBRARY, NoteTag, Toggle,
...@@ -126,6 +127,7 @@ struct PageHeader {...@@ -126,6 +127,7 @@ struct PageHeader {
126 created: Option<u64>,127 created: Option<u64>,
127 margin_origin: [f32; 2],128 margin_origin: [f32; 2],
128 color: Option<u32>,129 color: Option<u32>,
130 rule_lines: Option<onestore::page::RuleLines>,
129 areas: Vec<page::TitleArea>,131 areas: Vec<page::TitleArea>,
130}132}
131133
...@@ -754,6 +756,11 @@ enum RestoreFocus {...@@ -754,6 +756,11 @@ enum RestoreFocus {
754756
755enum History {757enum History {
756 Date(Box<PageDate>),758 Date(Box<PageDate>),
759 /// The page's colour and rule lines before a change to either.
760 Paper {
761 color: Option<u32>,
762 rule_lines: Option<onestore::page::RuleLines>,
763 },
757 Draft {764 Draft {
758 outlines: Box<[TextOutline; 2]>,765 outlines: Box<[TextOutline; 2]>,
759 index: usize,766 index: usize,
...@@ -765,10 +772,25 @@ enum History {...@@ -765,10 +772,25 @@ enum History {
765 outline: onestore::ExGuid,772 outline: onestore::ExGuid,
766 change: Box<TextChange>,773 change: Box<TextChange>,
767 },774 },
775 /// A page object's position: an outline, picture or ink drawing.
768 Position {776 Position {
769 outline: onestore::ExGuid,777 object: onestore::ExGuid,
770 position: [Option<f32>; 2],778 position: [Option<f32>; 2],
771 },779 },
780 /// Parts `outline`'s paragraphs from root node `at` into outline `part` at `position`.
781 Split {
782 outline: ExGuid,
783 at: usize,
784 part: ExGuid,
785 position: [f32; 2],
786 },
787 /// Returns `part`'s paragraphs to the end of `outline` and removes it: each root of
788 /// `part` under the parent it had, the outline when `None`.
789 Join {
790 outline: ExGuid,
791 part: ExGuid,
792 parents: Vec<(ExGuid, Option<ExGuid>)>,
793 },
772 Layout {794 Layout {
773 outline: ExGuid,795 outline: ExGuid,
774 layout: onestore::document::Layout,796 layout: onestore::document::Layout,
...@@ -797,8 +819,12 @@ enum History {...@@ -797,8 +819,12 @@ enum History {
797 selection: Selection,819 selection: Selection,
798 focus: RestoreFocus,820 focus: RestoreFocus,
799 },821 },
800 /// One undo step of edits to OneNote's page selection, undone last first.822 /// One undo step of several edits, undone last first; `page` when they edited OneNote's
801 Group(Vec<History>),823 /// page selection, which undoing selects again.
824 Group {
825 entries: Vec<History>,
826 page: bool,
827 },
802}828}
803829
804impl CanvasEditor {830impl CanvasEditor {
...@@ -876,6 +902,7 @@ impl CanvasEditor {...@@ -876,6 +902,7 @@ impl CanvasEditor {
876 created: page.created,902 created: page.created,
877 margin_origin: page.margin_origin,903 margin_origin: page.margin_origin,
878 color: page.color,904 color: page.color,
905 rule_lines: page.rule_lines,
879 areas,906 areas,
880 };907 };
881 let mut ids = BTreeSet::new();908 let mut ids = BTreeSet::new();
...@@ -1019,6 +1046,7 @@ impl CanvasEditor {...@@ -1019,6 +1046,7 @@ impl CanvasEditor {
1019 let gone = |focus: &RestoreFocus| matches!(focus, RestoreFocus::Outline(id) if !fresh.outlines.iter().any(|o| o.id == *id));1046 let gone = |focus: &RestoreFocus| matches!(focus, RestoreFocus::Outline(id) if !fresh.outlines.iter().any(|o| o.id == *id));
1020 let reaches_one = |history: &History| match history {1047 let reaches_one = |history: &History| match history {
1021 History::Date(_) => date_changed,1048 History::Date(_) => date_changed,
1049 History::Paper { .. } => false,
1022 History::Image { .. } | History::Picture { .. } => objects_changed,1050 History::Image { .. } | History::Picture { .. } => objects_changed,
1023 History::Draft { outlines, .. } => changed.contains(&outlines[0].id),1051 History::Draft { outlines, .. } => changed.contains(&outlines[0].id),
1024 History::Text { outline, change } => {1052 History::Text { outline, change } => {
...@@ -1028,16 +1056,18 @@ impl CanvasEditor {...@@ -1028,16 +1056,18 @@ impl CanvasEditor {
1028 .iter()1056 .iter()
1029 .any(|placement| objects_changed || changed.contains(&placement.id))1057 .any(|placement| objects_changed || changed.contains(&placement.id))
1030 }1058 }
1031 History::Position { outline, .. } | History::Layout { outline, .. } => {1059 History::Position { object, .. } => changed.contains(object) || objects_changed,
1032 changed.contains(outline)1060 History::Layout { outline, .. } => changed.contains(outline),
1061 History::Split { outline, part, .. } | History::Join { outline, part, .. } => {
1062 changed.contains(outline) || changed.contains(part) || objects_changed
1033 }1063 }
1034 History::Remove { outline, focus } => changed.contains(outline) || gone(focus),1064 History::Remove { outline, focus } => changed.contains(outline) || gone(focus),
1035 History::Insert { outline, focus, .. } => changed.contains(&outline.id) || gone(focus),1065 History::Insert { outline, focus, .. } => changed.contains(&outline.id) || gone(focus),
1036 History::Restore { source, focus, .. } => changed.contains(&source.id) || gone(focus),1066 History::Restore { source, focus, .. } => changed.contains(&source.id) || gone(focus),
1037 History::Group(_) => unreachable!("groups do not nest"),1067 History::Group { .. } => unreachable!("groups do not nest"),
1038 };1068 };
1039 let reaches = |history: &History| match history {1069 let reaches = |history: &History| match history {
1040 History::Group(entries) => entries.iter().any(reaches_one),1070 History::Group { entries, .. } => entries.iter().any(reaches_one),
1041 history => reaches_one(history),1071 history => reaches_one(history),
1042 };1072 };
1043 fresh.undo = std::mem::take(&mut self.undo)1073 fresh.undo = std::mem::take(&mut self.undo)
...@@ -1143,6 +1173,7 @@ impl CanvasEditor {...@@ -1143,6 +1173,7 @@ impl CanvasEditor {
1143 .or(self.header.created),1173 .or(self.header.created),
1144 margin_origin: self.header.margin_origin,1174 margin_origin: self.header.margin_origin,
1145 color: self.header.color,1175 color: self.header.color,
1176 rule_lines: self.header.rule_lines,
1146 objects,1177 objects,
1147 definitions,1178 definitions,
1148 })1179 })
...@@ -1215,6 +1246,11 @@ impl CanvasEditor {...@@ -1215,6 +1246,11 @@ impl CanvasEditor {
1215 self.header.color1246 self.header.color
1216 }1247 }
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
1218 /// Where the page's first body line starts, in points, under its title.1254 /// Where the page's first body line starts, in points, under its title.
1219 pub fn body_start(&self) -> Option<[f32; 2]> {1255 pub fn body_start(&self) -> Option<[f32; 2]> {
1220 let title = self1256 let title = self
...@@ -1419,6 +1455,44 @@ impl CanvasEditor {...@@ -1419,6 +1455,44 @@ impl CanvasEditor {
1419 Ok(true)1455 Ok(true)
1420 }1456 }
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
1422 /// Stored content; arrow-created paragraphs appear only in `visible_outlines`.1496 /// Stored content; arrow-created paragraphs appear only in `visible_outlines`.
1423 pub fn outlines(&self) -> &[TextOutline] {1497 pub fn outlines(&self) -> &[TextOutline] {
1424 &self.outlines1498 &self.outlines
...@@ -1665,7 +1739,7 @@ impl CanvasEditor {...@@ -1665,7 +1739,7 @@ impl CanvasEditor {
1665 let layout = &mut self.outlines[index].layout;1739 let layout = &mut self.outlines[index].layout;
1666 let previous = [layout.x, layout.y];1740 let previous = [layout.x, layout.y];
1667 self.undo.push(History::Position {1741 self.undo.push(History::Position {
1668 outline: id,1742 object: id,
1669 position: previous,1743 position: previous,
1670 });1744 });
1671 self.redo.clear();1745 self.redo.clear();
...@@ -2145,7 +2219,10 @@ impl CanvasEditor {...@@ -2145,7 +2219,10 @@ impl CanvasEditor {
2145 let result = edit(self);2219 let result = edit(self);
2146 let entries = self.undo.split_off(depth);2220 let entries = self.undo.split_off(depth);
2147 if !entries.is_empty() {2221 if !entries.is_empty() {
2148 self.undo.push(History::Group(entries));2222 self.undo.push(History::Group {
2223 entries,
2224 page: true,
2225 });
2149 }2226 }
2150 result2227 result
2151 }2228 }
...@@ -3212,10 +3289,9 @@ impl CanvasEditor {...@@ -3212,10 +3289,9 @@ impl CanvasEditor {
3212 };3289 };
3213 match self.apply_history(engine, history) {3290 match self.apply_history(engine, history) {
3214 Ok(inverse) => {3291 Ok(inverse) => {
3215 let group = matches!(inverse, History::Group(_));3292 let page = matches!(inverse, History::Group { page: true, .. });
3216 self.redo.push(inverse);3293 self.redo.push(inverse);
3217 // Undoing an edit to the page selection selects the page again.3294 if page {
3218 if group {
3219 self.select_page()?;3295 self.select_page()?;
3220 }3296 }
3221 Ok(true)3297 Ok(true)
...@@ -3255,6 +3331,7 @@ impl CanvasEditor {...@@ -3255,6 +3331,7 @@ impl CanvasEditor {
3255 .date3331 .date
3256 .as_ref()3332 .as_ref()
3257 .is_some_and(|current| current.source().id == date.source().id),3333 .is_some_and(|current| current.source().id == date.source().id),
3334 History::Paper { .. } => true,
3258 History::Draft {3335 History::Draft {
3259 outlines, index, ..3336 outlines, index, ..
3260 } => {3337 } => {
...@@ -3265,11 +3342,22 @@ impl CanvasEditor {...@@ -3265,11 +3342,22 @@ impl CanvasEditor {
3265 .position(|item| item.id == outlines[0].id)3342 .position(|item| item.id == outlines[0].id)
3266 .is_none_or(|found| found == *index)3343 .is_none_or(|found| found == *index)
3267 }3344 }
3268 History::Text { outline, .. }3345 History::Text { outline, .. } | History::Layout { outline, .. } => {
3269 | History::Position { outline, .. }
3270 | History::Layout { outline, .. } => {
3271 self.outlines.iter().any(|item| item.id == *outline)3346 self.outlines.iter().any(|item| item.id == *outline)
3272 }3347 }
3348 History::Position { object, .. } => self.object_layout_mut(*object).is_some(),
3349 History::Split {
3350 outline, at, part, ..
3351 } => {
3352 self.outlines
3353 .iter()
3354 .find(|item| item.id == *outline)
3355 .is_some_and(|item| (1..item.document.nodes().len()).contains(at))
3356 && !self.outlines.iter().any(|item| item.id == *part)
3357 }
3358 History::Join { outline, part, .. } => [outline, part]
3359 .iter()
3360 .all(|id| self.outlines.iter().any(|item| item.id == **id)),
3273 History::Image { image, .. } => self.image(*image).is_some(),3361 History::Image { image, .. } => self.image(*image).is_some(),
3274 History::Picture {3362 History::Picture {
3275 index,3363 index,
...@@ -3290,7 +3378,7 @@ impl CanvasEditor {...@@ -3290,7 +3378,7 @@ impl CanvasEditor {
3290 History::Insert { index, .. } | History::Restore { index, .. } => {3378 History::Insert { index, .. } | History::Restore { index, .. } => {
3291 *index <= self.outlines.len()3379 *index <= self.outlines.len()
3292 }3380 }
3293 History::Group(_) => true,3381 History::Group { .. } => true,
3294 };3382 };
3295 if !valid {3383 if !valid {
3296 return Err((history, EditError::InvalidRange.into()));3384 return Err((history, EditError::InvalidRange.into()));
...@@ -3301,6 +3389,7 @@ impl CanvasEditor {...@@ -3301,6 +3389,7 @@ impl CanvasEditor {
3301 self.record(Ok(self.date_ops()));3389 self.record(Ok(self.date_ops()));
3302 History::Date(Box::new(shown))3390 History::Date(Box::new(shown))
3303 }3391 }
3392 History::Paper { color, rule_lines } => self.paper(color, rule_lines),
3304 History::Draft {3393 History::Draft {
3305 outlines,3394 outlines,
3306 index,3395 index,
...@@ -3387,25 +3476,67 @@ impl CanvasEditor {...@@ -3387,25 +3476,67 @@ impl CanvasEditor {
3387 change: Box::new(inverse),3476 change: Box::new(inverse),
3388 }3477 }
3389 }3478 }
3390 History::Position { outline, position } => {3479 History::Position { object, position } => {
3391 let index = self3480 let layout = self.object_layout_mut(object).unwrap();
3392 .outlines
3393 .iter()
3394 .position(|item| item.id == outline)
3395 .unwrap();
3396 let layout = &mut self.outlines[index].layout;
3397 let previous = [layout.x, layout.y];3481 let previous = [layout.x, layout.y];
3398 [layout.x, layout.y] = position;3482 [layout.x, layout.y] = position;
3399 self.active = Focus::Outline(index);3483 if let Some(index) = self.outlines.iter().position(|item| item.id == object) {
3484 self.active = Focus::Outline(index);
3485 }
3400 self.record(self.placement_ops(&Placement {3486 self.record(self.placement_ops(&Placement {
3401 id: outline,3487 id: object,
3402 position: previous,3488 position: previous,
3403 }));3489 }));
3404 History::Position {3490 History::Position {
3405 outline,3491 object,
3406 position: previous,3492 position: previous,
3407 }3493 }
3408 }3494 }
3495 History::Split {
3496 outline,
3497 at,
3498 part,
3499 position,
3500 } => match self.split_outline(engine, outline, at, part, position) {
3501 Ok(parents) => History::Join {
3502 outline,
3503 part,
3504 parents,
3505 },
3506 Err(error) => {
3507 return Err((
3508 History::Split {
3509 outline,
3510 at,
3511 part,
3512 position,
3513 },
3514 error,
3515 ));
3516 }
3517 },
3518 History::Join {
3519 outline,
3520 part,
3521 parents,
3522 } => match self.join_outline(engine, outline, part, &parents) {
3523 Ok((at, position)) => History::Split {
3524 outline,
3525 at,
3526 part,
3527 position,
3528 },
3529 Err(error) => {
3530 return Err((
3531 History::Join {
3532 outline,
3533 part,
3534 parents,
3535 },
3536 error,
3537 ));
3538 }
3539 },
3409 History::Layout { outline, layout } => {3540 History::Layout { outline, layout } => {
3410 let index = self3541 let index = self
3411 .outlines3542 .outlines
...@@ -3554,18 +3685,21 @@ impl CanvasEditor {...@@ -3554,18 +3685,21 @@ impl CanvasEditor {
3554 self.active = Focus::Outline(index);3685 self.active = Focus::Outline(index);
3555 History::Remove { outline: id, focus }3686 History::Remove { outline: id, focus }
3556 }3687 }
3557 History::Group(mut entries) => {3688 History::Group { mut entries, page } => {
3558 let mut inverses = Vec::with_capacity(entries.len());3689 let mut inverses = Vec::with_capacity(entries.len());
3559 while let Some(entry) = entries.pop() {3690 while let Some(entry) = entries.pop() {
3560 match self.apply_history(engine, entry) {3691 match self.apply_history(engine, entry) {
3561 Ok(inverse) => inverses.push(inverse),3692 Ok(inverse) => inverses.push(inverse),
3562 Err((entry, error)) => {3693 Err((entry, error)) => {
3563 entries.push(entry);3694 entries.push(entry);
3564 return Err((History::Group(entries), error));3695 return Err((History::Group { entries, page }, error));
3565 }3696 }
3566 }3697 }
3567 }3698 }
3568 History::Group(inverses)3699 History::Group {
3700 entries: inverses,
3701 page,
3702 }
3569 }3703 }
3570 };3704 };
3571 self.preferred_x = None;3705 self.preferred_x = None;
...@@ -5445,6 +5579,7 @@ mod tests {...@@ -5445,6 +5579,7 @@ mod tests {
5445 created: None,5579 created: None,
5446 margin_origin: [36.0, 14.4],5580 margin_origin: [36.0, 14.4],
5447 color: None,5581 color: None,
5582 rule_lines: None,
5448 definitions: BTreeMap::new(),5583 definitions: BTreeMap::new(),
5449 objects: vec![PageObject::Image(background), PageObject::Image(picture)],5584 objects: vec![PageObject::Image(background), PageObject::Image(picture)],
5450 },5585 },
...@@ -5569,6 +5704,7 @@ mod tests {...@@ -5569,6 +5704,7 @@ mod tests {
5569 created: None,5704 created: None,
5570 margin_origin: [36.0, 14.4],5705 margin_origin: [36.0, 14.4],
5571 color: None,5706 color: None,
5707 rule_lines: None,
5572 definitions: BTreeMap::new(),5708 definitions: BTreeMap::new(),
5573 objects,5709 objects,
5574 },5710 },
...@@ -5627,6 +5763,7 @@ mod tests {...@@ -5627,6 +5763,7 @@ mod tests {
5627 created: None,5763 created: None,
5628 margin_origin: [36.0, 14.4],5764 margin_origin: [36.0, 14.4],
5629 color: None,5765 color: None,
5766 rule_lines: None,
5630 definitions: BTreeMap::new(),5767 definitions: BTreeMap::new(),
5631 objects: vec![PageObject::Outline(source)],5768 objects: vec![PageObject::Outline(source)],
5632 },5769 },
...@@ -5767,6 +5904,7 @@ mod tests {...@@ -5767,6 +5904,7 @@ mod tests {
5767 created: None,5904 created: None,
5768 margin_origin: [36.0, 14.4],5905 margin_origin: [36.0, 14.4],
5769 color: None,5906 color: None,
5907 rule_lines: None,
5770 definitions: BTreeMap::new(),5908 definitions: BTreeMap::new(),
5771 objects: vec![5909 objects: vec![
5772 PageObject::Outline(mixed),5910 PageObject::Outline(mixed),
...@@ -5858,6 +5996,7 @@ mod tests {...@@ -5858,6 +5996,7 @@ mod tests {
5858 created: None,5996 created: None,
5859 margin_origin: [36.0, 14.4],5997 margin_origin: [36.0, 14.4],
5860 color: None,5998 color: None,
5999 rule_lines: None,
5861 definitions: BTreeMap::new(),6000 definitions: BTreeMap::new(),
5862 objects,6001 objects,
5863 },6002 },
crates/canvas/src/editor/format.rs+12-3
...@@ -1895,13 +1895,19 @@ mod tests {...@@ -1895,13 +1895,19 @@ mod tests {
1895 |editor: &CanvasEditor| editor.active_outline().shaped.paragraphs[1].tags[0].icon;1895 |editor: &CanvasEditor| editor.active_outline().shaped.paragraphs[1].tags[0].icon;
1896 assert_eq!(1896 assert_eq!(
1897 icon(&editor),1897 icon(&editor),
1898 crate::outline::TagIcon::CheckBox { checked: false }1898 crate::outline::TagIcon::CheckBox {
1899 checked: false,
1900 mark: None
1901 }
1899 );1902 );
1900 editor.format(&mut engine, Formatting::Check).unwrap();1903 editor.format(&mut engine, Formatting::Check).unwrap();
1901 assert_eq!(text_tags(&editor, 1), [("To Do".into(), 1, true)]);1904 assert_eq!(text_tags(&editor, 1), [("To Do".into(), 1, true)]);
1902 assert_eq!(1905 assert_eq!(
1903 icon(&editor),1906 icon(&editor),
1904 crate::outline::TagIcon::CheckBox { checked: true }1907 crate::outline::TagIcon::CheckBox {
1908 checked: true,
1909 mark: None
1910 }
1905 );1911 );
1906 editor.select([at(1, 0); 2].into()).unwrap();1912 editor.select([at(1, 0); 2].into()).unwrap();
1907 editor.format(&mut engine, Formatting::Check).unwrap();1913 editor.format(&mut engine, Formatting::Check).unwrap();
...@@ -1976,7 +1982,10 @@ mod tests {...@@ -1976,7 +1982,10 @@ mod tests {
1976 assert_eq!(1982 assert_eq!(
1977 icons[..7],1983 icons[..7],
1978 [1984 [
1979 TagIcon::CheckBox { checked: false },1985 TagIcon::CheckBox {
1986 checked: false,
1987 mark: None
1988 },
1980 TagIcon::Star,1989 TagIcon::Star,
1981 TagIcon::Question,1990 TagIcon::Question,
1982 TagIcon::Highlight,1991 TagIcon::Highlight,
crates/canvas/src/editor/ops.rs+30-24
...@@ -173,7 +173,7 @@ fn lower(...@@ -173,7 +173,7 @@ fn lower(
173173
174/// An empty paragraph keeping a container from emptying while its paragraphs are removed174/// An empty paragraph keeping a container from emptying while its paragraphs are removed
175/// and inserted again; the lowering removes it after.175/// and inserted again; the lowering removes it after.
176fn anchor() -> Result<PageParagraph, EditError> {176pub(super) fn anchor() -> Result<PageParagraph, EditError> {
177 crate::document::node(177 crate::document::node(
178 Paragraph::new(String::new(), Format::default()),178 Paragraph::new(String::new(), Format::default()),
179 Format::default(),179 Format::default(),
...@@ -327,6 +327,9 @@ impl CanvasEditor {...@@ -327,6 +327,9 @@ impl CanvasEditor {
327 for object in &self.objects {327 for object in &self.objects {
328 match object {328 match object {
329 page::Content::Image(image) if image.id == placement.id => return picture(image),329 page::Content::Image(image) if image.id == placement.id => return picture(image),
330 page::Content::Ink(ink) if ink.id == placement.id => {
331 return layout_ops(ink.id, &old, &moved(&ink.layout));
332 }
330 page::Content::Outline { source, .. } if source.id == placement.id => {333 page::Content::Outline { source, .. } if source.id == placement.id => {
331 return layout_ops(source.id, &old, &moved(&source.layout));334 return layout_ops(source.id, &old, &moved(&source.layout));
332 }335 }
...@@ -352,29 +355,7 @@ impl CanvasEditor {...@@ -352,29 +355,7 @@ impl CanvasEditor {
352 match (old, new) {355 match (old, new) {
353 (None, None) => Ok(Vec::new()),356 (None, None) => Ok(Vec::new()),
354 (Some(_), None) => Ok(vec![PageOp::Delete { object: id }]),357 (Some(_), None) => Ok(vec![PageOp::Delete { object: id }]),
355 (None, Some(new)) => {358 (None, Some(new)) => self.added(new.snapshot()),
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 }
378 (Some(old), Some(new)) => {359 (Some(old), Some(new)) => {
379 let (a, b) = (old.document.nodes(), new.document.nodes());360 let (a, b) = (old.document.nodes(), new.document.nodes());
380 let prefix = a.iter().zip(b).take_while(|(x, y)| x == y).count();361 let prefix = a.iter().zip(b).take_while(|(x, y)| x == y).count();
...@@ -397,6 +378,31 @@ impl CanvasEditor {...@@ -397,6 +378,31 @@ impl CanvasEditor {
397 }378 }
398 }379 }
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
400 /// The page's date as the editor shows it.406 /// The page's date as the editor shows it.
401 pub(super) fn date_ops(&self) -> Vec<PageOp> {407 pub(super) fn date_ops(&self) -> Vec<PageOp> {
402 let Some(date) = &self.date else {408 let Some(date) = &self.date else {
crates/canvas/src/editor/page.rs+15-4
...@@ -76,9 +76,9 @@ impl Content {...@@ -76,9 +76,9 @@ impl Content {
76 match self {76 match self {
77 Self::Outline { source, .. } => Some((source.id, &mut source.layout)),77 Self::Outline { source, .. } => Some((source.id, &mut source.layout)),
78 Self::Image(source) => Some((source.id, &mut source.layout)),78 Self::Image(source) => Some((source.id, &mut source.layout)),
79 Self::Ink(source) => Some((source.id, &mut source.layout)),
79 Self::ReadOnly(object) => Some((object.source.id(), object.source.layout_mut())),80 Self::ReadOnly(object) => Some((object.source.id(), object.source.layout_mut())),
80 // Strokes carry their own page coordinates, so a layout move would not move them.81 Self::Date { .. } | Self::Editable(_) => None,
81 Self::Date { .. } | Self::Ink(_) | Self::Editable(_) => None,
82 }82 }
83 }83 }
84}84}
...@@ -357,8 +357,19 @@ pub(crate) fn build(...@@ -357,8 +357,19 @@ pub(crate) fn build(
357 })357 })
358}358}
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.
361pub(crate) fn ink_bounds(ink: &Ink) -> Option<[f32; 4]> {362pub(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]> {
362 ink.strokes373 ink.strokes
363 .iter()374 .iter()
364 .flat_map(|stroke| {375 .flat_map(|stroke| {
...@@ -368,7 +379,7 @@ pub(crate) fn ink_bounds(ink: &Ink) -> Option<[f32; 4]> {...@@ -368,7 +379,7 @@ pub(crate) fn ink_bounds(ink: &Ink) -> Option<[f32; 4]> {
368 .iter()379 .iter()
369 .map(move |[x, y]| [x - r, y - r, x + r, y + r])380 .map(move |[x, y]| [x - r, y - r, x + r, y + r])
370 })381 })
371 .chain(ink.groups.iter().filter_map(ink_bounds))382 .chain(ink.groups.iter().filter_map(stroke_bounds))
372 .reduce(|a, b| {383 .reduce(|a, b| {
373 [384 [
374 a[0].min(b[0]),385 a[0].min(b[0]),
crates/canvas/src/editor/space.rs created+336
...@@ -0,0 +1,336 @@
1//! OneNote 2010's Insert Space: what lies past a line across the page moves along one axis,
2//! and a body outline the line crosses parts between its paragraphs.
3
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;...@@ -4,7 +4,7 @@ mod profile;
44
5use crate::{5use crate::{
6 layout::{TextBrush, TextLayout},6 layout::{TextBrush, TextLayout},
7 outline::TagIcon,7 outline::{BoxMark, TagIcon},
8};8};
9use draw::{GlyphRun, Glyphs, Layer, Primitive, RenderError};9use draw::{GlyphRun, Glyphs, Layer, Primitive, RenderError};
10use parley::PositionedLayoutItem;10use parley::PositionedLayoutItem;
...@@ -33,6 +33,7 @@ impl Viewport {...@@ -33,6 +33,7 @@ impl Viewport {
33 origin: self.origin,33 origin: self.origin,
34 clip: None,34 clip: None,
35 backdrop: None,35 backdrop: None,
36 round: None,
36 motion: None,37 motion: None,
37 primitives,38 primitives,
38 }39 }
...@@ -70,7 +71,7 @@ impl Paper {...@@ -70,7 +71,7 @@ impl Paper {
70 /// A fill OneNote draws on white paper, moved onto this paper: as far from it in71 /// A fill OneNote draws on white paper, moved onto this paper: as far from it in
71 /// OKLab lightness as from white, in the same hue and chroma, so text keeps its72 /// OKLab lightness as from white, in the same hue and chroma, so text keeps its
72 /// contrast on it.73 /// contrast on it.
73 pub(crate) fn tint(&self, light: [f32; 4]) -> [f32; 4] {74 pub fn tint(&self, light: [f32; 4]) -> [f32; 4] {
74 let [lightness, a, b] = draw::oklab(light);75 let [lightness, a, b] = draw::oklab(light);
75 let [paper, ..] = draw::oklab(self.color);76 let [paper, ..] = draw::oklab(self.color);
76 let distance = 1.0 - lightness;77 let distance = 1.0 - lightness;
...@@ -153,13 +154,82 @@ const RED_SQUARE: &str = include_str!("../../assets/tags/red-square.svg");...@@ -153,13 +154,82 @@ const RED_SQUARE: &str = include_str!("../../assets/tags/red-square.svg");
153const YELLOW_SQUARE: &str = include_str!("../../assets/tags/yellow-square.svg");154const YELLOW_SQUARE: &str = include_str!("../../assets/tags/yellow-square.svg");
154const BLUE_SQUARE: &str = include_str!("../../assets/tags/blue-square.svg");155const BLUE_SQUARE: &str = include_str!("../../assets/tags/blue-square.svg");
155const FLAG: &str = include_str!("../../assets/tags/flag.svg");156const 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");
156const TAG: &str = include_str!("../../assets/tags/tag.svg");172const TAG: &str = include_str!("../../assets/tags/tag.svg");
157173
158/// The tag's artwork, drawn in order.174/// The tag's artwork, drawn in order.
159pub fn tag_sources(icon: TagIcon) -> &'static [&'static str] {175pub fn tag_sources(icon: TagIcon) -> &'static [&'static str] {
160 match icon {176 match icon {
161 TagIcon::CheckBox { checked: false } => &[CHECKBOX],177 TagIcon::CheckBox {
162 TagIcon::CheckBox { checked: true } => &[CHECKBOX, CHECKMARK],178 checked: false,
179 mark: None,
180 } => &[CHECKBOX],
181 TagIcon::CheckBox {
182 checked: true,
183 mark: None,
184 } => &[CHECKBOX, CHECKMARK],
185 TagIcon::CheckBox {
186 checked: false,
187 mark: Some(BoxMark::Person),
188 } => &[BOX_SMALL, MARK_PERSON],
189 TagIcon::CheckBox {
190 checked: true,
191 mark: Some(BoxMark::Person),
192 } => &[BOX_SMALL, CHECK_SMALL, MARK_PERSON],
193 TagIcon::CheckBox {
194 checked: false,
195 mark: Some(BoxMark::Manager),
196 } => &[BOX_SMALL, MARK_MANAGER],
197 TagIcon::CheckBox {
198 checked: true,
199 mark: Some(BoxMark::Manager),
200 } => &[BOX_SMALL, CHECK_SMALL, MARK_MANAGER],
201 TagIcon::CheckBox {
202 checked: false,
203 mark: Some(BoxMark::Arrow),
204 } => &[BOX_SMALL, MARK_ARROW],
205 TagIcon::CheckBox {
206 checked: true,
207 mark: Some(BoxMark::Arrow),
208 } => &[BOX_SMALL, CHECK_SMALL, MARK_ARROW],
209 TagIcon::CheckBox {
210 checked: false,
211 mark: Some(BoxMark::One),
212 } => &[BOX_SMALL, MARK_ONE],
213 TagIcon::CheckBox {
214 checked: true,
215 mark: Some(BoxMark::One),
216 } => &[BOX_SMALL, CHECK_SMALL, MARK_ONE],
217 TagIcon::CheckBox {
218 checked: false,
219 mark: Some(BoxMark::Two),
220 } => &[BOX_SMALL, MARK_TWO],
221 TagIcon::CheckBox {
222 checked: true,
223 mark: Some(BoxMark::Two),
224 } => &[BOX_SMALL, CHECK_SMALL, MARK_TWO],
225 TagIcon::CheckBox {
226 checked: false,
227 mark: Some(BoxMark::Client),
228 } => &[BOX_SMALL, MARK_CLIENT],
229 TagIcon::CheckBox {
230 checked: true,
231 mark: Some(BoxMark::Client),
232 } => &[BOX_SMALL, CHECK_SMALL, MARK_CLIENT],
163 TagIcon::Star => &[STAR],233 TagIcon::Star => &[STAR],
164 TagIcon::Question => &[QUESTION],234 TagIcon::Question => &[QUESTION],
165 TagIcon::Highlight => &[HIGHLIGHT],235 TagIcon::Highlight => &[HIGHLIGHT],
...@@ -172,6 +242,13 @@ pub fn tag_sources(icon: TagIcon) -> &'static [&'static str] {...@@ -172,6 +242,13 @@ pub fn tag_sources(icon: TagIcon) -> &'static [&'static str] {
172 TagIcon::YellowSquare => &[YELLOW_SQUARE],242 TagIcon::YellowSquare => &[YELLOW_SQUARE],
173 TagIcon::BlueSquare => &[BLUE_SQUARE],243 TagIcon::BlueSquare => &[BLUE_SQUARE],
174 TagIcon::Flag => &[FLAG],244 TagIcon::Flag => &[FLAG],
245 TagIcon::Idea => &[IDEA],
246 TagIcon::Password => &[PASSWORD],
247 TagIcon::Movie => &[MOVIE],
248 TagIcon::Book => &[BOOK],
249 TagIcon::Web => &[WEB],
250 TagIcon::Blog => &[BLOG],
251 TagIcon::Email => &[EMAIL],
175 TagIcon::Other => &[TAG],252 TagIcon::Other => &[TAG],
176 }253 }
177}254}
...@@ -288,8 +365,14 @@ mod tests {...@@ -288,8 +365,14 @@ mod tests {
288 });365 });
289 let mut renderer = draw::Renderer::new(device, queue, wgpu::TextureFormat::Rgba8UnormSrgb);366 let mut renderer = draw::Renderer::new(device, queue, wgpu::TextureFormat::Rgba8UnormSrgb);
290 let icons = [367 let icons = [
291 TagIcon::CheckBox { checked: false },368 TagIcon::CheckBox {
292 TagIcon::CheckBox { checked: true },369 checked: false,
370 mark: None,
371 },
372 TagIcon::CheckBox {
373 checked: true,
374 mark: None,
375 },
293 TagIcon::Star,376 TagIcon::Star,
294 TagIcon::Question,377 TagIcon::Question,
295 TagIcon::Highlight,378 TagIcon::Highlight,
crates/canvas/src/gpu/page.rs+12-1
...@@ -733,7 +733,8 @@ impl PageScene {...@@ -733,7 +733,8 @@ impl PageScene {
733 continue;733 continue;
734 }734 }
735 Content::Ink(ink) => {735 Content::Ink(ink) => {
736 append_ink(ink, offset, primitives);736 let [x, y] = crate::editor::page::ink_offset(ink);
737 append_ink(ink, [offset[0] + x, offset[1] + y], primitives);
737 continue;738 continue;
738 }739 }
739 };740 };
...@@ -1086,6 +1087,7 @@ mod tests {...@@ -1086,6 +1087,7 @@ mod tests {
1086 title: "Header".into(),1087 title: "Header".into(),
1087 margin_origin: [36.0, 14.4],1088 margin_origin: [36.0, 14.4],
1088 color: None,1089 color: None,
1090 rule_lines: None,
1089 definitions: BTreeMap::new(),1091 definitions: BTreeMap::new(),
1090 objects: vec![PageObject::Title(Title {1092 objects: vec![PageObject::Title(Title {
1091 date: Some(fields[1].id),1093 date: Some(fields[1].id),
...@@ -1528,6 +1530,7 @@ mod tests {...@@ -1528,6 +1530,7 @@ mod tests {
1528 title: "Header".into(),1530 title: "Header".into(),
1529 margin_origin: [36.0, 14.4],1531 margin_origin: [36.0, 14.4],
1530 color: None,1532 color: None,
1533 rule_lines: None,
1531 definitions: BTreeMap::new(),1534 definitions: BTreeMap::new(),
1532 objects: vec![PageObject::Title(Title {1535 objects: vec![PageObject::Title(Title {
1533 date: None,1536 date: None,
...@@ -1603,6 +1606,7 @@ mod tests {...@@ -1603,6 +1606,7 @@ mod tests {
1603 title: String::new(),1606 title: String::new(),
1604 margin_origin: [0.0; 2],1607 margin_origin: [0.0; 2],
1605 color: None,1608 color: None,
1609 rule_lines: None,
1606 definitions: BTreeMap::new(),1610 definitions: BTreeMap::new(),
1607 objects,1611 objects,
1608 };1612 };
...@@ -1686,6 +1690,7 @@ mod tests {...@@ -1686,6 +1690,7 @@ mod tests {
1686 title: String::new(),1690 title: String::new(),
1687 margin_origin: [36.0, 14.0],1691 margin_origin: [36.0, 14.0],
1688 color: None,1692 color: None,
1693 rule_lines: None,
1689 objects: vec![unsupported, PageObject::Outline(editable)],1694 objects: vec![unsupported, PageObject::Outline(editable)],
1690 definitions: BTreeMap::from([1695 definitions: BTreeMap::from([
1691 (1696 (
...@@ -1773,6 +1778,7 @@ mod tests {...@@ -1773,6 +1778,7 @@ mod tests {
1773 title: String::new(),1778 title: String::new(),
1774 margin_origin: [0.0; 2],1779 margin_origin: [0.0; 2],
1775 color: None,1780 color: None,
1781 rule_lines: None,
1776 definitions: BTreeMap::new(),1782 definitions: BTreeMap::new(),
1777 objects: vec![1783 objects: vec![
1778 PageObject::Outline(Outline {1784 PageObject::Outline(Outline {
...@@ -1953,6 +1959,7 @@ mod tests {...@@ -1953,6 +1959,7 @@ mod tests {
1953 title: String::new(),1959 title: String::new(),
1954 margin_origin: [0.0; 2],1960 margin_origin: [0.0; 2],
1955 color: None,1961 color: None,
1962 rule_lines: None,
1956 definitions: BTreeMap::new(),1963 definitions: BTreeMap::new(),
1957 objects: vec![1964 objects: vec![
1958 outline(2.0, "first"),1965 outline(2.0, "first"),
...@@ -2127,6 +2134,7 @@ mod tests {...@@ -2127,6 +2134,7 @@ mod tests {
2127 title: String::new(),2134 title: String::new(),
2128 margin_origin: [0.0; 2],2135 margin_origin: [0.0; 2],
2129 color: None,2136 color: None,
2137 rule_lines: None,
2130 definitions: BTreeMap::new(),2138 definitions: BTreeMap::new(),
2131 objects: vec![PageObject::Ink(ink.clone())],2139 objects: vec![PageObject::Ink(ink.clone())],
2132 };2140 };
...@@ -2187,6 +2195,7 @@ mod tests {...@@ -2187,6 +2195,7 @@ mod tests {
2187 title: String::new(),2195 title: String::new(),
2188 margin_origin: [0.0; 2],2196 margin_origin: [0.0; 2],
2189 color: None,2197 color: None,
2198 rule_lines: None,
2190 definitions: BTreeMap::new(),2199 definitions: BTreeMap::new(),
2191 objects: vec![PageObject::Image(Image {2200 objects: vec![PageObject::Image(Image {
2192 size: None,2201 size: None,
...@@ -2273,6 +2282,7 @@ mod tests {...@@ -2273,6 +2282,7 @@ mod tests {
2273 title: String::new(),2282 title: String::new(),
2274 margin_origin: [0.0; 2],2283 margin_origin: [0.0; 2],
2275 color: None,2284 color: None,
2285 rule_lines: None,
2276 definitions: BTreeMap::new(),2286 definitions: BTreeMap::new(),
2277 objects: vec![PageObject::Image(background), PageObject::Image(picture)],2287 objects: vec![PageObject::Image(background), PageObject::Image(picture)],
2278 };2288 };
...@@ -2341,6 +2351,7 @@ mod tests {...@@ -2341,6 +2351,7 @@ mod tests {
2341 title: String::new(),2351 title: String::new(),
2342 margin_origin: [0.0; 2],2352 margin_origin: [0.0; 2],
2343 color: None,2353 color: None,
2354 rule_lines: None,
2344 definitions: BTreeMap::new(),2355 definitions: BTreeMap::new(),
2345 objects: pictures.iter().cloned().map(PageObject::Image).collect(),2356 objects: pictures.iter().cloned().map(PageObject::Image).collect(),
2346 };2357 };
crates/canvas/src/gpu/page/background.rs+1
...@@ -910,6 +910,7 @@ mod tests {...@@ -910,6 +910,7 @@ mod tests {
910 title: String::new(),910 title: String::new(),
911 margin_origin: [0.0; 2],911 margin_origin: [0.0; 2],
912 color: None,912 color: None,
913 rule_lines: None,
913 definitions: BTreeMap::new(),914 definitions: BTreeMap::new(),
914 objects: vec![PageObject::Image(background)],915 objects: vec![PageObject::Image(background)],
915 };916 };
crates/canvas/src/interaction/accessibility.rs+11-2
...@@ -609,6 +609,13 @@ fn runs_of(shaped: &ParagraphLayout, breaks: bool) -> Result<Vec<(Node, Vec<u32>...@@ -609,6 +609,13 @@ fn runs_of(shaped: &ParagraphLayout, breaks: bool) -> Result<Vec<(Node, Vec<u32>
609 TagIcon::YellowSquare => "Project B",609 TagIcon::YellowSquare => "Project B",
610 TagIcon::BlueSquare => "Project C",610 TagIcon::BlueSquare => "Project C",
611 TagIcon::Flag => "Outlook task",611 TagIcon::Flag => "Outlook task",
612 TagIcon::Idea => "Idea",
613 TagIcon::Password => "Password",
614 TagIcon::Movie => "Movie to see",
615 TagIcon::Book => "Book to read",
616 TagIcon::Web => "Web site to visit",
617 TagIcon::Blog => "Remember for blog",
618 TagIcon::Email => "Send in email",
612 TagIcon::Other => "Tag",619 TagIcon::Other => "Tag",
613 };620 };
614 let label = if tag.label.is_empty() {621 let label = if tag.label.is_empty() {
...@@ -617,8 +624,8 @@ fn runs_of(shaped: &ParagraphLayout, breaks: bool) -> Result<Vec<(Node, Vec<u32>...@@ -617,8 +624,8 @@ fn runs_of(shaped: &ParagraphLayout, breaks: bool) -> Result<Vec<(Node, Vec<u32>
617 &tag.label624 &tag.label
618 };625 };
619 let state = match tag.icon {626 let state = match tag.icon {
620 TagIcon::CheckBox { checked: true } => ", completed",627 TagIcon::CheckBox { checked: true, .. } => ", completed",
621 TagIcon::CheckBox { checked: false } => ", incomplete",628 TagIcon::CheckBox { checked: false, .. } => ", incomplete",
622 _ => "",629 _ => "",
623 };630 };
624 format!(631 format!(
...@@ -739,6 +746,7 @@ pub(super) mod tests {...@@ -739,6 +746,7 @@ pub(super) mod tests {
739 created: None,746 created: None,
740 margin_origin: [36.0, 14.4],747 margin_origin: [36.0, 14.4],
741 color: None,748 color: None,
749 rule_lines: None,
742 definitions: Default::default(),750 definitions: Default::default(),
743 objects: vec![751 objects: vec![
744 PageObject::Title(Title {752 PageObject::Title(Title {
...@@ -832,6 +840,7 @@ pub(super) mod tests {...@@ -832,6 +840,7 @@ pub(super) mod tests {
832 title: String::new(),840 title: String::new(),
833 margin_origin: [0.0; 2],841 margin_origin: [0.0; 2],
834 color: None,842 color: None,
843 rule_lines: None,
835 definitions: Default::default(),844 definitions: Default::default(),
836 objects: vec![PageObject::Unsupported(Unsupported {845 objects: vec![PageObject::Unsupported(Unsupported {
837 id: Default::default(),846 id: Default::default(),
crates/canvas/src/interaction/mod.rs+175-6
...@@ -6,6 +6,7 @@ pub mod accessibility;...@@ -6,6 +6,7 @@ pub mod accessibility;
6#[cfg(test)]6#[cfg(test)]
7mod profile;7mod profile;
8mod scroll;8mod scroll;
9mod space;
910
10pub use scroll::Scroll;11pub use scroll::Scroll;
11#[cfg(test)]12#[cfg(test)]
...@@ -42,6 +43,10 @@ pub enum Cursor {...@@ -42,6 +43,10 @@ pub enum Cursor {
42 NsResize,43 NsResize,
43 NwseResize,44 NwseResize,
44 NeswResize,45 NeswResize,
46 /// Insert Space's line across the page.
47 RowResize,
48 /// Insert Space's line down the page.
49 ColResize,
45}50}
4651
47/// Work only the host can do, asked for by an event.52/// Work only the host can do, asked for by an event.
...@@ -151,6 +156,8 @@ enum Drag {...@@ -151,6 +156,8 @@ enum Drag {
151 press: [f32; 2],156 press: [f32; 2],
152 pending_press: Option<[f32; 2]>,157 pending_press: Option<[f32; 2]>,
153 },158 },
159 /// Insert Space from a press on `line` along `axis`, 0 for x.
160 Space { axis: usize, line: f32 },
154}161}
155162
156#[derive(Clone, Copy)]163#[derive(Clone, Copy)]
...@@ -205,6 +212,8 @@ pub struct PageView {...@@ -205,6 +212,8 @@ pub struct PageView {
205 pointer: [f32; 2],212 pointer: [f32; 2],
206 pointer_inside: bool,213 pointer_inside: bool,
207 drag: Option<Drag>,214 drag: Option<Drag>,
215 /// Insert Space waits for its press: its line follows the pointer.
216 space: bool,
208 object_focus: Option<ObjectFocus>,217 object_focus: Option<ObjectFocus>,
209 modifiers: Modifiers,218 modifiers: Modifiers,
210 focused: bool,219 focused: bool,
...@@ -269,6 +278,7 @@ impl PageView {...@@ -269,6 +278,7 @@ impl PageView {
269 pointer: [0.0; 2],278 pointer: [0.0; 2],
270 pointer_inside: false,279 pointer_inside: false,
271 drag: None,280 drag: None,
281 space: false,
272 object_focus: None,282 object_focus: None,
273 modifiers: Modifiers::default(),283 modifiers: Modifiers::default(),
274 focused: true,284 focused: true,
...@@ -287,6 +297,7 @@ impl PageView {...@@ -287,6 +297,7 @@ impl PageView {
287 self.editor = editor;297 self.editor = editor;
288 self.scene = scene;298 self.scene = scene;
289 self.drag = None;299 self.drag = None;
300 self.space = false;
290 self.object_focus = None;301 self.object_focus = None;
291 self.found.clear();302 self.found.clear();
292 }303 }
...@@ -349,6 +360,32 @@ impl PageView {...@@ -349,6 +360,32 @@ impl PageView {
349 self.object_focus360 self.object_focus
350 }361 }
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
352 /// Text input goes to the editor unless an object holds focus.389 /// Text input goes to the editor unless an object holds focus.
353 pub fn accepts_text(&self) -> bool {390 pub fn accepts_text(&self) -> bool {
354 self.object_focus.is_none()391 self.object_focus.is_none()
...@@ -622,6 +659,12 @@ impl PageView {...@@ -622,6 +659,12 @@ impl PageView {
622 self.moved()659 self.moved()
623 }660 }
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
625 /// Scales the view by `factor`, keeping the document point under `anchor` in place.668 /// Scales the view by `factor`, keeping the document point under `anchor` in place.
626 fn zoom_about(&mut self, factor: f32, anchor: [f32; 2]) {669 fn zoom_about(&mut self, factor: f32, anchor: [f32; 2]) {
627 let point = self.viewport.document_point(anchor);670 let point = self.viewport.document_point(anchor);
...@@ -721,7 +764,7 @@ impl PageView {...@@ -721,7 +764,7 @@ impl PageView {
721 .map(|(id, origin, size)| PointerFeedback::Image(id, origin, size))764 .map(|(id, origin, size)| PointerFeedback::Image(id, origin, size))
722 })765 })
723 .or_else(|| {766 .or_else(|| {
724 if !self.pointer_inside {767 if !self.pointer_inside || self.space {
725 return None;768 return None;
726 }769 }
727 match page_hit_test(770 match page_hit_test(
...@@ -737,7 +780,18 @@ impl PageView {...@@ -737,7 +780,18 @@ impl PageView {
737 }780 }
738 }),781 }),
739 };782 };
740 page_primitives(783 let [left, top] = self.viewport.document_point([0.0; 2]);
784 let [right, bottom] = self
785 .viewport
786 .document_point(self.viewport.size.map(|side| side as f32));
787 let mut primitives = rule_primitives(
788 self.editor.rule_lines(),
789 self.editor.margin_origin(),
790 [left, top, right, bottom],
791 self.pixel(),
792 colors.paper,
793 );
794 primitives.extend(page_primitives(
741 &self.editor,795 &self.editor,
742 self.scene.as_ref(),796 self.scene.as_ref(),
743 preview,797 preview,
...@@ -746,7 +800,12 @@ impl PageView {...@@ -746,7 +800,12 @@ impl PageView {
746 caret: if self.focused800 caret: if self.focused
747 && !matches!(801 && !matches!(
748 self.drag,802 self.drag,
749 Some(Drag::Outline { .. } | Drag::Resize { .. } | Drag::Image { .. })803 Some(
804 Drag::Outline { .. }
805 | Drag::Resize { .. }
806 | Drag::Image { .. }
807 | Drag::Space { .. }
808 )
750 ) {809 ) {
751 self.caret810 self.caret
752 } else {811 } else {
...@@ -760,11 +819,36 @@ impl PageView {...@@ -760,11 +819,36 @@ impl PageView {
760 chrome: !self.touch || self.focused,819 chrome: !self.touch || self.focused,
761 found: &self.found,820 found: &self.found,
762 },821 },
763 )822 )?);
823 if let Some(axis) = self
824 .space_axis()
825 .filter(|_| self.pointer_inside || self.drag.is_some())
826 {
827 let point = self.viewport.document_point(self.pointer)[axis];
828 let (line, to, moved) = match self.drag {
829 Some(Drag::Space { line, .. }) => {
830 let delta = (point - line).max(self.editor.space_limit(axis, line));
831 (line, line + delta, self.editor.space_preview(axis, line))
832 }
833 _ => (point, point, Vec::new()),
834 };
835 space::append(
836 axis,
837 [line, to],
838 &moved,
839 [left, top, right, bottom],
840 self.pixel(),
841 &mut primitives,
842 );
843 }
844 Ok(primitives)
764 }845 }
765846
766 /// The pointer shape at the pointer's position.847 /// The pointer shape at the pointer's position.
767 pub fn cursor(&self) -> Cursor {848 pub fn cursor(&self) -> Cursor {
849 if let Some(axis) = self.space_axis() {
850 return [Cursor::ColResize, Cursor::RowResize][axis];
851 }
768 let hit = self.hit_test(self.viewport.document_point(self.pointer));852 let hit = self.hit_test(self.viewport.document_point(self.pointer));
769 match (&self.drag, hit) {853 match (&self.drag, hit) {
770 (Some(Drag::Image { handle, .. }), _) => handle_cursor(*handle),854 (Some(Drag::Image { handle, .. }), _) => handle_cursor(*handle),
...@@ -816,6 +900,7 @@ impl PageView {...@@ -816,6 +900,7 @@ impl PageView {
816 self.focused = focused;900 self.focused = focused;
817 if !focused {901 if !focused {
818 self.drag = None;902 self.drag = None;
903 self.space = false;
819 }904 }
820 self.changed()905 self.changed()
821 }906 }
...@@ -864,6 +949,7 @@ impl PageView {...@@ -864,6 +949,7 @@ impl PageView {
864 }949 }
865 self.changed()950 self.changed()
866 }951 }
952 Some(Drag::Space { .. }) => Ok(Response::redraw()),
867 Some(Drag::Resize { outline, grab }) => {953 Some(Drag::Resize { outline, grab }) => {
868 let point = self.viewport.document_point(self.pointer);954 let point = self.viewport.document_point(self.pointer);
869 let width = (point[0] - self.editor.active_outline().origin()[0] - *grab).max(36.0);955 let width = (point[0] - self.editor.active_outline().origin()[0] - *grab).max(36.0);
...@@ -886,6 +972,15 @@ impl PageView {...@@ -886,6 +972,15 @@ impl PageView {
886 /// A primary-button press at the pointer's position, at `now` for click counting.972 /// A primary-button press at the pointer's position, at `now` for click counting.
887 pub fn pointer_pressed(&mut self, now: Instant) -> Result<Response> {973 pub fn pointer_pressed(&mut self, now: Instant) -> Result<Response> {
888 let point = self.viewport.document_point(self.pointer);974 let point = self.viewport.document_point(self.pointer);
975 if let Some(axis) = self.space_axis().filter(|_| self.space) {
976 self.space = false;
977 self.set_object_focus(None);
978 self.drag = Some(Drag::Space {
979 axis,
980 line: point[axis],
981 });
982 return Ok(Response::redraw());
983 }
889 let unit = self984 let unit = self
890 .clicks985 .clicks
891 .press(now, self.pointer, 4.0 * self.display_scale);986 .press(now, self.pointer, 4.0 * self.display_scale);
...@@ -987,6 +1082,13 @@ impl PageView {...@@ -987,6 +1082,13 @@ impl PageView {
987 }1082 }
9881083
989 pub fn pointer_released(&mut self) -> Result<Response> {1084 pub fn pointer_released(&mut self) -> Result<Response> {
1085 if let Some(Drag::Space { axis, line }) = self.drag {
1086 self.drag = None;
1087 let delta = self.viewport.document_point(self.pointer)[axis] - line;
1088 self.editor
1089 .insert_space(&mut self.engine, axis, line, delta)?;
1090 return self.changed();
1091 }
990 // As in OneNote, an outline or picture dropped outside the view stays where it was.1092 // As in OneNote, an outline or picture dropped outside the view stays where it was.
991 let inside =1093 let inside =
992 (0..2).all(|axis| (0.0..self.viewport.size[axis] as f32).contains(&self.pointer[axis]));1094 (0..2).all(|axis| (0.0..self.viewport.size[axis] as f32).contains(&self.pointer[axis]));
...@@ -1302,6 +1404,18 @@ impl PageView {...@@ -1302,6 +1404,18 @@ impl PageView {
1302 self.changed()1404 self.changed()
1303 }1405 }
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
1305 /// Gives keyboard focus to a read-only object, as an assistive technology asks.1419 /// Gives keyboard focus to a read-only object, as an assistive technology asks.
1306 pub fn focus_read_only(&mut self, index: usize) -> Result<Response> {1420 pub fn focus_read_only(&mut self, index: usize) -> Result<Response> {
1307 self.set_object_focus(Some(ObjectFocus::ReadOnly(index)));1421 self.set_object_focus(Some(ObjectFocus::ReadOnly(index)));
...@@ -1355,9 +1469,16 @@ impl PageView {...@@ -1355,9 +1469,16 @@ impl PageView {
1355 }1469 }
1356 if matches!(1470 if matches!(
1357 self.drag,1471 self.drag,
1358 Some(Drag::Outline { .. } | Drag::Resize { .. } | Drag::Image { .. })1472 Some(
1359 ) {1473 Drag::Outline { .. }
1474 | Drag::Resize { .. }
1475 | Drag::Image { .. }
1476 | Drag::Space { .. }
1477 )
1478 ) || self.space
1479 {
1360 self.drag = None;1480 self.drag = None;
1481 self.space = false;
1361 if key == &Key::Named(NamedKey::Escape) {1482 if key == &Key::Named(NamedKey::Escape) {
1362 return self.changed();1483 return self.changed();
1363 }1484 }
...@@ -1712,6 +1833,54 @@ fn page_hit(...@@ -1712,6 +1833,54 @@ fn page_hit(
1712 .find_map(pass)1833 .find_map(pass)
1713}1834}
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
1715fn page_primitives<'a>(1884fn page_primitives<'a>(
1716 editor: &'a CanvasEditor,1885 editor: &'a CanvasEditor,
1717 scene: Option<&'a (PageScene, [f32; 2])>,1886 scene: Option<&'a (PageScene, [f32; 2])>,
crates/canvas/src/interaction/space.rs created+94
...@@ -0,0 +1,94 @@
1//! Insert Space's feedback, as OneNote 2010 draws it: lines across the view where the drag
2//! began and where it is, an arrow between them in the middle of the view, and a tint where
3//! what moves lands.
4
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()...@@ -52,6 +52,7 @@ fn date_buttons_keep_accessibility_identity_and_match_mouse_hits_after_reflow()
52 created: Some(1),52 created: Some(1),
53 margin_origin: [36.0, 14.4],53 margin_origin: [36.0, 14.4],
54 color: None,54 color: None,
55 rule_lines: None,
55 definitions: Default::default(),56 definitions: Default::default(),
56 objects: vec![onestore::page::PageObject::Title(onestore::page::Title {57 objects: vec![onestore::page::PageObject::Title(onestore::page::Title {
57 id: onestore::ExGuid::default(),58 id: onestore::ExGuid::default(),
...@@ -584,6 +585,7 @@ fn extension_hits_yield_to_objects_and_keep_their_source_coordinate_frame() {...@@ -584,6 +585,7 @@ fn extension_hits_yield_to_objects_and_keep_their_source_coordinate_frame() {
584 created: None,585 created: None,
585 margin_origin: [0.0; 2],586 margin_origin: [0.0; 2],
586 color: None,587 color: None,
588 rule_lines: None,
587 definitions: Default::default(),589 definitions: Default::default(),
588 objects: vec![onestore::page::PageObject::Outline(source)],590 objects: vec![onestore::page::PageObject::Outline(source)],
589 };591 };
...@@ -707,6 +709,7 @@ fn a_picture_in_an_outline_takes_the_click_over_its_text() {...@@ -707,6 +709,7 @@ fn a_picture_in_an_outline_takes_the_click_over_its_text() {
707 created: None,709 created: None,
708 margin_origin: [36.0, 14.4],710 margin_origin: [36.0, 14.4],
709 color: None,711 color: None,
712 rule_lines: None,
710 definitions: Default::default(),713 definitions: Default::default(),
711 objects: vec![PageObject::Outline(source)],714 objects: vec![PageObject::Outline(source)],
712 },715 },
...@@ -1219,6 +1222,7 @@ fn overlapping_objects_follow_paint_order_through_creation_movement_and_undo() {...@@ -1219,6 +1222,7 @@ fn overlapping_objects_follow_paint_order_through_creation_movement_and_undo() {
1219 title: String::new(),1222 title: String::new(),
1220 margin_origin: [0.0; 2],1223 margin_origin: [0.0; 2],
1221 color: None,1224 color: None,
1225 rule_lines: None,
1222 definitions: Default::default(),1226 definitions: Default::default(),
1223 objects,1227 objects,
1224 };1228 };
...@@ -1416,6 +1420,7 @@ fn read_only_focus_retires_text_overlays_and_draws_a_scaled_focus_border() {...@@ -1416,6 +1420,7 @@ fn read_only_focus_retires_text_overlays_and_draws_a_scaled_focus_border() {
1416 title: String::new(),1420 title: String::new(),
1417 margin_origin: [0.0; 2],1421 margin_origin: [0.0; 2],
1418 color: None,1422 color: None,
1423 rule_lines: None,
1419 definitions: Default::default(),1424 definitions: Default::default(),
1420 objects: vec![PageObject::Unsupported(Unsupported {1425 objects: vec![PageObject::Unsupported(Unsupported {
1421 id: Default::default(),1426 id: Default::default(),
...@@ -1531,6 +1536,7 @@ fn picture_view() -> (PageView, onestore::ExGuid) {...@@ -1531,6 +1536,7 @@ fn picture_view() -> (PageView, onestore::ExGuid) {
1531 created: None,1536 created: None,
1532 margin_origin: [36.0, 14.4],1537 margin_origin: [36.0, 14.4],
1533 color: None,1538 color: None,
1539 rule_lines: None,
1534 definitions: Default::default(),1540 definitions: Default::default(),
1535 objects: vec![PageObject::Outline(source)],1541 objects: vec![PageObject::Outline(source)],
1536 },1542 },
...@@ -1568,6 +1574,7 @@ fn a_page_outline_emptied_by_backspace_still_draws() {...@@ -1568,6 +1574,7 @@ fn a_page_outline_emptied_by_backspace_still_draws() {
1568 created: None,1574 created: None,
1569 margin_origin: [36.0, 14.4],1575 margin_origin: [36.0, 14.4],
1570 color: None,1576 color: None,
1577 rule_lines: None,
1571 definitions: Default::default(),1578 definitions: Default::default(),
1572 objects,1579 objects,
1573 };1580 };
...@@ -2018,6 +2025,7 @@ fn page_keys_scroll_or_carry_the_caret_as_the_platform_does() {...@@ -2018,6 +2025,7 @@ fn page_keys_scroll_or_carry_the_caret_as_the_platform_does() {
2018 created: None,2025 created: None,
2019 margin_origin: [36.0, 14.4],2026 margin_origin: [36.0, 14.4],
2020 color: None,2027 color: None,
2028 rule_lines: None,
2021 definitions: Default::default(),2029 definitions: Default::default(),
2022 objects: vec![onestore::page::PageObject::Outline(outline.snapshot())],2030 objects: vec![onestore::page::PageObject::Outline(outline.snapshot())],
2023 },2031 },
...@@ -2109,6 +2117,7 @@ fn page_selection_view() -> (PageView, [onestore::ExGuid; 4]) {...@@ -2109,6 +2117,7 @@ fn page_selection_view() -> (PageView, [onestore::ExGuid; 4]) {
2109 created: None,2117 created: None,
2110 margin_origin: [36.0, 14.4],2118 margin_origin: [36.0, 14.4],
2111 color: None,2119 color: None,
2120 rule_lines: None,
2112 definitions: Default::default(),2121 definitions: Default::default(),
2113 objects: std::iter::once(onestore::page::PageObject::Title(onestore::page::Title {2122 objects: std::iter::once(onestore::page::PageObject::Title(onestore::page::Title {
2114 id: onestore::page::text::new_id().unwrap(),2123 id: onestore::page::text::new_id().unwrap(),
...@@ -2425,3 +2434,194 @@ fn the_page_selection_does_not_return_with_its_text_selection() {...@@ -2425,3 +2434,194 @@ fn the_page_selection_does_not_return_with_its_text_selection() {
2425 view.editor.select(whole).unwrap();2434 view.editor.select(whole).unwrap();
2426 assert_eq!(view.editor.whole(), None);2435 assert_eq!(view.editor.whole(), None);
2427}2436}
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 {...@@ -213,6 +213,8 @@ pub struct ParagraphLayout {
213pub enum TagIcon {213pub enum TagIcon {
214 CheckBox {214 CheckBox {
215 checked: bool,215 checked: bool,
216 /// The badge on the small box of OneNote's check box variants.
217 mark: Option<BoxMark>,
216 },218 },
217 Star,219 Star,
218 Question,220 Question,
...@@ -227,10 +229,28 @@ pub enum TagIcon {...@@ -227,10 +229,28 @@ pub enum TagIcon {
227 BlueSquare,229 BlueSquare,
228 /// An Outlook task flag.230 /// An Outlook task flag.
229 Flag,231 Flag,
232 Idea,
233 Password,
234 Movie,
235 Book,
236 Web,
237 Blog,
238 Email,
230 /// A tag symbol without artwork of its own.239 /// A tag symbol without artwork of its own.
231 Other,240 Other,
232}241}
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
234impl TagIcon {254impl TagIcon {
235 /// How the page draws tag symbol `shape`, `checked` where it is a check box; none for255 /// How the page draws tag symbol `shape`, `checked` where it is a check box; none for
236 /// shape 0, which marks the text instead.256 /// shape 0, which marks the text instead.
...@@ -238,7 +258,21 @@ impl TagIcon {...@@ -238,7 +258,21 @@ impl TagIcon {
238 Some(match shape {258 Some(match shape {
239 0 => return None,259 0 => return None,
240 // OneNote's check boxes, plain and with a person, arrow, number or bulb.260 // OneNote's check boxes, plain and with a person, arrow, number or bulb.
241 3 | 8 | 12 | 28 | 71 | 94 | 95 => Self::CheckBox { checked },261 3 => Self::CheckBox {
262 checked,
263 mark: None,
264 },
265 8 | 12 | 28 | 71 | 94 | 95 => Self::CheckBox {
266 checked,
267 mark: Some(match shape {
268 8 => BoxMark::Client,
269 12 => BoxMark::Arrow,
270 28 => BoxMark::One,
271 71 => BoxMark::Two,
272 94 => BoxMark::Person,
273 _ => BoxMark::Manager,
274 }),
275 },
242 13 => Self::Star,276 13 => Self::Star,
243 15 => Self::Question,277 15 => Self::Question,
244 17 => Self::Exclamation,278 17 => Self::Exclamation,
...@@ -250,6 +284,13 @@ impl TagIcon {...@@ -250,6 +284,13 @@ impl TagIcon {
250 101 => Self::YellowSquare,284 101 => Self::YellowSquare,
251 102 => Self::BlueSquare,285 102 => Self::BlueSquare,
252 121 => Self::Music,286 121 => Self::Music,
287 21 => Self::Idea,
288 131 => Self::Password,
289 122 => Self::Movie,
290 132 => Self::Book,
291 125 => Self::Web,
292 24 => Self::Blog,
293 106 => Self::Email,
253 _ => Self::Other,294 _ => Self::Other,
254 })295 })
255 }296 }
...@@ -1214,6 +1255,19 @@ impl OutlineLayout {...@@ -1214,6 +1255,19 @@ impl OutlineLayout {
1214 .flatten()1255 .flatten()
1215 }1256 }
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
1217 /// The paragraphs, tables and objects of root nodes `range`.1271 /// The paragraphs, tables and objects of root nodes `range`.
1218 pub(crate) fn pieces(&self, range: Range<usize>) -> [Range<usize>; 3] {1272 pub(crate) fn pieces(&self, range: Range<usize>) -> [Range<usize>; 3] {
1219 let first = |root: usize| {1273 let first = |root: usize| {
...@@ -2039,7 +2093,13 @@ mod tests {...@@ -2039,7 +2093,13 @@ mod tests {
2039 extra_set: 0,2093 extra_set: 0,
2040 });2094 });
2041 for (shape, expected) in [2095 for (shape, expected) in [
2042 (3, TagIcon::CheckBox { checked: true }),2096 (
2097 3,
2098 TagIcon::CheckBox {
2099 checked: true,
2100 mark: None,
2101 },
2102 ),
2043 (13, TagIcon::Star),2103 (13, TagIcon::Star),
2044 (15, TagIcon::Question),2104 (15, TagIcon::Question),
2045 (17, TagIcon::Exclamation),2105 (17, TagIcon::Exclamation),
crates/canvas/src/search.rs+116-13
...@@ -6,6 +6,7 @@ use crate::document::TextPosition;...@@ -6,6 +6,7 @@ use crate::document::TextPosition;
6use crate::editor::{CanvasEditor, Selection};6use crate::editor::{CanvasEditor, Selection};
7use icu_normalizer::DecomposingNormalizerBorrowed;7use icu_normalizer::DecomposingNormalizerBorrowed;
8use onestore::ExGuid;8use onestore::ExGuid;
9use onestore::document::Kind;
9use onestore::page::{Page, PageObject, PageParagraph, ParagraphContent, text::Paragraph};10use onestore::page::{Page, PageObject, PageParagraph, ParagraphContent, text::Paragraph};
10use std::ops::Range;11use std::ops::Range;
1112
...@@ -176,32 +177,96 @@ pub fn shown(paragraph: &Paragraph) -> String {...@@ -176,32 +177,96 @@ pub fn shown(paragraph: &Paragraph) -> String {
176 .collect()177 .collect()
177}178}
178179
179/// A page's text outside its title, a paragraph to a line: outlines in page order, tables180/// Calls `visit` with each text paragraph of the page's outlines, in page order, tables
180/// cell by cell.181/// cell by cell.
181pub fn page_text(page: &Page) -> String {182fn text_paragraphs<'a>(page: &'a Page, mut visit: impl FnMut(&'a PageParagraph, &'a Paragraph)) {
182 fn walk(paragraphs: &[PageParagraph], out: &mut Vec<String>) {183 fn walk<'a>(
184 paragraphs: &'a [PageParagraph],
185 visit: &mut impl FnMut(&'a PageParagraph, &'a Paragraph),
186 ) {
183 for paragraph in paragraphs {187 for paragraph in paragraphs {
184 match &paragraph.content {188 match &paragraph.content {
185 ParagraphContent::Text(text) => out.push(shown(&text.text)),189 ParagraphContent::Text(text) => visit(paragraph, &text.text),
186 ParagraphContent::Table(table) => {190 ParagraphContent::Table(table) => {
187 for cell in table.rows.iter().flat_map(|row| &row.cells) {191 for cell in table.rows.iter().flat_map(|row| &row.cells) {
188 walk(&cell.paragraphs, out);192 walk(&cell.paragraphs, visit);
189 }193 }
190 }194 }
191 _ => {}195 _ => {}
192 }196 }
193 }197 }
194 }198 }
195 let mut out = Vec::new();
196 for object in &page.objects {199 for object in &page.objects {
197 if let PageObject::Outline(outline) = object {200 if let PageObject::Outline(outline) = object {
198 walk(&outline.paragraphs, &mut out);201 walk(&outline.paragraphs, &mut visit);
199 }202 }
200 }203 }
204}
205
206/// A page's text outside its title, a paragraph to a line.
207pub fn page_text(page: &Page) -> String {
208 let mut out = Vec::new();
209 text_paragraphs(page, |_, text| out.push(shown(text)));
201 out.retain(|line| !line.trim().is_empty());210 out.retain(|line| !line.trim().is_empty());
202 out.join("\n")211 out.join("\n")
203}212}
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
205/// A page as search knows it.270/// A page as search knows it.
206pub struct Entry {271pub struct Entry {
207 /// The section's key, which the host chooses.272 /// The section's key, which the host chooses.
...@@ -213,6 +278,7 @@ pub struct Entry {...@@ -213,6 +278,7 @@ pub struct Entry {
213 text: String,278 text: String,
214 folded_title: String,279 folded_title: String,
215 folded_text: String,280 folded_text: String,
281 tagged: Vec<Tagged>,
216}282}
217283
218impl Entry {284impl Entry {
...@@ -227,6 +293,7 @@ impl Entry {...@@ -227,6 +293,7 @@ impl Entry {
227 title: page.title.clone(),293 title: page.title.clone(),
228 modified,294 modified,
229 text,295 text,
296 tagged: tagged(section, space, page, modified),
230 }297 }
231 }298 }
232}299}
...@@ -237,6 +304,9 @@ pub struct Found {...@@ -237,6 +304,9 @@ pub struct Found {
237 pub section: String,304 pub section: String,
238 pub space: ExGuid,305 pub space: ExGuid,
239 pub title: String,306 pub title: String,
307 pub modified: u64,
308 /// The page's place in the index, which keeps each section's pages in order.
309 pub order: usize,
240 /// Every word is in the title, as OneNote's "Title contains" lists it.310 /// Every word is in the title, as OneNote's "Title contains" lists it.
241 pub in_title: bool,311 pub in_title: bool,
242 /// Byte ranges of the title the words match.312 /// Byte ranges of the title the words match.
...@@ -261,6 +331,11 @@ impl Index {...@@ -261,6 +331,11 @@ impl Index {
261 self.entries.is_empty()331 self.entries.is_empty()
262 }332 }
263333
334 /// Every page, each section's in order.
335 pub fn entries(&self) -> &[Entry] {
336 &self.entries
337 }
338
264 /// Adds `entry`, replacing the page it names.339 /// Adds `entry`, replacing the page it names.
265 pub fn set(&mut self, entry: Entry) {340 pub fn set(&mut self, entry: Entry) {
266 match self341 match self
...@@ -291,29 +366,32 @@ impl Index {...@@ -291,29 +366,32 @@ impl Index {
291 if query.is_empty() {366 if query.is_empty() {
292 return Vec::new();367 return Vec::new();
293 }368 }
294 let mut found: Vec<(&Entry, bool)> = self369 let mut found: Vec<(usize, &Entry, bool)> = self
295 .entries370 .entries
296 .iter()371 .iter()
297 .filter(|entry| scope(&entry.section))372 .enumerate()
298 .filter_map(|entry| {373 .filter(|(_, entry)| scope(&entry.section))
374 .filter_map(|(order, entry)| {
299 let in_title = query.all_in(&entry.folded_title);375 let in_title = query.all_in(&entry.folded_title);
300 let matches = in_title376 let matches = in_title
301 || query.terms.iter().all(|term| {377 || query.terms.iter().all(|term| {
302 starts_word(term, &entry.folded_title)378 starts_word(term, &entry.folded_title)
303 || starts_word(term, &entry.folded_text)379 || starts_word(term, &entry.folded_text)
304 });380 });
305 matches.then_some((entry, in_title))381 matches.then_some((order, entry, in_title))
306 })382 })
307 .collect();383 .collect();
308 found.sort_by_key(|(entry, in_title)| (!in_title, std::cmp::Reverse(entry.modified)));384 found.sort_by_key(|(_, entry, in_title)| (!in_title, std::cmp::Reverse(entry.modified)));
309 found385 found
310 .into_iter()386 .into_iter()
311 .map(|(entry, in_title)| {387 .map(|(order, entry, in_title)| {
312 let (snippet, snippet_hits) = snippet(entry, query);388 let (snippet, snippet_hits) = snippet(entry, query);
313 Found {389 Found {
314 section: entry.section.clone(),390 section: entry.section.clone(),
315 space: entry.space,391 space: entry.space,
316 title: entry.title.clone(),392 title: entry.title.clone(),
393 modified: entry.modified,
394 order,
317 in_title,395 in_title,
318 title_hits: query.find(&entry.title),396 title_hits: query.find(&entry.title),
319 snippet,397 snippet,
...@@ -322,6 +400,16 @@ impl Index {...@@ -322,6 +400,16 @@ impl Index {
322 })400 })
323 .collect()401 .collect()
324 }402 }
403
404 /// The tagged paragraphs of the pages whose section and page `scope` accepts, in page
405 /// order.
406 pub fn tagged(&self, scope: impl Fn(&Entry) -> bool) -> Vec<Tagged> {
407 self.entries
408 .iter()
409 .filter(|entry| scope(entry))
410 .flat_map(|entry| entry.tagged.iter().cloned())
411 .collect()
412 }
325}413}
326414
327/// Part of the line holding the first match in the entry's text, the matches within it;415/// Part of the line holding the first match in the entry's text, the matches within it;
...@@ -428,3 +516,18 @@ pub fn page_matches(editor: &CanvasEditor, query: &Query) -> Vec<PageMatch> {...@@ -428,3 +516,18 @@ pub fn page_matches(editor: &CanvasEditor, query: &Query) -> Vec<PageMatch> {
428 }516 }
429 matches517 matches
430}518}
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 @@...@@ -1,6 +1,8 @@
1//! The page templates Snowbound offers: OneNote 2010's background art, recreated1//! The page templates Snowbound offers: OneNote 2010's background art, recreated
2//! (`gpu::page::template_picture`), placed where OneNote's templates place it, and plain2//! (`gpu::page::template_picture`), placed where OneNote's templates place it, plain
3//! page colours.3//! page colours and rule lines.
4
5use onestore::page::{RuleLines, VerticalRule};
46
5/// One background picture of a template: the recreated art's name, and the picture's7/// One background picture of a template: the recreated art's name, and the picture's
6/// position and size in points from the page origin as OneNote's template stores them.8/// position and size in points from the page origin as OneNote's template stores them.
...@@ -623,6 +625,42 @@ pub const PAGE_COLORS: &[(&str, u32)] = &[...@@ -623,6 +625,42 @@ pub const PAGE_COLORS: &[(&str, u32)] = &[
623 ("Teal", 0xf2f9d4),625 ("Teal", 0xf2f9d4),
624];626];
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
626/// The template named `name`.664/// The template named `name`.
627pub fn find(name: &str) -> Option<&'static Template> {665pub fn find(name: &str) -> Option<&'static Template> {
628 TEMPLATES.iter().find(|template| template.name == name)666 TEMPLATES.iter().find(|template| template.name == name)
crates/canvas/tests/ops_differential.rs+33-1
...@@ -95,11 +95,14 @@ enum Kind {...@@ -95,11 +95,14 @@ enum Kind {
95 PageType,95 PageType,
96 PageMove,96 PageMove,
97 PageBold,97 PageBold,
98 InsertSpace,
99 RemoveSpace,
100 InsertSpaceRight,
98 Undo,101 Undo,
99 Redo,102 Redo,
100}103}
101104
102const KINDS: [Kind; 52] = [105const KINDS: [Kind; 55] = [
103 Kind::Type,106 Kind::Type,
104 Kind::TypeEnd,107 Kind::TypeEnd,
105 Kind::Compose,108 Kind::Compose,
...@@ -150,6 +153,9 @@ const KINDS: [Kind; 52] = [...@@ -150,6 +153,9 @@ const KINDS: [Kind; 52] = [
150 Kind::PageType,153 Kind::PageType,
151 Kind::PageMove,154 Kind::PageMove,
152 Kind::PageBold,155 Kind::PageBold,
156 Kind::InsertSpace,
157 Kind::RemoveSpace,
158 Kind::InsertSpaceRight,
153 Kind::Undo,159 Kind::Undo,
154 Kind::Redo,160 Kind::Redo,
155];161];
...@@ -438,6 +444,24 @@ fn perform(...@@ -438,6 +444,24 @@ fn perform(
438 .format(engine, Formatting::Toggle(Toggle::Bold))444 .format(engine, Formatting::Toggle(Toggle::Bold))
439 .is_ok()445 .is_ok()
440 }446 }
447 Kind::InsertSpace | Kind::RemoveSpace => {
448 // A line at a paragraph's top parts the outline there, or moves all of it.
449 let outline = editor.active_outline();
450 let paragraphs = &outline.shaped().paragraphs;
451 let line = outline.origin()[1]
452 + paragraphs[random.below(paragraphs.len())].origin[1]
453 + [0.0, 0.5][random.below(2)];
454 let delta = if kind == Kind::InsertSpace {
455 36.0
456 } else {
457 -200.0
458 };
459 editor.insert_space(engine, 1, line, delta).unwrap_or(false)
460 }
461 Kind::InsertSpaceRight => {
462 let line = editor.active_outline().origin()[0] - random.below(40) as f32;
463 editor.insert_space(engine, 0, line, 45.0).unwrap_or(false)
464 }
441 Kind::Undo => editor.undo(engine).unwrap_or(false),465 Kind::Undo => editor.undo(engine).unwrap_or(false),
442 Kind::Redo => editor.redo(engine).unwrap_or(false),466 Kind::Redo => editor.redo(engine).unwrap_or(false),
443 }467 }
...@@ -812,4 +836,12 @@ fn editor_ops_store_the_editor_s_page() {...@@ -812,4 +836,12 @@ fn editor_ops_store_the_editor_s_page() {
812 println!("{report}");836 println!("{report}");
813 let differs = totals[4];837 let differs = totals[4];
814 assert_eq!(differs, 0, "{report}");838 assert_eq!(differs, 0, "{report}");
839 // Undoing stores what the edit it undoes stored, so storage never refuses it.
840 for kind in [Kind::Undo, Kind::Redo] {
841 assert_eq!(
842 per_kind.get(&kind).map_or(0, |counts| counts[1]),
843 0,
844 "{report}"
845 );
846 }
815}847}
crates/canvas/tests/search.rs+45-1
...@@ -5,7 +5,7 @@ use canvas::{...@@ -5,7 +5,7 @@ use canvas::{
5 document::TextDocument,5 document::TextDocument,
6 editor::CanvasEditor,6 editor::CanvasEditor,
7 layout::TextEngine,7 layout::TextEngine,
8 search::{Entry, Index, Query, fold, page_matches},8 search::{Entry, Index, Query, fold, page_matches, paragraph_match},
9};9};
10use onestore::{10use onestore::{
11 ExGuid, RevisionIndex, Store,11 ExGuid, RevisionIndex, Store,
...@@ -214,3 +214,47 @@ fn words_match_word_starts_and_pages_rank_by_title_then_recency() {...@@ -214,3 +214,47 @@ fn words_match_word_starts_and_pages_rank_by_title_then_recency() {
214 assert_eq!(order(&index, "cafe"), ["Café list"]);214 assert_eq!(order(&index, "cafe"), ["Café list"]);
215 assert_eq!(index.len(), 2);215 assert_eq!(index.len(), 2);
216}216}
217
218/// OneNote's tag gallery (`corpus/structural-probe`): a paragraph under each default tag,
219/// listed once for each tag with its stored name, and selected whole from the summary.
220#[test]
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;...@@ -8,12 +8,16 @@ attribute vec4 color;
8attribute vec2 local;8attribute vec2 local;
9attribute vec4 shape;9attribute vec4 shape;
10attribute float stroke;10attribute float stroke;
11attribute vec2 clip_local;
12attribute vec3 clip;
11#endif13#endif
12varying vec2 v_uv;14varying vec2 v_uv;
13varying vec4 v_color;15varying vec4 v_color;
14varying vec2 v_local;16varying vec2 v_local;
15varying vec4 v_shape;17varying vec4 v_shape;
16varying float v_stroke;18varying float v_stroke;
19varying vec2 v_clip_local;
20varying vec3 v_clip;
1721
18#ifdef VERTEX22#ifdef VERTEX
19void main() {23void main() {
...@@ -22,6 +26,8 @@ void main() {...@@ -22,6 +26,8 @@ void main() {
22 v_local = local;26 v_local = local;
23 v_shape = shape;27 v_shape = shape;
24 v_stroke = stroke;28 v_stroke = stroke;
29 v_clip_local = clip_local;
30 v_clip = clip;
25 gl_Position = vec4(position, 0.0, 1.0);31 gl_Position = vec4(position, 0.0, 1.0);
26}32}
27#else33#else
...@@ -97,6 +103,11 @@ void main() {...@@ -97,6 +103,11 @@ void main() {
97 }103 }
98 color *= coverage;104 color *= coverage;
99 }105 }
106 if (v_clip.x > 0.0) {
107 vec2 q = abs(v_clip_local) - v_clip.xy + v_clip.z;
108 float distance = length(max(q, vec2(0.0))) + min(max(q.x, q.y), 0.0) - v_clip.z;
109 color *= clamp(0.5 - distance, 0.0, 1.0);
110 }
100 gl_FragColor = color;111 gl_FragColor = color;
101}112}
102#endif113#endif
crates/draw/src/draw.wgsl+10-2
...@@ -5,6 +5,8 @@ struct Vertex {...@@ -5,6 +5,8 @@ struct Vertex {
5 @location(2) local: vec2<f32>,5 @location(2) local: vec2<f32>,
6 @location(3) @interpolate(flat) shape: vec4<f32>,6 @location(3) @interpolate(flat) shape: vec4<f32>,
7 @location(4) @interpolate(flat) stroke: f32,7 @location(4) @interpolate(flat) stroke: f32,
8 @location(5) clip_local: vec2<f32>,
9 @location(6) @interpolate(flat) clip: vec3<f32>,
8}10}
911
10@group(0) @binding(0) var atlas: texture_2d<f32>;12@group(0) @binding(0) var atlas: texture_2d<f32>;
...@@ -13,8 +15,9 @@ struct Vertex {...@@ -13,8 +15,9 @@ struct Vertex {
13@vertex15@vertex
14fn vertex(@location(0) position: vec2<f32>, @location(1) uv: vec2<f32>,16fn vertex(@location(0) position: vec2<f32>, @location(1) uv: vec2<f32>,
15 @location(2) color: vec4<f32>, @location(3) local: vec2<f32>,17 @location(2) color: vec4<f32>, @location(3) local: vec2<f32>,
16 @location(4) shape: vec4<f32>, @location(5) stroke: f32) -> Vertex {18 @location(4) shape: vec4<f32>, @location(5) stroke: f32,
17 return Vertex(vec4<f32>(position, 0.0, 1.0), uv, color, local, shape, stroke);19 @location(6) clip_local: vec2<f32>, @location(7) clip: vec3<f32>) -> Vertex {
20 return Vertex(vec4<f32>(position, 0.0, 1.0), uv, color, local, shape, stroke, clip_local, clip);
18}21}
1922
20fn ellipse_arc(angle: f32, radius: vec2<f32>) -> f32 {23fn ellipse_arc(angle: f32, radius: vec2<f32>) -> f32 {
...@@ -83,5 +86,10 @@ fn fragment(input: Vertex) -> @location(0) vec4<f32> {...@@ -83,5 +86,10 @@ fn fragment(input: Vertex) -> @location(0) vec4<f32> {
83 }86 }
84 color *= coverage;87 color *= coverage;
85 }88 }
89 if input.clip.x > 0.0 {
90 let q = abs(input.clip_local) - input.clip.xy + input.clip.z;
91 let distance = length(max(q, vec2<f32>(0.0))) + min(max(q.x, q.y), 0.0) - input.clip.z;
92 color *= clamp(0.5 - distance, 0.0, 1.0);
93 }
86 return color;94 return color;
87}95}
crates/draw/src/render.rs+179-23
...@@ -170,6 +170,12 @@ struct Batch {...@@ -170,6 +170,12 @@ struct Batch {
170 scissor: [u32; 4],170 scissor: [u32; 4],
171}171}
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
173/// How a batch meets what lies beneath it.179/// How a batch meets what lies beneath it.
174#[derive(Clone, Copy, PartialEq)]180#[derive(Clone, Copy, PartialEq)]
175enum Blend {181enum Blend {
...@@ -189,6 +195,10 @@ struct Vertex {...@@ -189,6 +195,10 @@ struct Vertex {
189 local: [f32; 2],195 local: [f32; 2],
190 shape: [f32; 4],196 shape: [f32; 4],
191 stroke: f32,197 stroke: f32,
198 /// The position from the middle of the rounded rectangle the vertex paints within, and
199 /// that rectangle's half size and corner radius; a zero half size paints everywhere.
200 clip_local: [f32; 2],
201 clip: [f32; 3],
192}202}
193203
194#[derive(Hash, PartialEq, Eq)]204#[derive(Hash, PartialEq, Eq)]
...@@ -244,6 +254,10 @@ pub struct Layer<'a> {...@@ -244,6 +254,10 @@ pub struct Layer<'a> {
244 /// The colour behind the layer: on a dark one, text in dark colours of its own is254 /// The colour behind the layer: on a dark one, text in dark colours of its own is
245 /// lifted to stay legible against what lies behind it, as OneNote's dark page does.255 /// lifted to stay legible against what lies behind it, as OneNote's dark page does.
246 pub backdrop: Option<[f32; 4]>,256 pub backdrop: Option<[f32; 4]>,
257 /// Device bounds and corner radius of a rounded rectangle the layer paints only inside.
258 pub round: Option<([f32; 4], f32)>,
259 /// Consecutive layers with the same motion appear as one: painted together offscreen,
260 /// then faded and leaned back as a whole, so none shows through another.
247 pub motion: Option<Motion>,261 pub motion: Option<Motion>,
248 pub primitives: &'a [Primitive<'a>],262 pub primitives: &'a [Primitive<'a>],
249}263}
...@@ -260,28 +274,16 @@ pub struct Motion {...@@ -260,28 +274,16 @@ pub struct Motion {
260}274}
261275
262impl Motion {276impl Motion {
263 fn apply(&self, vertex: &mut Vertex, size: [u32; 2], blend: Blend) {277 /// Where device point `[x, y]` on a target `height` tall shows, turned.
264 match blend {278 fn project(&self, [x, y]: [f32; 2], height: f32) -> [f32; 2] {
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);
272 // Seen from twice the target's height away, so a small turn reads as depth.279 // Seen from twice the target's height away, so a small turn reads as depth.
273 let distance = 2.0 * height;280 let distance = 2.0 * height;
274 let [x, y] = [
275 (vertex.position[0] + 1.0) * width / 2.0,
276 (1.0 - vertex.position[1]) * height / 2.0,
277 ];
278 let below = y - self.pivot[1];281 let below = y - self.pivot[1];
279 let near = distance / (distance + below * self.tilt.sin());282 let near = distance / (distance + below * self.tilt.sin());
280 let [x, y] = [283 [
281 self.pivot[0] + (x - self.pivot[0]) * near,284 self.pivot[0] + (x - self.pivot[0]) * near,
282 self.pivot[1] + below * self.tilt.cos() * near,285 self.pivot[1] + below * self.tilt.cos() * near,
283 ];286 ]
284 vertex.position = [x * 2.0 / width - 1.0, 1.0 - y * 2.0 / height];
285 }287 }
286}288}
287289
...@@ -292,7 +294,7 @@ struct Space {...@@ -292,7 +294,7 @@ struct Space {
292 scale: f32,294 scale: f32,
293 origin: [f32; 2],295 origin: [f32; 2],
294 backdrop: Option<[f32; 4]>,296 backdrop: Option<[f32; 4]>,
295 motion: Option<Motion>,297 round: Option<([f32; 4], f32)>,
296}298}
297299
298impl Space {300impl Space {
...@@ -458,6 +460,9 @@ pub struct Renderer {...@@ -458,6 +460,9 @@ pub struct Renderer {
458 glyphs: HashMap<AtlasKey, Option<AtlasGlyph>>,460 glyphs: HashMap<AtlasKey, Option<AtlasGlyph>>,
459 images: HashMap<u64, CachedImage>,461 images: HashMap<u64, CachedImage>,
460 batches: Vec<Batch>,462 batches: Vec<Batch>,
463 groups: Vec<Group>,
464 /// Batches before this one take no more primitives.
465 barrier: usize,
461 scaler: ScaleContext,466 scaler: ScaleContext,
462 pen: [u32; 2],467 pen: [u32; 2],
463 row_height: u32,468 row_height: u32,
...@@ -479,6 +484,8 @@ impl Renderer {...@@ -479,6 +484,8 @@ impl Renderer {
479 glyphs: HashMap::new(),484 glyphs: HashMap::new(),
480 images: HashMap::new(),485 images: HashMap::new(),
481 batches: Vec::new(),486 batches: Vec::new(),
487 groups: Vec::new(),
488 barrier: 0,
482 scaler: ScaleContext::with_max_entries(32),489 scaler: ScaleContext::with_max_entries(32),
483 pen: [1, 0],490 pen: [1, 0],
484 row_height: 1,491 row_height: 1,
...@@ -557,7 +564,7 @@ impl Renderer {...@@ -557,7 +564,7 @@ impl Renderer {
557 scale: layer.scale,564 scale: layer.scale,
558 origin: layer.origin,565 origin: layer.origin,
559 backdrop: layer.backdrop,566 backdrop: layer.backdrop,
560 motion: layer.motion,567 round: layer.round,
561 };568 };
562 for primitive in layer.primitives {569 for primitive in layer.primitives {
563 if let Primitive::Image { image, rect } = primitive570 if let Primitive::Image { image, rect } = primitive
...@@ -574,6 +581,8 @@ impl Renderer {...@@ -574,6 +581,8 @@ impl Renderer {
574 for attempt in 0..2 {581 for attempt in 0..2 {
575 self.vertices.clear();582 self.vertices.clear();
576 self.batches.clear();583 self.batches.clear();
584 self.groups.clear();
585 self.barrier = 0;
577 match self.prepare(size, layers, &active_images) {586 match self.prepare(size, layers, &active_images) {
578 Err(RenderError::AtlasFull) if attempt == 0 => self.clear_glyph_cache(),587 Err(RenderError::AtlasFull) if attempt == 0 => self.clear_glyph_cache(),
579 result => {588 result => {
...@@ -582,7 +591,7 @@ impl Renderer {...@@ -582,7 +591,7 @@ impl Renderer {
582 }591 }
583 }592 }
584 }593 }
585 self.submit(target, clear);594 self.submit(target, size, clear);
586 Ok(())595 Ok(())
587 }596 }
588597
...@@ -592,13 +601,26 @@ impl Renderer {...@@ -592,13 +601,26 @@ impl Renderer {
592 layers: &[Layer<'_>],601 layers: &[Layer<'_>],
593 active_images: &HashSet<u64>,602 active_images: &HashSet<u64>,
594 ) -> Result<(), RenderError> {603 ) -> Result<(), RenderError> {
604 // The motion the layers painted since `start` share, where they paint as a group.
605 let mut group: Option<(Motion, usize)> = None;
595 for layer in layers {606 for layer in layers {
607 let motion = layer
608 .motion
609 .filter(|motion| motion.opacity < 1.0 || motion.tilt != 0.0);
610 if motion != group.map(|(motion, _)| motion) {
611 if let Some((motion, start)) = group {
612 self.group(motion, start, size)?;
613 }
614 // A group's batches never merge with those outside it.
615 self.barrier = self.batches.len();
616 group = motion.map(|motion| (motion, self.batches.len()));
617 }
596 let space = Space {618 let space = Space {
597 size,619 size,
598 scale: layer.scale,620 scale: layer.scale,
599 origin: layer.origin,621 origin: layer.origin,
600 backdrop: layer.backdrop,622 backdrop: layer.backdrop,
601 motion: layer.motion,623 round: layer.round,
602 };624 };
603 let bounds = match layer.clip {625 let bounds = match layer.clip {
604 Some(clip) => space.scissor(clip),626 Some(clip) => space.scissor(clip),
...@@ -611,6 +633,67 @@ impl Renderer {...@@ -611,6 +633,67 @@ impl Renderer {
611 self.primitive(space, bounds, primitive, active_images)?;633 self.primitive(space, bounds, primitive, active_images)?;
612 }634 }
613 }635 }
636 if let Some((motion, start)) = group {
637 self.group(motion, start, size)?;
638 }
639 Ok(())
640 }
641
642 /// Makes the batches from `start` a group appearing by `motion`: the vertices that
643 /// paint its offscreen picture over the target, in strips so the turn stays in
644 /// perspective across the picture.
645 fn group(&mut self, motion: Motion, start: usize, size: [u32; 2]) -> Result<(), RenderError> {
646 let batches = start..self.batches.len();
647 if batches.is_empty() {
648 return Ok(());
649 }
650 let [width, height] = size.map(|side| side as f32);
651 let [mut left, mut top, mut right, mut bottom] = [width, height, 0.0, 0.0];
652 let first = self.batches[start].vertices.start as usize;
653 for vertex in &self.vertices[first..] {
654 let [x, y] = [
655 (vertex.position[0] + 1.0) * width / 2.0,
656 (1.0 - vertex.position[1]) * height / 2.0,
657 ];
658 [left, top] = [left.min(x), top.min(y)];
659 [right, bottom] = [right.max(x), bottom.max(y)];
660 }
661 let [left, top] = [left.max(0.0).floor(), top.max(0.0).floor()];
662 let [right, bottom] = [right.min(width).ceil(), bottom.min(height).ceil()];
663 let strips = if motion.tilt == 0.0 { 1 } else { 32 };
664 if self.vertices.len() + 6 * strips > MAX_VERTICES {
665 return Err(RenderError::FrameTooLarge);
666 }
667 let from = self.vertices.len() as u32;
668 let corner = |x: f32, y: f32| {
669 let [shown_x, shown_y] = motion.project([x, y], height);
670 let v = y / height;
671 Vertex {
672 position: [shown_x * 2.0 / width - 1.0, 1.0 - shown_y * 2.0 / height],
673 uv: [x / width, if backend::FLIPPED { 1.0 - v } else { v }],
674 color: [motion.opacity; 4],
675 local: [0.0; 2],
676 shape: [0.0; 4],
677 stroke: 0.0,
678 clip_local: [0.0; 2],
679 clip: [0.0; 3],
680 }
681 };
682 for strip in 0..strips {
683 let [y0, y1] =
684 [strip, strip + 1].map(|edge| top + (bottom - top) * edge as f32 / strips as f32);
685 let [a, b, c, d] = [
686 corner(left, y0),
687 corner(left, y1),
688 corner(right, y1),
689 corner(right, y0),
690 ];
691 self.vertices.extend([a, b, c, a, c, d]);
692 }
693 self.groups.push(Group {
694 batches,
695 composite: from..self.vertices.len() as u32,
696 });
614 Ok(())697 Ok(())
615 }698 }
616699
...@@ -714,13 +797,21 @@ impl Renderer {...@@ -714,13 +797,21 @@ impl Renderer {
714 }797 }
715 }798 }
716 let end = self.vertices.len() as u32;799 let end = self.vertices.len() as u32;
717 if let Some(motion) = space.motion {800 if let Some((rect, radius)) = space.round {
801 let [width, height] = space.size.map(|side| side as f32);
802 let half = [(rect[2] - rect[0]) / 2.0, (rect[3] - rect[1]) / 2.0];
803 let middle = [rect[0] + half[0], rect[1] + half[1]];
718 for vertex in &mut self.vertices[start as usize..] {804 for vertex in &mut self.vertices[start as usize..] {
719 motion.apply(vertex, space.size, blend);805 vertex.clip_local = [
806 (vertex.position[0] + 1.0) * width / 2.0 - middle[0],
807 (1.0 - vertex.position[1]) * height / 2.0 - middle[1],
808 ];
809 vertex.clip = [half[0], half[1], radius.min(half[0]).min(half[1])];
720 }810 }
721 }811 }
722 if start != end {812 if start != end {
723 if let Some(last) = self.batches.last_mut()813 if self.batches.len() > self.barrier
814 && let Some(last) = self.batches.last_mut()
724 && last.blend == blend815 && last.blend == blend
725 && last.scissor == scissor816 && last.scissor == scissor
726 {817 {
...@@ -1232,6 +1323,8 @@ impl Renderer {...@@ -1232,6 +1323,8 @@ impl Renderer {
1232 local,1323 local,
1233 shape: [half[0], half[1], corner, corner],1324 shape: [half[0], half[1], corner, corner],
1234 stroke: 0.0,1325 stroke: 0.0,
1326 clip_local: [0.0; 2],
1327 clip: [0.0; 3],
1235 });1328 });
1236 }1329 }
1237 Ok(())1330 Ok(())
...@@ -1283,6 +1376,8 @@ impl Renderer {...@@ -1283,6 +1376,8 @@ impl Renderer {
1283 local,1376 local,
1284 shape: [0.0; 4],1377 shape: [0.0; 4],
1285 stroke: 0.0,1378 stroke: 0.0,
1379 clip_local: [0.0; 2],
1380 clip: [0.0; 3],
1286 });1381 });
1287 }1382 }
1288 Ok(())1383 Ok(())
...@@ -1715,11 +1810,66 @@ mod tests {...@@ -1715,11 +1810,66 @@ mod tests {
1715 origin: [24.0; 2],1810 origin: [24.0; 2],
1716 clip: None,1811 clip: None,
1717 backdrop: None,1812 backdrop: None,
1813 round: None,
1718 motion: None,1814 motion: None,
1719 primitives,1815 primitives,
1720 }]1816 }]
1721 }1817 }
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
1723 #[test]1873 #[test]
1724 #[ignore = "requires a native GPU adapter"]1874 #[ignore = "requires a native GPU adapter"]
1725 fn eviction_and_repaint_reproduce_pixels() {1875 fn eviction_and_repaint_reproduce_pixels() {
...@@ -2119,6 +2269,7 @@ mod tests {...@@ -2119,6 +2269,7 @@ mod tests {
2119 origin: [0.0; 2],2269 origin: [0.0; 2],
2120 clip: None,2270 clip: None,
2121 backdrop: None,2271 backdrop: None,
2272 round: None,
2122 motion: None,2273 motion: None,
2123 primitives: &primitives,2274 primitives: &primitives,
2124 }],2275 }],
...@@ -2191,6 +2342,7 @@ mod tests {...@@ -2191,6 +2342,7 @@ mod tests {
2191 origin: [0.0; 2],2342 origin: [0.0; 2],
2192 clip: None,2343 clip: None,
2193 backdrop: None,2344 backdrop: None,
2345 round: None,
2194 motion: None,2346 motion: None,
2195 primitives: &primitives,2347 primitives: &primitives,
2196 }],2348 }],
...@@ -2236,6 +2388,7 @@ mod tests {...@@ -2236,6 +2388,7 @@ mod tests {
2236 origin: [0.0; 2],2388 origin: [0.0; 2],
2237 clip: None,2389 clip: None,
2238 backdrop: None,2390 backdrop: None,
2391 round: None,
2239 motion: None,2392 motion: None,
2240 primitives: &primitives,2393 primitives: &primitives,
2241 }],2394 }],
...@@ -2288,6 +2441,7 @@ mod tests {...@@ -2288,6 +2441,7 @@ mod tests {
2288 origin: [0.0; 2],2441 origin: [0.0; 2],
2289 clip: Some([100.0, 50.0, 200.5, 80.0]),2442 clip: Some([100.0, 50.0, 200.5, 80.0]),
2290 backdrop: None,2443 backdrop: None,
2444 round: None,
2291 motion: None,2445 motion: None,
2292 primitives: &fill,2446 primitives: &fill,
2293 },2447 },
...@@ -2296,6 +2450,7 @@ mod tests {...@@ -2296,6 +2450,7 @@ mod tests {
2296 origin: [300.0, 10.0],2450 origin: [300.0, 10.0],
2297 clip: None,2451 clip: None,
2298 backdrop: None,2452 backdrop: None,
2453 round: None,
2299 motion: None,2454 motion: None,
2300 primitives: &chrome,2455 primitives: &chrome,
2301 },2456 },
...@@ -2324,6 +2479,7 @@ mod tests {...@@ -2324,6 +2479,7 @@ mod tests {
2324 origin: [0.0; 2],2479 origin: [0.0; 2],
2325 clip: None,2480 clip: None,
2326 backdrop: None,2481 backdrop: None,
2482 round: None,
2327 motion: None,2483 motion: None,
2328 primitives: &fill,2484 primitives: &fill,
2329 }]2485 }]
crates/draw/src/render/gl.rs+164-55
...@@ -38,6 +38,7 @@ const CLAMP_TO_EDGE: GLint = 0x812F;...@@ -38,6 +38,7 @@ const CLAMP_TO_EDGE: GLint = 0x812F;
38const TEXTURE0: GLenum = 0x84C0;38const TEXTURE0: GLenum = 0x84C0;
39const ARRAY_BUFFER: GLenum = 0x8892;39const ARRAY_BUFFER: GLenum = 0x8892;
40const STREAM_DRAW: GLenum = 0x88E0;40const STREAM_DRAW: GLenum = 0x88E0;
41const STATIC_DRAW: GLenum = 0x88E4;
41const FRAGMENT_SHADER: GLenum = 0x8B30;42const FRAGMENT_SHADER: GLenum = 0x8B30;
42const VERTEX_SHADER: GLenum = 0x8B31;43const VERTEX_SHADER: GLenum = 0x8B31;
43const COMPILE_STATUS: GLenum = 0x8B81;44const COMPILE_STATUS: GLenum = 0x8B81;
...@@ -103,6 +104,7 @@ unsafe extern "C" {...@@ -103,6 +104,7 @@ unsafe extern "C" {
103 fn glBindBuffer(target: GLenum, buffer: GLuint);104 fn glBindBuffer(target: GLenum, buffer: GLuint);
104 fn glBufferData(target: GLenum, size: isize, data: *const c_void, usage: GLenum);105 fn glBufferData(target: GLenum, size: isize, data: *const c_void, usage: GLenum);
105 fn glEnableVertexAttribArray(index: GLuint);106 fn glEnableVertexAttribArray(index: GLuint);
107 fn glDisableVertexAttribArray(index: GLuint);
106 fn glVertexAttribPointer(108 fn glVertexAttribPointer(
107 index: GLuint,109 index: GLuint,
108 size: GLint,110 size: GLint,
...@@ -132,6 +134,7 @@ unsafe extern "C" {...@@ -132,6 +134,7 @@ unsafe extern "C" {
132 fn glUseProgram(program: GLuint);134 fn glUseProgram(program: GLuint);
133 fn glGetUniformLocation(program: GLuint, name: *const c_char) -> GLint;135 fn glGetUniformLocation(program: GLuint, name: *const c_char) -> GLint;
134 fn glUniform1i(location: GLint, value: GLint);136 fn glUniform1i(location: GLint, value: GLint);
137 fn glUniform2f(location: GLint, x: f32, y: f32);
135138
136 fn glGenFramebuffersEXT(n: GLsizei, framebuffers: *mut GLuint);139 fn glGenFramebuffersEXT(n: GLsizei, framebuffers: *mut GLuint);
137 fn glDeleteFramebuffersEXT(n: GLsizei, framebuffers: *const GLuint);140 fn glDeleteFramebuffersEXT(n: GLsizei, framebuffers: *const GLuint);
...@@ -158,6 +161,9 @@ unsafe extern "C" {...@@ -158,6 +161,9 @@ unsafe extern "C" {
158 );161 );
159}162}
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
161/// An sRGB RGBA texture, deleted with its value.167/// An sRGB RGBA texture, deleted with its value.
162pub(super) struct Image {168pub(super) struct Image {
163 name: GLuint,169 name: GLuint,
...@@ -283,65 +289,96 @@ impl Drop for Target {...@@ -283,65 +289,96 @@ impl Drop for Target {
283 }289 }
284}290}
285291
292/// Whether an offscreen picture's rows run bottom first.
293pub(super) const FLIPPED: bool = true;
294
286pub(super) struct Gpu {295pub(super) struct Gpu {
287 program: GLuint,296 program: GLuint,
288 buffer: GLuint,297 buffer: GLuint,
298 /// `translucent.glsl`, and the triangle it covers a window with.
299 translucent: GLuint,
300 triangle: GLuint,
289 atlas: Image,301 atlas: Image,
290 max_texture: u32,302 max_texture: u32,
303 /// Offscreen pictures for groups, the target's size.
304 groups: Vec<Target>,
291}305}
292306
293/// Each attribute's name, component count and offset, bound to its index in the shader.307/// 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] = [
295 (c"position", 2, offset_of!(Vertex, position)),309 (c"position", 2, offset_of!(Vertex, position)),
296 (c"uv", 2, offset_of!(Vertex, uv)),310 (c"uv", 2, offset_of!(Vertex, uv)),
297 (c"color", 4, offset_of!(Vertex, color)),311 (c"color", 4, offset_of!(Vertex, color)),
298 (c"local", 2, offset_of!(Vertex, local)),312 (c"local", 2, offset_of!(Vertex, local)),
299 (c"shape", 4, offset_of!(Vertex, shape)),313 (c"shape", 4, offset_of!(Vertex, shape)),
300 (c"stroke", 1, offset_of!(Vertex, stroke)),314 (c"stroke", 1, offset_of!(Vertex, stroke)),
315 (c"clip_local", 2, offset_of!(Vertex, clip_local)),
316 (c"clip", 3, offset_of!(Vertex, clip)),
301];317];
302318
303impl Renderer {319impl Renderer {
304 /// Draws with the OpenGL context current on this thread, which must stay current320 /// Draws with the OpenGL context current on this thread, which must stay current
305 /// whenever the renderer or a `Target` is used.321 /// whenever the renderer or a `Target` is used.
306 pub fn new() -> Result<Self, String> {322 pub fn new() -> Result<Self, String> {
307 let source = include_str!("../draw.glsl");323 let names: Vec<_> = ATTRIBUTES.iter().map(|(name, _, _)| *name).collect();
308 let program = unsafe {324 let draw = unsafe { program(include_str!("../draw.glsl"), &names)? };
309 let vertex = compile(325 let translucent = unsafe { program(include_str!("translucent.glsl"), &[c"position"])? };
310 VERTEX_SHADER,326 let [mut buffer, mut triangle] = [0; 2];
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;
332 let mut max_texture = 0;327 let mut max_texture = 0;
333 unsafe {328 unsafe {
334 glGenBuffers(1, &mut buffer);329 glGenBuffers(1, &mut buffer);
330 glGenBuffers(1, &mut triangle);
331 glBindBuffer(ARRAY_BUFFER, triangle);
332 let corners: [f32; 6] = [-1.0, -1.0, 3.0, -1.0, -1.0, 3.0];
333 glBufferData(
334 ARRAY_BUFFER,
335 size_of_val(&corners) as isize,
336 corners.as_ptr().cast(),
337 STATIC_DRAW,
338 );
335 glGetIntegerv(MAX_TEXTURE_SIZE, &mut max_texture);339 glGetIntegerv(MAX_TEXTURE_SIZE, &mut max_texture);
336 }340 }
337 Ok(Self::with_gpu(Gpu {341 Ok(Self::with_gpu(Gpu {
338 program,342 program: draw,
339 buffer,343 buffer,
344 translucent,
345 triangle,
340 atlas: Image::new([ATLAS_SIZE; 2], NEAREST, None),346 atlas: Image::new([ATLAS_SIZE; 2], NEAREST, None),
341 max_texture: max_texture as u32,347 max_texture: max_texture as u32,
348 groups: Vec::new(),
342 }))349 }))
343 }350 }
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
345 /// The widest and tallest texture the driver takes, in pixels.382 /// The widest and tallest texture the driver takes, in pixels.
346 pub fn max_texture_dimension(&self) -> u32 {383 pub fn max_texture_dimension(&self) -> u32 {
347 self.gpu.max_texture384 self.gpu.max_texture
...@@ -368,17 +405,68 @@ impl Renderer {...@@ -368,17 +405,68 @@ impl Renderer {
368 Image::new(image.size, LINEAR, Some(image.pixels()))405 Image::new(image.size, LINEAR, Some(image.pixels()))
369 }406 }
370407
371 /// Clears `target` to linear `clear` and draws the prepared batches.408 /// Clears `target`, `size` device pixels, to linear `clear` and draws the prepared
372 pub(super) fn submit(&mut self, target: &Target, clear: [f32; 4]) {409 /// batches, each group's offscreen first.
373 let [width, height] = target.size().map(|side| side as GLsizei);410 pub(super) fn submit(&mut self, target: &Target, size: [u32; 2], clear: [f32; 4]) {
411 if self
412 .gpu
413 .groups
414 .first()
415 .is_some_and(|picture| picture.size() != size)
416 {
417 self.gpu.groups.clear();
418 }
419 while self.gpu.groups.len() < self.groups.len() {
420 match Target::new(size) {
421 Ok(picture) => unsafe {
422 // Filtered, so a picture leaning back stays smooth.
423 glBindTexture(TEXTURE_2D, picture.texture.name);
424 glTexParameteri(TEXTURE_2D, TEXTURE_MIN_FILTER, LINEAR);
425 glTexParameteri(TEXTURE_2D, TEXTURE_MAG_FILTER, LINEAR);
426 self.gpu.groups.push(picture);
427 },
428 Err(error) => {
429 eprintln!("{error}");
430 return;
431 }
432 }
433 }
434 let [width, height] = size.map(|side| side as GLsizei);
374 let gpu = &self.gpu;435 let gpu = &self.gpu;
375 unsafe {436 let begin = |target: &Target, clear: [f32; 4]| unsafe {
376 glBindFramebufferEXT(FRAMEBUFFER, target.framebuffer);437 glBindFramebufferEXT(FRAMEBUFFER, target.framebuffer);
377 glEnable(FRAMEBUFFER_SRGB);
378 glViewport(0, 0, width, height);
379 glDisable(SCISSOR_TEST);438 glDisable(SCISSOR_TEST);
380 glClearColor(clear[0], clear[1], clear[2], clear[3]);439 glClearColor(clear[0], clear[1], clear[2], clear[3]);
381 glClear(COLOR_BUFFER_BIT);440 glClear(COLOR_BUFFER_BIT);
441 glEnable(SCISSOR_TEST);
442 };
443 let draw = |batches: &[Batch]| unsafe {
444 for batch in batches {
445 let [x, y, w, h] = batch.scissor.map(|value| value as GLint);
446 glScissor(x, height - y - h, w, h);
447 let (texture, [src_rgb, dst_rgb, src_alpha, dst_alpha]) = match batch.blend {
448 Blend::Over => (
449 gpu.atlas.name,
450 [SRC_ALPHA, ONE_MINUS_SRC_ALPHA, ONE, ONE_MINUS_SRC_ALPHA],
451 ),
452 Blend::Image(id) => (self.images[&id].texture.name, PREMULTIPLIED),
453 Blend::Erase => (
454 gpu.atlas.name,
455 [ZERO, ONE_MINUS_SRC_ALPHA, ZERO, ONE_MINUS_SRC_ALPHA],
456 ),
457 };
458 glBlendFuncSeparate(src_rgb, dst_rgb, src_alpha, dst_alpha);
459 glBindTexture(TEXTURE_2D, texture);
460 glDrawArrays(
461 TRIANGLES,
462 batch.vertices.start as GLint,
463 batch.vertices.len() as GLsizei,
464 );
465 }
466 };
467 unsafe {
468 glEnable(FRAMEBUFFER_SRGB);
469 glViewport(0, 0, width, height);
382 glUseProgram(gpu.program);470 glUseProgram(gpu.program);
383 glUniform1i(glGetUniformLocation(gpu.program, c"atlas".as_ptr()), 0);471 glUniform1i(glGetUniformLocation(gpu.program, c"atlas".as_ptr()), 0);
384 glActiveTexture(TEXTURE0);472 glActiveTexture(TEXTURE0);
...@@ -401,32 +489,26 @@ impl Renderer {...@@ -401,32 +489,26 @@ impl Renderer {
401 );489 );
402 }490 }
403 glEnable(BLEND);491 glEnable(BLEND);
404 glEnable(SCISSOR_TEST);492 for (group, picture) in self.groups.iter().zip(&gpu.groups) {
405 for batch in &self.batches {493 begin(picture, [0.0; 4]);
406 let [x, y, w, h] = batch.scissor.map(|value| value as GLint);494 draw(&self.batches[group.batches.clone()]);
407 glScissor(x, height - y - h, w, h);495 }
408 let (texture, [src_rgb, dst_rgb, src_alpha, dst_alpha]) = match batch.blend {496 begin(target, clear);
409 Blend::Over => (497 let mut next = 0;
410 &gpu.atlas,498 for (group, picture) in self.groups.iter().zip(&gpu.groups) {
411 [SRC_ALPHA, ONE_MINUS_SRC_ALPHA, ONE, ONE_MINUS_SRC_ALPHA],499 draw(&self.batches[next..group.batches.start]);
412 ),500 glScissor(0, 0, width, height);
413 Blend::Image(id) => (501 let [src_rgb, dst_rgb, src_alpha, dst_alpha] = PREMULTIPLIED;
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 };
422 glBlendFuncSeparate(src_rgb, dst_rgb, src_alpha, dst_alpha);502 glBlendFuncSeparate(src_rgb, dst_rgb, src_alpha, dst_alpha);
423 glBindTexture(TEXTURE_2D, texture.name);503 glBindTexture(TEXTURE_2D, picture.texture.name);
424 glDrawArrays(504 glDrawArrays(
425 TRIANGLES,505 TRIANGLES,
426 batch.vertices.start as GLint,506 group.composite.start as GLint,
427 batch.vertices.len() as GLsizei,507 group.composite.len() as GLsizei,
428 );508 );
509 next = group.batches.end;
429 }510 }
511 draw(&self.batches[next..]);
430 glDisable(SCISSOR_TEST);512 glDisable(SCISSOR_TEST);
431 glDisable(BLEND);513 glDisable(BLEND);
432 glBindFramebufferEXT(FRAMEBUFFER, 0);514 glBindFramebufferEXT(FRAMEBUFFER, 0);
...@@ -434,6 +516,33 @@ impl Renderer {...@@ -434,6 +516,33 @@ impl Renderer {
434 }516 }
435}517}
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
437unsafe fn compile(kind: GLenum, source: &str) -> Result<GLuint, String> {546unsafe fn compile(kind: GLenum, source: &str) -> Result<GLuint, String> {
438 unsafe {547 unsafe {
439 let shader = glCreateShader(kind);548 let shader = glCreateShader(kind);
...@@ -444,7 +553,7 @@ unsafe fn compile(kind: GLenum, source: &str) -> Result<GLuint, String> {...@@ -444,7 +553,7 @@ unsafe fn compile(kind: GLenum, source: &str) -> Result<GLuint, String> {
444 glGetShaderiv(shader, COMPILE_STATUS, &mut compiled);553 glGetShaderiv(shader, COMPILE_STATUS, &mut compiled);
445 if compiled == 0 {554 if compiled == 0 {
446 return Err(format!(555 return Err(format!(
447 "Compiling the draw shader failed: {}",556 "Compiling a shader failed: {}",
448 log(shader, glGetShaderiv, glGetShaderInfoLog)557 log(shader, glGetShaderiv, glGetShaderInfoLog)
449 ));558 ));
450 }559 }
crates/draw/src/render/translucent.glsl created+22
...@@ -0,0 +1,22 @@
1// GLSL 1.20 twin of translucent.wgsl: the frame, blended in linear light, premultiplied
2// again in sRGB for a window server that composites that way.
3#ifdef VERTEX
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;...@@ -6,6 +6,9 @@ pub type Target = wgpu::TextureView;
66
7pub(super) type Image = wgpu::BindGroup;7pub(super) type Image = wgpu::BindGroup;
88
9/// Whether an offscreen picture's rows run bottom first.
10pub(super) const FLIPPED: bool = false;
11
9pub(super) struct Gpu {12pub(super) struct Gpu {
10 pipeline: wgpu::RenderPipeline,13 pipeline: wgpu::RenderPipeline,
11 image_pipeline: wgpu::RenderPipeline,14 image_pipeline: wgpu::RenderPipeline,
...@@ -14,6 +17,10 @@ pub(super) struct Gpu {...@@ -14,6 +17,10 @@ pub(super) struct Gpu {
14 bind_group: wgpu::BindGroup,17 bind_group: wgpu::BindGroup,
15 image_sampler: wgpu::Sampler,18 image_sampler: wgpu::Sampler,
16 vertex_buffer: wgpu::Buffer,19 vertex_buffer: wgpu::Buffer,
20 format: wgpu::TextureFormat,
21 /// Offscreen pictures for groups, the target's size, and how each is sampled.
22 groups: Vec<(wgpu::TextureView, wgpu::BindGroup)>,
23 groups_size: [u32; 2],
17}24}
1825
19impl Renderer {26impl Renderer {
...@@ -56,7 +63,7 @@ impl Renderer {...@@ -56,7 +63,7 @@ impl Renderer {
56 buffers: &[Some(wgpu::VertexBufferLayout {63 buffers: &[Some(wgpu::VertexBufferLayout {
57 array_stride: size_of::<Vertex>() as u64,64 array_stride: size_of::<Vertex>() as u64,
58 step_mode: wgpu::VertexStepMode::Vertex,65 step_mode: wgpu::VertexStepMode::Vertex,
59 attributes: &wgpu::vertex_attr_array![0 => Float32x2, 1 => Float32x2, 2 => Float32x4, 3 => Float32x2, 4 => Float32x4, 5 => Float32],66 attributes: &wgpu::vertex_attr_array![0 => Float32x2, 1 => Float32x2, 2 => Float32x4, 3 => Float32x2, 4 => Float32x4, 5 => Float32, 6 => Float32x2, 7 => Float32x3],
60 })],67 })],
61 },68 },
62 fragment: Some(wgpu::FragmentState {69 fragment: Some(wgpu::FragmentState {
...@@ -148,6 +155,9 @@ impl Renderer {...@@ -148,6 +155,9 @@ impl Renderer {
148 bind_group,155 bind_group,
149 image_sampler,156 image_sampler,
150 vertex_buffer,157 vertex_buffer,
158 format,
159 groups: Vec::new(),
160 groups_size: [0; 2],
151 },161 },
152 )162 )
153 }163 }
...@@ -225,18 +235,76 @@ impl Renderer {...@@ -225,18 +235,76 @@ impl Renderer {
225 })235 })
226 }236 }
227237
228 /// Clears `target` to linear `clear` and draws the prepared batches.238 /// An offscreen picture `size` large for each group, kept for later frames.
229 pub(super) fn submit(&mut self, target: &Target, clear: [f32; 4]) {239 fn group_pictures(&mut self, size: [u32; 2]) {
240 let gpu = &mut self.gpu;
241 if gpu.groups_size != size {
242 gpu.groups.clear();
243 gpu.groups_size = size;
244 }
245 while gpu.groups.len() < self.groups.len() {
246 let texture = self.device.create_texture(&wgpu::TextureDescriptor {
247 label: Some("Draw group"),
248 size: wgpu::Extent3d {
249 width: size[0],
250 height: size[1],
251 depth_or_array_layers: 1,
252 },
253 mip_level_count: 1,
254 sample_count: 1,
255 dimension: wgpu::TextureDimension::D2,
256 format: gpu.format,
257 usage: wgpu::TextureUsages::RENDER_ATTACHMENT
258 | wgpu::TextureUsages::TEXTURE_BINDING,
259 view_formats: &[],
260 });
261 let view = texture.create_view(&Default::default());
262 let binding = self.device.create_bind_group(&wgpu::BindGroupDescriptor {
263 label: Some("Draw group"),
264 layout: &gpu.pipeline.get_bind_group_layout(0),
265 entries: &[
266 wgpu::BindGroupEntry {
267 binding: 0,
268 resource: wgpu::BindingResource::TextureView(&view),
269 },
270 wgpu::BindGroupEntry {
271 binding: 1,
272 resource: wgpu::BindingResource::Sampler(&gpu.image_sampler),
273 },
274 ],
275 });
276 gpu.groups.push((view, binding));
277 }
278 }
279
280 /// Clears `target`, `size` device pixels, to linear `clear` and draws the prepared
281 /// batches, each group's offscreen first.
282 pub(super) fn submit(&mut self, target: &Target, size: [u32; 2], clear: [f32; 4]) {
283 self.group_pictures(size);
230 let gpu = &self.gpu;284 let gpu = &self.gpu;
231 self.queue285 self.queue
232 .write_buffer(&gpu.vertex_buffer, 0, bytemuck::cast_slice(&self.vertices));286 .write_buffer(&gpu.vertex_buffer, 0, bytemuck::cast_slice(&self.vertices));
233 let mut encoder = self.device.create_command_encoder(&Default::default());287 let mut encoder = self.device.create_command_encoder(&Default::default());
234 {288 let draw = |pass: &mut wgpu::RenderPass, batches: &[Batch]| {
289 for batch in batches {
290 let [x, y, width, height] = batch.scissor;
291 pass.set_scissor_rect(x, y, width, height);
292 let (pipeline, binding) = match batch.blend {
293 Blend::Over => (&gpu.pipeline, &gpu.bind_group),
294 Blend::Image(id) => (&gpu.image_pipeline, &self.images[&id].texture),
295 Blend::Erase => (&gpu.erase_pipeline, &gpu.bind_group),
296 };
297 pass.set_pipeline(pipeline);
298 pass.set_bind_group(0, binding, &[]);
299 pass.draw(batch.vertices.clone(), 0..1);
300 }
301 };
302 let begin = |encoder: &mut wgpu::CommandEncoder, view, clear: [f32; 4]| {
235 let [r, g, b, a] = clear.map(f64::from);303 let [r, g, b, a] = clear.map(f64::from);
236 let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {304 let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
237 label: Some("Draw"),305 label: Some("Draw"),
238 color_attachments: &[Some(wgpu::RenderPassColorAttachment {306 color_attachments: &[Some(wgpu::RenderPassColorAttachment {
239 view: target,307 view,
240 depth_slice: None,308 depth_slice: None,
241 resolve_target: None,309 resolve_target: None,
242 ops: wgpu::Operations {310 ops: wgpu::Operations {
...@@ -247,19 +315,24 @@ impl Renderer {...@@ -247,19 +315,24 @@ impl Renderer {
247 ..Default::default()315 ..Default::default()
248 });316 });
249 pass.set_vertex_buffer(0, gpu.vertex_buffer.slice(..));317 pass.set_vertex_buffer(0, gpu.vertex_buffer.slice(..));
250 for batch in &self.batches {318 pass.forget_lifetime()
251 let [x, y, width, height] = batch.scissor;319 };
252 pass.set_scissor_rect(x, y, width, height);320 for (group, (view, _)) in self.groups.iter().zip(&gpu.groups) {
253 let (pipeline, binding) = match batch.blend {321 let mut pass = begin(&mut encoder, view, [0.0; 4]);
254 Blend::Over => (&gpu.pipeline, &gpu.bind_group),322 draw(&mut pass, &self.batches[group.batches.clone()]);
255 Blend::Image(id) => (&gpu.image_pipeline, &self.images[&id].texture),323 }
256 Blend::Erase => (&gpu.erase_pipeline, &gpu.bind_group),324 let mut pass = begin(&mut encoder, target, clear);
257 };325 let mut next = 0;
258 pass.set_pipeline(pipeline);326 for (group, (_, binding)) in self.groups.iter().zip(&gpu.groups) {
259 pass.set_bind_group(0, binding, &[]);327 draw(&mut pass, &self.batches[next..group.batches.start]);
260 pass.draw(batch.vertices.clone(), 0..1);328 pass.set_scissor_rect(0, 0, size[0], size[1]);
261 }329 pass.set_pipeline(&gpu.image_pipeline);
330 pass.set_bind_group(0, binding, &[]);
331 pass.draw(group.composite.clone(), 0..1);
332 next = group.batches.end;
262 }333 }
334 draw(&mut pass, &self.batches[next..]);
335 drop(pass);
263 self.queue.submit([encoder.finish()]);336 self.queue.submit([encoder.finish()]);
264 }337 }
265}338}
crates/notebook/examples/keystroke_probe.rs+1
...@@ -136,6 +136,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {...@@ -136,6 +136,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
136 created: None,136 created: None,
137 margin_origin: [0.0; 2],137 margin_origin: [0.0; 2],
138 color: None,138 color: None,
139 rule_lines: None,
139 objects: vec![PageObject::Outline(Outline {140 objects: vec![PageObject::Outline(Outline {
140 id: new_id()?,141 id: new_id()?,
141 title: false,142 title: false,
crates/notebook/examples/open_probe.rs+1
...@@ -45,6 +45,7 @@ fn generated(count: usize) -> Result<Vec<u8>, Box<dyn std::error::Error>> {...@@ -45,6 +45,7 @@ fn generated(count: usize) -> Result<Vec<u8>, Box<dyn std::error::Error>> {
45 created: None,45 created: None,
46 margin_origin: [0.0; 2],46 margin_origin: [0.0; 2],
47 color: None,47 color: None,
48 rule_lines: None,
48 objects: vec![PageObject::Outline(Outline {49 objects: vec![PageObject::Outline(Outline {
49 id: new_id()?,50 id: new_id()?,
50 title: false,51 title: false,
crates/notebook/examples/save_probe.rs+1
...@@ -51,6 +51,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {...@@ -51,6 +51,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
51 created: None,51 created: None,
52 margin_origin: [0.0; 2],52 margin_origin: [0.0; 2],
53 color: None,53 color: None,
54 rule_lines: None,
54 objects: vec![PageObject::Outline(Outline {55 objects: vec![PageObject::Outline(Outline {
55 id: new_id()?,56 id: new_id()?,
56 title: false,57 title: false,
crates/notebook/src/merge.rs+10-6
...@@ -153,7 +153,7 @@ fn named(op: &PageOp) -> Vec<ExGuid> {...@@ -153,7 +153,7 @@ fn named(op: &PageOp) -> Vec<ExGuid> {
153 | PageOp::Equation { text, .. }153 | PageOp::Equation { text, .. }
154 | PageOp::Split { text, .. } => vec![*text],154 | PageOp::Split { text, .. } => vec![*text],
155 PageOp::Date { fields, .. } => fields.iter().map(|(text, _)| *text).collect(),155 PageOp::Date { fields, .. } => fields.iter().map(|(text, _)| *text).collect(),
156 PageOp::Color(_) => Vec::new(),156 PageOp::Color(_) | PageOp::RuleLines(_) => Vec::new(),
157 PageOp::Join { left, right } => vec![*left, *right],157 PageOp::Join { left, right } => vec![*left, *right],
158 PageOp::Insert { container, .. } => vec![*container],158 PageOp::Insert { container, .. } => vec![*container],
159 PageOp::Move { object, .. }159 PageOp::Move { object, .. }
...@@ -655,8 +655,11 @@ impl Diff {...@@ -655,8 +655,11 @@ impl Diff {
655 self.region(text)?;655 self.region(text)?;
656 }656 }
657 }657 }
658 // A page's colour is the latest one set, whichever side set it.658 // A page's colour and rule lines are the latest set, whichever side set them.
659 PageOp::Insert { .. } | PageOp::Add { .. } | PageOp::Color(_) => {}659 PageOp::Insert { .. }
660 | PageOp::Add { .. }
661 | PageOp::Color(_)
662 | PageOp::RuleLines(_) => {}
660 PageOp::Move {663 PageOp::Move {
661 object,664 object,
662 parent,665 parent,
...@@ -692,9 +695,10 @@ impl Diff {...@@ -692,9 +695,10 @@ impl Diff {
692 // What the edit sets, as each side has it.695 // What the edit sets, as each side has it.
693 let part = |index: &Index| {696 let part = |index: &Index| {
694 index.nodes.get(object).map(|node| match (&node.own, edit) {697 index.nodes.get(object).map(|node| match (&node.own, edit) {
695 (Own::Outline { layout, .. }, OutlineEdit::Position { .. }) => {698 (
696 (layout.x, layout.y, None, None)699 Own::Outline { layout, .. } | Own::Ink(Ink { layout, .. }),
697 }700 OutlineEdit::Position { .. },
701 ) => (layout.x, layout.y, None, None),
698 (Own::Outline { layout, .. }, OutlineEdit::Width { .. }) => (702 (Own::Outline { layout, .. }, OutlineEdit::Width { .. }) => (
699 layout.max_width,703 layout.max_width,
700 layout.reserved_width,704 layout.reserved_width,
crates/onestore/README.md+3-2
...@@ -46,7 +46,7 @@ harness also accepts `--client-profile release`....@@ -46,7 +46,7 @@ harness also accepts `--client-profile release`.
46| `Page::copy` | The page's content under fresh identities for copying into another section, definitions and payloads included; indent levels only an outline group carries refuse |46| `Page::copy` | The page's content under fresh identities for copying into another section, definitions and payloads included; indent levels only an outline group carries refuse |
47| `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 |47| `page::link::internal_link`, `page::link::parse_internal_link` | Build and read the `onenote:#…` URLs OneNote stores for links to sections, pages and paragraphs, by identity |
48| `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 |48| `page::Recording`, `page::MediaIndex` | A recording attachment's identity and type, and a paragraph's link to a moment in recordings; read from native pages and kept through edits, never authored |
49| `page::Page`, `page::Paragraph`, `page::Ink`, `page::Math` | Build an editable page model (title, outlines, paragraphs with coalesced text spans, tables, images, attachments, ink drawings and handwriting decoded to stroke polylines in page points) with stored identities; equations parse from their linear text and run data into a tree that renders the MathML OneNote exports; content outside the model is retained as `Unsupported` |49| `page::Page`, `page::Paragraph`, `page::Ink`, `page::Math` | Build an editable page model (title, outlines, paragraphs with coalesced text spans, tables, images, attachments, ink drawings and handwriting decoded to stroke polylines in page points, a moved drawing offset by its position) with stored identities; equations parse from their linear text and run data into a tree that renders the MathML OneNote exports; content outside the model is retained as `Unsupported` |
50| `protected::UnlockedSection` (optional feature) | Own decoded buffers for explicit known-password inspection; clear those buffers on drop; derived document strings/exports remain caller-owned |50| `protected::UnlockedSection` (optional feature) | Own decoded buffers for explicit known-password inspection; clear those buffers on drop; derived document strings/exports remain caller-owned |
51| `create_section` | Create one page containing one plain-text paragraph and an author, including Unicode |51| `create_section` | Create one page containing one plain-text paragraph and an author, including Unicode |
52| `PageCreation`, `SectionOp::Create` | Add an empty top-level page and its section entry atomically, under identities the intent retains across retries |52| `PageCreation`, `SectionOp::Create` | Add an empty top-level page and its section entry atomically, under identities the intent retains across retries |
...@@ -91,7 +91,8 @@ text, as OneNote titles a new page; `keeping(identity, created)` keeps another p...@@ -91,7 +91,8 @@ text, as OneNote titles a new page; `keeping(identity, created)` keeps another p
91identity and creation time, as a page moved to the recycle bin keeps them. The page has91identity and creation time, as a page moved to the recycle bin keeps them. The page has
92no body outlines or applied template; `create_empty_section` makes a section for such92no body outlines or applied template; `create_empty_section` makes a section for such
93pages, and `PageOp::Color` sets or clears a page's colour (`0x14001d2a` on the page node,93pages, 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)).
95Retain the intent to preserve its page, title and space identities; existing96Retain the intent to preserve its page, title and space identities; existing
96identities require reconciliation before retry. Body insertion and title edits97identities require reconciliation before retry. Body insertion and title edits
97use those identities through page ops. [Native page-creation fixtures](../../corpus/page-lifecycle/creation/README.md)98use those identities through page ops. [Native page-creation fixtures](../../corpus/page-lifecycle/creation/README.md)
crates/onestore/src/op/apply.rs+20
...@@ -638,6 +638,26 @@ impl<'a> Writer<'_, 'a> {...@@ -638,6 +638,26 @@ impl<'a> Writer<'_, 'a> {
638 }638 }
639 Ok(BTreeMap::from([(node, object)]))639 Ok(BTreeMap::from([(node, object)]))
640 }),640 }),
641 PageOp::RuleLines(lines) => self.write(|page| {
642 let [node] = crate::active::manifest_pages(&page.view)[..] else {
643 return Err(invalid("The page has no page node to rule"));
644 };
645 let mut object =
646 crate::write::PropertyObject::from_object(&page.live.revision.objects[&node])?;
647 // OneNote's "None" removes the properties.
648 match lines {
649 Some(lines) => {
650 let properties = lines.properties();
651 let values: Vec<_> = properties
652 .iter()
653 .map(|(id, value)| (*id, &value[..]))
654 .collect();
655 object.set(&values)?
656 }
657 None => object.remove(&crate::page::RULE_LINES)?,
658 }
659 Ok(BTreeMap::from([(node, object)]))
660 }),
641 PageOp::Insert {661 PageOp::Insert {
642 container,662 container,
643 before,663 before,
crates/onestore/src/op/lower.rs+23-2
...@@ -45,6 +45,7 @@ pub fn lower(...@@ -45,6 +45,7 @@ pub fn lower(
45 created: None,45 created: None,
46 margin_origin: [0.0; 2],46 margin_origin: [0.0; 2],
47 color: None,47 color: None,
48 rule_lines: None,
48 objects: vec![PageObject::Outline(Outline {49 objects: vec![PageObject::Outline(Outline {
49 id: container,50 id: container,
50 title: false,51 title: false,
...@@ -1424,8 +1425,25 @@ impl Lowering {...@@ -1424,8 +1425,25 @@ impl Lowering {
1424 }1425 }
14251426
1426 fn strokes(&mut self, stored: &crate::page::Ink, ink: &crate::page::Ink) -> Result<(), Error> {1427 fn strokes(&mut self, stored: &crate::page::Ink, ink: &crate::page::Ink) -> Result<(), Error> {
1427 if stored.layout != ink.layout || stored.groups != ink.groups {1428 let placed = |layout: &crate::document::Layout| crate::document::Layout {
1428 return Err(invalid("Ink position and groups stay as stored"));1429 x: None,
1430 y: None,
1431 ..layout.clone()
1432 };
1433 if placed(&stored.layout) != placed(&ink.layout) || stored.groups != ink.groups {
1434 return Err(invalid("Ink size and groups stay as stored"));
1435 }
1436 if (stored.layout.x, stored.layout.y) != (ink.layout.x, ink.layout.y) {
1437 let (Some(x), Some(y)) = (ink.layout.x, ink.layout.y) else {
1438 return Err(invalid("An ink position needs both coordinates"));
1439 };
1440 self.emit(PageOp::Outline {
1441 object: ink.id,
1442 edit: crate::OutlineEdit::Position { x, y },
1443 })?;
1444 if stored.strokes == ink.strokes {
1445 return Ok(());
1446 }
1429 }1447 }
1430 let mut add = Vec::new();1448 let mut add = Vec::new();
1431 for stroke in &ink.strokes {1449 for stroke in &ink.strokes {
...@@ -1569,6 +1587,9 @@ impl Lowering {...@@ -1569,6 +1587,9 @@ impl Lowering {
1569 if before.color != after.color {1587 if before.color != after.color {
1570 self.emit(PageOp::Color(after.color))?;1588 self.emit(PageOp::Color(after.color))?;
1571 }1589 }
1590 if before.rule_lines != after.rule_lines {
1591 self.emit(PageOp::RuleLines(after.rule_lines))?;
1592 }
1572 if before.created == after.created && shown {1593 if before.created == after.created && shown {
1573 return Ok(());1594 return Ok(());
1574 }1595 }
crates/onestore/src/op/mod.rs+2
...@@ -88,6 +88,8 @@ pub enum PageOp {...@@ -88,6 +88,8 @@ pub enum PageOp {
88 },88 },
89 /// The page's colour (View, Page Color), COLORREF; `None` is OneNote's "No color".89 /// The page's colour (View, Page Color), COLORREF; `None` is OneNote's "No color".
90 Color(Option<u32>),90 Color(Option<u32>),
91 /// The page's rule lines (View, Rule Lines); `None` is OneNote's "None".
92 RuleLines(Option<crate::page::RuleLines>),
91 /// Inserts paragraphs before a direct child of `container`, or last. Children follow93 /// Inserts paragraphs before a direct child of `container`, or last. Children follow
92 /// their parents in `paragraphs`; levels are absolute. Text keeps its spans' formats;94 /// their parents in `paragraphs`; levels are absolute. Text keeps its spans' formats;
93 /// lists, tags, styles and collapse state are set by their own ops.95 /// lists, tags, styles and collapse state are set by their own ops.
crates/onestore/src/op/model.rs+7-3
...@@ -627,6 +627,7 @@ fn interpret(page: &mut Page, op: &PageOp) -> Result<(), Error> {...@@ -627,6 +627,7 @@ fn interpret(page: &mut Page, op: &PageOp) -> Result<(), Error> {
627 page.created = Some(*created);627 page.created = Some(*created);
628 }628 }
629 PageOp::Color(color) => page.color = *color,629 PageOp::Color(color) => page.color = *color,
630 PageOp::RuleLines(lines) => page.rule_lines = *lines,
630 PageOp::Text { text, range, with } => {631 PageOp::Text { text, range, with } => {
631 let object = text_mut(page, *text)?;632 let object = text_mut(page, *text)?;
632 object.text = replace_text(&object.text, range.clone(), with)?;633 object.text = replace_text(&object.text, range.clone(), with)?;
...@@ -923,9 +924,12 @@ fn interpret(page: &mut Page, op: &PageOp) -> Result<(), Error> {...@@ -923,9 +924,12 @@ fn interpret(page: &mut Page, op: &PageOp) -> Result<(), Error> {
923 PageOp::Outline { object, edit } => match edit {924 PageOp::Outline { object, edit } => match edit {
924 OutlineEdit::Collapsed(value) => paragraph_mut(page, *object)?.collapsed = *value,925 OutlineEdit::Collapsed(value) => paragraph_mut(page, *object)?.collapsed = *value,
925 OutlineEdit::Position { x, y } => {926 OutlineEdit::Position { x, y } => {
926 let outline = outline_mut(page, *object)?;927 let layout = match page.objects.iter_mut().find(|o| o.id() == *object) {
927 outline.layout.x = Some(stored(*x));928 Some(PageObject::Ink(ink)) => &mut ink.layout,
928 outline.layout.y = Some(stored(*y));929 _ => &mut outline_mut(page, *object)?.layout,
930 };
931 layout.x = Some(stored(*x));
932 layout.y = Some(stored(*y));
929 retitle(page, None);933 retitle(page, None);
930 }934 }
931 OutlineEdit::Width { points, user_set } => {935 OutlineEdit::Width { points, user_set } => {
crates/onestore/src/op/tests.rs+190-2
...@@ -8,7 +8,8 @@ use crate::{...@@ -8,7 +8,8 @@ use crate::{
8 document::{Document, Format, Kind, Layout},8 document::{Document, Format, Kind, Layout},
9 page::{9 page::{
10 Attachment, Definition, Image, Ink, InkStroke, Outline, PageObject, PageParagraph,10 Attachment, Definition, Image, Ink, InkStroke, Outline, PageObject, PageParagraph,
11 ParagraphContent, Table, TableCell, TableColumn, TableRow, TextObject, text::new_id,11 ParagraphContent, Table, TableCell, TableColumn, TableRow, TextObject, VerticalRule,
12 text::new_id,
12 },13 },
13 write::GUIDS,14 write::GUIDS,
14};15};
...@@ -352,11 +353,12 @@ pub(super) enum Family {...@@ -352,11 +353,12 @@ pub(super) enum Family {
352 AttachmentRename,353 AttachmentRename,
353 NewInk,354 NewInk,
354 InkStrokes,355 InkStrokes,
356 InkPosition,
355 Link,357 Link,
356 Equation,358 Equation,
357}359}
358360
359pub(super) const FAMILIES: [Family; 33] = [361pub(super) const FAMILIES: [Family; 34] = [
360 Family::Text,362 Family::Text,
361 Family::Format,363 Family::Format,
362 Family::Insert,364 Family::Insert,
...@@ -388,6 +390,7 @@ pub(super) const FAMILIES: [Family; 33] = [...@@ -388,6 +390,7 @@ pub(super) const FAMILIES: [Family; 33] = [
388 Family::AttachmentRename,390 Family::AttachmentRename,
389 Family::NewInk,391 Family::NewInk,
390 Family::InkStrokes,392 Family::InkStrokes,
393 Family::InkPosition,
391 Family::Link,394 Family::Link,
392 Family::Equation,395 Family::Equation,
393];396];
...@@ -1223,6 +1226,17 @@ pub(super) fn mutate(page: &mut Page, rng: &mut Rng, family: Family) -> bool {...@@ -1223,6 +1226,17 @@ pub(super) fn mutate(page: &mut Page, rng: &mut Rng, family: Family) -> bool {
1223 }1226 }
1224 true1227 true
1225 }1228 }
1229 Family::InkPosition => {
1230 let Some(ink) = page.objects.iter_mut().find_map(|o| match o {
1231 PageObject::Ink(ink) => Some(ink),
1232 _ => None,
1233 }) else {
1234 return false;
1235 };
1236 ink.layout.x = Some((rng.next() % 40) as f32 - 20.0);
1237 ink.layout.y = Some((rng.next() % 90) as f32);
1238 true
1239 }
1226 }1240 }
1227}1241}
12281242
...@@ -2769,3 +2783,177 @@ fn a_page_date_stores_what_onenote_stores() {...@@ -2769,3 +2783,177 @@ fn a_page_date_stores_what_onenote_stores() {
2769 .unwrap();2783 .unwrap();
2770 }2784 }
2771}2785}
2786
2787/// OneNote 2010's View, Rule Lines (`corpus/rule-lines/native`): each page is titled for
2788/// what its menu gave it, and "Narrow" was ruled, then set back to None.
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};...@@ -13,7 +13,8 @@ use std::collections::{BTreeMap, BTreeSet};
13#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]13#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
14#[serde(deny_unknown_fields)]14#[serde(deny_unknown_fields)]
15pub enum OutlineEdit {15pub enum OutlineEdit {
16 /// Moves an ordinary page outline to coordinates measured in points.16 /// Moves an ordinary page outline to coordinates measured in points, or offsets a page's
17 /// ink drawing from its strokes' coordinates by that much, as OneNote 2010 moves one.
17 /// Refreshes automatic page titles when the leading outline changes.18 /// Refreshes automatic page titles when the leading outline changes.
18 Position { x: f32, y: f32 },19 Position { x: f32, y: f32 },
19 /// Changes an ordinary outline's maximum width in points, at least 36.20 /// Changes an ordinary outline's maximum width in points, at least 36.
...@@ -63,7 +64,17 @@ impl OutlineEdit {...@@ -63,7 +64,17 @@ impl OutlineEdit {
63 return Err(invalid("Select a paragraph for its saved expansion state"));64 return Err(invalid("Select a paragraph for its saved expansion state"));
64 }65 }
65 }66 }
66 _ => {67 Self::Position { .. } => {
68 if !matches!(node.kind, Kind::Outline { .. } | Kind::Ink { .. })
69 || parents.get(&object).map(Vec::as_slice) != Some(pages.as_slice())
70 || !view.nodes[page].children.contains(&object)
71 {
72 return Err(invalid(
73 "Select an ordinary outline or ink drawing directly on the page",
74 ));
75 }
76 }
77 Self::Width { .. } => {
67 if !matches!(node.kind, Kind::Outline { .. })78 if !matches!(node.kind, Kind::Outline { .. })
68 || parents.get(&object).map(Vec::as_slice) != Some(pages.as_slice())79 || parents.get(&object).map(Vec::as_slice) != Some(pages.as_slice())
69 || !view.nodes[page].children.contains(&object)80 || !view.nodes[page].children.contains(&object)
crates/onestore/src/page/ink.rs+2-1
...@@ -27,6 +27,7 @@ const DIMENSION_Y: [u8; 16] = [...@@ -27,6 +27,7 @@ const DIMENSION_Y: [u8; 16] = [
27#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]27#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
28pub struct Ink {28pub struct Ink {
29 pub id: ExGuid,29 pub id: ExGuid,
30 /// A drawing's `x` and `y` offset every stroke point, as OneNote moves ink.
30 pub layout: Layout,31 pub layout: Layout,
31 pub strokes: Vec<InkStroke>,32 pub strokes: Vec<InkStroke>,
32 /// Nested ink containers, as newer OneNote versions group handwriting.33 /// Nested ink containers, as newer OneNote versions group handwriting.
...@@ -36,7 +37,7 @@ pub struct Ink {...@@ -36,7 +37,7 @@ pub struct Ink {
36#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]37#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
37pub struct InkStroke {38pub struct InkStroke {
38 pub id: ExGuid,39 pub id: ExGuid,
39 /// Absolute page coordinates in points.40 /// Page coordinates in points, before the drawing's offset.
40 pub points: Vec<[f32; 2]>,41 pub points: Vec<[f32; 2]>,
41 /// Pen width and height in points.42 /// Pen width and height in points.
42 pub width: f32,43 pub width: f32,
crates/onestore/src/page/mod.rs+94
...@@ -56,6 +56,94 @@ pub use text::Paragraph;...@@ -56,6 +56,94 @@ pub use text::Paragraph;
56/// MS-ONE, observed in OneNote 2010's pages (`corpus/notebook-management/native/page-color`).56/// MS-ONE, observed in OneNote 2010's pages (`corpus/notebook-management/native/page-color`).
57pub(crate) const PAGE_COLOR: u32 = 0x14001d2a;57pub(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
59/// The role of a title-outline paragraph that displays the page's creation date or time.147/// The role of a title-outline paragraph that displays the page's creation date or time.
60#[derive(Clone, Copy, Debug, PartialEq, serde::Serialize, serde::Deserialize)]148#[derive(Clone, Copy, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
61pub enum DateField {149pub enum DateField {
...@@ -74,6 +162,8 @@ pub struct Page {...@@ -74,6 +162,8 @@ pub struct Page {
74 /// The page's colour (View, Page Color), COLORREF; `None` is OneNote's "No color".162 /// The page's colour (View, Page Color), COLORREF; `None` is OneNote's "No color".
75 #[serde(default)]163 #[serde(default)]
76 pub color: Option<u32>,164 pub color: Option<u32>,
165 #[serde(default)]
166 pub rule_lines: Option<RuleLines>,
77 pub objects: Vec<PageObject>,167 pub objects: Vec<PageObject>,
78 pub definitions: BTreeMap<ExGuid, Definition>,168 pub definitions: BTreeMap<ExGuid, Definition>,
79}169}
...@@ -617,6 +707,10 @@ impl Page {...@@ -617,6 +707,10 @@ impl Page {
617 _ => Err(invalid("The page color is damaged")),707 _ => Err(invalid("The page color is damaged")),
618 })708 })
619 .transpose()?,709 .transpose()?,
710 rule_lines: root
711 .extra
712 .first()
713 .and_then(|fields| RuleLines::read(fields)),
620 objects: Vec::new(),714 objects: Vec::new(),
621 definitions: BTreeMap::new(),715 definitions: BTreeMap::new(),
622 };716 };
crates/snowbound/Cargo.toml+1-1
...@@ -29,7 +29,7 @@ accesskit_winit = { version = "0.34.0", default-features = false, features = ["r...@@ -29,7 +29,7 @@ accesskit_winit = { version = "0.34.0", default-features = false, features = ["r
29[target.'cfg(target_os = "macos")'.dependencies]29[target.'cfg(target_os = "macos")'.dependencies]
30libc = "0.2"30libc = "0.2"
31objc2 = "0.5.2"31objc2 = "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"] }
33objc2-foundation = { version = "0.2.2", default-features = false, features = ["NSString", "NSThread", "NSAttributedString", "NSDate", "NSCalendar", "NSDateFormatter", "NSGeometry", "NSLocale", "NSPathUtilities", "NSTimeZone", "NSURL", "NSArray"] }33objc2-foundation = { version = "0.2.2", default-features = false, features = ["NSString", "NSThread", "NSAttributedString", "NSDate", "NSCalendar", "NSDateFormatter", "NSGeometry", "NSLocale", "NSPathUtilities", "NSTimeZone", "NSURL", "NSArray"] }
3434
35[target.'cfg(target_os = "linux")'.dependencies]35[target.'cfg(target_os = "linux")'.dependencies]
crates/snowbound/assets/icons/full-page-view.svg deleted-12
...@@ -1,12 +0,0 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <defs>
3 <linearGradient id="g0" x2="0" y2="1"><stop offset="0" stop-color="#ffffff"/><stop offset="1" stop-color="#e3e9f0"/></linearGradient>
4 <linearGradient id="g1" x2="0" y2="1"><stop offset="0" stop-color="#f1f4f8"/><stop offset="1" stop-color="#c9d3df"/></linearGradient>
5 </defs>
6 <path d="M4.5 4.25L9.5 4.25L11.5 6.25L11.5 13.25L4.5 13.25Z" fill="#000000" fill-opacity="0.16"/>
7 <path d="M4.5 3.5L9.5 3.5L11.5 5.5L11.5 12.5L4.5 12.5Z" fill="url(#g0)" stroke="#98a4b3" stroke-width="0.8" stroke-linecap="round" stroke-linejoin="round"/>
8 <path d="M9.5 4.25L9.5 6.25L11.5 6.25Z" fill="#000000" fill-opacity="0.16"/>
9 <path d="M9.5 3.5L9.5 5.5L11.5 5.5Z" fill="url(#g1)" stroke="#98a4b3" stroke-width="0.8" stroke-linecap="round" stroke-linejoin="round"/>
10 <path class="accent" d="M6 7H9M6 9H9" fill="none" stroke="#8cc0f5" stroke-width="1" stroke-linecap="round" stroke-linejoin="round"/>
11 <path class="accent" d="M1.5 5V1.5H5M11 1.5H14.5V5M14.5 11V14.5H11M5 14.5H1.5V11" fill="none" stroke="#1f6fe0" stroke-width="1" stroke-linecap="round" stroke-linejoin="round"/>
12</svg>
crates/snowbound/assets/icons/rule-lines.svg deleted-9
...@@ -1,9 +0,0 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <defs>
3 <linearGradient id="g0" x2="0" y2="1"><stop offset="0" stop-color="#ffffff"/><stop offset="1" stop-color="#e3e9f0"/></linearGradient>
4 </defs>
5 <path d="M3 2.25H13A0.5 0.5 0 0 1 13.5 2.75V14.75A0.5 0.5 0 0 1 13 15.25H3A0.5 0.5 0 0 1 2.5 14.75V2.75A0.5 0.5 0 0 1 3 2.25Z" fill="#000000" fill-opacity="0.16"/>
6 <path d="M3 1.5H13A0.5 0.5 0 0 1 13.5 2V14A0.5 0.5 0 0 1 13 14.5H3A0.5 0.5 0 0 1 2.5 14V2A0.5 0.5 0 0 1 3 1.5Z" fill="url(#g0)" stroke="#98a4b3" stroke-width="0.8" stroke-linecap="round" stroke-linejoin="round"/>
7 <path class="accent" d="M3 4H13V5H3ZM3 7H13V8H3ZM3 10H13V11H3ZM3 13H13V14H3Z" fill="#8cc0f5"/>
8 <path d="M5 2H6V14H5Z" fill="#f03b30"/>
9</svg>
crates/snowbound/assets/icons/sync-busy.svg created+4
...@@ -0,0 +1,4 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <path d="M12.6 6.75A4.75 4.75 0 0 0 4.1 5.2M3.4 9.25A4.75 4.75 0 0 0 11.9 10.8" fill="none" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>
3 <path d="M3.75 2.75V5.5H6.5M12.25 13.25V10.5H9.5" fill="none" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>
4</svg>
crates/snowbound/assets/icons/sync-done.svg created+4
...@@ -0,0 +1,4 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <path d="M4.25 12.25H11.5A2.75 2.75 0 0 0 11.9 6.78A4 4 0 0 0 4.3 5.9A3.2 3.2 0 0 0 4.25 12.25Z" fill="none" stroke="currentColor" stroke-width="1.25" stroke-linejoin="round"/>
3 <path d="M5.9 8.9L7.4 10.4L10.1 7.4" fill="none" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>
4</svg>
crates/snowbound/assets/icons/sync-error.svg created+4
...@@ -0,0 +1,4 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <path d="M8 2.25L14 12.75H2Z" fill="none" stroke="currentColor" stroke-width="1.25" stroke-linejoin="round"/>
3 <path d="M8 6.5V9.25M8 11.25V11.3" fill="none" stroke="currentColor" stroke-width="1.35" stroke-linecap="round"/>
4</svg>
crates/snowbound/assets/icons/sync-offline.svg created+4
...@@ -0,0 +1,4 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <path d="M4.25 12.25H11.5A2.75 2.75 0 0 0 11.9 6.78A4 4 0 0 0 4.3 5.9A3.2 3.2 0 0 0 4.25 12.25Z" fill="none" stroke="currentColor" stroke-width="1.25" stroke-linejoin="round"/>
3 <path d="M2.75 2.75L13.25 13.25" fill="none" stroke="currentColor" stroke-width="1.25" stroke-linecap="round"/>
4</svg>
crates/snowbound/src/aqua.rs created+324
...@@ -0,0 +1,324 @@
1//! Mac OS X 10.6's window, scrollers and resize grip. The window is textured, its title's
2//! gradient running on through the toolbar's row. Its scrollers sit beside the content
3//! rather than over it, so AppKit paints them here, into bitmaps the interface draws; it
4//! resizes only from the grip, which the OpenGL surface covers, so the app draws that.
5
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");...@@ -53,16 +53,18 @@ pub const DATE_TIME: &[&str] = art!("icons/date-time");
53pub const FIND_TAGS: &[&str] = art!("icons/find-tags");53pub const FIND_TAGS: &[&str] = art!("icons/find-tags");
54pub const FORMAT_PAINTER: &[&str] = art!("icons/format-painter");54pub const FORMAT_PAINTER: &[&str] = art!("icons/format-painter");
55pub const FORWARD: &[&str] = art!("icons/forward");55pub const FORWARD: &[&str] = art!("icons/forward");
56pub const FULL_PAGE_VIEW: &[&str] = art!("icons/full-page-view");
57pub const INSERT_SPACE: &[&str] = art!("icons/insert-space");56pub const INSERT_SPACE: &[&str] = art!("icons/insert-space");
58pub const PAGE_COLOR: &[&str] = art!("icons/page-color");57pub const PAGE_COLOR: &[&str] = art!("icons/page-color");
59pub const PASTE: &[&str] = art!("icons/paste");58pub const PASTE: &[&str] = art!("icons/paste");
60pub const RECORD_AUDIO: &[&str] = art!("icons/record-audio");59pub const RECORD_AUDIO: &[&str] = art!("icons/record-audio");
61pub const RECORD_VIDEO: &[&str] = art!("icons/record-video");60pub const RECORD_VIDEO: &[&str] = art!("icons/record-video");
62pub const RULE_LINES: &[&str] = art!("icons/rule-lines");
63pub const SCREEN_CLIPPING: &[&str] = art!("icons/screen-clipping");61pub const SCREEN_CLIPPING: &[&str] = art!("icons/screen-clipping");
64pub const SPELLING: &[&str] = art!("icons/spelling");62pub const SPELLING: &[&str] = art!("icons/spelling");
65pub const SYMBOL: &[&str] = art!("icons/symbol");63pub 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
67pub const TAG_REMEMBER: &[&str] = art!("tags/remember");69pub const TAG_REMEMBER: &[&str] = art!("tags/remember");
68pub const TAG_DEFINITION: &[&str] = art!("tags/definition");70pub 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 {...@@ -29,6 +29,8 @@ pub enum Id {
29 SelectAll,29 SelectAll,
30 Find,30 Find,
31 Search,31 Search,
32 SearchResults,
33 CommandPalette,
32 Back,34 Back,
33 Forward,35 Forward,
34 ZoomIn,36 ZoomIn,
...@@ -63,6 +65,7 @@ pub enum Id {...@@ -63,6 +65,7 @@ pub enum Id {
63 ClearFormatting,65 ClearFormatting,
64 Tag(NoteTag),66 Tag(NoteTag),
65 RemoveTags,67 RemoveTags,
68 FindTags,
66 Help,69 Help,
67}70}
6871
...@@ -81,6 +84,10 @@ pub enum Choice {...@@ -81,6 +84,10 @@ pub enum Choice {
81 Table([usize; 2]),84 Table([usize; 2]),
82 /// An index into `PAGE_COLORS`, or `None` for no colour.85 /// An index into `PAGE_COLORS`, or `None` for no colour.
83 PageColor(Option<usize>),86 PageColor(Option<usize>),
87 /// An index into `RULE_LINES`, or `None` for none.
88 RuleLines(Option<usize>),
89 /// A template whose art becomes the page's background, or `None` for none.
90 Art(Option<&'static str>),
84}91}
8592
86#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]93#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
...@@ -225,6 +232,19 @@ pub const COMMANDS: &[Command] = &[...@@ -225,6 +232,19 @@ pub const COMMANDS: &[Command] = &[
225 row!(Id::SelectAll, "Select All", &[cmd('a')], &[cmd('a')]),232 row!(Id::SelectAll, "Select All", &[cmd('a')], &[cmd('a')]),
226 row!(Id::Find, "Find on This Page", &[cmd('f')], &[cmd('f')]),233 row!(Id::Find, "Find on This Page", &[cmd('f')], &[cmd('f')]),
227 row!(Id::Search, "Search Notebooks", &[cmd('e')], &[cmd('e')]),234 row!(Id::Search, "Search Notebooks", &[cmd('e')], &[cmd('e')]),
235 // OneNote for Mac searches all notebooks with Option-Command-F.
236 row!(
237 Id::SearchResults,
238 "Search Results Pane",
239 &[cmd('f').option()],
240 &[key('o').option()],
241 ),
242 row!(
243 Id::CommandPalette,
244 "Command Palette…",
245 &[cmd('p').shift()],
246 &[cmd('p').shift()]
247 ),
228 // Option-Command-arrows move the caret; Xcode goes back with Control-Command.248 // Option-Command-arrows move the caret; Xcode goes back with Control-Command.
229 row!(249 row!(
230 Id::Back,250 Id::Back,
...@@ -252,7 +272,7 @@ pub const COMMANDS: &[Command] = &[...@@ -252,7 +272,7 @@ pub const COMMANDS: &[Command] = &[
252 &[cmd('0')],272 &[cmd('0')],
253 &[cmd('0').option()]273 &[cmd('0').option()]
254 ),274 ),
255 row!(Id::Sidebar, "Sidebar", &[cmd('s').control()], NONE),275 row!(Id::Sidebar, "Notebook List", &[cmd('s').control()], NONE),
256 row!(Id::PageList, "Page List", NONE, NONE),276 row!(Id::PageList, "Page List", NONE, NONE),
257 row!(Id::DarkPages, "Dark Pages", NONE, NONE),277 row!(Id::DarkPages, "Dark Pages", NONE, NONE),
258 // AppKit's Enter Full Screen takes Control-Command-F.278 // AppKit's Enter Full Screen takes Control-Command-F.
...@@ -393,6 +413,7 @@ pub const COMMANDS: &[Command] = &[...@@ -393,6 +413,7 @@ pub const COMMANDS: &[Command] = &[
393 &[cmd('0').control()],413 &[cmd('0').control()],
394 &[cmd('0')]414 &[cmd('0')]
395 ),415 ),
416 row!(Id::FindTags, "Find Tags", NONE, NONE),
396 row!(Id::Help, "Snowbound Help", NONE, NONE),417 row!(Id::Help, "Snowbound Help", NONE, NONE),
397];418];
398419
...@@ -442,7 +463,12 @@ pub fn title(choice: Choice) -> String {...@@ -442,7 +463,12 @@ pub fn title(choice: Choice) -> String {
442 .iter()463 .iter()
443 .find(|(listed, _)| *listed == color)464 .find(|(listed, _)| *listed == color)
444 .map_or_else(String::new, |(_, name)| (*name).to_owned()),465 .map_or_else(String::new, |(_, name)| (*name).to_owned()),
445 Choice::Color(_) | Choice::List(_) | Choice::Table(_) | Choice::PageColor(_) => {466 Choice::Color(_)
467 | Choice::List(_)
468 | Choice::Table(_)
469 | Choice::PageColor(_)
470 | Choice::RuleLines(_)
471 | Choice::Art(_) => {
446 unreachable!("Only the toolbar offers these")472 unreachable!("Only the toolbar offers these")
447 }473 }
448 }474 }
...@@ -626,10 +652,12 @@ impl State {...@@ -626,10 +652,12 @@ impl State {
626 Choice::Size(size) => return checked(format.font_size == Some(*size)),652 Choice::Size(size) => return checked(format.font_size == Some(*size)),
627 Choice::Highlight(_) | Choice::Color(_) | Choice::List(_) => return enabled(text),653 Choice::Highlight(_) | Choice::Color(_) | Choice::List(_) => return enabled(text),
628 Choice::Table(_) => Id::Table,654 Choice::Table(_) => Id::Table,
629 Choice::PageColor(_) => Id::PageColor,655 Choice::PageColor(_) | Choice::RuleLines(_) | Choice::Art(_) => Id::PageColor,
630 };656 };
631 match id {657 match id {
632 Id::Settings | Id::NewNotebook | Id::OpenNotebook | Id::Help => enabled(!modal),658 Id::Settings | Id::NewNotebook | Id::OpenNotebook | Id::CommandPalette | Id::Help => {
659 enabled(!modal)
660 }
633 Id::CloseNotebook | Id::ShowNotebook => enabled(!modal && self.notebook().is_some()),661 Id::CloseNotebook | Id::ShowNotebook => enabled(!modal && self.notebook().is_some()),
634 Id::NewSection | Id::NewSectionGroup => enabled(662 Id::NewSection | Id::NewSectionGroup => enabled(
635 !modal663 !modal
...@@ -669,6 +697,14 @@ impl State {...@@ -669,6 +697,14 @@ impl State {
669 enabled: !modal && session.is_some(),697 enabled: !modal && session.is_some(),
670 checked: self.pages_open,698 checked: self.pages_open,
671 },699 },
700 Id::SearchResults => Status {
701 enabled: !modal && session.is_some(),
702 checked: matches!(self.search.pane, Some(crate::pane::Pane::Search { .. })),
703 },
704 Id::FindTags => Status {
705 enabled: !modal && session.is_some(),
706 checked: matches!(self.search.pane, Some(crate::pane::Pane::Tags { .. })),
707 },
672 Id::DarkPages => Status {708 Id::DarkPages => Status {
673 enabled: !modal,709 enabled: !modal,
674 checked: !self.light_pages,710 checked: !self.light_pages,
...@@ -679,8 +715,12 @@ impl State {...@@ -679,8 +715,12 @@ impl State {
679 },715 },
680 Id::PageColor => enabled(writable && session.is_some()),716 Id::PageColor => enabled(writable && session.is_some()),
681 Id::ScreenClipping => enabled(text && cfg!(target_os = "macos")),717 Id::ScreenClipping => enabled(text && cfg!(target_os = "macos")),
682 // Attach File awaits #23, Record Audio and Video #36, and Insert Space a drag mode.718 // Attach File awaits #23, Record Audio and Video #36.
683 Id::Attachment | Id::RecordAudio | Id::RecordVideo | Id::InsertSpace => enabled(false),719 Id::Attachment | Id::RecordAudio | Id::RecordVideo => enabled(false),
720 Id::InsertSpace => Status {
721 enabled: writable,
722 checked: self.view.inserting_space(),
723 },
684 Id::Table724 Id::Table
685 | Id::Picture725 | Id::Picture
686 | Id::Symbol726 | Id::Symbol
...@@ -776,7 +816,14 @@ impl State {...@@ -776,7 +816,14 @@ impl State {
776 }816 }
777 Choice::PageColor(color) => {817 Choice::PageColor(color) => {
778 let color = color.map(|index| canvas::template::PAGE_COLORS[index].1);818 let color = color.map(|index| canvas::template::PAGE_COLORS[index].1);
779 return self.color_page(color);819 return self.paper_page(color, self.view.editor.rule_lines());
820 }
821 Choice::Art(name) => {
822 return self.art_page(name.and_then(canvas::template::find));
823 }
824 Choice::RuleLines(lines) => {
825 let lines = lines.map(|index| canvas::template::RULE_LINES[index].1);
826 return self.paper_page(self.view.editor.page_color(), lines);
780 }827 }
781 };828 };
782 let field = self.ui.focused().filter(|focus| *focus != page());829 let field = self.ui.focused().filter(|focus| *focus != page());
...@@ -860,6 +907,14 @@ impl State {...@@ -860,6 +907,14 @@ impl State {
860 self.start_search();907 self.start_search();
861 return Ok(());908 return Ok(());
862 }909 }
910 Id::SearchResults | Id::FindTags => {
911 self.toggle_pane(id == Id::FindTags);
912 return Ok(());
913 }
914 Id::CommandPalette => {
915 self.ui.open_popup(crate::palette::id());
916 return Ok(());
917 }
863 Id::Back | Id::Forward => {918 Id::Back | Id::Forward => {
864 self.travel(id == Id::Forward);919 self.travel(id == Id::Forward);
865 return Ok(());920 return Ok(());
...@@ -917,7 +972,12 @@ impl State {...@@ -917,7 +972,12 @@ impl State {
917 self.save_settings();972 self.save_settings();
918 return Ok(());973 return Ok(());
919 }974 }
920 Id::Attachment | Id::RecordAudio | Id::RecordVideo | Id::InsertSpace => {975 Id::Attachment | Id::RecordAudio | Id::RecordVideo => {
976 return Ok(());
977 }
978 Id::InsertSpace => {
979 let response = self.view.insert_space();
980 self.respond(response);
921 return Ok(());981 return Ok(());
922 }982 }
923 Id::Link => {983 Id::Link => {
...@@ -1125,8 +1185,8 @@ mod tests {...@@ -1125,8 +1185,8 @@ mod tests {
1125 fn toolbar_buttons_run_commands() {1185 fn toolbar_buttons_run_commands() {
1126 let source = include_str!("main.rs");1186 let source = include_str!("main.rs");
1127 let start = source1187 let start = source
1128 .find(" fn toolbar(")1188 .find(" fn tools(")
1129 .expect("The toolbar is built in main.rs");1189 .expect("The toolbar's groups are built in main.rs");
1130 let body = &source[start..];1190 let body = &source[start..];
1131 let body = &body[..body[1..]1191 let body = &body[..body[1..]
1132 .find("\n fn ")1192 .find("\n fn ")
crates/snowbound/src/conflict_render.rs+1
...@@ -133,6 +133,7 @@ fn paint(...@@ -133,6 +133,7 @@ fn paint(
133 ],133 ],
134 clip: Some(rect.map(|value| value * SCALE)),134 clip: Some(rect.map(|value| value * SCALE)),
135 backdrop: Some(ui.theme.paper),135 backdrop: Some(ui.theme.paper),
136 round: None,
136 motion: None,137 motion: None,
137 primitives: &primitives,138 primitives: &primitives,
138 },139 },
crates/snowbound/src/library.rs+48
...@@ -288,6 +288,17 @@ impl Library {...@@ -288,6 +288,17 @@ impl Library {
288 Ok(section)288 Ok(section)
289 }289 }
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
291 /// How the notebook's files are reached.302 /// How the notebook's files are reached.
292 pub fn transport(&self) -> String {303 pub fn transport(&self) -> String {
293 match (&self.server, &self.notice) {304 match (&self.server, &self.notice) {
...@@ -584,6 +595,43 @@ mod tests {...@@ -584,6 +595,43 @@ mod tests {
584 let _ = std::fs::remove_dir_all(&cache);595 let _ = std::fs::remove_dir_all(&cache);
585 }596 }
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
587 #[test]635 #[test]
588 fn mounts_name_their_server_share_account_and_folder() {636 fn mounts_name_their_server_share_account_and_folder() {
589 assert_eq!(637 assert_eq!(
crates/snowbound/src/linux.rs+145-52
...@@ -17,7 +17,7 @@ use ui::{Flags, Spec, Ui, px};...@@ -17,7 +17,7 @@ use ui::{Flags, Spec, Ui, px};
17use winit::{17use winit::{
18 error::EventLoopError,18 error::EventLoopError,
19 event_loop::{EventLoop, EventLoopProxy},19 event_loop::{EventLoop, EventLoopProxy},
20 platform::wayland::WindowAttributesExtWayland,20 platform::wayland::{EventLoopExtWayland, WindowAttributesExtWayland},
21 window::{Icon, ResizeDirection, Theme, Window, WindowAttributes},21 window::{Icon, ResizeDirection, Theme, Window, WindowAttributes},
22};22};
2323
...@@ -48,25 +48,34 @@ pub fn event_loop(_headless: bool) -> Result<EventLoop<crate::UserEvent>, EventL...@@ -48,25 +48,34 @@ pub fn event_loop(_headless: bool) -> Result<EventLoop<crate::UserEvent>, EventL
48 if desktop() == Desktop::Kde {48 if desktop() == Desktop::Kde {
49 follow_breeze_radius(event_loop.create_proxy());49 follow_breeze_radius(event_loop.create_proxy());
50 }50 }
51 let libadwaita = desktop() == Desktop::Gnome && event_loop.is_wayland();
52 sctk_adwaita::LIBADWAITA_EDGE.store(libadwaita, Ordering::Relaxed);
51 Ok(event_loop)53 Ok(event_loop)
52}54}
5355
54/// How far the window manager rounds the window's corners: KWin rounds the bottom corners56/// How far the window's corners round unless it is maximized or full screen: KWin rounds
55/// of Breeze's borderless windows unless they are maximized or full screen.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.
56pub fn corner_radius(window: &Window) -> f32 {59pub fn corner_radius(window: &Window) -> f32 {
57 let units = f32::from_bits(BREEZE_RADIUS.load(Ordering::Relaxed));60 if !window.is_decorated() || window.is_maximized() || window.fullscreen().is_some() {
58 if units == 0.0
59 || !window.is_decorated()
60 || window.is_maximized()
61 || window.fullscreen().is_some()
62 {
63 return 0.0;61 return 0.0;
64 }62 }
63 if cuts_corners() {
64 return sctk_adwaita::WINDOW_RADIUS;
65 }
65 // Breeze snaps its radius to the device pixel grid.66 // Breeze snaps its radius to the device pixel grid.
67 let units = f32::from_bits(BREEZE_RADIUS.load(Ordering::Relaxed));
66 let scale = window.scale_factor() as f32;68 let scale = window.scale_factor() as f32;
67 (units * scale).round() / scale69 (units * scale).round() / scale
68}70}
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
70/// Reads Breeze's corner radius now and again whenever KWin is told to reload its79/// Reads Breeze's corner radius now and again whenever KWin is told to reload its
71/// configuration, as Breeze's and the border size's settings pages do, redrawing then.80/// configuration, as Breeze's and the border size's settings pages do, redrawing then.
72fn follow_breeze_radius(proxy: EventLoopProxy<crate::UserEvent>) {81fn follow_breeze_radius(proxy: EventLoopProxy<crate::UserEvent>) {
...@@ -141,8 +150,12 @@ pub fn window_attributes() -> WindowAttributes {...@@ -141,8 +150,12 @@ pub fn window_attributes() -> WindowAttributes {
141 Window::default_attributes()150 Window::default_attributes()
142 .with_name(APP_ID, APP_ID)151 .with_name(APP_ID, APP_ID)
143 .with_window_icon(icon)152 .with_window_icon(icon)
153 .with_transparent(cuts_corners())
144}154}
145155
156/// The window manager or the frame draws the title bar.
157pub fn install_title_bar(_: &Window) {}
158
146/// Wayland's clipboard through the window's own connection, as not every compositor offers159/// Wayland's clipboard through the window's own connection, as not every compositor offers
147/// a clipboard to clients without a window; X11's otherwise.160/// a clipboard to clients without a window; X11's otherwise.
148pub enum Clipboard {161pub enum Clipboard {
...@@ -180,10 +193,11 @@ impl Clipboard {...@@ -180,10 +193,11 @@ impl Clipboard {
180 }193 }
181}194}
182195
183/// Whether the system draws the title bar; false only where Wayland's client-side frame196/// Whether the system draws the title bar: the window manager, or on Wayland the compositor.
184/// could not be made, leaving the app's own.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.
185pub fn system_titlebar(window: &Window) -> bool {199pub fn system_titlebar(window: &Window) -> bool {
186 window.is_decorated()200 window.is_decorated() && !sctk_adwaita::FRAMED.load(Ordering::Relaxed)
187}201}
188202
189/// Window managers show no document of a window's.203/// Window managers show no document of a window's.
...@@ -463,41 +477,63 @@ fn adwaita_titlebar(appearance: Theme) -> [[u8; 3]; 2] {...@@ -463,41 +477,63 @@ fn adwaita_titlebar(appearance: Theme) -> [[u8; 3]; 2] {
463 })477 })
464}478}
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.
467pub fn window_controls(ui: &mut Ui, window: &Window) {483pub fn window_controls(ui: &mut Ui, window: &Window) {
468 let theme = ui.theme.clone();484 static CONTROLS: OnceLock<Vec<&'static str>> = OnceLock::new();
469 let mut button = |part: &str, icon| {485 let controls = CONTROLS.get_or_init(|| {
470 ui.leaf(486 let layout = portal_setting("org.gnome.desktop.wm.preferences", "button-layout")
471 part,487 .and_then(|value| String::try_from(value).ok());
472 Spec {488 match layout {
473 flags: Flags::CLICKABLE,489 Some(layout) => layout
474 size: [px(crate::TITLE + 12.0), px(crate::TITLE)],490 .split([':', ','])
475 icon: Some(icon),491 .filter_map(|name| {
476 color: Some(theme.text),492 ["minimize", "maximize", "close"]
477 hover_fill: Some(theme.hover()),493 .into_iter()
478 center: true,494 .find(|known| *known == name)
479 ..Spec::default()495 })
480 },496 .collect(),
481 )497 None if desktop() == Desktop::Gnome => vec!["close"],
482 .clicked498 None => vec!["minimize", "maximize", "close"],
483 };499 }
484 if button("minimize", MINIMIZE) {500 });
485 window.set_minimized(true);501 let text = ui.theme.text;
486 }502 let disc = |alpha| [text[0], text[1], text[2], alpha];
487 if button(503 for control in controls {
488 "maximize",504 let icon = match *control {
489 if window.is_maximized() {505 "minimize" => MINIMIZE,
490 RESTORE506 "maximize" if window.is_maximized() => RESTORE,
491 } else {507 "maximize" => MAXIMIZE,
492 MAXIMIZE508 _ => crate::art::CLOSE,
493 },509 };
494 ) {510 // Round, on a faint disc, as libadwaita's are.
495 zoom(window);511 let clicked = ui
496 }512 .leaf(
497 if button("close", crate::art::CLOSE)513 control,
498 && let Some(proxy) = QUIT.get()514 Spec {
499 {515 flags: Flags::CLICKABLE,
500 let _ = proxy.send_event(crate::UserEvent::Quit);516 size: [px(ui::shell::TOOL); 2],
517 icon: Some(icon),
518 color: Some(text),
519 fill: Some(disc(0.1)),
520 hover_fill: Some(disc(0.18)),
521 radius: ui::shell::TOOL / 2.0,
522 center: true,
523 ..Spec::default()
524 },
525 )
526 .clicked;
527 match *control {
528 _ if !clicked => {}
529 "minimize" => window.set_minimized(true),
530 "maximize" => zoom(window),
531 _ => {
532 if let Some(proxy) = QUIT.get() {
533 let _ = proxy.send_event(crate::UserEvent::Quit);
534 }
535 }
536 }
501 }537 }
502}538}
503539
...@@ -539,6 +575,21 @@ pub fn zoom(window: &Window) {...@@ -539,6 +575,21 @@ pub fn zoom(window: &Window) {
539/// Winit delivers typed text with its key events on Linux.575/// Winit delivers typed text with its key events on Linux.
540pub fn install_text_input(_: &Window) {}576pub 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
542/// Options opens from the sidebar's footer and Ctrl+Comma, as Linux apps have no shared menu.593/// Options opens from the sidebar's footer and Ctrl+Comma, as Linux apps have no shared menu.
543pub fn install_menu() {}594pub fn install_menu() {}
544595
...@@ -639,6 +690,45 @@ fn portal_setting(namespace: &str, key: &str) -> Option<zbus::zvariant::OwnedVal...@@ -639,6 +690,45 @@ fn portal_setting(namespace: &str, key: &str) -> Option<zbus::zvariant::OwnedVal
639 value.try_into_owned().ok()690 value.try_into_owned().ok()
640}691}
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
642pub fn cache_dir() -> Option<PathBuf> {732pub fn cache_dir() -> Option<PathBuf> {
643 xdg_dir("XDG_CACHE_HOME", ".cache")733 xdg_dir("XDG_CACHE_HOME", ".cache")
644}734}
...@@ -727,12 +817,6 @@ fn xdg_base(variable: &str, fallback: &str) -> Option<PathBuf> {...@@ -727,12 +817,6 @@ fn xdg_base(variable: &str, fallback: &str) -> Option<PathBuf> {
727 .or_else(|| Some(PathBuf::from(std::env::var_os("HOME")?).join(fallback)))817 .or_else(|| Some(PathBuf::from(std::env::var_os("HOME")?).join(fallback)))
728}818}
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
736/// Looks beside the executable first, as the release archive installs it, then in the820/// Looks beside the executable first, as the release archive installs it, then in the
737/// XDG data directories.821/// XDG data directories.
738fn icon_pixels(pixels: u32) -> Option<(u32, Vec<u8>)> {822fn icon_pixels(pixels: u32) -> Option<(u32, Vec<u8>)> {
...@@ -1225,6 +1309,15 @@ mod tests {...@@ -1225,6 +1309,15 @@ mod tests {
1225 assert_eq!(dark.motion, ui::PopupMotion::Cut);1309 assert_eq!(dark.motion, ui::PopupMotion::Cut);
1226 }1310 }
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
1228 #[test]1321 #[test]
1229 fn kconfig_colours_read_as_channels_or_hex() {1322 fn kconfig_colours_read_as_channels_or_hex() {
1230 assert_eq!(kde_color("222,224,226"), Some([222, 224, 226]));1323 assert_eq!(kde_color("222,224,226"), Some([222, 224, 226]));
crates/snowbound/src/macos.rs+106-16
...@@ -29,6 +29,8 @@ use winit::{...@@ -29,6 +29,8 @@ use winit::{
29 window::{Window, WindowAttributes},29 window::{Window, WindowAttributes},
30};30};
3131
32pub use crate::aqua::{move_cursor, resize_grip, scrollers, set_window_drags};
33
32static QUIT: OnceLock<EventLoopProxy<crate::UserEvent>> = OnceLock::new();34static QUIT: OnceLock<EventLoopProxy<crate::UserEvent>> = OnceLock::new();
33static INPUT_CLASS: OnceLock<&'static AnyClass> = OnceLock::new();35static INPUT_CLASS: OnceLock<&'static AnyClass> = OnceLock::new();
34static BACKDROP_CLASS: OnceLock<&'static AnyClass> = OnceLock::new();36static BACKDROP_CLASS: OnceLock<&'static AnyClass> = OnceLock::new();
...@@ -86,8 +88,20 @@ pub const LEADING: f32 = 78.0;...@@ -86,8 +88,20 @@ pub const LEADING: f32 = 78.0;
86/// How far AppKit rounds a window's corners.88/// How far AppKit rounds a window's corners.
87pub const CORNER_RADIUS: f32 = 10.0;89pub 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.
89pub fn corner_radius(_: &Window) -> f32 {93pub fn corner_radius(_: &Window) -> f32 {
90 CORNER_RADIUS94 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()
91}105}
92106
93/// A transparent title bar over the content, where the window draws its own.107/// A transparent title bar over the content, where the window draws its own.
...@@ -97,6 +111,57 @@ pub fn window_attributes() -> WindowAttributes {...@@ -97,6 +111,57 @@ pub fn window_attributes() -> WindowAttributes {
97 .with_fullsize_content_view(true)111 .with_fullsize_content_view(true)
98}112}
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
100pub struct Clipboard(arboard::Clipboard);165pub struct Clipboard(arboard::Clipboard);
101166
102impl Clipboard {167impl Clipboard {
...@@ -189,9 +254,9 @@ unsafe extern "C" fn hit_nothing(_: &AnyObject, _: Sel, _: NSPoint) -> *mut AnyO...@@ -189,9 +254,9 @@ unsafe extern "C" fn hit_nothing(_: &AnyObject, _: Sel, _: NSPoint) -> *mut AnyO
189/// through it to the view it lies in.254/// through it to the view it lies in.
190pub fn install_backdrop(window: &Window) -> bool {255pub fn install_backdrop(window: &Window) -> bool {
191 MainThreadMarker::new().expect("Views belong to the main thread");256 MainThreadMarker::new().expect("Views belong to the main thread");
192 // Before 10.10 AppKit has no materials.257 // Before 10.10 AppKit has no materials; 10.6's textured window stands in.
193 let Some(effect) = AnyClass::get("NSVisualEffectView") else {258 let Some(effect) = AnyClass::get("NSVisualEffectView") else {
194 return false;259 return crate::aqua::textured(window, crate::TITLE);
195 };260 };
196 let RawWindowHandle::AppKit(handle) =261 let RawWindowHandle::AppKit(handle) =
197 window.window_handle().expect("Live AppKit window").as_raw()262 window.window_handle().expect("Live AppKit window").as_raw()
...@@ -230,6 +295,18 @@ pub fn resize_direction(_: &Window, _: [f32; 2]) -> Option<winit::window::Resize...@@ -230,6 +295,18 @@ pub fn resize_direction(_: &Window, _: [f32; 2]) -> Option<winit::window::Resize
230 None295 None
231}296}
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
233pub fn cache_dir() -> Option<std::path::PathBuf> {310pub fn cache_dir() -> Option<std::path::PathBuf> {
234 Some(std::path::PathBuf::from(std::env::var_os("HOME")?).join("Library/Caches/snowbound"))311 Some(std::path::PathBuf::from(std::env::var_os("HOME")?).join("Library/Caches/snowbound"))
235}312}
...@@ -453,6 +530,12 @@ pub fn install_text_input(window: &Window) {...@@ -453,6 +530,12 @@ pub fn install_text_input(window: &Window) {
453 sel!(insertText:replacementRange:),530 sel!(insertText:replacementRange:),
454 insert_text as unsafe extern "C" fn(_, _, _, _),531 insert_text as unsafe extern "C" fn(_, _, _, _),
455 );532 );
533 if crate::aqua::before_lion() {
534 class.add_method(
535 sel!(mouseDownCanMoveWindow),
536 crate::aqua::window_drags as extern "C" fn(_, _) -> _,
537 );
538 }
456 class.register()539 class.register()
457 });540 });
458 assert_eq!(class.superclass(), Some(view.class()));541 assert_eq!(class.superclass(), Some(view.class()));
...@@ -676,11 +759,6 @@ pub fn commit_presentation(window: &Window) {...@@ -676,11 +759,6 @@ pub fn commit_presentation(window: &Window) {
676 }759 }
677}760}
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
684/// The insertion point's colour and selected text's fill with and without keyboard focus,762/// The insertion point's colour and selected text's fill with and without keyboard focus,
685/// linear RGBA, in `window`'s appearance.763/// linear RGBA, in `window`'s appearance.
686pub fn text_colors(window: &Window) -> [[f32; 4]; 3] {764pub fn text_colors(window: &Window) -> [[f32; 4]; 3] {
...@@ -770,17 +848,29 @@ declare_class!(...@@ -770,17 +848,29 @@ declare_class!(
770 objc2::runtime::Bool::new(status.enabled)848 objc2::runtime::Bool::new(status.enabled)
771 }849 }
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.
774 #[method(sendEvent:)]853 #[method(sendEvent:)]
775 fn send_event(&self, event: &NSEvent) {854 fn send_event(&self, event: &NSEvent) {
776 unsafe {855 unsafe {
777 if event.r#type() == NSEventType::KeyDown856 let key = (event.r#type() == NSEventType::KeyDown)
778 && self.modalWindow().is_some()857 .then(|| event.charactersIgnoringModifiers())
779 && event.charactersIgnoringModifiers().is_some_and(|text| text.to_string() == "\u{1b}")858 .flatten()
780 {859 .map(|text| text.to_string());
781 self.stopModal();860 let modal = self.modalWindow();
782 } else {861 match (key.as_deref(), &modal) {
783 let _: () = msg_send![super(self), sendEvent: event];862 (Some("\u{1b}"), Some(_)) => self.stopModal(),
863 (Some("\r" | "\u{3}"), Some(modal)) if {
864 let responder: *mut AnyObject = msg_send![modal, firstResponder];
865 !responder.is_null()
866 && msg_send![responder, isKindOfClass: NSDatePicker::class()]
867 } => {
868 let cell: *mut AnyObject = msg_send![modal, defaultButtonCell];
869 let _: () = msg_send![cell, performClick: std::ptr::null::<AnyObject>()];
870 }
871 _ => {
872 let _: () = msg_send![super(self), sendEvent: event];
873 }
784 }874 }
785 }875 }
786 }876 }
crates/snowbound/src/main.rs+832-599
...@@ -1,4 +1,7 @@...@@ -1,4 +1,7 @@
1#[cfg(target_os = "macos")]
2mod aqua;
1mod art;3mod art;
4mod background;
2mod commands;5mod commands;
3#[cfg(test)]6#[cfg(test)]
4mod conflict_render;7mod conflict_render;
...@@ -12,6 +15,8 @@ mod menubar;...@@ -12,6 +15,8 @@ mod menubar;
12mod menus;15mod menus;
13mod navigation;16mod navigation;
14mod options;17mod options;
18mod palette;
19mod pane;
15#[cfg_attr(target_os = "linux", path = "linux.rs")]20#[cfg_attr(target_os = "linux", path = "linux.rs")]
16#[cfg_attr(target_os = "macos", path = "macos.rs")]21#[cfg_attr(target_os = "macos", path = "macos.rs")]
17mod platform;22mod platform;
...@@ -36,7 +41,6 @@ use canvas::{...@@ -36,7 +41,6 @@ use canvas::{
36use canvas::{41use canvas::{
37 gpu::{colorref, tag_sources},42 gpu::{colorref, tag_sources},
38 outline::TagIcon,43 outline::TagIcon,
39 template::PAGE_COLORS,
40};44};
41use draw::Renderer;45use draw::Renderer;
42use library::Library;46use library::Library;
...@@ -61,8 +65,12 @@ use winit::{...@@ -61,8 +65,12 @@ use winit::{
61 window::{CursorIcon, Window, WindowId},65 window::{CursorIcon, Window, WindowId},
62};66};
6367
64/// Height of the title bar, which holds the window's controls.68/// Height of the toolbar's row, which is the title bar where the platform lets it: a
65const TITLE: f32 = 28.0;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;
66const TAB_ROW: f32 = 28.0;74const TAB_ROW: f32 = 28.0;
67/// Width of the section colour around the page.75/// Width of the section colour around the page.
68const FRAME: f32 = 6.0;76const FRAME: f32 = 6.0;
...@@ -185,11 +193,13 @@ fn cursor_icon(cursor: Cursor) -> CursorIcon {...@@ -185,11 +193,13 @@ fn cursor_icon(cursor: Cursor) -> CursorIcon {
185 Cursor::Default => CursorIcon::Default,193 Cursor::Default => CursorIcon::Default,
186 Cursor::Text => CursorIcon::Text,194 Cursor::Text => CursorIcon::Text,
187 Cursor::Pointer => CursorIcon::Pointer,195 Cursor::Pointer => CursorIcon::Pointer,
188 Cursor::Move => CursorIcon::Move,196 Cursor::Move => platform::move_cursor(),
189 Cursor::EwResize => CursorIcon::EwResize,197 Cursor::EwResize => CursorIcon::EwResize,
190 Cursor::NsResize => CursorIcon::NsResize,198 Cursor::NsResize => CursorIcon::NsResize,
191 Cursor::NwseResize => CursorIcon::NwseResize,199 Cursor::NwseResize => CursorIcon::NwseResize,
192 Cursor::NeswResize => CursorIcon::NeswResize,200 Cursor::NeswResize => CursorIcon::NeswResize,
201 Cursor::RowResize => CursorIcon::RowResize,
202 Cursor::ColResize => CursorIcon::ColResize,
193 }203 }
194}204}
195205
...@@ -489,6 +499,7 @@ struct State {...@@ -489,6 +499,7 @@ struct State {
489 last_pages: HashMap<String, ExGuid>,499 last_pages: HashMap<String, ExGuid>,
490 /// Pages visited, for Back and Forward.500 /// Pages visited, for Back and Forward.
491 trail: navigation::Trail,501 trail: navigation::Trail,
502 swipe: navigation::Swipe,
492 /// Asks for a frame when a worker thread finishes something the page shows.503 /// Asks for a frame when a worker thread finishes something the page shows.
493 redraw: std::task::Waker,504 redraw: std::task::Waker,
494 surface: surface::Surface,505 surface: surface::Surface,
...@@ -547,8 +558,6 @@ struct State {...@@ -547,8 +558,6 @@ struct State {
547 recent_fonts: Vec<String>,558 recent_fonts: Vec<String>,
548 /// What the toolbar's buttons apply from their menus' last picks.559 /// What the toolbar's buttons apply from their menus' last picks.
549 toolbar: settings::Toolbar,560 toolbar: settings::Toolbar,
550 /// The application's icon at the display's scale, for the title bar.
551 app_icon: Option<draw::RasterImage>,
552 commands: Vec<Command>,561 commands: Vec<Command>,
553 /// The page changed during this frame, to be saved and announced after it.562 /// The page changed during this frame, to be saved and announced after it.
554 changed: bool,563 changed: bool,
...@@ -567,6 +576,9 @@ struct State {...@@ -567,6 +576,9 @@ struct State {
567 strip_held: bool,576 strip_held: bool,
568 /// Where a replay asked the next frame to be written.577 /// Where a replay asked the next frame to be written.
569 snapshot: Option<PathBuf>,578 snapshot: Option<PathBuf>,
579 /// `SNOWBOUND_FRAMES`: a directory every frame drawn is also written to, named by
580 /// milliseconds since the window opened.
581 frames: Option<(PathBuf, Instant)>,
570 initial: Vec<(onestore::ExGuid, TextDocument)>,582 initial: Vec<(onestore::ExGuid, TextDocument)>,
571 initial_layouts: Vec<(onestore::ExGuid, onestore::document::Layout)>,583 initial_layouts: Vec<(onestore::ExGuid, onestore::document::Layout)>,
572 initial_date: Option<u64>,584 initial_date: Option<u64>,
...@@ -590,6 +602,10 @@ fn frame() -> Id {...@@ -590,6 +602,10 @@ fn frame() -> Id {
590 Id::ROOT.child("frame")602 Id::ROOT.child("frame")
591}603}
592604
605fn sections() -> Id {
606 Id::ROOT.child("sections")
607}
608
593impl State {609impl State {
594 async fn new(610 async fn new(
595 event_loop: &ActiveEventLoop,611 event_loop: &ActiveEventLoop,
...@@ -624,6 +640,7 @@ impl State {...@@ -624,6 +640,7 @@ impl State {
624 .with_min_inner_size(LogicalSize::new(600.0, 400.0)),640 .with_min_inner_size(LogicalSize::new(600.0, 400.0)),
625 )?,641 )?,
626 );642 );
643 platform::install_title_bar(&window);
627 platform::install_text_input(&window);644 platform::install_text_input(&window);
628 // Beneath the surface's layer, which is added over it.645 // Beneath the surface's layer, which is added over it.
629 let backdrop = visible && platform::install_backdrop(&window);646 let backdrop = visible && platform::install_backdrop(&window);
...@@ -743,6 +760,7 @@ impl State {...@@ -743,6 +760,7 @@ impl State {
743 let titlebar = platform::titlebar(appearance).unwrap_or([ui.theme.strip; 2]);760 let titlebar = platform::titlebar(appearance).unwrap_or([ui.theme.strip; 2]);
744 // A window shown but never focused hears no focus event; a hidden one draws as focused.761 // A window shown but never focused hears no focus event; a hidden one draws as focused.
745 ui.window_focused = !visible || window.has_focus();762 ui.window_focused = !visible || window.has_focus();
763 ui.scrollers = platform::scrollers();
746 ui.set_focus(Some(page()));764 ui.set_focus(Some(page()));
747 for family in FONTS {765 for family in FONTS {
748 for (face, _) in engine.substitute(family).map_or(&[][..], |s| &s.faces) {766 for (face, _) in engine.substitute(family).map_or(&[][..], |s| &s.faces) {
...@@ -806,7 +824,6 @@ impl State {...@@ -806,7 +824,6 @@ impl State {
806 fonts,824 fonts,
807 recent_fonts: stored.recent_fonts,825 recent_fonts: stored.recent_fonts,
808 toolbar: stored.toolbar,826 toolbar: stored.toolbar,
809 app_icon: platform::app_icon((16.0 * dpr).round() as u32),
810 commands: Vec::new(),827 commands: Vec::new(),
811 changed: false,828 changed: false,
812 moved: false,829 moved: false,
...@@ -816,6 +833,7 @@ impl State {...@@ -816,6 +833,7 @@ impl State {
816 strip_press: None,833 strip_press: None,
817 strip_held: false,834 strip_held: false,
818 snapshot: None,835 snapshot: None,
836 frames: std::env::var_os("SNOWBOUND_FRAMES").map(|dir| (dir.into(), Instant::now())),
819 initial,837 initial,
820 initial_date,838 initial_date,
821 initial_layouts,839 initial_layouts,
...@@ -829,6 +847,7 @@ impl State {...@@ -829,6 +847,7 @@ impl State {
829 places: HashMap::new(),847 places: HashMap::new(),
830 last_pages: HashMap::new(),848 last_pages: HashMap::new(),
831 trail: navigation::Trail::default(),849 trail: navigation::Trail::default(),
850 swipe: navigation::Swipe::default(),
832 access_adapter,851 access_adapter,
833 accessibility: accessibility::Accessibility::default(),852 accessibility: accessibility::Accessibility::default(),
834 };853 };
...@@ -903,10 +922,32 @@ impl State {...@@ -903,10 +922,32 @@ impl State {
903 let (section, open_tab, open_page) = self.build()?;922 let (section, open_tab, open_page) = self.build()?;
904 self.options_dialog();923 self.options_dialog();
905 self.link_dialog()?;924 self.link_dialog()?;
925 self.palette();
906 self.sync_popup()?;926 self.sync_popup()?;
907 self.text_menu()?;927 self.text_menu()?;
928 platform::resize_grip(&mut self.ui, &self.window, size);
908 self.ui.end();929 self.ui.end();
909 self.edges(section, open_tab, open_page);930 self.edges(section, open_tab, open_page);
931 if platform::cuts_corners() {
932 let [width, height] = size;
933 let radius = platform::corner_radius(&self.window);
934 // Where the toolbar's row is the title bar, its corners are the window's too.
935 let top = if platform::system_titlebar(&self.window) {
936 0.0
937 } else {
938 radius
939 };
940 self.ui.round_corners(
941 &[
942 ([0.0, 0.0], top),
943 ([width, 0.0], top),
944 ([width, height], radius),
945 ([0.0, height], radius),
946 ],
947 [0.0; 4],
948 |_| [0.0; 4],
949 );
950 }
910 if let Some(rect) = self.ui.rect(page()) {951 if let Some(rect) = self.ui.rect(page()) {
911 let size = [952 let size = [
912 ((rect[2] - rect[0]) * scale).round() as u32,953 ((rect[2] - rect[0]) * scale).round() as u32,
...@@ -978,15 +1019,13 @@ impl State {...@@ -978,15 +1019,13 @@ impl State {
978 // The page, and the open page's tab joined to it, take the page's colour.1019 // The page, and the open page's tab joined to it, take the page's colour.
979 let mut theme = self.ui.theme.clone();1020 let mut theme = self.ui.theme.clone();
980 theme.paper = self.paper().color;1021 theme.paper = self.paper().color;
981 if !platform::system_titlebar(&self.window) {
982 self.title_bar(&theme);
983 }
984 platform::update_menu(|| self.statuses());1022 platform::update_menu(|| self.statuses());
985 if self.session.is_none() && !self.temporary && self.sectionless.is_none() {1023 let welcome = self.session.is_none() && !self.temporary && self.sectionless.is_none();
1024 self.toolbar(&theme, !welcome)?;
1025 if welcome {
986 self.welcome(&theme);1026 self.welcome(&theme);
987 return Ok((section, Id::ROOT, None));1027 return Ok((section, Id::ROOT, None));
988 }1028 }
989 self.toolbar(&theme)?;
990 self.ui.open(1029 self.ui.open(
991 "body",1030 "body",
992 Spec {1031 Spec {
...@@ -1017,7 +1056,19 @@ impl State {...@@ -1017,7 +1056,19 @@ impl State {
1017 ..Spec::default()1056 ..Spec::default()
1018 },1057 },
1019 );1058 );
1020 self.sidebar_button(&theme, sidebar);1059 if !self.temporary {
1060 // Past the row's padding, the frame's corner and the first tab's shadow, the tabs'
1061 // outlines start where the button's square ends; the room shrinks on the sidebar's
1062 // easing, so the tabs ease with it.
1063 let room = TAB_ROW - FRAME - self.rounding() - ui::SHADOW[0];
1064 self.ui.leaf(
1065 "rail",
1066 Spec {
1067 size: [px(room * (1.0 - sidebar / sidebar::WIDTH)), px(1.0)],
1068 ..Spec::default()
1069 },
1070 );
1071 }
1021 self.ui.leaf(1072 self.ui.leaf(
1022 "corner",1073 "corner",
1023 Spec {1074 Spec {
...@@ -1025,7 +1076,7 @@ impl State {...@@ -1025,7 +1076,7 @@ impl State {
1025 ..Spec::default()1076 ..Spec::default()
1026 },1077 },
1027 );1078 );
1028 let row = self.ui.id("sections");1079 let row = sections();
1029 let (clicked, open_tab) = match &self.session {1080 let (clicked, open_tab) = match &self.session {
1030 Some(session) => {1081 Some(session) => {
1031 // A tab being renamed takes the name typed, which its field covers.1082 // A tab being renamed takes the name typed, which its field covers.
...@@ -1054,13 +1105,14 @@ impl State {...@@ -1054,13 +1105,14 @@ impl State {
1054 open: open_tab,1105 open: open_tab,
1055 } = ui::shell::section_tabs(1106 } = ui::shell::section_tabs(
1056 &mut self.ui,1107 &mut self.ui,
1057 "sections",1108 row,
1058 &tabs,1109 &tabs,
1059 session.tab,1110 session.tab,
1060 lit,1111 lit,
1061 dragged,1112 dragged,
1062 &section,1113 &section,
1063 TAB_ROW,1114 TAB_ROW,
1115 theme.strip,
1064 );1116 );
1065 self.drag_tabs(held, slot, settled, row);1117 self.drag_tabs(held, slot, settled, row);
1066 let Some(session) = &self.session else {1118 let Some(session) = &self.session else {
...@@ -1100,19 +1152,27 @@ impl State {...@@ -1100,19 +1152,27 @@ impl State {
1100 let shown = if self.temporary { &tabs[..] } else { &[] };1152 let shown = if self.temporary { &tabs[..] } else { &[] };
1101 let open_tab = ui::shell::section_tabs(1153 let open_tab = ui::shell::section_tabs(
1102 &mut self.ui,1154 &mut self.ui,
1103 "sections",1155 row,
1104 shown,1156 shown,
1105 0,1157 0,
1106 None,1158 None,
1107 None,1159 None,
1108 &section,1160 &section,
1109 TAB_ROW,1161 TAB_ROW,
1162 theme.strip,
1110 )1163 )
1111 .open;1164 .open;
1112 (None, open_tab)1165 (None, open_tab)
1113 }1166 }
1114 };1167 };
1115 self.commands.extend(clicked);1168 self.commands.extend(clicked);
1169 self.ui.leaf(
1170 "space",
1171 Spec {
1172 size: [fill(), px(1.0)],
1173 ..Spec::default()
1174 },
1175 );
1116 if self.session.is_some() {1176 if self.session.is_some() {
1117 self.page_tools(&theme);1177 self.page_tools(&theme);
1118 }1178 }
...@@ -1153,6 +1213,7 @@ impl State {...@@ -1153,6 +1213,7 @@ impl State {
1153 self.ui.close();1213 self.ui.close();
1154 self.ui.close();1214 self.ui.close();
1155 self.ui.close();1215 self.ui.close();
1216 self.sidebar_button(&theme, height);
1156 self.ui.close();1217 self.ui.close();
1157 self.context_menu();1218 self.context_menu();
1158 return Ok((section, open_tab, None));1219 return Ok((section, open_tab, None));
...@@ -1195,6 +1256,8 @@ impl State {...@@ -1195,6 +1256,8 @@ impl State {
1195 let open_page = self.page_list(&theme, &section, row);1256 let open_page = self.page_list(&theme, &section, row);
1196 self.ui.close();1257 self.ui.close();
1197 self.ui.close();1258 self.ui.close();
1259 self.task_pane(&theme);
1260 self.sidebar_button(&theme, height);
1198 self.ui.close();1261 self.ui.close();
1199 self.context_menu();1262 self.context_menu();
1200 // No tab stands on the frame in Full Page View.1263 // No tab stands on the frame in Full Page View.
...@@ -1291,10 +1354,16 @@ impl State {...@@ -1291,10 +1354,16 @@ impl State {
1291 |_| strip,1354 |_| strip,
1292 );1355 );
1293 }1356 }
1357 // The border breaks where the open tab shows, which the row may scroll it out of.
1358 let [left, right] = self
1359 .ui
1360 .rect(sections())
1361 .map_or([end; 2], |row| [row[0], row[2]]);
1294 let [foot, toe] = self1362 let [foot, toe] = self
1295 .ui1363 .ui
1296 .rect(open_tab)1364 .rect(open_tab)
1297 .map_or([end; 2], |tab| ui::shell::tab_base(tab, TAB_ROW));1365 .map_or([end; 2], |tab| ui::shell::tab_base(tab, TAB_ROW))
1366 .map(|x| x.clamp(left, right));
1298 // The border runs down both sides to the window's bottom, round the bottom corner1367 // The border runs down both sides to the window's bottom, round the bottom corner
1299 // beside the sidebar.1368 // beside the sidebar.
1300 self.ui.border(1369 self.ui.border(
...@@ -1318,62 +1387,75 @@ impl State {...@@ -1318,62 +1387,75 @@ impl State {
1318 );1387 );
1319 }1388 }
13201389
1321 /// The window's title bar beside the traffic lights: the application's icon, the1390 /// The toolbar: one row of groups that fold as the window narrows. Where the platform
1322 /// notebook's name and the sync status.1391 /// lets it, the row is the window's title bar, and on the welcome page, without `tools`,
1323 fn title_bar(&mut self, theme: &Theme) {1392 /// that is all it is.
1324 self.ui.open_as(1393 fn toolbar(&mut self, theme: &Theme, tools: bool) -> Result<(), Box<dyn Error>> {
1325 strip(),1394 let title = !platform::system_titlebar(&self.window);
1326 Spec {1395 if !tools && !title {
1327 flags: Flags::CLICKABLE,1396 return Ok(());
1328 size: [fill(), px(TITLE)],1397 }
1329 fill: Some(theme.strip),1398 // Over the title's gradient, the row's empty space drags the window as the title does.
1330 ..Spec::default()1399 let strip_row = title || self.surface.translucent();
1400 let spec = Spec {
1401 flags: if strip_row {
1402 Flags::CLICKABLE
1403 } else {
1404 Flags::default()
1331 },1405 },
1332 );1406 size: [fill(), px(TITLE)],
1407 fill: Some(theme.strip),
1408 pad: [0.0, (TITLE - ui::shell::TOOL) / 2.0],
1409 gap: GAP,
1410 ..Spec::default()
1411 };
1412 if strip_row {
1413 self.ui.open_as(strip(), spec);
1414 } else {
1415 self.ui.open("toolbar", spec);
1416 }
1417 let lead = if title { platform::LEADING } else { EDGE };
1333 self.ui.leaf(1418 self.ui.leaf(
1334 "lights",1419 "lead",
1335 Spec {1420 Spec {
1336 size: [px(platform::LEADING), px(1.0)],1421 size: [px(lead - GAP), px(1.0)],
1337 ..Spec::default()1422 ..Spec::default()
1338 },1423 },
1339 );1424 );
1340 // AppKit centres the window's title over the strip.1425 if tools {
1341 if !cfg!(target_os = "macos") {1426 self.tools(theme);
1427 } else {
1342 self.ui.leaf(1428 self.ui.leaf(
1343 "notebook",1429 "space",
1344 Spec {1430 Spec {
1345 size: [fit(), px(TITLE)],1431 size: [fill(), px(1.0)],
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 }),
1352 ..Spec::default()1432 ..Spec::default()
1353 },1433 },
1354 );1434 );
1355 }1435 }
1436 if title {
1437 platform::window_controls(&mut self.ui, &self.window);
1438 }
1356 self.ui.leaf(1439 self.ui.leaf(
1357 "space",1440 "trail",
1358 Spec {1441 Spec {
1359 size: [fill(), px(1.0)],1442 size: [px(EDGE - GAP), px(1.0)],
1360 ..Spec::default()1443 ..Spec::default()
1361 },1444 },
1362 );1445 );
1363 if let Some(session) = &self.session {
1364 sync::control(&mut self.ui, session, theme, TITLE);
1365 }
1366 platform::window_controls(&mut self.ui, &self.window);
1367 self.ui.close();1446 self.ui.close();
1447 Ok(())
1368 }1448 }
13691449
1370 /// Two rows of navigation, clipboard, formatting, tag, insert and view buttons, grouped1450 /// The toolbar's groups, as OneNote's ribbon groups its buttons. The groups used most in
1371 /// as OneNote's ribbon groups them.1451 /// taking notes fold last, and those with chords or a place in the menu bar first.
1372 fn toolbar(&mut self, theme: &Theme) -> Result<(), Box<dyn Error>> {1452 fn tools(&mut self, theme: &Theme) {
1453 use Entry::{Open, Rule, Run, Soon};
1373 use canvas::editor::{1454 use canvas::editor::{
1374 Alignment, BULLET_LIBRARY, ListStyle, NUMBER_LIBRARY, NoteTag, Toggle,1455 Alignment, BULLET_LIBRARY, ListStyle, NUMBER_LIBRARY, NoteTag, Toggle,
1375 };1456 };
1376 use commands::{Choice, Id as Cmd, shortcut};1457 use commands::{Choice, Id as Cmd};
1458 use ui::shell::TOOL;
1377 // A focused picture has no text to show a format for.1459 // A focused picture has no text to show a format for.
1378 let state = self.format_state();1460 let state = self.format_state();
1379 let statuses: Vec<_> = commands::COMMANDS1461 let statuses: Vec<_> = commands::COMMANDS
...@@ -1391,52 +1473,39 @@ impl State {...@@ -1391,52 +1473,39 @@ impl State {
1391 .find(|(listed, _)| *listed == id)1473 .find(|(listed, _)| *listed == id)
1392 .map_or_else(commands::Status::default, |(_, status)| *status)1474 .map_or_else(commands::Status::default, |(_, status)| *status)
1393 };1475 };
1476 let status_of = &status_of;
1394 let fonts = &self.fonts;1477 let fonts = &self.fonts;
1395 let recent_fonts = &self.recent_fonts;1478 let recent_fonts = &self.recent_fonts;
1396 let pens = &self.toolbar;1479 let pens = &self.toolbar;
1397 let engine = &self.view.engine;1480 let engine = &self.view.engine;
1398 // Under the system's title bar the sync status joins the toolbar.1481 let session = self.session.as_ref();
1399 let status = platform::system_titlebar(&self.window)1482 let label = format!("{:.0}%", self.view.zoom() * 100.0);
1400 .then_some(self.session.as_ref())
1401 .flatten();
1402 let ui = &mut self.ui;1483 let ui = &mut self.ui;
1403 let text = theme.text;1484 let text = theme.text;
1404 let on = |toggle| state.toggles.contains(&toggle);1485 let tags = NoteTag::ALL.map(Cmd::Tag);
1405 let mut choice = None;1486 let mut choice = group(
1406 ui.open(1487 ui,
1407 "toolbar",1488 "navigate",
1408 Spec {1489 3,
1409 size: [fill(), children()],1490 |ui| {
1410 fill: Some(theme.strip),1491 let mut choice = None;
1411 pad: [8.0, 3.0],1492 for id in [Cmd::Back, Cmd::Forward, Cmd::Undo, Cmd::Redo] {
1412 gap: 6.0,1493 choice = tool(ui, id, status_of(id)).or(choice);
1413 ..Spec::default()1494 }
1495 choice
1496 },
1497 |ui| {
1498 let entries = [Run(Cmd::Redo), Rule, Run(Cmd::Back), Run(Cmd::Forward)];
1499 dropdown(ui, "menu", Head::Split(Cmd::Undo), &entries, status_of)
1414 },1500 },
1415 );1501 );
1416 group(ui, "navigate", |ui| {1502 choice = group(
1417 for (index, buttons) in [1503 ui,
1418 [1504 "clipboard",
1419 ("back", art::BACK, Cmd::Back),1505 2,
1420 ("forward", art::FORWARD, Cmd::Forward),1506 |ui| {
1421 ],1507 divider(ui, theme);
1422 [1508 let mut choice = None;
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| {
1440 let paste = ui.id("paste");1509 let paste = ui.id("paste");
1441 if !status_of(Cmd::Paste).enabled {1510 if !status_of(Cmd::Paste).enabled {
1442 ui::shell::unavailable(ui, "paste", art::PASTE, text, true);1511 ui::shell::unavailable(ui, "paste", art::PASTE, text, true);
...@@ -1474,138 +1543,251 @@ impl State {...@@ -1474,138 +1543,251 @@ impl State {
1474 if ui::popup::menu(ui, toolbar_popup("paste"), anchor, &items, None).is_some() {1543 if ui::popup::menu(ui, toolbar_popup("paste"), anchor, &items, None).is_some() {
1475 choice = Some(Choice::Command(Cmd::Paste));1544 choice = Some(Choice::Command(Cmd::Paste));
1476 }1545 }
1477 choice = tool(ui, "cut", art::CUT, text, Cmd::Cut, status_of(Cmd::Cut))1546 for id in [Cmd::Cut, Cmd::Copy, Cmd::FormatPainter] {
1478 .or(choice.take());1547 choice = tool(ui, id, status_of(id)).or(choice);
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());
1486 }1548 }
1487 });1549 choice
1488 });1550 },
1489 divider(ui, "clipboard", theme);1551 |ui| {
1552 let entries = [
1553 Run(Cmd::Cut),
1554 Run(Cmd::Copy),
1555 Run(Cmd::FormatPainter),
1556 Rule,
1557 Open("paste", Cmd::Paste),
1558 ];
1559 dropdown(ui, "menu", Head::Split(Cmd::Paste), &entries, status_of)
1560 },
1561 )
1562 .or(choice);
1490 let font = state.font.clone().unwrap_or_default();1563 let font = state.font.clone().unwrap_or_default();
1491 let size = state1564 let size = state
1492 .font_size1565 .font_size
1493 .map_or(String::new(), |size| format!("{size}"));1566 .map_or(String::new(), |size| format!("{size}"));
1494 group(ui, "text", |ui| {1567 ui.open(
1495 row(ui, 0, |ui| {1568 "font",
1496 let combo = ui.id("font");1569 Spec {
1497 if ui::shell::combo(ui, "font", &font, 120.0).pressed {1570 gap: 1.0,
1498 ui.open_popup(toolbar_popup("font"));1571 ..Spec::default()
1499 }1572 },
1500 // Each family is named with the substitute it shows in, and picked by its1573 );
1501 // own name; a family of None heads a group. Typing searches the full list.1574 divider(ui, theme);
1502 let label = |name: &str| {1575 let combo = ui.id("font");
1503 engine.substitute(name).map_or_else(1576 // It gives up room last of all, once every group has folded.
1504 || name.to_owned(),1577 let width = ui::Extent {
1505 |substitute| format!("{} ({name})", substitute.name),1578 size: ui::Size::Pixels(120.0),
1506 )1579 strictness: 64.0 / 120.0,
1507 };1580 };
1508 let mut choices: Vec<(String, Option<&str>, bool)> = Vec::new();1581 if ui::shell::combo(ui, "font", &font, width).pressed {
1509 if ui::popup::query(ui, toolbar_popup("font")).is_none_or(str::is_empty) {1582 ui.open_popup(toolbar_popup("font"));
1510 choices.extend(FONTS.map(|name| (label(name), Some(name), false)));1583 }
1511 if !recent_fonts.is_empty() {1584 // Each family is named with the substitute it shows in, and picked by its own name; a
1512 choices.push(("Recent".to_owned(), None, true));1585 // family of None heads a group. Typing searches the full list.
1513 choices.extend(1586 let named = |name: &str| {
1514 recent_fonts1587 engine.substitute(name).map_or_else(
1588 || name.to_owned(),
1589 |substitute| format!("{} ({name})", substitute.name),
1590 )
1591 };
1592 let mut choices: Vec<(String, Option<&str>, bool)> = Vec::new();
1593 if ui::popup::query(ui, toolbar_popup("font")).is_none_or(str::is_empty) {
1594 choices.extend(FONTS.map(|name| (named(name), Some(name), false)));
1595 if !recent_fonts.is_empty() {
1596 choices.push(("Recent".to_owned(), None, true));
1597 choices.extend(
1598 recent_fonts
1599 .iter()
1600 .map(|name| (named(name), Some(name.as_str()), false)),
1601 );
1602 }
1603 }
1604 let listed = choices.len();
1605 choices.extend(
1606 fonts
1607 .iter()
1608 .enumerate()
1609 .map(|(index, name)| (named(name), Some(name.as_str()), index == 0 && listed > 0)),
1610 );
1611 let items: Vec<_> = choices
1612 .iter()
1613 .map(|(label, name, separated)| ui::popup::Item {
1614 text: label,
1615 font: *name,
1616 current: *name == Some(font.as_str()),
1617 heading: name.is_none(),
1618 separated: *separated,
1619 ..Default::default()
1620 })
1621 .collect();
1622 let anchor = ui::Anchor::Over(ui.rect(combo).unwrap_or_default());
1623 if let Some(index) =
1624 ui::popup::menu(ui, toolbar_popup("font"), anchor, &items, Some("Font"))
1625 && let Some(name) = choices[index].1
1626 {
1627 choice = Some(Choice::Font(name.to_owned()));
1628 }
1629 let combo = ui.id("size");
1630 if ui::shell::combo(ui, "size", &size, 44.0).pressed {
1631 ui.open_popup(toolbar_popup("size"));
1632 }
1633 // A size typed in the field joins the list, in half points as stored.
1634 let mut sizes = SIZES.to_vec();
1635 if let Some(typed) = ui::popup::query(ui, toolbar_popup("size"))
1636 .and_then(|query| query.trim().parse::<f32>().ok())
1637 .map(|typed| (typed * 2.0).round() / 2.0)
1638 .filter(|typed| onestore::FONT_SIZES.contains(typed) && !sizes.contains(typed))
1639 {
1640 let at = sizes.partition_point(|size| *size < typed);
1641 sizes.insert(at, typed);
1642 }
1643 let labels: Vec<_> = sizes.iter().map(|size| format!("{size}")).collect();
1644 let items: Vec<_> = labels
1645 .iter()
1646 .map(|label| ui::popup::Item {
1647 text: label,
1648 checked: *label == size,
1649 ..Default::default()
1650 })
1651 .collect();
1652 let anchor = ui::Anchor::Over(ui.rect(combo).unwrap_or_default());
1653 if let Some(index) = ui::popup::menu(ui, toolbar_popup("size"), anchor, &items, Some(&size))
1654 {
1655 choice = Some(Choice::Size(sizes[index]));
1656 }
1657 for toggle in [Toggle::Bold, Toggle::Italic, Toggle::Underline] {
1658 let id = Cmd::Toggle(toggle);
1659 choice = tool(ui, id, status_of(id)).or(choice);
1660 }
1661 ui.close();
1662 choice = group(
1663 ui,
1664 "character",
1665 6,
1666 |ui| {
1667 divider(ui, theme);
1668 let mut choice = None;
1669 // Each button applies its menu's last pick: the highlighter shows it in its
1670 // artwork, the font colour in a bar.
1671 for (part, id, swatches, columns, none, bar) in [
1672 (
1673 "highlight",
1674 Cmd::Highlight,
1675 &HIGHLIGHTS.map(|(color, _)| color)[..],
1676 5,
1677 "No Color",
1678 None,
1679 ),
1680 (
1681 "color",
1682 Cmd::FontColor,
1683 &FONT_COLORS[..],
1684 10,
1685 "Automatic",
1686 Some(pens.font_color.map_or(text, colorref)),
1687 ),
1688 ] {
1689 let split = ui.id(part);
1690 let icon = artwork(id).unwrap_or_default();
1691 if ui::shell::split_button(ui, part, icon, bar, false, toolbar_popup(part))
1692 .clicked
1693 {
1694 choice = Some(Choice::Command(id));
1695 }
1696 tip(ui, id);
1697 let colors: Vec<_> = swatches.iter().map(|color| colorref(*color)).collect();
1698 let anchor = ui::Anchor::Below(ui.rect(split).unwrap_or_default());
1699 if let Some(chosen) =
1700 ui::popup::colors(ui, toolbar_popup(part), anchor, none, &colors, columns)
1701 {
1702 let color = chosen.and_then(|chosen| {
1703 swatches
1515 .iter()1704 .iter()
1516 .map(|name| (label(name), Some(name.as_str()), false)),1705 .zip(&colors)
1517 );1706 .find(|(_, color)| **color == chosen)
1707 .map(|(stored, _)| *stored)
1708 });
1709 choice = Some(if id == Cmd::Highlight {
1710 Choice::Highlight(color)
1711 } else {
1712 Choice::Color(color)
1713 });
1518 }1714 }
1519 }1715 }
1520 let listed = choices.len();1716 dropdown(
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(
1574 ui,1717 ui,
1575 "bullets",1718 "script",
1576 art::BULLETS,1719 Head::Menu(art::STRIKETHROUGH),
1577 None,1720 &[
1578 state.bullets,1721 Run(Cmd::Toggle(Toggle::Strikethrough)),
1579 toolbar_popup("bullets"),1722 Run(Cmd::Toggle(Toggle::Subscript)),
1580 )1723 Run(Cmd::Toggle(Toggle::Superscript)),
1581 .clicked1724 Rule,
1582 {1725 Run(Cmd::ClearFormatting),
1583 choice = Some(Choice::Command(Cmd::Bullets));1726 ],
1584 }1727 status_of,
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"),
1593 )1728 )
1594 .clicked1729 .or(choice)
1595 {1730 },
1596 choice = Some(Choice::Command(Cmd::Numbering));1731 |ui| {
1732 let entries = [
1733 Open("highlight", Cmd::Highlight),
1734 Run(Cmd::FontColor),
1735 Open("color", Cmd::FontColor),
1736 Rule,
1737 Run(Cmd::Toggle(Toggle::Strikethrough)),
1738 Run(Cmd::Toggle(Toggle::Subscript)),
1739 Run(Cmd::Toggle(Toggle::Superscript)),
1740 Rule,
1741 Run(Cmd::ClearFormatting),
1742 ];
1743 dropdown(ui, "menu", Head::Split(Cmd::Highlight), &entries, status_of)
1744 },
1745 )
1746 .or(choice);
1747 let alignments = [Alignment::Left, Alignment::Center, Alignment::Right].map(Cmd::Align);
1748 let paragraph = [
1749 Rule,
1750 Run(alignments[0]),
1751 Run(alignments[1]),
1752 Run(alignments[2]),
1753 Rule,
1754 Run(Cmd::Outdent),
1755 Run(Cmd::Indent),
1756 ];
1757 choice = group(
1758 ui,
1759 "paragraph",
1760 5,
1761 |ui| {
1762 divider(ui, theme);
1763 let mut choice = None;
1764 // The buttons apply OneNote's default bullet and number, as its own do.
1765 for id in [Cmd::Bullets, Cmd::Numbering] {
1766 let part = if id == Cmd::Bullets {
1767 "bullets"
1768 } else {
1769 "numbering"
1770 };
1771 let icon = artwork(id).unwrap_or_default();
1772 let on = status_of(id).checked;
1773 if ui::shell::split_button(ui, part, icon, None, on, toolbar_popup(part))
1774 .clicked
1775 {
1776 choice = Some(Choice::Command(id));
1777 }
1778 tip(ui, id);
1597 }1779 }
1598 tip(ui, Cmd::Numbering);1780 let [bullets, numbering] =
1781 ["bullets", "numbering"].map(|part| ui.rect(ui.id(part)).unwrap_or_default());
1599 let current = |bullet| match state.list {1782 let current = |bullet| match state.list {
1600 Some(ListStyle::Bullet(place)) if bullet => Some(place),1783 Some(ListStyle::Bullet(place)) if bullet => Some(place),
1601 Some(ListStyle::Number(place)) if !bullet => Some(place),1784 Some(ListStyle::Number(place)) if !bullet => Some(place),
1602 _ => None,1785 _ => None,
1603 };1786 };
1604 let anchor = ui::Anchor::Below(ui.rect(bullets).unwrap_or_default());
1605 if let Some(place) = list_gallery(1787 if let Some(place) = list_gallery(
1606 ui,1788 ui,
1607 toolbar_popup("bullets"),1789 toolbar_popup("bullets"),
1608 anchor,1790 ui::Anchor::Below(bullets),
1609 ["Recently Used Bullets", "Bullet Library"],1791 ["Recently Used Bullets", "Bullet Library"],
1610 (&pens.bullets, BULLET_LIBRARY.len()),1792 (&pens.bullets, BULLET_LIBRARY.len()),
1611 current(true),1793 current(true),
...@@ -1627,11 +1809,10 @@ impl State {...@@ -1627,11 +1809,10 @@ impl State {
1627 ) {1809 ) {
1628 choice = Some(Choice::List(place.map(ListStyle::Bullet)));1810 choice = Some(Choice::List(place.map(ListStyle::Bullet)));
1629 }1811 }
1630 let anchor = ui::Anchor::Below(ui.rect(numbering).unwrap_or_default());
1631 if let Some(place) = list_gallery(1812 if let Some(place) = list_gallery(
1632 ui,1813 ui,
1633 toolbar_popup("numbering"),1814 toolbar_popup("numbering"),
1634 anchor,1815 ui::Anchor::Below(numbering),
1635 ["Recently Used Number Formats", "Numbering Library"],1816 ["Recently Used Number Formats", "Numbering Library"],
1636 (&pens.numbering, NUMBER_LIBRARY.len()),1817 (&pens.numbering, NUMBER_LIBRARY.len()),
1637 current(false),1818 current(false),
...@@ -1673,234 +1854,68 @@ impl State {...@@ -1673,234 +1854,68 @@ impl State {
1673 ) {1854 ) {
1674 choice = Some(Choice::List(place.map(ListStyle::Number)));1855 choice = Some(Choice::List(place.map(ListStyle::Number)));
1675 }1856 }
1676 let clear = Cmd::ClearFormatting;1857 let aligned = alignments
1677 choice = tool(1858 .into_iter()
1678 ui,1859 .find(|id| status_of(*id).checked)
1679 "clear",1860 .unwrap_or(alignments[0]);
1680 art::CLEAR_FORMATTING,1861 let icon = artwork(aligned).unwrap_or_default();
1681 text,1862 dropdown(ui, "align", Head::Menu(icon), &paragraph[1..], status_of).or(choice)
1682 clear,1863 },
1683 status_of(clear),1864 |ui| {
1684 )1865 let mut entries = vec![
1685 .or(choice.take());1866 Open("bullets", Cmd::Bullets),
1686 });1867 Run(Cmd::Numbering),
1687 row(ui, 1, |ui| {1868 Open("numbering", Cmd::Numbering),
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),
1805 ];1869 ];
1806 let keys = alignments.map(|(alignment, _)| shortcut(Cmd::Align(alignment)));1870 entries.extend(paragraph);
1807 let current = alignments1871 dropdown(ui, "menu", Head::Split(Cmd::Bullets), &entries, status_of)
1808 .iter()1872 },
1809 .find(|(alignment, _)| Some(*alignment) == state.alignment)1873 )
1810 .unwrap_or(&alignments[0]);1874 .or(choice);
1811 let anchor = ui::shell::menu_button(ui, "align", current.1, toolbar_popup("align"));1875 // The gallery's menu: every tag, then managing them.
1812 tip(ui, Cmd::Align(current.0));1876 let mut all_tags: Vec<_> = tags.into_iter().map(Run).collect();
1813 // The current alignment starts highlighted, keeping its icon under the button's.1877 all_tags.extend([
1814 let items: Vec<_> = alignments1878 Rule,
1815 .iter()1879 // Customize Tags awaits #8.
1816 .zip(&keys)1880 Soon("Customize Tags…", None),
1817 .map(|((alignment, icon), key)| ui::popup::Item {1881 Run(Cmd::RemoveTags),
1818 text: commands::command(Cmd::Align(*alignment)).title,1882 Rule,
1819 icon: Some(icon),1883 Run(Cmd::FindTags),
1820 shortcut: key,1884 ]);
1821 current: Some(*alignment) == state.alignment,1885 choice = group(
1822 ..Default::default()1886 ui,
1823 })1887 "tags",
1824 .collect();1888 7,
1825 if let Some(index) =1889 |ui| {
1826 ui::popup::menu(ui, toolbar_popup("align"), anchor, &items, None)1890 divider(ui, theme);
1827 {1891 let mut choice = None;
1828 choice = Some(Choice::Command(Cmd::Align(alignments[index].0)));1892 for id in &tags[..3] {
1893 choice = tool(ui, *id, status_of(*id)).or(choice);
1829 }1894 }
1830 });1895 dropdown(ui, "tags", Head::More, &all_tags, status_of).or(choice)
1831 });1896 },
1832 divider(ui, "text", theme);1897 |ui| dropdown(ui, "menu", Head::Split(tags[0]), &all_tags, status_of),
1833 let tags = NoteTag::ALL.map(|tag| (tag_icon(tag), tag));1898 )
1834 group(ui, "tags", |ui| {1899 .or(choice);
1835 // OneNote's first nine, Ctrl+1 to Ctrl+9, then the gallery's arrow.1900 let inserted = [Cmd::Picture, Cmd::Link, Cmd::Date, Cmd::Equation];
1836 for (index, shown) in tags[..9].chunks(5).enumerate() {1901 let more = [
1837 row(ui, index, |ui| {1902 Cmd::ScreenClipping,
1838 for (column, (icon, tag)) in shown.iter().enumerate() {1903 Cmd::Attachment,
1839 let id = Cmd::Tag(*tag);1904 Cmd::InsertSpace,
1840 choice =1905 Cmd::Time,
1841 tool(ui, column, icon, [1.0; 4], id, status_of(id)).or(choice.take());1906 Cmd::DateTime,
1842 }1907 Cmd::Symbol,
1843 if index == 1 {1908 Cmd::RecordAudio,
1844 let more = ui.id("more");1909 Cmd::RecordVideo,
1845 let open = ui.popup_open(toolbar_popup("tags"));1910 ]
1846 if ui::shell::tool_button(ui, "more", ui::shell::CHEVRON, text, open)1911 .map(Run);
1847 .pressed1912 choice = group(
1848 {1913 ui,
1849 ui.open_popup(toolbar_popup("tags"));1914 "insert",
1850 }1915 1,
1851 let shortcuts: Vec<_> = tags1916 |ui| {
1852 .iter()1917 divider(ui, theme);
1853 .map(|(_, tag)| shortcut(Cmd::Tag(*tag)))1918 let mut choice = None;
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| {
1904 if status_of(Cmd::Table).enabled {1919 if status_of(Cmd::Table).enabled {
1905 let anchor =1920 let anchor =
1906 ui::shell::menu_button(ui, "table", art::TABLE, toolbar_popup("table"));1921 ui::shell::menu_button(ui, "table", art::TABLE, toolbar_popup("table"));
...@@ -1913,30 +1928,20 @@ impl State {...@@ -1913,30 +1928,20 @@ impl State {
1913 } else {1928 } else {
1914 ui::shell::unavailable(ui, "table", art::TABLE, text, true);1929 ui::shell::unavailable(ui, "table", art::TABLE, text, true);
1915 }1930 }
1916 for (part, icon, id) in [1931 for id in inserted {
1917 ("picture", art::PICTURE, Cmd::Picture),1932 choice = tool(ui, id, status_of(id)).or(choice);
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());
1937 }1933 }
1938 });1934 dropdown(ui, "more", Head::More, &more, status_of).or(choice)
1939 });1935 },
1936 |ui| {
1937 let mut entries = vec![Open("table", Cmd::Table)];
1938 entries.extend(inserted.map(Run));
1939 entries.push(Rule);
1940 entries.extend(more);
1941 dropdown(ui, "menu", Head::Menu(art::PLUS), &entries, status_of)
1942 },
1943 )
1944 .or(choice);
1940 ui.leaf(1945 ui.leaf(
1941 "space",1946 "space",
1942 Spec {1947 Spec {
...@@ -1944,13 +1949,35 @@ impl State {...@@ -1944,13 +1949,35 @@ impl State {
1944 ..Spec::default()1949 ..Spec::default()
1945 },1950 },
1946 );1951 );
1947 if let Some(session) = status {1952 if let Some(session) = session {
1948 sync::control(ui, session, theme, ui::shell::TOOL);1953 ui.open(
1954 "sync",
1955 Spec {
1956 fold: Some(4),
1957 ..Spec::default()
1958 },
1959 );
1960 for compact in [false, true] {
1961 sync::control(ui, session, theme, compact);
1962 }
1963 ui.close();
1949 }1964 }
1950 let zoom = self.view.zoom();1965 // Spelling awaits a platform spell checker.
1951 let label = format!("{:.0}%", zoom * 100.0);1966 let views = [
1952 group(ui, "view", |ui| {1967 Run(Cmd::Sidebar),
1953 row(ui, 0, |ui| {1968 Run(Cmd::PageList),
1969 Run(Cmd::DarkPages),
1970 Run(Cmd::FullPageView),
1971 Rule,
1972 Soon("Spelling", Some(art::SPELLING)),
1973 ];
1974 choice = group(
1975 ui,
1976 "view",
1977 0,
1978 |ui| {
1979 divider(ui, theme);
1980 let mut choice = None;
1954 if status_of(Cmd::PageColor).enabled {1981 if status_of(Cmd::PageColor).enabled {
1955 let anchor = ui::shell::menu_button(1982 let anchor = ui::shell::menu_button(
1956 ui,1983 ui,
...@@ -1959,83 +1986,28 @@ impl State {...@@ -1959,83 +1986,28 @@ impl State {
1959 toolbar_popup("page color"),1986 toolbar_popup("page color"),
1960 );1987 );
1961 tip(ui, Cmd::PageColor);1988 tip(ui, Cmd::PageColor);
1962 let swatches: Vec<_> = PAGE_COLORS1989 choice = background::menu(
1963 .iter()
1964 .map(|(_, color)| colorref(*color))
1965 .collect();
1966 if let Some(chosen) = ui::popup::colors(
1967 ui,1990 ui,
1968 toolbar_popup("page color"),1991 toolbar_popup("page color"),
1969 anchor,1992 anchor,
1970 "No Color",1993 &mut self.thumbnails,
1971 &swatches,1994 canvas::gpu::Paper {
1972 4,1995 color: theme.paper,
1973 ) {1996 ink: theme.paper_ink,
1974 let index = chosen.and_then(|chosen| {1997 },
1975 swatches.iter().position(|swatch| *swatch == chosen)1998 self.view.editor.page_color(),
1976 });1999 self.view.editor.rule_lines(),
1977 choice = Some(Choice::PageColor(index));2000 )
1978 }2001 .or(choice.take());
1979 } else {2002 } else {
1980 ui::shell::unavailable(ui, "page color", art::PAGE_COLOR, text, true);2003 ui::shell::unavailable(ui, "page color", art::PAGE_COLOR, text, true);
1981 }2004 }
1982 // Nothing reads or writes rule lines yet.2005 choice = tool(ui, Cmd::ZoomOut, status_of(Cmd::ZoomOut)).or(choice);
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());
2034 let level = ui.leaf(2006 let level = ui.leaf(
2035 "level",2007 "level",
2036 Spec {2008 Spec {
2037 flags: Flags::CLICKABLE,2009 flags: Flags::CLICKABLE,
2038 size: [px(44.0), px(ui::shell::TOOL)],2010 size: [px(44.0), px(TOOL)],
2039 text: Some(&label),2011 text: Some(&label),
2040 hover_fill: Some(theme.hover()),2012 hover_fill: Some(theme.hover()),
2041 radius: 4.0,2013 radius: 4.0,
...@@ -2047,51 +2019,76 @@ impl State {...@@ -2047,51 +2019,76 @@ impl State {
2047 choice = Some(Choice::Command(Cmd::ActualSize));2019 choice = Some(Choice::Command(Cmd::ActualSize));
2048 }2020 }
2049 tip(ui, Cmd::ActualSize);2021 tip(ui, Cmd::ActualSize);
2050 choice = tool(2022 choice = tool(ui, Cmd::ZoomIn, status_of(Cmd::ZoomIn)).or(choice);
2051 ui,2023 dropdown(ui, "view", Head::More, &views, status_of).or(choice)
2052 "in",2024 },
2053 art::ZOOM_IN,2025 |ui| {
2054 text,2026 let mut entries = vec![
2055 Cmd::ZoomIn,2027 Run(Cmd::ZoomIn),
2056 status_of(Cmd::ZoomIn),2028 Run(Cmd::ZoomOut),
2057 )2029 Run(Cmd::ActualSize),
2058 .or(choice.take());2030 Rule,
2059 });2031 Open("page color", Cmd::PageColor),
2060 });2032 Rule,
2061 ui.close();2033 ];
2034 entries.extend(views);
2035 dropdown(ui, "menu", Head::Menu(art::ZOOM_IN), &entries, status_of)
2036 },
2037 )
2038 .or(choice);
2062 if let Some(choice) = choice {2039 if let Some(choice) = choice {
2063 self.choose(choice);2040 self.choose(choice);
2064 }2041 }
2065 Ok(())
2066 }2042 }
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.
2069 fn page_tools(&mut self, theme: &Theme) {2047 fn page_tools(&mut self, theme: &Theme) {
2048 let finding = self.search.finding;
2070 self.ui.open(2049 self.ui.open(
2071 "tools",2050 "tools",
2072 Spec {2051 Spec {
2073 size: [px(PAGE_LIST), px(TAB_ROW)],2052 fold: (!finding).then_some(0),
2074 pad: [0.0, (TAB_ROW - ui::shell::TOOL) / 2.0],
2075 gap: 3.0,
2076 ..Spec::default()2053 ..Spec::default()
2077 },2054 },
2078 );2055 );
2079 if let Err(error) = self.search_box(theme) {2056 for folded in [false, true].into_iter().take(if finding { 1 } else { 2 }) {
2080 eprintln!("{error}");2057 self.ui.open(
2081 }2058 folded,
2082 if ui::shell::tool_button(&mut self.ui, "new", art::PLUS, theme.text, false).clicked {2059 Spec {
2083 self.commands.push(Command::NewPage { under: None });2060 size: [if folded { children() } else { px(PAGE_LIST) }, px(TAB_ROW)],
2084 }2061 pad: [0.0, (TAB_ROW - ui::shell::TOOL) / 2.0],
2085 tip(&mut self.ui, commands::Id::NewPage);2062 gap: 3.0,
2086 let toggle = if self.pages_open {2063 ..Spec::default()
2087 art::SIDEBAR_COLLAPSE2064 },
2088 } else {2065 );
2089 art::SIDEBAR_EXPAND2066 if folded {
2090 };2067 let open = self.ui.popup_open(search::results());
2091 if ui::shell::tool_button(&mut self.ui, "toggle", toggle, theme.text, false).clicked {2068 if ui::shell::tool_button(&mut self.ui, "search", art::SEARCH, theme.text, open)
2092 self.pages_open = !self.pages_open;2069 .pressed
2070 {
2071 self.start_search();
2072 }
2073 tip(&mut self.ui, commands::Id::Search);
2074 } else if let Err(error) = self.search_box(theme) {
2075 eprintln!("{error}");
2076 }
2077 if ui::shell::tool_button(&mut self.ui, "new", art::PLUS, theme.text, false).clicked {
2078 self.commands.push(Command::NewPage { under: None });
2079 }
2080 tip(&mut self.ui, commands::Id::NewPage);
2081 let toggle = if self.pages_open {
2082 art::SIDEBAR_COLLAPSE
2083 } else {
2084 art::SIDEBAR_EXPAND
2085 };
2086 if ui::shell::tool_button(&mut self.ui, "toggle", toggle, theme.text, false).clicked {
2087 self.pages_open = !self.pages_open;
2088 }
2089 tip(&mut self.ui, commands::Id::PageList);
2090 self.ui.close();
2093 }2091 }
2094 tip(&mut self.ui, commands::Id::PageList);
2095 self.ui.close();2092 self.ui.close();
2096 }2093 }
20972094
...@@ -2941,6 +2938,17 @@ impl State {...@@ -2941,6 +2938,17 @@ impl State {
2941 if let Some(path) = self.snapshot.take() {2938 if let Some(path) = self.snapshot.take() {
2942 self.snapshot(&path)?;2939 self.snapshot(&path)?;
2943 }2940 }
2941 if let Some((dir, opened)) = &self.frames {
2942 let path = dir.join(format!("{:09.2}.png", opened.elapsed().as_secs_f64() * 1e3));
2943 let pixels = self.capture()?;
2944 let size = self.surface.size;
2945 // Encoding off the frame keeps the frames at the pace they are drawn.
2946 std::thread::spawn(move || {
2947 if let Err(error) = write_png(&path, size, &pixels) {
2948 eprintln!("{error}");
2949 }
2950 });
2951 }
2944 if self.occluded {2952 if self.occluded {
2945 return Ok(());2953 return Ok(());
2946 }2954 }
...@@ -3006,6 +3014,7 @@ impl State {...@@ -3006,6 +3014,7 @@ impl State {
3006 ],3014 ],
3007 clip: Some(rect.map(|value| value * scale)),3015 clip: Some(rect.map(|value| value * scale)),
3008 backdrop: Some(paper.color),3016 backdrop: Some(paper.color),
3017 round: None,
3009 motion: None,3018 motion: None,
3010 primitives: &page_primitives,3019 primitives: &page_primitives,
3011 },3020 },
...@@ -3022,25 +3031,48 @@ impl State {...@@ -3022,25 +3031,48 @@ impl State {
30223031
3023 /// Writes the frame to a PNG, so a covered window can be reviewed.3032 /// Writes the frame to a PNG, so a covered window can be reviewed.
3024 fn snapshot(&mut self, path: &Path) -> Result<(), Box<dyn Error>> {3033 fn snapshot(&mut self, path: &Path) -> Result<(), Box<dyn Error>> {
3025 let size = self.surface.size;3034 let pixels = self.capture()?;
3035 write_png(path, self.surface.size, &pixels)
3036 }
3037
3038 /// The frame's sRGB RGBA rows, drawn offscreen.
3039 fn capture(&mut self) -> Result<Vec<u8>, Box<dyn Error>> {
3026 let offscreen = self.surface.offscreen(&self.renderer)?;3040 let offscreen = self.surface.offscreen(&self.renderer)?;
3027 self.paint(&offscreen.target)?;3041 self.paint(&offscreen.target)?;
3028 let pixels = self.surface.read(&self.renderer, offscreen)?;3042 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(())
3036 }3043 }
30373044
3038 /// Queues input for the next frame. A press on the strip moves the window: at once on3045 /// Queues input for the next frame. A press on the strip moves the window: at once on
3039 /// macOS, while AppKit still holds the press, and elsewhere once the pointer moves, as3046 /// macOS, while AppKit still holds the press, and elsewhere once the pointer moves, as
3040 /// the window manager's move would swallow a second click; a double press zooms it.3047 /// the window manager's move would swallow a second click; a double press zooms it.
3048 fn over_page(&self) -> bool {
3049 let [x0, y0, x1, y1] = self.ui.rect(page()).unwrap_or_default();
3050 let [x, y] = self.pointer;
3051 (x0..x1).contains(&x) && (y0..y1).contains(&y)
3052 }
3053
3054 /// Zooms the page by `factor` about the pointer, when it is over the page.
3055 fn pinch(&mut self, factor: f32) -> Result<(), Box<dyn Error>> {
3056 if self.over_page() {
3057 let response = self.view.pinch(factor)?;
3058 self.respond(response);
3059 }
3060 Ok(())
3061 }
3062
3063 fn swipe_scroll(&mut self, phase: winit::event::TouchPhase, delta: [f32; 2]) {
3064 let scroll = self.view.scroll();
3065 let armed = scroll.max[0] <= scroll.min[0] && self.over_page();
3066 if let Some(forward) = self.swipe.scroll(phase, delta, armed, Instant::now()) {
3067 self.travel(forward);
3068 self.window.request_redraw();
3069 }
3070 }
3071
3041 fn input(&mut self, event: ui::Event) {3072 fn input(&mut self, event: ui::Event) {
3042 if let ui::Event::PointerMoved(point) = event {3073 if let ui::Event::PointerMoved(point) = event {
3043 self.pointer = point;3074 self.pointer = point;
3075 platform::set_window_drags(self.ui.box_at(point) == Some(strip()));
3044 if std::mem::take(&mut self.strip_held) {3076 if std::mem::take(&mut self.strip_held) {
3045 let _ = self.window.drag_window();3077 let _ = self.window.drag_window();
3046 }3078 }
...@@ -3658,7 +3690,14 @@ fn list_gallery(...@@ -3658,7 +3690,14 @@ fn list_gallery(
3658 size: [f32; 2],3690 size: [f32; 2],
3659 preview: impl Fn(&mut Ui, usize),3691 preview: impl Fn(&mut Ui, usize),
3660) -> Option<Option<usize>> {3692) -> Option<Option<usize>> {
3661 let groups = [(headings[0], recent.len()), (headings[1], count + 1)];3693 let groups = [(headings[0], recent.len()), (headings[1], count + 1)].map(|(heading, cells)| {
3694 ui::popup::Group {
3695 heading,
3696 cells,
3697 columns: 5,
3698 size,
3699 }
3700 });
3662 let place = |index: usize| match index.checked_sub(recent.len()) {3701 let place = |index: usize| match index.checked_sub(recent.len()) {
3663 None => Some(recent[index]),3702 None => Some(recent[index]),
3664 Some(at) => at.checked_sub(1),3703 Some(at) => at.checked_sub(1),
...@@ -3668,9 +3707,7 @@ fn list_gallery(...@@ -3668,9 +3707,7 @@ fn list_gallery(
3668 id,3707 id,
3669 anchor,3708 anchor,
3670 &groups[usize::from(recent.is_empty())..],3709 &groups[usize::from(recent.is_empty())..],
3671 5,3710 current.map(|place| recent.len() + 1 + place).as_slice(),
3672 size,
3673 current.map(|place| recent.len() + 1 + place),
3674 |ui, index| match place(index) {3711 |ui, index| match place(index) {
3675 Some(place) => preview(ui, place),3712 Some(place) => preview(ui, place),
3676 None => {3713 None => {
...@@ -3689,44 +3726,161 @@ fn list_gallery(...@@ -3689,44 +3726,161 @@ fn list_gallery(
3689 Some(place(chosen))3726 Some(place(chosen))
3690}3727}
36913728
3692/// A toolbar group's rows, stacked.3729/// A toolbar group that folds by `priority`: `full` builds its full form, `folded` the form
3693fn group(ui: &mut Ui, part: &str, rows: impl FnOnce(&mut Ui)) {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> {
3694 ui.open(3738 ui.open(
3695 part,3739 part,
3696 Spec {3740 Spec {
3697 axis: Axis::Y,3741 fold: Some(priority),
3698 gap: 2.0,
3699 ..Spec::default()3742 ..Spec::default()
3700 },3743 },
3701 );3744 );
3702 rows(ui);
3703 ui.close();
3704}
3705
3706fn row(ui: &mut Ui, part: usize, buttons: impl FnOnce(&mut Ui)) {
3707 ui.open(3745 ui.open(
3708 part,3746 "full",
3709 Spec {3747 Spec {
3710 gap: 1.0,3748 gap: 1.0,
3711 ..Spec::default()3749 ..Spec::default()
3712 },3750 },
3713 );3751 );
3714 buttons(ui);3752 let shown = full(ui);
3753 ui.close();
3754 ui.open("folded", Spec::default());
3755 let choice = folded(ui).or(shown);
3756 ui.close();
3715 ui.close();3757 ui.close();
3758 choice
3716}3759}
37173760
3718/// A tooltip naming command `id` and its chord on the box built last. It names the command,3761/// A row of a toolbar menu.
3719/// not a dialog it opens, so it leaves off the menu's ellipsis.3762#[derive(Clone, Copy)]
3720/// A tool button for command `id`, lit while `status` checks it and faded while it does not3763enum Entry {
3721/// apply; returns the command when clicked.3764 Run(commands::Id),
3722fn tool(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(
3723 ui: &mut Ui,3785 ui: &mut Ui,
3724 part: impl std::hash::Hash,3786 part: &str,
3725 icon: &'static [&'static str],3787 head: Head,
3726 tint: [f32; 4],3788 entries: &[Entry],
3727 id: commands::Id,3789 status_of: &impl Fn(commands::Id) -> commands::Status,
3728 status: commands::Status,
3729) -> Option<commands::Choice> {3790) -> Option<commands::Choice> {
3791 use commands::Choice;
3792 let button = ui.id(part);
3793 let menu = button.child("menu");
3794 let mut choice = None;
3795 let anchor = match head {
3796 Head::Split(id) if status_of(id).enabled => {
3797 let icon = artwork(id).unwrap_or_default();
3798 let on = status_of(id).checked;
3799 if ui::shell::split_button(ui, part, icon, None, on, menu).clicked {
3800 choice = Some(Choice::Command(id));
3801 }
3802 tip(ui, id);
3803 ui::Anchor::Below(ui.rect(button).unwrap_or_default())
3804 }
3805 // Where its command does not apply, the button only opens the menu.
3806 Head::Split(id) => ui::shell::menu_button(ui, part, artwork(id).unwrap_or_default(), menu),
3807 Head::Menu(icon) => ui::shell::menu_button(ui, part, icon, menu),
3808 Head::More => {
3809 let open = ui.popup_open(menu);
3810 let text = ui.theme.text;
3811 if ui::shell::tool_button(ui, part, ui::shell::CHEVRON, text, open).pressed {
3812 ui.open_popup(menu);
3813 }
3814 ui::Anchor::Below(ui.rect(button).unwrap_or_default())
3815 }
3816 };
3817 let keys: Vec<_> = entries
3818 .iter()
3819 .map(|entry| match entry {
3820 Entry::Run(id) => commands::shortcut(*id),
3821 _ => String::new(),
3822 })
3823 .collect();
3824 let (mut items, mut actions) = (Vec::new(), Vec::new());
3825 let mut separated = false;
3826 for (entry, key) in entries.iter().zip(&keys) {
3827 let item = match *entry {
3828 Entry::Rule => {
3829 separated = true;
3830 continue;
3831 }
3832 Entry::Run(id) => ui::popup::Item {
3833 text: commands::command(id).title,
3834 icon: artwork(id),
3835 colored: matches!(id, commands::Id::Tag(_)),
3836 shortcut: key,
3837 checked: status_of(id).checked,
3838 disabled: !status_of(id).enabled,
3839 ..Default::default()
3840 },
3841 Entry::Open(_, id) => ui::popup::Item {
3842 text: commands::command(id).title,
3843 icon: artwork(id),
3844 shortcut: "›",
3845 disabled: !status_of(id).enabled,
3846 ..Default::default()
3847 },
3848 Entry::Soon(text, icon) => ui::popup::Item {
3849 text,
3850 icon,
3851 disabled: true,
3852 ..Default::default()
3853 },
3854 };
3855 items.push(ui::popup::Item {
3856 separated: std::mem::take(&mut separated),
3857 ..item
3858 });
3859 actions.push(*entry);
3860 }
3861 match ui::popup::menu(ui, menu, anchor, &items, None).map(|index| actions[index]) {
3862 Some(Entry::Run(id)) => Some(Choice::Command(id)),
3863 Some(Entry::Open(name, _)) => {
3864 ui.open_popup(toolbar_popup(name));
3865 None
3866 }
3867 _ => choice,
3868 }
3869}
3870
3871/// A tool button for command `id`, lit while `status` checks it and faded while it does not
3872/// apply; returns the command when clicked.
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 };
3730 if !status.enabled {3884 if !status.enabled {
3731 ui::shell::unavailable(ui, part, icon, tint, false);3885 ui::shell::unavailable(ui, part, icon, tint, false);
3732 return None;3886 return None;
...@@ -3736,6 +3890,57 @@ fn tool(...@@ -3736,6 +3890,57 @@ fn tool(
3736 clicked.then_some(commands::Choice::Command(id))3890 clicked.then_some(commands::Choice::Command(id))
3737}3891}
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
3739/// The artwork the toolbar shows for `tag`.3944/// The artwork the toolbar shows for `tag`.
3740fn tag_icon(tag: canvas::editor::NoteTag) -> &'static [&'static str] {3945fn tag_icon(tag: canvas::editor::NoteTag) -> &'static [&'static str] {
3741 use canvas::editor::NoteTag;3946 use canvas::editor::NoteTag;
...@@ -3751,18 +3956,21 @@ fn toolbar_popup(name: &str) -> Id {...@@ -3751,18 +3956,21 @@ fn toolbar_popup(name: &str) -> Id {
3751 Id::ROOT.child(("popup", name))3956 Id::ROOT.child(("popup", name))
3752}3957}
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.
3754fn tip(ui: &mut Ui, id: commands::Id) {3961fn tip(ui: &mut Ui, id: commands::Id) {
3755 let title = commands::command(id).title.trim_end_matches('…');3962 let title = commands::command(id).title.trim_end_matches('…');
3756 ui::popup::tooltip(ui, title, &commands::shortcut(id), None);3963 ui::popup::tooltip(ui, title, &commands::shortcut(id), None);
3757}3964}
37583965
3759/// The line between two toolbar groups.3966/// The line leading a toolbar group, as far from its buttons as the groups are apart.
3760fn divider(ui: &mut Ui, part: &str, theme: &Theme) {3967fn divider(ui: &mut Ui, theme: &Theme) {
3761 ui.leaf(3968 ui.leaf(
3762 ("divider", part),3969 "divider",
3763 Spec {3970 Spec {
3764 size: [px(1.0), px(2.0 * ui::shell::TOOL + 2.0)],3971 size: [px(GAP), px(ui::shell::TOOL)],
3765 fill: Some(theme.chip),3972 fill: Some(theme.chip),
3973 inset: [0.0, 0.0, GAP - 1.0, 0.0],
3766 ..Spec::default()3974 ..Spec::default()
3767 },3975 },
3768 );3976 );
...@@ -4054,9 +4262,8 @@ impl ApplicationHandler<UserEvent> for App {...@@ -4054,9 +4262,8 @@ impl ApplicationHandler<UserEvent> for App {
4054 state.set_appearance(state.color_scheme.theme().unwrap_or(appearance));4262 state.set_appearance(state.color_scheme.theme().unwrap_or(appearance));
4055 state.window.request_redraw();4263 state.window.request_redraw();
4056 }4264 }
4057 WindowEvent::ScaleFactorChanged { scale_factor, .. } => {4265 WindowEvent::ScaleFactorChanged { .. } => {
4058 state.renderer.clear_glyph_cache();4266 state.renderer.clear_glyph_cache();
4059 state.app_icon = platform::app_icon((16.0 * scale_factor).round() as u32);
4060 state.window.request_redraw();4267 state.window.request_redraw();
4061 }4268 }
4062 WindowEvent::Focused(focused) => {4269 WindowEvent::Focused(focused) => {
...@@ -4090,7 +4297,10 @@ impl ApplicationHandler<UserEvent> for App {...@@ -4090,7 +4297,10 @@ impl ApplicationHandler<UserEvent> for App {
4090 pressed: pressed == ElementState::Pressed,4297 pressed: pressed == ElementState::Pressed,
4091 at: Instant::now(),4298 at: Instant::now(),
4092 }),4299 }),
4093 WindowEvent::MouseWheel { delta, .. } => {4300 WindowEvent::MouseWheel { delta, phase, .. } => {
4301 if let MouseScrollDelta::PixelDelta(p) = delta {
4302 state.swipe_scroll(phase, [p.x as f32 / scale, p.y as f32 / scale]);
4303 }
4094 state.input(ui::Event::Wheel(match delta {4304 state.input(ui::Event::Wheel(match delta {
4095 MouseScrollDelta::LineDelta(x, y) => [x * 32.0, y * 32.0],4305 MouseScrollDelta::LineDelta(x, y) => [x * 32.0, y * 32.0],
4096 MouseScrollDelta::PixelDelta(p) => [p.x as f32 / scale, p.y as f32 / scale],4306 MouseScrollDelta::PixelDelta(p) => [p.x as f32 / scale, p.y as f32 / scale],
...@@ -4105,6 +4315,13 @@ impl ApplicationHandler<UserEvent> for App {...@@ -4105,6 +4315,13 @@ impl ApplicationHandler<UserEvent> for App {
4105 })4315 })
4106 }4316 }
4107 WindowEvent::Ime(ime) => state.input(ui::Event::Ime(ime)),4317 WindowEvent::Ime(ime) => state.input(ui::Event::Ime(ime)),
4318 WindowEvent::PinchGesture { delta, .. } => state.pinch(1.0 + delta as f32)?,
4319 // Smart zoom: to 200% about the fingers, or back to 100%.
4320 WindowEvent::DoubleTapGesture { .. } => {
4321 let zoom = state.view.zoom();
4322 let target = if (zoom - 1.0).abs() < 0.01 { 2.0 } else { 1.0 };
4323 state.pinch(target / zoom)?
4324 }
4108 _ => {}4325 _ => {}
4109 }4326 }
4110 Ok(())4327 Ok(())
...@@ -4137,6 +4354,16 @@ impl ApplicationHandler<UserEvent> for App {...@@ -4137,6 +4354,16 @@ impl ApplicationHandler<UserEvent> for App {
4137 }4354 }
4138}4355}
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
4140/// Feeds a development script to the window from another thread, one command per line4367/// Feeds a development script to the window from another thread, one command per line
4141/// in logical pixels: `move X Y`, `press [right]`, `release [right]`, `wheel DX DY`, `key NAME`, `type4368/// in logical pixels: `move X Y`, `press [right]`, `release [right]`, `wheel DX DY`, `key NAME`, `type
4142/// TEXT`, `modifiers [shift] [command]`, `wait MILLISECONDS`, `snapshot PNG_PATH`,4369/// TEXT`, `modifiers [shift] [command]`, `wait MILLISECONDS`, `snapshot PNG_PATH`,
...@@ -4214,9 +4441,15 @@ fn replay(script: String, proxy: EventLoopProxy<UserEvent>) -> Result<(), Box<dy...@@ -4214,9 +4441,15 @@ fn replay(script: String, proxy: EventLoopProxy<UserEvent>) -> Result<(), Box<dy
4214 let _ = proxy.send_event(UserEvent::Replay(replay));4441 let _ = proxy.send_event(UserEvent::Replay(replay));
4215 }4442 }
4216 Err(duration) => {4443 Err(duration) => {
4444 // Ticks keep a 60 Hz display's pace, dropping those a slow frame missed.
4217 let start = Instant::now();4445 let start = Instant::now();
4446 let period = std::time::Duration::from_micros(16_667);
4447 let mut tick = start;
4218 while start.elapsed() < duration {4448 while start.elapsed() < duration {
4219 std::thread::sleep(std::time::Duration::from_millis(16));4449 while tick <= Instant::now() {
4450 tick += period;
4451 }
4452 std::thread::sleep(tick - Instant::now());
4220 let _ = proxy.send_event(UserEvent::Replay(Replay::Tick));4453 let _ = proxy.send_event(UserEvent::Replay(Replay::Tick));
4221 }4454 }
4222 }4455 }
crates/snowbound/src/manage.rs+142-51
...@@ -2,6 +2,7 @@...@@ -2,6 +2,7 @@
2//! structure through `notebook::session::Notebook`, pages as ops on the open section.2//! structure through `notebook::session::Notebook`, pages as ops on the open section.
33
4use crate::{Command, Library, Loaded, State, notify, platform, read_session};4use crate::{Command, Library, Loaded, State, notify, platform, read_session};
5use canvas::template::Template;
5use notebook::session::Notebook;6use notebook::session::Notebook;
6use onestore::{7use onestore::{
7 ExGuid, PageCreation, PageEdit,8 ExGuid, PageCreation, PageEdit,
...@@ -133,13 +134,28 @@ impl State {...@@ -133,13 +134,28 @@ impl State {
133 self.edit_page(ops)134 self.edit_page(ops)
134 }135 }
135136
136 /// View, Page Color: gives the open page `color`, a COLORREF, or none; its art stays.137 /// Gives the open page `template`'s art, or none, in place of the art it had; its colour
137 pub(crate) fn color_page(&mut self, color: Option<u32>) -> Result<(), Box<dyn Error>> {138 /// and rule lines stay.
138 if self.view.editor.page_color() == color {139 pub(crate) fn art_page(
139 return Ok(());140 &mut self,
140 }141 template: Option<&'static Template>,
142 ) -> Result<(), Box<dyn Error>> {
141 self.persist()?;143 self.persist()?;
142 self.edit_page(vec![PageOp::Color(color)])144 let session = self.session.as_ref().ok_or("No section is open")?;
145 let ops = art_ops(&session.section.page(session.space)?, template)?;
146 self.edit_page(ops)
147 }
148
149 /// View, Page Color and Rule Lines: gives the open page `color`, a COLORREF, and
150 /// `rule_lines` as one undo step; its art stays.
151 pub(crate) fn paper_page(
152 &mut self,
153 color: Option<u32>,
154 rule_lines: Option<onestore::page::RuleLines>,
155 ) -> Result<(), Box<dyn Error>> {
156 let response = self.view.set_paper(color, rule_lines)?;
157 self.respond(response);
158 Ok(())
143 }159 }
144160
145 /// Applies `ops` to the open page as one edit and shows the result.161 /// Applies `ops` to the open page as one edit and shows the result.
...@@ -429,60 +445,26 @@ impl State {...@@ -429,60 +445,26 @@ impl State {
429 }445 }
430}446}
431447
432/// The ops giving `page` `choice`'s background: its template art (OneNote's pictures'448/// The ops giving `page` `choice`'s background: its template art or a page colour, in
433/// places, our recreations' bytes) or a page colour, in place of the background it had.449/// place of the background it had.
434pub fn template_ops(450pub fn template_ops(
435 page: &Page,451 page: &Page,
436 choice: crate::templates::Choice,452 choice: crate::templates::Choice,
437) -> Result<Vec<PageOp>, Box<dyn Error>> {453) -> Result<Vec<PageOp>, Box<dyn Error>> {
438 use crate::templates::Choice;454 use crate::templates::Choice;
439 let mut ops: Vec<PageOp> = page455 let mut ops = match choice {
440 .objects456 Choice::Template(name) => art_ops(
441 .iter()457 page,
442 .filter(|object| matches!(object, PageObject::Image(image) if image.background))458 Some(canvas::template::find(name).ok_or("That template is not available")?),
443 .map(|object| PageOp::Delete {459 )?,
444 object: object.id(),460 Choice::Color(_) => art_ops(page, None)?,
445 })461 Choice::More | Choice::Dismiss | Choice::Colors => return Ok(Vec::new()),
446 .collect();462 };
447 match choice {463 match choice {
448 Choice::Template(name) => {464 Choice::Template(name) => {
449 let template = canvas::template::find(name).ok_or("That template is not available")?;
450 if page.color.is_some() {465 if page.color.is_some() {
451 ops.push(PageOp::Color(None));466 ops.push(PageOp::Color(None));
452 }467 }
453 // Art lies under everything else on the page, first of its children (the title
454 // is no child).
455 let under = page
456 .objects
457 .iter()
458 .find(|object| match object {
459 PageObject::Title(_) => false,
460 PageObject::Image(image) => !image.background,
461 _ => true,
462 })
463 .map(PageObject::id);
464 for art in template.art {
465 let bytes = canvas::gpu::page::template_picture(art.art)
466 .ok_or("That template's art is missing")?;
467 ops.push(PageOp::Add {
468 object: PageObject::Image(Image {
469 id: onestore::page::text::new_id()?,
470 layout: onestore::document::Layout {
471 x: Some(art.position[0]),
472 y: Some(art.position[1]),
473 max_width: art.size.map(|size| size[0]),
474 max_height: art.size.map(|size| size[1]),
475 width_set_by_user: art.size.map(|_| true),
476 ..Default::default()
477 },
478 size: art.size,
479 bytes: Some(bytes.into()),
480 alt: None,
481 background: true,
482 }),
483 before: under,
484 });
485 }
486 // The one template with content worth keeping.468 // The one template with content worth keeping.
487 if name == "Informal Meeting Notes" {469 if name == "Informal Meeting Notes" {
488 ops.extend(onestore::op::lower_page(470 ops.extend(onestore::op::lower_page(
...@@ -494,7 +476,54 @@ pub fn template_ops(...@@ -494,7 +476,54 @@ pub fn template_ops(
494 Choice::Color(index) => {476 Choice::Color(index) => {
495 ops.push(PageOp::Color(Some(canvas::template::PAGE_COLORS[index].1)))477 ops.push(PageOp::Color(Some(canvas::template::PAGE_COLORS[index].1)))
496 }478 }
497 Choice::More | Choice::Dismiss | Choice::Colors => return Ok(Vec::new()),479 Choice::More | Choice::Dismiss | Choice::Colors => {}
480 }
481 Ok(ops)
482}
483
484/// The ops giving `page` `template`'s art (OneNote's pictures' places, our recreations'
485/// bytes), or none, in place of the background pictures it had.
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 });
498 }527 }
499 Ok(ops)528 Ok(ops)
500}529}
...@@ -842,4 +871,66 @@ mod tests {...@@ -842,4 +871,66 @@ mod tests {
842 "New Section Group 2"871 "New Section Group 2"
843 );872 );
844 }873 }
874
875 /// Backgrounds the Page Color menu gives OneNote's ruled pages
876 /// (`corpus/rule-lines/native`): art on a page with rule lines, keeping them, art taken
877 /// away, and a colour outside OneNote's menu. `SNOWBOUND_BACKGROUND_EXPORT` names a new
878 /// directory that receives the notebook for a cold reopen in OneNote 2010.
879 #[test]
880 fn backgrounds_go_on_and_off_pages_that_have_content() {
881 let temporary =
882 std::env::temp_dir().join(format!("snowbound-background-{}", std::process::id()));
883 let _ = std::fs::remove_dir_all(&temporary);
884 copy(
885 Path::new(concat!(
886 env!("CARGO_MANIFEST_DIR"),
887 "/../../corpus/rule-lines/native/notebook"
888 )),
889 &temporary,
890 );
891 let file = temporary.join("Rules.one");
892 let titled = |title: &str| {
893 pages(&file)
894 .into_iter()
895 .find(|(_, name, _)| name == title)
896 .unwrap()
897 .0
898 };
899 let on_page = |space, ops: Vec<PageOp>| {
900 edit(
901 &file,
902 ops.into_iter().map(|op| Op::Page { space, op }).collect(),
903 )
904 };
905 let ivy = canvas::template::find("Ivy");
906 let (standard, wide, grid) = (titled("Standard"), titled("Wide"), titled("SmallGrid"));
907 on_page(standard, art_ops(&page(&file, standard), ivy).unwrap());
908 on_page(wide, art_ops(&page(&file, wide), ivy).unwrap());
909 on_page(wide, art_ops(&page(&file, wide), None).unwrap());
910 // A warm grey no menu offers.
911 on_page(grid, vec![PageOp::Color(Some(0x00c8d8e8))]);
912
913 let standard = page(&file, standard);
914 assert!(standard.rule_lines.is_some());
915 assert_eq!(
916 standard
917 .objects
918 .iter()
919 .filter(|object| matches!(object, PageObject::Image(image) if image.background))
920 .count(),
921 ivy.unwrap().art.len()
922 );
923 let wide = page(&file, wide);
924 assert!(
925 !wide
926 .objects
927 .iter()
928 .any(|object| matches!(object, PageObject::Image(_)))
929 );
930 assert_eq!(page(&file, grid).color, Some(0x00c8d8e8));
931 if let Some(directory) = std::env::var_os("SNOWBOUND_BACKGROUND_EXPORT") {
932 copy(&temporary, Path::new(&directory));
933 }
934 std::fs::remove_dir_all(&temporary).unwrap();
935 }
845}936}
crates/snowbound/src/menubar.rs+4
...@@ -79,6 +79,7 @@ const MENUS: &[Item] = &[...@@ -79,6 +79,7 @@ const MENUS: &[Item] = &[
79 S,79 S,
80 C(Id::Find),80 C(Id::Find),
81 C(Id::Search),81 C(Id::Search),
82 C(Id::SearchResults),
82 S,83 S,
83 Item::System("Start Dictation…", "startDictation:", None),84 Item::System("Start Dictation…", "startDictation:", None),
84 Item::System(85 Item::System(
...@@ -92,6 +93,8 @@ const MENUS: &[Item] = &[...@@ -92,6 +93,8 @@ const MENUS: &[Item] = &[
92 Item::Menu(93 Item::Menu(
93 "View",94 "View",
94 &[95 &[
96 C(Id::CommandPalette),
97 S,
95 C(Id::Back),98 C(Id::Back),
96 C(Id::Forward),99 C(Id::Forward),
97 S,100 S,
...@@ -192,6 +195,7 @@ const MENUS: &[Item] = &[...@@ -192,6 +195,7 @@ const MENUS: &[Item] = &[
192 ),195 ),
193 S,196 S,
194 C(Id::RemoveTags),197 C(Id::RemoveTags),
198 C(Id::FindTags),
195 ],199 ],
196 ),200 ),
197 Item::Menu(201 Item::Menu(
crates/snowbound/src/navigation.rs+68
...@@ -2,6 +2,8 @@...@@ -2,6 +2,8 @@
2//! 2010's Quick Access Toolbar offers them.2//! 2010's Quick Access Toolbar offers them.
33
4use super::*;4use super::*;
5use std::time::Duration;
6use winit::event::TouchPhase;
57
6/// A page shown: its notebook's location, its section's catalog path and the page.8/// A page shown: its notebook's location, its section's catalog path and the page.
7#[derive(Clone, Debug, PartialEq)]9#[derive(Clone, Debug, PartialEq)]
...@@ -107,6 +109,50 @@ impl State {...@@ -107,6 +109,50 @@ impl State {
107 }109 }
108}110}
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
110#[cfg(test)]156#[cfg(test)]
111mod tests {157mod tests {
112 use super::*;158 use super::*;
...@@ -122,6 +168,28 @@ mod tests {...@@ -122,6 +168,28 @@ mod tests {
122 }168 }
123 }169 }
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
125 #[test]193 #[test]
126 fn back_and_forward_retrace_visits_across_sections() {194 fn back_and_forward_retrace_visits_across_sections() {
127 let mut trail = Trail::default();195 let mut trail = Trail::default();
crates/snowbound/src/palette.rs created+147
...@@ -0,0 +1,147 @@
1//! The command palette: the command table's commands, then the open notebooks' sections and
2//! every page the search index holds to go to, narrowed as typed.
3
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;...@@ -9,13 +9,12 @@ use winit::window::Theme as Appearance;
9/// drawn at `SCALE` pixels per point or fewer where the GPU caps textures.9/// drawn at `SCALE` pixels per point or fewer where the GPU caps textures.
10const SIZE: [f32; 2] = [1440.0, 900.0];10const SIZE: [f32; 2] = [1440.0, 900.0];
11const SCALE: f32 = 2.0;11const SCALE: f32 = 2.0;
12/// A standard window's close, minimize and zoom buttons as AppKit places them beside a12/// The close, minimize and zoom buttons' fill and rim.
13/// transparent title bar: centres in points, fill and rim.
14#[cfg(target_os = "macos")]13#[cfg(target_os = "macos")]
15const LIGHTS: [(f32, [u8; 3], [u8; 3]); 3] = [14const LIGHTS: [([u8; 3], [u8; 3]); 3] = [
16 (14.0, [0xff, 0x5f, 0x57], [0xe2, 0x46, 0x3f]),15 ([0xff, 0x5f, 0x57], [0xe2, 0x46, 0x3f]),
17 (34.0, [0xfe, 0xbc, 0x2e], [0xe1, 0xa1, 0x16]),16 ([0xfe, 0xbc, 0x2e], [0xe1, 0xa1, 0x16]),
18 (54.0, [0x28, 0xc8, 0x40], [0x12, 0xac, 0x28]),17 ([0x28, 0xc8, 0x40], [0x12, 0xac, 0x28]),
19];18];
2019
21impl State {20impl State {
...@@ -32,7 +31,6 @@ impl State {...@@ -32,7 +31,6 @@ impl State {
32 let scale = SCALE.min(most / size[0].max(size[1]));31 let scale = SCALE.min(most / size[0].max(size[1]));
33 self.surface.size = size.map(|side| (side * scale).max(1.0) as u32);32 self.surface.size = size.map(|side| (side * scale).max(1.0) as u32);
34 self.renderer.clear_glyph_cache();33 self.renderer.clear_glyph_cache();
35 self.app_icon = platform::app_icon((16.0 * scale) as u32);
36 let response = self.view.scale_factor_changed(scale)?;34 let response = self.view.scale_factor_changed(scale)?;
37 self.respond(response);35 self.respond(response);
38 self.ui.set_focus(None);36 self.ui.set_focus(None);
...@@ -66,16 +64,26 @@ impl State {...@@ -66,16 +64,26 @@ impl State {
66 let path = PathBuf::from(format!("{}-{name}.png", prefix.display()));64 let path = PathBuf::from(format!("{}-{name}.png", prefix.display()));
67 self.snapshot(&path)?;65 self.snapshot(&path)?;
68 #[cfg(target_os = "macos")]66 #[cfg(target_os = "macos")]
69 window_frame(&path, appearance, scale)?;67 window_frame(
68 &path,
69 appearance,
70 scale,
71 platform::traffic_lights(&self.window),
72 )?;
70 }73 }
71 Ok(())74 Ok(())
72 }75 }
73}76}
7477
75/// Paints the traffic lights into the PNG at `path`, rounds its corners to transparency78/// Paints the traffic lights centred at `lights` into the PNG at `path`, rounds its corners
76/// and edges them with the window's hairline.79/// to transparency and edges them with the window's hairline.
77#[cfg(target_os = "macos")]80#[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>> {
79 let mut reader =87 let mut reader =
80 png::Decoder::new(std::io::BufReader::new(std::fs::File::open(path)?)).read_info()?;88 png::Decoder::new(std::io::BufReader::new(std::fs::File::open(path)?)).read_info()?;
81 let mut pixels = vec![0; reader.output_buffer_size().ok_or("Snapshot too large")?];89 let mut pixels = vec![0; reader.output_buffer_size().ok_or("Snapshot too large")?];
...@@ -98,8 +106,8 @@ fn window_frame(path: &Path, appearance: Appearance, scale: f32) -> Result<(), B...@@ -98,8 +106,8 @@ fn window_frame(path: &Path, appearance: Appearance, scale: f32) -> Result<(), B
98 for (index, pixel) in pixels.chunks_exact_mut(4).enumerate() {106 for (index, pixel) in pixels.chunks_exact_mut(4).enumerate() {
99 let x = (index as u32 % info.width) as f32 + 0.5;107 let x = (index as u32 % info.width) as f32 + 0.5;
100 let y = (index as u32 / info.width) as f32 + 0.5;108 let y = (index as u32 / info.width) as f32 + 0.5;
101 for (centre, fill, rim) in LIGHTS {109 for ([cx, cy], (fill, rim)) in lights.into_iter().zip(LIGHTS) {
102 let distance = (x - centre * scale).hypot(y - TITLE / 2.0 * scale);110 let distance = (x - cx * scale).hypot(y - cy * scale);
103 blend(111 blend(
104 pixel,112 pixel,
105 [rim[0], rim[1], rim[2], 255],113 [rim[0], rim[1], rim[2], 255],
crates/snowbound/src/search.rs+95-44
...@@ -2,8 +2,9 @@...@@ -2,8 +2,9 @@
2//! results dropping down from it, and Find on This Page. The index is kept on a thread of2//! results dropping down from it, and Find on This Page. The index is kept on a thread of
3//! its own, from the section files and the open section's replica.3//! its own, from the section files and the open section's replica.
44
5use crate::pane::Pane;
5use crate::{Command, Library, State, Theme, art, commands, lap, page};6use 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};
7use notebook::Replica;8use notebook::Replica;
8use onestore::ExGuid;9use onestore::ExGuid;
9use std::{10use std::{
...@@ -31,9 +32,9 @@ pub enum Scope {...@@ -31,9 +32,9 @@ pub enum Scope {
31}32}
3233
33impl Scope {34impl 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 {
37 match self {38 match self {
38 Scope::Section => "This Section",39 Scope::Section => "This Section",
39 Scope::Group => "This Section Group",40 Scope::Group => "This Section Group",
...@@ -52,7 +53,7 @@ const PLACE: f32 = 150.0;...@@ -52,7 +53,7 @@ const PLACE: f32 = 150.0;
52/// How long typing pauses before the index reads the pages it changed.53/// How long typing pauses before the index reads the pages it changed.
53const SETTLE: Duration = Duration::from_millis(400);54const SETTLE: Duration = Duration::from_millis(400);
5455
55fn results() -> Id {56pub(crate) fn results() -> Id {
56 Id::ROOT.child("results")57 Id::ROOT.child("results")
57}58}
5859
...@@ -71,7 +72,12 @@ fn find_field() -> Id {...@@ -71,7 +72,12 @@ fn find_field() -> Id {
7172
72/// Whether `focus` is one of the box's fields, which take typed text.73/// Whether `focus` is one of the box's fields, which take typed text.
73pub fn takes_text(focus: Option<Id>) -> bool {74pub fn takes_text(focus: Option<Id>) -> bool {
74 [Some(field()), Some(find_field())].contains(&focus)75 [
76 Some(field()),
77 Some(find_field()),
78 Some(crate::pane::field()),
79 ]
80 .contains(&focus)
75}81}
7682
77/// Work for the index thread.83/// Work for the index thread.
...@@ -97,26 +103,27 @@ pub struct Search {...@@ -97,26 +103,27 @@ pub struct Search {
97 /// Scope searches start in, as "Set This Scope as Default" leaves it.103 /// Scope searches start in, as "Set This Scope as Default" leaves it.
98 pub default: Scope,104 pub default: Scope,
99 /// Find on This Page is open, at the match selected.105 /// Find on This Page is open, at the match selected.
100 finding: bool,106 pub(crate) finding: bool,
101 current: Option<usize>,107 current: Option<usize>,
102 /// The page a result opened, whose first match is selected once it shows.108 /// The page a result opened, whose first match, or paragraph, is selected once it shows.
103 reveal: Option<ExGuid>,109 reveal: Option<(ExGuid, Option<ExGuid>)>,
104 index: Arc<Mutex<Index>>,110 pub(crate) index: Arc<Mutex<Index>>,
105 /// Counts the index's changes, so results are made again when it changes.111 /// Counts the index's changes, so results are made again when it changes.
106 version: Arc<AtomicU64>,112 version: Arc<AtomicU64>,
107 busy: Arc<AtomicBool>,113 pub(crate) busy: Arc<AtomicBool>,
108 jobs: mpsc::Sender<Job>,114 jobs: mpsc::Sender<Job>,
109 found: Vec<Found>,115 pub(crate) found: Vec<Found>,
110 found_for: Option<(Query, Scope, u64, String)>,116 found_for: Option<(Query, Scope, u64, String)>,
111 selected: Option<u64>,117 pub(crate) selected: Option<u64>,
112 /// The notebooks and open section the index was last brought to, by identity.118 /// The notebooks and open section the index was last brought to, by identity.
113 synced: Vec<usize>,119 synced: Vec<usize>,
114 /// The results drop down, until chosen from or dismissed.120 /// The results drop down, until chosen from or dismissed.
115 open: bool,121 pub(crate) open: bool,
116 /// Where the scope menu opens.122 /// Where the scope menu opens.
117 anchor: Option<Anchor>,123 anchor: Option<Anchor>,
118 /// The find field takes the keys once built.124 /// The find field, or the Search Results pane's, takes the keys once built.
119 claim: bool,125 pub(crate) claim: bool,
126 pub(crate) pane: Option<Pane>,
120}127}
121128
122impl Search {129impl Search {
...@@ -148,6 +155,7 @@ impl Search {...@@ -148,6 +155,7 @@ impl Search {
148 open: false,155 open: false,
149 anchor: None,156 anchor: None,
150 claim: false,157 claim: false,
158 pane: None,
151 }159 }
152 }160 }
153161
...@@ -188,7 +196,7 @@ fn stamp(file: &Path) -> Stamp {...@@ -188,7 +196,7 @@ fn stamp(file: &Path) -> Stamp {
188}196}
189197
190/// Now in Time32, seconds since 1980, as page modification times are kept.198/// Now in Time32, seconds since 1980, as page modification times are kept.
191fn now() -> u64 {199pub(crate) fn now() -> u64 {
192 SystemTime::now()200 SystemTime::now()
193 .duration_since(SystemTime::UNIX_EPOCH)201 .duration_since(SystemTime::UNIX_EPOCH)
194 .map_or(0, |since| since.as_secs().saturating_sub(315_532_800))202 .map_or(0, |since| since.as_secs().saturating_sub(315_532_800))
...@@ -395,7 +403,13 @@ fn reread(...@@ -395,7 +403,13 @@ fn reread(
395}403}
396404
397/// A text box's label with `hits`, byte ranges of `text`, marked in OneNote's yellow.405/// 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) {
399 let height = match spec.size[1].size {413 let height = match spec.size[1].size {
400 ui::Size::Pixels(height) => height,414 ui::Size::Pixels(height) => height,
401 _ => 0.0,415 _ => 0.0,
...@@ -481,7 +495,7 @@ impl State {...@@ -481,7 +495,7 @@ impl State {
481 }495 }
482496
483 /// Whether `key` names a section the search's scope takes in.497 /// Whether `key` names a section the search's scope takes in.
484 fn in_scope(&self, scope: Scope, key: &str) -> bool {498 pub(crate) fn in_scope(&self, scope: Scope, key: &str) -> bool {
485 let Some(session) = &self.session else {499 let Some(session) = &self.session else {
486 return scope == Scope::All;500 return scope == Scope::All;
487 };501 };
...@@ -499,7 +513,7 @@ impl State {...@@ -499,7 +513,7 @@ impl State {
499 }513 }
500514
501 /// The results for the query in its scope, made again when either or the index changed.515 /// The results for the query in its scope, made again when either or the index changed.
502 fn refresh_results(&mut self) -> Result<(), Box<dyn Error>> {516 pub(crate) fn refresh_results(&mut self) -> Result<(), Box<dyn Error>> {
503 let query = Query::new(&self.search.query);517 let query = Query::new(&self.search.query);
504 let version = self.search.version.load(Ordering::Relaxed);518 let version = self.search.version.load(Ordering::Relaxed);
505 let open = self519 let open = self
...@@ -538,33 +552,37 @@ impl State {...@@ -538,33 +552,37 @@ impl State {
538 }552 }
539553
540 /// Marks the query's matches on the page; with `select`, selects the match Find on554 /// Marks the query's matches on the page; with `select`, selects the match Find on
541 /// This Page is at, or the first when a result opened the page.555 /// This Page is at, or the first, or the paragraph, when a result opened the page.
542 pub(crate) fn refind(&mut self, select: bool) -> Result<(), Box<dyn Error>> {556 pub(crate) fn refind(&mut self, select: bool) -> Result<(), Box<dyn Error>> {
543 self.view.found = page_matches(&self.view.editor, &self.search.shown());557 self.view.found = page_matches(&self.view.editor, &self.search.shown());
544 if !select {558 if !select {
545 return Ok(());559 return Ok(());
546 }560 }
547 let space = self.session.as_ref().map(|session| session.space);561 let space = self.session.as_ref().map(|session| session.space);
548 let at = if self.search.finding {562 let target = if self.search.finding {
549 self.search.current = match self.search.current {563 self.search.current = match self.search.current {
550 Some(at) if at < self.view.found.len() => Some(at),564 Some(at) if at < self.view.found.len() => Some(at),
551 _ => (!self.view.found.is_empty()).then_some(0),565 _ => (!self.view.found.is_empty()).then_some(0),
552 };566 };
553 self.search.current567 self.search
554 } else if self.search.reveal.is_some() && self.search.reveal == space {568 .current
569 .and_then(|at| self.view.found.get(at).copied())
570 } else if let Some((_, paragraph)) =
571 self.search.reveal.filter(|(page, _)| Some(*page) == space)
572 {
555 self.search.reveal = None;573 self.search.reveal = None;
556 (!self.view.found.is_empty()).then_some(0)574 match paragraph {
575 Some(paragraph) => paragraph_match(&self.view.editor, paragraph),
576 None => self.view.found.first().copied(),
577 }
557 } else {578 } else {
558 None579 None
559 };580 };
560 match at {581 self.select(target)
561 Some(at) => self.select_match(at),
562 None => Ok(()),
563 }
564 }582 }
565583
566 fn select_match(&mut self, at: usize) -> Result<(), Box<dyn Error>> {584 fn select(&mut self, target: Option<PageMatch>) -> Result<(), Box<dyn Error>> {
567 let Some(&(outline, selection)) = self.view.found.get(at) else {585 let Some((outline, selection)) = target else {
568 return Ok(());586 return Ok(());
569 };587 };
570 self.view.editor.focus_outline(outline)?;588 self.view.editor.focus_outline(outline)?;
...@@ -598,26 +616,38 @@ impl State {...@@ -598,26 +616,38 @@ impl State {
598 }616 }
599617
600 /// Ends the search or find: the box empties, the marks go and the page takes the keys,618 /// Ends the search or find: the box empties, the marks go and the page takes the keys,
601 /// keeping any match selected.619 /// keeping any match selected. The Search Results pane keeps the search while open.
602 fn end_search(&mut self) {620 pub(crate) fn end_search(&mut self) {
603 self.search.open = false;621 self.search.open = false;
604 self.search.query.clear();
605 self.search.finding = false;622 self.search.finding = false;
606 self.search.current = None;623 self.search.current = None;
607 self.search.scope = self.search.default;
608 self.view.found.clear();
609 self.ui.close_popup(results());624 self.ui.close_popup(results());
610 self.ui.set_focus(Some(page()));625 self.ui.set_focus(Some(page()));
626 if !matches!(self.search.pane, Some(Pane::Search { .. })) {
627 self.search.query.clear();
628 self.search.scope = self.search.default;
629 self.view.found.clear();
630 }
611 }631 }
612632
613 /// Opens a result's page, whose first match is selected once it shows.633 /// Opens a result's page, whose first match is selected once it shows.
614 fn open_result(&mut self, index: usize) -> Result<(), Box<dyn Error>> {634 fn open_result(&mut self, index: usize) -> Result<(), Box<dyn Error>> {
615 let Some(found) = self.search.found.get(index) else {635 match self.search.found.get(index) {
616 return Ok(());636 Some(found) => self.reveal(found.section.clone(), found.space, None),
617 };637 None => Ok(()),
618 let (location, path) = found.section.split_once('\n').unwrap_or_default();638 }
619 let (space, section) = (found.space, found.section.clone());639 }
620 self.search.reveal = Some(space);640
641 /// Opens page `space` of `section`, whose first match, or `paragraph`, is selected once
642 /// it shows.
643 pub(crate) fn reveal(
644 &mut self,
645 section: String,
646 space: ExGuid,
647 paragraph: Option<ExGuid>,
648 ) -> Result<(), Box<dyn Error>> {
649 let (location, path) = section.split_once('\n').unwrap_or_default();
650 self.search.reveal = Some((space, paragraph));
621 let open = self.session.as_ref().map(|session| {651 let open = self.session.as_ref().map(|session| {
622 (652 (
623 session.library.key(&session.tabs[session.tab].path),653 session.library.key(&session.tabs[session.tab].path),
...@@ -633,9 +663,9 @@ impl State {...@@ -633,9 +663,9 @@ impl State {
633 .iter()663 .iter()
634 .find(|library| library.location == location)664 .find(|library| library.location == location)
635 .ok_or("The notebook is no longer open")?;665 .ok_or("The notebook is no longer open")?;
636 self.last_pages.insert(section, space);
637 self.commands666 self.commands
638 .push(Command::OpenSection(Arc::clone(library), path.to_owned()));667 .push(Command::OpenSection(Arc::clone(library), path.to_owned()));
668 self.last_pages.insert(section, space);
639 }669 }
640 }670 }
641 Ok(())671 Ok(())
...@@ -984,8 +1014,29 @@ impl State {...@@ -984,8 +1014,29 @@ impl State {
984 .or_else(|| entered.then_some(0))1014 .or_else(|| entered.then_some(0))
985 });1015 });
986 }1016 }
1017 let link = format!(
1018 "Open Search Results Pane ({})",
1019 commands::shortcut(commands::Id::SearchResults)
1020 );
1021 let pane = self
1022 .ui
1023 .leaf(
1024 "pane",
1025 Spec {
1026 flags: Flags::CLICKABLE,
1027 size: [fit(), px(22.0)],
1028 icon: Some(art::SEARCH),
1029 text: Some(&link),
1030 color: Some(theme.accent),
1031 pad: [6.0, 0.0],
1032 ..Spec::default()
1033 },
1034 )
1035 .clicked;
987 self.ui.close();1036 self.ui.close();
988 if let Some(index) = chosen {1037 if pane {
1038 self.open_search_pane();
1039 } else if let Some(index) = chosen {
989 self.open_result(index)?;1040 self.open_result(index)?;
990 self.search.open = false;1041 self.search.open = false;
991 self.ui.close_popup(results());1042 self.ui.close_popup(results());
...@@ -1084,7 +1135,7 @@ impl State {...@@ -1084,7 +1135,7 @@ impl State {
1084 (at + count - 1) % count1135 (at + count - 1) % count
1085 };1136 };
1086 self.search.current = Some(next);1137 self.search.current = Some(next);
1087 self.select_match(next)?;1138 self.select(self.view.found.get(next).copied())?;
1088 }1139 }
1089 Ok(())1140 Ok(())
1090 }1141 }
crates/snowbound/src/sidebar.rs+6-15
...@@ -139,7 +139,7 @@ fn sidebar(ui: &mut Ui, tree: &mut Tree, notebooks: &[Arc<Library>], header: f32...@@ -139,7 +139,7 @@ fn sidebar(ui: &mut Ui, tree: &mut Tree, notebooks: &[Arc<Library>], header: f32
139 ..Spec::default()139 ..Spec::default()
140 },140 },
141 );141 );
142 // The button's square, which floats over this place from the section tabs.142 // The notebook button's square, which floats over this place.
143 ui.leaf(143 ui.leaf(
144 "toggle",144 "toggle",
145 Spec {145 Spec {
...@@ -710,26 +710,17 @@ impl crate::State {...@@ -710,26 +710,17 @@ impl crate::State {
710 /// The sidebar's button in its square at the window's edge, and the room the section710 /// The sidebar's button in its square at the window's edge, and the room the section
711 /// tabs, standing `width` from that edge, leave it; the button stays put as the sidebar711 /// tabs, standing `width` from that edge, leave it; the button stays put as the sidebar
712 /// eases open and shut.712 /// eases open and shut.
713 pub(crate) fn sidebar_button(&mut self, theme: &Theme, width: f32) {713 /// The notebook button, floating at the body's corner over the section tabs' row, `height`
714 /// tall as it eases, or over the sidebar's header while that is open.
715 pub(crate) fn sidebar_button(&mut self, theme: &Theme, height: f32) {
714 if self.temporary {716 if self.temporary {
715 return;717 return;
716 }718 }
717 // Past the tab row's padding and the frame's corner, the tabs start where the square
718 // ends; the room shrinks on the sidebar's own easing, so the tabs ease with it.
719 let room = RAIL - crate::FRAME - self.rounding();
720 self.ui.leaf(
721 "rail",
722 Spec {
723 size: [px(room * (1.0 - width / WIDTH)), px(1.0)],
724 ..Spec::default()
725 },
726 );
727 self.ui.open(719 self.ui.open(
728 "toggle",720 "toggle",
729 Spec {721 Spec {
730 flags: Flags::FLOAT,722 flags: Flags::FLOAT | Flags::CLIP,
731 size: [px(RAIL), px(RAIL)],723 size: [px(RAIL), px(height)],
732 position: [-width, 0.0],
733 pad: [(RAIL - ui::shell::TOOL) / 2.0; 2],724 pad: [(RAIL - ui::shell::TOOL) / 2.0; 2],
734 ..Spec::default()725 ..Spec::default()
735 },726 },
crates/snowbound/src/surface.rs+16-12
...@@ -58,6 +58,12 @@ impl Surface {...@@ -58,6 +58,12 @@ impl Surface {
58 config.view_formats.push(window_format);58 config.view_formats.push(window_format);
59 draw::Translucent::new(&device, config.format, window_format)59 draw::Translucent::new(&device, config.format, window_format)
60 });60 });
61 if platform::cuts_corners() {
62 let alpha = &surface.get_capabilities(&adapter).alpha_modes;
63 if alpha.contains(&wgpu::CompositeAlphaMode::PreMultiplied) {
64 config.alpha_mode = wgpu::CompositeAlphaMode::PreMultiplied;
65 }
66 }
61 surface.configure(&device, &config);67 surface.configure(&device, &config);
62 eprintln!("Canvas GPU: {:?}", adapter.get_info());68 eprintln!("Canvas GPU: {:?}", adapter.get_info());
63 let renderer = Renderer::new(device, queue, config.format);69 let renderer = Renderer::new(device, queue, config.format);
...@@ -205,17 +211,15 @@ impl Surface {...@@ -205,17 +211,15 @@ impl Surface {
205 self.config.format,211 self.config.format,
206 wgpu::TextureFormat::Bgra8Unorm | wgpu::TextureFormat::Bgra8UnormSrgb212 wgpu::TextureFormat::Bgra8Unorm | wgpu::TextureFormat::Bgra8UnormSrgb
207 );213 );
208 Ok(buffer214 let mut pixels = Vec::with_capacity(size[0] as usize * size[1] as usize * 4);
209 .get_mapped_range(..)?215 for line in buffer.get_mapped_range(..)?.chunks_exact(row as usize) {
210 .chunks_exact(row as usize)216 pixels.extend_from_slice(&line[..size[0] as usize * 4]);
211 .flat_map(|line| line[..size[0] as usize * 4].chunks_exact(4))217 }
212 .flat_map(|pixel| {218 if bgra {
213 if bgra {219 for pixel in pixels.chunks_exact_mut(4) {
214 [pixel[2], pixel[1], pixel[0], pixel[3]]220 pixel.swap(0, 2);
215 } else {221 }
216 [pixel[0], pixel[1], pixel[2], pixel[3]]222 }
217 }223 Ok(pixels)
218 })
219 .collect())
220 }224 }
221}225}
crates/snowbound/src/surface_gl.rs+18-5
...@@ -18,6 +18,9 @@ pub struct Surface {...@@ -18,6 +18,9 @@ pub struct Surface {
18 /// Device pixels frames and snapshots are drawn at; `configure` gives the window it.18 /// Device pixels frames and snapshots are drawn at; `configure` gives the window it.
19 pub size: [u32; 2],19 pub size: [u32; 2],
20 context: Retained<AnyObject>,20 context: Retained<AnyObject>,
21 /// Frames leave the window's textured backdrop showing through their transparent
22 /// pixels.
23 translucent: bool,
21 /// The frame target, kept between frames.24 /// The frame target, kept between frames.
22 target: Option<Target>,25 target: Option<Target>,
23}26}
...@@ -29,10 +32,10 @@ pub struct Frame {...@@ -29,10 +32,10 @@ pub struct Frame {
29pub type Offscreen = Frame;32pub type Offscreen = Frame;
3033
31impl Surface {34impl Surface {
32 /// No backdrop shows through an OpenGL view.35 /// With `backdrop`, the surface is transparent where frames are.
33 pub async fn new(36 pub async fn new(
34 window: Arc<Window>,37 window: Arc<Window>,
35 _backdrop: bool,38 backdrop: bool,
36 ) -> Result<(Self, Renderer), Box<dyn Error>> {39 ) -> Result<(Self, Renderer), Box<dyn Error>> {
37 let RawWindowHandle::AppKit(handle) = window.window_handle()?.as_raw() else {40 let RawWindowHandle::AppKit(handle) = window.window_handle()?.as_raw() else {
38 unreachable!()41 unreachable!()
...@@ -59,6 +62,11 @@ impl Surface {...@@ -59,6 +62,11 @@ impl Surface {
59 // NSOpenGLCPSwapInterval: present on the display's refresh.62 // NSOpenGLCPSwapInterval: present on the display's refresh.
60 let interval: i32 = 1;63 let interval: i32 = 1;
61 let _: () = msg_send![&context, setValues: &interval, forParameter: 222 as NSInteger];64 let _: () = msg_send![&context, setValues: &interval, forParameter: 222 as NSInteger];
65 if backdrop {
66 // NSOpenGLCPSurfaceOpacity.
67 let opaque: i32 = 0;
68 let _: () = msg_send![&context, setValues: &opaque, forParameter: 236 as NSInteger];
69 }
62 context70 context
63 };71 };
64 let renderer = Renderer::new()?;72 let renderer = Renderer::new()?;
...@@ -68,6 +76,7 @@ impl Surface {...@@ -68,6 +76,7 @@ impl Surface {
68 Self {76 Self {
69 size: [size.width, size.height],77 size: [size.width, size.height],
70 context,78 context,
79 translucent: backdrop,
71 target: None,80 target: None,
72 },81 },
73 renderer,82 renderer,
...@@ -75,7 +84,7 @@ impl Surface {...@@ -75,7 +84,7 @@ impl Surface {
75 }84 }
7685
77 pub fn translucent(&self) -> bool {86 pub fn translucent(&self) -> bool {
78 false87 self.translucent
79 }88 }
8089
81 pub fn configure(&mut self, _: &Renderer) {90 pub fn configure(&mut self, _: &Renderer) {
...@@ -94,8 +103,12 @@ impl Surface {...@@ -94,8 +103,12 @@ impl Surface {
94 Ok(Some(Frame { target }))103 Ok(Some(Frame { target }))
95 }104 }
96105
97 pub fn present(&mut self, _: &Renderer, frame: Frame) {106 pub fn present(&mut self, renderer: &Renderer, frame: Frame) {
98 frame.target.present();107 if self.translucent {
108 renderer.present_translucent(&frame.target);
109 } else {
110 frame.target.present();
111 }
99 let _: () = unsafe { msg_send![&self.context, flushBuffer] };112 let _: () = unsafe { msg_send![&self.context, flushBuffer] };
100 self.target = Some(frame.target);113 self.target = Some(frame.target);
101 }114 }
crates/snowbound/src/sync.rs+73-22
...@@ -1,10 +1,12 @@...@@ -1,10 +1,12 @@
1//! The sync status at the top right, and the popup it opens: OneNote's Shared Notebook1//! The sync status at the top right, and the popup it opens: OneNote's Shared Notebook
2//! Synchronization for the open section.2//! Synchronization for the open section.
33
4use crate::{Session, State, filetime, library, platform};4use crate::{Session, State, art, filetime, library, platform};
5use notebook::session::SyncStatus;5use notebook::session::SyncStatus;
6use std::{error::Error, io::ErrorKind};6use 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
9const WIDTH: f32 = 380.0;11const WIDTH: f32 = 380.0;
1012
...@@ -16,24 +18,27 @@ fn button() -> Id {...@@ -16,24 +18,27 @@ fn button() -> Id {
16 Id::ROOT.child("sync-button")18 Id::ROOT.child("sync-button")
17}19}
1820
19/// The status's label, and what the reader can do about an error.21/// The status's label and icon, and what the reader can do about an error.
20fn describe(sync: &SyncStatus) -> (&'static str, Option<&'static str>) {22fn describe(sync: &SyncStatus) -> (&'static str, &'static [&'static str], Option<&'static str>) {
21 use ErrorKind::*;23 use ErrorKind::*;
22 if library::offline() {24 if library::offline() {
23 return (25 return (
24 "Working offline",26 "Working offline",
27 art::SYNC_OFFLINE,
25 Some("Changes stay on this computer until you sync."),28 Some("Changes stay on this computer until you sync."),
26 );29 );
27 }30 }
28 match sync.error.as_ref().map(std::io::Error::kind) {31 match sync.error.as_ref().map(std::io::Error::kind) {
29 Some(PermissionDenied | ReadOnlyFilesystem) => (32 Some(PermissionDenied | ReadOnlyFilesystem) => (
30 "Unable to sync",33 "Unable to sync",
34 art::SYNC_ERROR,
31 Some(35 Some(
32 "You can’t change this notebook where it’s stored. Changes stay on this computer.",36 "You can’t change this notebook where it’s stored. Changes stay on this computer.",
33 ),37 ),
34 ),38 ),
35 Some(WouldBlock | ResourceBusy) => (39 Some(WouldBlock | ResourceBusy) => (
36 "Section in use",40 "Section in use",
41 art::SYNC_BUSY,
37 Some("Someone else is saving this section. Sync continues when they finish."),42 Some("Someone else is saving this section. Sync continues when they finish."),
38 ),43 ),
39 Some(44 Some(
...@@ -42,11 +47,12 @@ fn describe(sync: &SyncStatus) -> (&'static str, Option<&'static str>) {...@@ -42,11 +47,12 @@ fn describe(sync: &SyncStatus) -> (&'static str, Option<&'static str>) {
42 | AddrNotAvailable,47 | AddrNotAvailable,
43 ) => (48 ) => (
44 "Not connected",49 "Not connected",
50 art::SYNC_OFFLINE,
45 Some("Changes stay on this computer and sync when the notebook is back."),51 Some("Changes stay on this computer and sync when the notebook is back."),
46 ),52 ),
47 Some(_) => ("Unable to sync", None),53 Some(_) => ("Unable to sync", art::SYNC_ERROR, None),
48 None if sync.synced.is_none() || sync.queued > 0 => ("Syncing…", None),54 None if sync.synced.is_none() || sync.queued > 0 => ("Syncing…", art::SYNC_BUSY, None),
49 None => ("Up to date", None),55 None => ("Up to date", art::SYNC_DONE, None),
50 }56 }
51}57}
5258
...@@ -65,25 +71,47 @@ fn when(time: u64) -> String {...@@ -65,25 +71,47 @@ fn when(time: u64) -> String {
65 }71 }
66}72}
6773
68/// The status beside the window's controls, which opens the popup.74/// The status in the toolbar, which opens the popup: its label, or where `compact` its icon,
69pub(crate) fn control(ui: &mut Ui, session: &Session, theme: &Theme, height: f32) {75/// naming it in a tooltip.
76pub(crate) fn control(ui: &mut Ui, session: &Session, theme: &Theme, compact: bool) {
70 let strong = ui.popup_open(id()) || session.sync.error.is_some() && !library::offline();77 let strong = ui.popup_open(id()) || session.sync.error.is_some() && !library::offline();
78 let (label, icon, _) = describe(&session.sync);
79 let spec = Spec {
80 flags: Flags::CLICKABLE,
81 color: Some(if strong { theme.text } else { theme.text_dim }),
82 hover_fill: Some(theme.hover()),
83 radius: 4.0,
84 center: true,
85 ..Spec::default()
86 };
87 // The popup opens from the label's box, which stands where the icon shows.
88 let shown = if compact {
89 button().child("icon")
90 } else {
91 button()
92 };
71 ui.open_as(93 ui.open_as(
72 button(),94 shown,
73 Spec {95 if compact {
74 flags: Flags::CLICKABLE,96 Spec {
75 size: [fit(), px(height)],97 size: [px(TOOL), px(TOOL)],
76 text: Some(label(&session.sync)),98 icon: Some(icon),
77 color: Some(if strong { theme.text } else { theme.text_dim }),99 ..spec
78 hover_fill: Some(theme.hover()),100 }
79 radius: 4.0,101 } else {
80 pad: [8.0, 0.0],102 Spec {
81 center: true,103 size: [fit(), px(TOOL)],
82 ..Spec::default()104 text: Some(label),
105 pad: [8.0, 0.0],
106 ..spec
107 }
83 },108 },
84 );109 );
85 ui.close();110 ui.close();
86 if ui.signal(button()).clicked {111 if compact {
112 ui::popup::tooltip(ui, label, "", None);
113 }
114 if ui.signal(shown).clicked {
87 if ui.popup_open(id()) {115 if ui.popup_open(id()) {
88 ui.close_popup(id());116 ui.close_popup(id());
89 } else {117 } else {
...@@ -107,7 +135,7 @@ impl State {...@@ -107,7 +135,7 @@ impl State {
107 session.sync = session.section.sync_status()?;135 session.sync = session.section.sync_status()?;
108 let theme = ui.theme.clone();136 let theme = ui.theme.clone();
109 let offline = library::offline();137 let offline = library::offline();
110 let (progress, advice) = describe(&session.sync);138 let (progress, _, advice) = describe(&session.sync);
111 ui.open_as(139 ui.open_as(
112 id(),140 id(),
113 Spec {141 Spec {
...@@ -209,6 +237,26 @@ impl State {...@@ -209,6 +237,26 @@ impl State {
209 ..Spec::default()237 ..Spec::default()
210 },238 },
211 );239 );
240 let file = session.library.local(session.section.file());
241 let show = match file {
242 Some(_) => ui::button(ui, "show-file", platform::SHOW_FILE).clicked,
243 None => {
244 ui.leaf(
245 "show-file",
246 Spec {
247 size: [fit(), px(theme.font_size * 2.0)],
248 text: Some(platform::SHOW_FILE),
249 color: Some(theme.text_dim),
250 fill: Some(theme.chip),
251 radius: 4.0,
252 pad: [theme.font_size * 0.75, 0.0],
253 center: true,
254 ..Spec::default()
255 },
256 );
257 false
258 }
259 };
212 let now = ui::button(ui, "sync-now", "Sync Now").clicked;260 let now = ui::button(ui, "sync-now", "Sync Now").clicked;
213 ui.close();261 ui.close();
214 ui.close();262 ui.close();
...@@ -219,6 +267,9 @@ impl State {...@@ -219,6 +267,9 @@ impl State {
219 if now {267 if now {
220 session.section.wake();268 session.section.wake();
221 }269 }
270 if let Some(file) = file.filter(|_| show) {
271 platform::show_file(&file);
272 }
222 Ok(())273 Ok(())
223 }274 }
224}275}
crates/snowbound/src/templates.rs+2-2
...@@ -12,7 +12,7 @@ use ui::{Axis, Flags, Spec, Theme, Ui, fill, px};...@@ -12,7 +12,7 @@ use ui::{Axis, Flags, Spec, Theme, Ui, fill, px};
1212
13/// What a thumbnail shows: the top of a letter page, in points from the page origin.13/// What a thumbnail shows: the top of a letter page, in points from the page origin.
14const REGION: [f32; 4] = [-72.0, -40.0, 540.0, 419.0];14const 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];
16const SWATCH: f32 = 24.0;16const SWATCH: f32 = 24.0;
17const LABEL: f32 = 22.0;17const LABEL: f32 = 22.0;
18const GAP: f32 = 12.0;18const GAP: f32 = 12.0;
...@@ -59,7 +59,7 @@ pub struct Thumbnails(HashMap<(&'static str, bool), Art>);...@@ -59,7 +59,7 @@ pub struct Thumbnails(HashMap<(&'static str, bool), Art>);
5959
60impl Thumbnails {60impl Thumbnails {
61 /// `template`'s art and where it lies in a `TILE`, drawn at `scale` for `paper`.61 /// `template`'s art and where it lies in a `TILE`, drawn at `scale` for `paper`.
62 fn art(62 pub(crate) fn art(
63 &mut self,63 &mut self,
64 template: &'static Template,64 template: &'static Template,
65 paper: Paper,65 paper: Paper,
crates/ui/examples/popups.rs+1-1
...@@ -761,7 +761,7 @@ fn palette(scene: &mut Scene) {...@@ -761,7 +761,7 @@ fn palette(scene: &mut Scene) {
761 });761 });
762 let id = Id::ROOT.child("palette");762 let id = Id::ROOT.child("palette");
763 scene.open(id, typed("ne"), |ui, _| {763 scene.open(id, typed("ne"), |ui, _| {
764 ui::popup::palette(ui, id, &commands);764 ui::popup::palette(ui, id, &commands, "Search commands");
765 });765 });
766}766}
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...@@ -2,8 +2,9 @@ use crate::{Anchor, Axis, Built, Flags, ICON, ICON_GAP, Id, Overflow, Size, Stat
2use std::collections::HashMap;2use std::collections::HashMap;
33
4/// Sizes each box on both axes, then places it: standalone sizes, sizes taken from4/// 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 out5/// ancestors (pre-order), sizes summed from children (post-order), overflow taken back
6/// by strictness, and positions along each parent's flow. Labels too wide for their6/// (from space sized by ancestors, then by folding a row's groups, then from the least
7/// strict boxes first), and positions along each parent's flow. Labels too wide for their
7/// solved width wrap or shorten before heights are solved. Boxes are in build order, so8/// solved width wrap or shorten before heights are solved. Boxes are in build order, so
8/// index order is pre-order.9/// index order is pre-order.
9pub(crate) fn solve(10pub(crate) fn solve(
...@@ -13,6 +14,12 @@ pub(crate) fn solve(...@@ -13,6 +14,12 @@ pub(crate) fn solve(
13 texts: &mut Texts,14 texts: &mut Texts,
14 frame: u64,15 frame: u64,
15) {16) {
17 for index in 0..nodes.len() {
18 if nodes[index].fold.is_some() {
19 let folded = nodes[index].children[1];
20 nodes[folded].hidden = true;
21 }
22 }
16 for axis in 0..2 {23 for axis in 0..2 {
17 if axis == 1 {24 if axis == 1 {
18 fit_labels(nodes, texts, frame);25 fit_labels(nodes, texts, frame);
...@@ -48,7 +55,7 @@ pub(crate) fn solve(...@@ -48,7 +55,7 @@ pub(crate) fn solve(
48 while ancestor != 0 && nodes[ancestor].size[axis].size == Size::Children {55 while ancestor != 0 && nodes[ancestor].size[axis].size == Size::Children {
49 ancestor = nodes[ancestor].parent;56 ancestor = nodes[ancestor].parent;
50 }57 }
51 let room = nodes[ancestor].computed[axis] - 2.0 * nodes[ancestor].pad[axis];58 let room = across(nodes, ancestor, index, axis) - 2.0 * nodes[ancestor].pad[axis];
52 nodes[index].computed[axis] = room.max(0.0) * fraction;59 nodes[index].computed[axis] = room.max(0.0) * fraction;
53 }60 }
54 }61 }
...@@ -66,22 +73,48 @@ pub(crate) fn solve(...@@ -66,22 +73,48 @@ pub(crate) fn solve(
66 let room = (node.computed[axis] - 2.0 * node.pad[axis]).max(0.0);73 let room = (node.computed[axis] - 2.0 * node.pad[axis]).max(0.0);
67 let children: Vec<_> = in_flow(nodes, index).collect();74 let children: Vec<_> = in_flow(nodes, index).collect();
68 if along(node, axis) {75 if along(node, axis) {
69 let excess = flow(nodes, index, axis) - room;76 let space =
70 let give: f32 = children77 |child: &&usize| matches!(nodes[**child].size[axis].size, Size::Fraction(_));
78 let (space, sized): (Vec<_>, Vec<_>) = children.iter().partition(space);
79 let mut excess = flow(nodes, index, axis) - room;
80 excess = give_back(nodes, &space, axis, excess);
81 while excess > 0.0 && axis == 0 {
82 let Some(group) = children
83 .iter()
84 .copied()
85 .filter(|child| {
86 nodes[*child].fold.is_some() && !nodes[nodes[*child].children[0]].hidden
87 })
88 .min_by_key(|child| nodes[*child].fold)
89 else {
90 break;
91 };
92 let [full, folded] = [nodes[group].children[0], nodes[group].children[1]];
93 nodes[full].hidden = true;
94 nodes[folded].hidden = false;
95 let width = nodes[folded].computed[0] + 2.0 * nodes[group].pad[0];
96 excess -= nodes[group].computed[0] - width;
97 nodes[group].computed[0] = width;
98 }
99 let mut tiers: Vec<f32> = sized
71 .iter()100 .iter()
72 .map(|child| {101 .map(|child| strictness(nodes, *child, axis))
73 nodes[*child].computed[axis] * (1.0 - strictness(nodes, *child, axis))102 .filter(|strictness| *strictness < 1.0)
74 })103 .collect();
75 .sum();104 tiers.sort_by(f32::total_cmp);
76 if excess > 0.0 && give > 0.0 {105 tiers.dedup();
77 for child in children {106 for tier in tiers {
78 let share =107 let members: Vec<_> = sized
79 nodes[child].computed[axis] * (1.0 - strictness(nodes, child, axis));108 .iter()
80 nodes[child].computed[axis] -= excess.min(give) * share / give;109 .copied()
81 }110 .filter(|child| strictness(nodes, *child, axis) == tier)
111 .collect();
112 excess = give_back(nodes, &members, axis, excess);
82 }113 }
83 } else {114 } else {
84 for child in children {115 for child in children {
116 let room =
117 (across(nodes, index, child, axis) - 2.0 * nodes[index].pad[axis]).max(0.0);
85 let over = nodes[child].computed[axis] - room;118 let over = nodes[child].computed[axis] - room;
86 if over > 0.0 {119 if over > 0.0 {
87 nodes[child].computed[axis] -=120 nodes[child].computed[axis] -=
...@@ -93,6 +126,13 @@ pub(crate) fn solve(...@@ -93,6 +126,13 @@ pub(crate) fn solve(
93 let window = nodes[0].computed[axis];126 let window = nodes[0].computed[axis];
94 for index in 0..nodes.len() {127 for index in 0..nodes.len() {
95 let mut cursor = nodes[index].pad[axis];128 let mut cursor = nodes[index].pad[axis];
129 // Where a popup widening over its anchor lays its children out, from where it is.
130 let shift = match (nodes[index].anchor, nodes[index].size[axis].size) {
131 (Some(anchor @ Anchor::Over(_)), Size::Pixels(full)) if axis == 0 => {
132 anchor.place(axis, full, full, window) - nodes[index].relative[axis]
133 }
134 _ => 0.0,
135 };
96 for child in nodes[index].children.clone() {136 for child in nodes[index].children.clone() {
97 nodes[child].relative[axis] = if let Some(anchor) = nodes[child].anchor {137 nodes[child].relative[axis] = if let Some(anchor) = nodes[child].anchor {
98 let shown = nodes[child].computed[axis];138 let shown = nodes[child].computed[axis];
...@@ -103,6 +143,8 @@ pub(crate) fn solve(...@@ -103,6 +143,8 @@ pub(crate) fn solve(
103 anchor.place(axis, size, shown, window)143 anchor.place(axis, size, shown, window)
104 } else if nodes[child].flags.contains(Flags::FLOAT) {144 } else if nodes[child].flags.contains(Flags::FLOAT) {
105 nodes[child].position[axis]145 nodes[child].position[axis]
146 } else if nodes[child].hidden {
147 nodes[index].pad[axis]
106 } else if along(&nodes[index], axis) {148 } else if along(&nodes[index], axis) {
107 let at = cursor;149 let at = cursor;
108 cursor += nodes[child].computed[axis] + nodes[index].gap;150 cursor += nodes[child].computed[axis] + nodes[index].gap;
...@@ -110,6 +152,9 @@ pub(crate) fn solve(...@@ -110,6 +152,9 @@ pub(crate) fn solve(
110 } else {152 } else {
111 nodes[index].pad[axis]153 nodes[index].pad[axis]
112 };154 };
155 if !nodes[child].flags.contains(Flags::STILL) {
156 nodes[child].relative[axis] += shift;
157 }
113 }158 }
114 if axis == 1 {159 if axis == 1 {
115 nodes[index].content = flow(nodes, index, axis) + 2.0 * nodes[index].pad[axis];160 nodes[index].content = flow(nodes, index, axis) + 2.0 * nodes[index].pad[axis];
...@@ -124,7 +169,13 @@ pub(crate) fn solve(...@@ -124,7 +169,13 @@ pub(crate) fn solve(
124 snap(nodes[0].computed[1]),169 snap(nodes[0].computed[1]),
125 ];170 ];
126 for index in 1..nodes.len() {171 for index in 1..nodes.len() {
127 let parent = &nodes[nodes[index].parent];172 let parent = nodes[index].parent;
173 if nodes[index].hidden || nodes[parent].hidden {
174 nodes[index].hidden = true;
175 nodes[index].rect = nodes[parent].rect;
176 continue;
177 }
178 let parent = &nodes[parent];
128 let scroll =179 let scroll =
129 if parent.flags.contains(Flags::SCROLL) && !nodes[index].flags.contains(Flags::FLOAT) {180 if parent.flags.contains(Flags::SCROLL) && !nodes[index].flags.contains(Flags::FLOAT) {
130 states.get(&parent.id).map_or(0.0, |state| state.scroll)181 states.get(&parent.id).map_or(0.0, |state| state.scroll)
...@@ -143,17 +194,60 @@ pub(crate) fn solve(...@@ -143,17 +194,60 @@ pub(crate) fn solve(
143 }194 }
144}195}
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
146fn along(node: &Built, axis: usize) -> bool {228fn along(node: &Built, axis: usize) -> bool {
147 (node.axis == Axis::Y) == (axis == 1)229 (node.axis == Axis::Y) == (axis == 1)
148}230}
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.
150fn strictness(nodes: &[Built], index: usize, axis: usize) -> f32 {234fn strictness(nodes: &[Built], index: usize, axis: usize) -> f32 {
151 nodes[index].size[axis].strictness.clamp(0.0, 1.0)235 let node = &nodes[index];
236 let declared = node.size[axis].strictness.clamp(0.0, 1.0);
237 if node.size[axis].size != Size::Children || !along(node, axis) || node.computed[axis] <= 0.0 {
238 return declared;
239 }
240 let give: f32 = in_flow(nodes, index)
241 .map(|child| nodes[child].computed[axis] * (1.0 - strictness(nodes, child, axis)))
242 .sum();
243 declared.min(1.0 - give / node.computed[axis])
152}244}
153245
154fn in_flow(nodes: &[Built], index: usize) -> impl Iterator<Item = usize> + '_ {246fn in_flow(nodes: &[Built], index: usize) -> impl Iterator<Item = usize> + '_ {
155 nodes[index].children.iter().copied().filter(|child| {247 nodes[index].children.iter().copied().filter(|child| {
156 !nodes[*child].flags.contains(Flags::FLOAT) && nodes[*child].anchor.is_none()248 !nodes[*child].flags.contains(Flags::FLOAT)
249 && nodes[*child].anchor.is_none()
250 && !nodes[*child].hidden
157 })251 })
158}252}
159253
crates/ui/src/lib.rs+169-26
...@@ -13,7 +13,10 @@ mod widgets;...@@ -13,7 +13,10 @@ mod widgets;
1313
14pub use list::{List, Row, Rows, list};14pub use list::{List, Row, Rows, list};
15pub use theme::{Menu, PopupMotion, Section, Shades, Shadow, Theme};15pub 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
18use draw::{21use draw::{
19 PathStyle, Primitive, RasterImage, Stroke,22 PathStyle, Primitive, RasterImage, Stroke,
...@@ -38,7 +41,7 @@ use winit::{...@@ -38,7 +41,7 @@ use winit::{
38const HALF_LIFE: f32 = 0.03;41const HALF_LIFE: f32 = 0.03;
39/// How far a box's shadow spreads, in logical pixels, and how far below the box it42/// How far a box's shadow spreads, in logical pixels, and how far below the box it
40/// falls; a popup's, floating higher, spreads and falls further.43/// 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];
42const POPUP_SHADOW: [f32; 2] = [12.0, 4.0];45const POPUP_SHADOW: [f32; 2] = [12.0, 4.0];
43/// Logical size of a box's icon, and its distance from the label.46/// Logical size of a box's icon, and its distance from the label.
44const ICON: f32 = 16.0;47const ICON: f32 = 16.0;
...@@ -93,6 +96,13 @@ impl Default for Extent {...@@ -93,6 +96,13 @@ impl Default for Extent {
93 }96 }
94}97}
9598
99/// Logical pixels, as `px`.
100impl From<f32> for Extent {
101 fn from(pixels: f32) -> Self {
102 px(pixels)
103 }
104}
105
96pub fn px(pixels: f32) -> Extent {106pub fn px(pixels: f32) -> Extent {
97 Extent {107 Extent {
98 size: Size::Pixels(pixels),108 size: Size::Pixels(pixels),
...@@ -202,6 +212,9 @@ pub enum Anchor {...@@ -202,6 +212,9 @@ pub enum Anchor {
202 Point([f32; 2]),212 Point([f32; 2]),
203 /// Centred in the window over the interface, which dims, as a dialog opens.213 /// Centred in the window over the interface, which dims, as a dialog opens.
204 Dialog,214 Dialog,
215 /// Centred across the window near its top, swinging in as a dialog does, as a command
216 /// palette opens.
217 Top,
205}218}
206219
207impl Anchor {220impl Anchor {
...@@ -215,6 +228,8 @@ impl Anchor {...@@ -215,6 +228,8 @@ impl Anchor {
215 Anchor::Over(rect) => (rect, None),228 Anchor::Over(rect) => (rect, None),
216 Anchor::Point([x, y]) => ([x, y, x, y], Some(1)),229 Anchor::Point([x, y]) => ([x, y, x, y], Some(1)),
217 Anchor::Dialog => return ((room - size) / 2.0).max(0.0),230 Anchor::Dialog => return ((room - size) / 2.0).max(0.0),
231 Anchor::Top if axis == 0 => return ((room - size) / 2.0).max(0.0),
232 Anchor::Top => return room / 8.0,
218 };233 };
219 let [low, high] = [rect[axis], rect[axis + 2]];234 let [low, high] = [rect[axis], rect[axis + 2]];
220 let (first, second) = if along == Some(axis) {235 let (first, second) = if along == Some(axis) {
...@@ -231,31 +246,37 @@ impl Anchor {...@@ -231,31 +246,37 @@ impl Anchor {
231 }246 }
232 }247 }
233248
234 /// How a popup laid out at `rect` beside the anchor shows `open` of the way open: it249 /// How a popup laid out at `rect` beside the anchor shows `open` of the way open: where it
235 /// grows out of the anchor as it fades in where it `grows`, and a dialog swings up into250 /// `grows`, it swings out of the anchor's edge as it grows and fades in, and a dialog swings
236 /// place.251 /// up into place.
237 fn motion(self, rect: [f32; 4], open: f32, grows: bool) -> Motion {252 fn motion(self, rect: [f32; 4], open: f32, grows: bool) -> Motion {
238 let pivot = match self {253 let pivot = match self {
239 Anchor::Below(anchor) => [anchor[0], anchor[3]],254 Anchor::Below(anchor) => [anchor[0], anchor[3]],
240 Anchor::Right(anchor) => [anchor[2], anchor[1]],255 Anchor::Right(anchor) => [anchor[2], anchor[1]],
241 Anchor::Over(anchor) => [anchor[0], anchor[1]],256 Anchor::Over(anchor) => [anchor[0], anchor[1]],
242 Anchor::Point(point) => point,257 Anchor::Point(point) => point,
243 Anchor::Dialog => [(rect[0] + rect[2]) / 2.0, rect[1]],258 Anchor::Dialog | Anchor::Top => [(rect[0] + rect[2]) / 2.0, rect[1]],
244 };259 };
245 let (from, tilt) = match self {260 let (from, tilt) = match self {
246 _ if !grows => (1.0, 0.0),261 _ if !grows => (1.0, 0.0),
247 Anchor::Dialog => (0.95, 0.4),262 Anchor::Dialog | Anchor::Top => (0.95, 0.2),
248 // Over a box, the popup widens out of it in layout instead of growing.263 // Over a box, the popup widens out of it in layout instead of growing.
249 Anchor::Over(_) => (1.0, 0.2),264 Anchor::Over(_) => (1.0, 0.2),
250 Anchor::Point(_) => (0.94, 0.2),265 _ => (0.94, 0.2),
251 _ => (0.94, 0.0),266 };
267 let pivot = [
268 pivot[0].clamp(rect[0], rect[2]),
269 pivot[1].clamp(rect[1], rect[3]),
270 ];
271 // A popup flipped above its anchor swings out of its bottom edge, its top leaning away.
272 let tilt = if pivot[1] > (rect[1] + rect[3]) / 2.0 {
273 -tilt
274 } else {
275 tilt
252 };276 };
253 Motion {277 Motion {
254 zoom: from + (1.0 - from) * open,278 zoom: from + (1.0 - from) * open,
255 pivot: [279 pivot,
256 pivot[0].clamp(rect[0], rect[2]),
257 pivot[1].clamp(rect[1], rect[3]),
258 ],
259 tilt: tilt * (1.0 - open),280 tilt: tilt * (1.0 - open),
260 opacity: open,281 opacity: open,
261 }282 }
...@@ -263,7 +284,7 @@ impl Anchor {...@@ -263,7 +284,7 @@ impl Anchor {
263284
264 /// Seconds the popup takes to open and to close.285 /// Seconds the popup takes to open and to close.
265 fn durations(self) -> [f32; 2] {286 fn durations(self) -> [f32; 2] {
266 if self == Anchor::Dialog {287 if matches!(self, Anchor::Dialog | Anchor::Top) {
267 DIALOG288 DIALOG
268 } else {289 } else {
269 POPUP290 POPUP
...@@ -332,6 +353,16 @@ pub struct Spec<'a> {...@@ -332,6 +353,16 @@ pub struct Spec<'a> {
332 /// Floats the box over all others beside a rectangle in the window, as a popup353 /// Floats the box over all others beside a rectangle in the window, as a popup
333 /// `Ui::open_popup` opened; boxes beneath take no input while one is open.354 /// `Ui::open_popup` opened; boxes beneath take no input while one is open.
334 pub anchor: Option<Anchor>,355 pub anchor: Option<Anchor>,
356 /// Makes the box a group of a row in two forms, its first two children, both built every
357 /// frame: its full form, and the one it folds to where the row lacks room. The row folds
358 /// its groups by ascending priority until it fits, once the space sized by ancestors has
359 /// yielded and before any other box gives up room. The form not shown takes no room,
360 /// paint or input, and its boxes take the group's rectangle, so what opens from them
361 /// opens from the form shown.
362 pub fold: Option<u32>,
363 /// How far the children fade out into the box's fill towards its leading and trailing
364 /// edges, as a row of them cut there does.
365 pub fade: [f32; 2],
335}366}
336367
337/// Input the host forwards; positions and wheel distances are logical pixels.368/// Input the host forwards; positions and wheel distances are logical pixels.
...@@ -387,6 +418,8 @@ pub enum Layer<'a> {...@@ -387,6 +418,8 @@ pub enum Layer<'a> {
387/// opening or closing.418/// opening or closing.
388pub struct Primitives<'a> {419pub struct Primitives<'a> {
389 pub clip: Option<[f32; 4]>,420 pub clip: Option<[f32; 4]>,
421 /// A rounded rectangle and its corner radius the primitives paint only inside.
422 round: Option<([f32; 4], f32)>,
390 motion: Option<Motion>,423 motion: Option<Motion>,
391 pub primitives: Vec<Primitive<'a>>,424 pub primitives: Vec<Primitive<'a>>,
392}425}
...@@ -411,14 +444,18 @@ impl Primitives<'_> {...@@ -411,14 +444,18 @@ impl Primitives<'_> {
411 pivot[axis] + (point[axis] - pivot[axis]) * zoom444 pivot[axis] + (point[axis] - pivot[axis]) * zoom
412 }))445 }))
413 };446 };
447 let moved_rect = |[left, top, right, bottom]: [f32; 4]| {
448 let [[left, top], [right, bottom]] = [moved([left, top]), moved([right, bottom])];
449 [left, top, right, bottom]
450 };
414 draw::Layer {451 draw::Layer {
415 scale: scale * zoom,452 scale: scale * zoom,
416 origin: moved([0.0; 2]),453 origin: moved([0.0; 2]),
417 clip: self.clip.map(|[left, top, right, bottom]| {454 clip: self.clip.map(moved_rect),
418 let [[left, top], [right, bottom]] = [moved([left, top]), moved([right, bottom])];
419 [left, top, right, bottom]
420 }),
421 backdrop: None,455 backdrop: None,
456 round: self
457 .round
458 .map(|(rect, radius)| (moved_rect(rect), radius * scale * zoom)),
422 motion: self.motion.map(|_| draw::Motion {459 motion: self.motion.map(|_| draw::Motion {
423 opacity,460 opacity,
424 tilt,461 tilt,
...@@ -462,6 +499,11 @@ struct Built {...@@ -462,6 +499,11 @@ struct Built {
462 motion: Option<Motion>,499 motion: Option<Motion>,
463 /// Rectangles relative to the box, painted over its fill.500 /// Rectangles relative to the box, painted over its fill.
464 marks: Vec<([f32; 4], [f32; 4], f32)>,501 marks: Vec<([f32; 4], [f32; 4], f32)>,
502 fold: Option<u32>,
503 fade: [f32; 2],
504 /// Folded away with the form it belongs to: it takes no room, paint or input, and takes
505 /// its group's rectangle.
506 hidden: bool,
465 computed: [f32; 2],507 computed: [f32; 2],
466 relative: [f32; 2],508 relative: [f32; 2],
467 rect: [f32; 4],509 rect: [f32; 4],
...@@ -486,6 +528,8 @@ struct State {...@@ -486,6 +528,8 @@ struct State {
486 select_all: bool,528 select_all: bool,
487 /// Where a dragged scrollbar thumb was taken, from its start.529 /// Where a dragged scrollbar thumb was taken, from its start.
488 grab: f32,530 grab: f32,
531 /// A system scroller's part held down, and when a held arrow or track next repeats.
532 held: Option<(ScrollerPart, Instant)>,
489 /// An animated value and its target, for `Ui::animate`.533 /// An animated value and its target, for `Ui::animate`.
490 tween: Option<[f32; 2]>,534 tween: Option<[f32; 2]>,
491}535}
...@@ -531,6 +575,8 @@ struct Hit {...@@ -531,6 +575,8 @@ struct Hit {
531#[derive(Clone)]575#[derive(Clone)]
532enum Display {576enum Display {
533 Clip(Option<[f32; 4]>),577 Clip(Option<[f32; 4]>),
578 /// A rounded rectangle and its corner radius, clipping what follows.
579 Round(Option<([f32; 4], f32)>),
534 Motion(Option<Motion>),580 Motion(Option<Motion>),
535 Rect {581 Rect {
536 rect: [f32; 4],582 rect: [f32; 4],
...@@ -582,6 +628,9 @@ pub struct Ui {...@@ -582,6 +628,9 @@ pub struct Ui {
582 /// Whether the window has keyboard focus; without it a field hides its caret and dims628 /// Whether the window has keyboard focus; without it a field hides its caret and dims
583 /// its selection.629 /// its selection.
584 pub window_focused: bool,630 pub window_focused: bool,
631 /// The platform's scrollers, where it draws fixed ones beside the content; scrollbars
632 /// overlay the content otherwise.
633 pub scrollers: Option<Scrollers>,
585 frame: u64,634 frame: u64,
586 now: Instant,635 now: Instant,
587 scale: f32,636 scale: f32,
...@@ -608,6 +657,8 @@ pub struct Ui {...@@ -608,6 +657,8 @@ pub struct Ui {
608 modifiers: ModifiersState,657 modifiers: ModifiersState,
609 clicks: Clicks,658 clicks: Clicks,
610 display: Vec<Display>,659 display: Vec<Display>,
660 /// The painting of what holds still in the popup being painted, which goes over it.
661 still: Vec<Display>,
611 /// Where the popups' painting starts in `display`, for edges drawn beneath them.662 /// Where the popups' painting starts in `display`, for edges drawn beneath them.
612 popups_painted: usize,663 popups_painted: usize,
613 /// Each popup painted, its anchor and rectangle, and its painting's place after664 /// Each popup painted, its anchor and rectangle, and its painting's place after
...@@ -634,6 +685,7 @@ impl Ui {...@@ -634,6 +685,7 @@ impl Ui {
634 theme,685 theme,
635 icon_palette: draw::Palette::default(),686 icon_palette: draw::Palette::default(),
636 window_focused: true,687 window_focused: true,
688 scrollers: None,
637 frame: 0,689 frame: 0,
638 now: Instant::now(),690 now: Instant::now(),
639 scale: 1.0,691 scale: 1.0,
...@@ -656,6 +708,7 @@ impl Ui {...@@ -656,6 +708,7 @@ impl Ui {
656 modifiers: ModifiersState::empty(),708 modifiers: ModifiersState::empty(),
657 clicks: Clicks::new(double_click),709 clicks: Clicks::new(double_click),
658 display: Vec::new(),710 display: Vec::new(),
711 still: Vec::new(),
659 popups_painted: 0,712 popups_painted: 0,
660 painted: Vec::new(),713 painted: Vec::new(),
661 closing: Vec::new(),714 closing: Vec::new(),
...@@ -1188,6 +1241,15 @@ impl Ui {...@@ -1188,6 +1241,15 @@ impl Ui {
1188 }1241 }
1189 let from = self.display.len() - self.popups_painted;1242 let from = self.display.len() - self.popups_painted;
1190 self.paint(index, None, None);1243 self.paint(index, None, None);
1244 if !self.still.is_empty() {
1245 let still = std::mem::take(&mut self.still);
1246 self.display.extend(still);
1247 self.display.extend([
1248 Display::Motion(None),
1249 Display::Clip(None),
1250 Display::Round(None),
1251 ]);
1252 }
1191 painted.push((1253 painted.push((
1192 id,1254 id,
1193 anchor,1255 anchor,
...@@ -1279,9 +1341,10 @@ impl Ui {...@@ -1279,9 +1341,10 @@ impl Ui {
1279 })1341 })
1280 }1342 }
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.
1283 fn popup_motion(&self, anchor: Anchor) -> PopupMotion {1346 fn popup_motion(&self, anchor: Anchor) -> PopupMotion {
1284 if anchor == Anchor::Dialog {1347 if matches!(anchor, Anchor::Dialog | Anchor::Top) {
1285 PopupMotion::Grow1348 PopupMotion::Grow
1286 } else {1349 } else {
1287 self.theme.menu().motion1350 self.theme.menu().motion
...@@ -1311,6 +1374,18 @@ impl Ui {...@@ -1311,6 +1374,18 @@ impl Ui {
1311 }1374 }
13121375
1313 fn paint(&mut self, index: usize, clip: Option<[f32; 4]>, motion: Option<Motion>) {1376 fn paint(&mut self, index: usize, clip: Option<[f32; 4]>, motion: Option<Motion>) {
1377 // Painted over the popup it holds still in, which appears as one picture beneath.
1378 if self.nodes[index].flags.contains(Flags::STILL) && motion.is_some() {
1379 let from = self.display.len();
1380 self.paint_box(index, clip, motion);
1381 let still = self.display.split_off(from);
1382 self.still.extend(still);
1383 } else {
1384 self.paint_box(index, clip, motion);
1385 }
1386 }
1387
1388 fn paint_box(&mut self, index: usize, clip: Option<[f32; 4]>, motion: Option<Motion>) {
1314 let node = &self.nodes[index];1389 let node = &self.nodes[index];
1315 // The motion the box and its children paint with, where it changes it.1390 // The motion the box and its children paint with, where it changes it.
1316 let own = if node.flags.contains(Flags::STILL) {1391 let own = if node.flags.contains(Flags::STILL) {
...@@ -1485,7 +1560,9 @@ impl Ui {...@@ -1485,7 +1560,9 @@ impl Ui {
1485 });1560 });
1486 }1561 }
1487 }1562 }
1488 let inner_clip = if node.flags.contains(Flags::CLIP) {1563 // A popup's contents show only inside its rounded outline.
1564 let round = node.anchor.map(|_| (rect, node.radius));
1565 let inner_clip = if node.flags.contains(Flags::CLIP) || round.is_some() {
1489 Some(visible.unwrap_or([rect[0], rect[1], rect[0], rect[1]]))1566 Some(visible.unwrap_or([rect[0], rect[1], rect[0], rect[1]]))
1490 } else {1567 } else {
1491 clip1568 clip
...@@ -1493,11 +1570,18 @@ impl Ui {...@@ -1493,11 +1570,18 @@ impl Ui {
1493 if inner_clip != clip {1570 if inner_clip != clip {
1494 self.display.push(Display::Clip(inner_clip));1571 self.display.push(Display::Clip(inner_clip));
1495 }1572 }
1573 if round.is_some() {
1574 self.display.push(Display::Round(round));
1575 }
1496 for child in node.children.clone() {1576 for child in node.children.clone() {
1497 if self.nodes[child].anchor.is_none() {1577 if self.nodes[child].anchor.is_none() && !self.nodes[child].hidden {
1498 self.paint(child, inner_clip, own.unwrap_or(motion));1578 self.paint(child, inner_clip, own.unwrap_or(motion));
1499 }1579 }
1500 }1580 }
1581 if round.is_some() {
1582 self.display.push(Display::Round(None));
1583 }
1584 self.fade(index);
1501 if inner_clip != clip {1585 if inner_clip != clip {
1502 self.display.push(Display::Clip(clip));1586 self.display.push(Display::Clip(clip));
1503 }1587 }
...@@ -1506,21 +1590,69 @@ impl Ui {...@@ -1506,21 +1590,69 @@ impl Ui {
1506 }1590 }
1507 }1591 }
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
1509 /// This frame's painting in order, in logical pixels.1638 /// This frame's painting in order, in logical pixels.
1510 pub fn layers(&self) -> Vec<Layer<'_>> {1639 pub fn layers(&self) -> Vec<Layer<'_>> {
1511 let mut layers = Vec::new();1640 let mut layers = Vec::new();
1512 let mut clip = None;1641 let mut clip = None;
1642 let mut round = None;
1513 let mut motion = None;1643 let mut motion = None;
1514 let mut primitives = Vec::new();1644 let mut primitives = Vec::new();
1515 fn flush<'a>(1645 fn flush<'a>(
1516 layers: &mut Vec<Layer<'a>>,1646 layers: &mut Vec<Layer<'a>>,
1517 clip: Option<[f32; 4]>,1647 clip: Option<[f32; 4]>,
1648 round: Option<([f32; 4], f32)>,
1518 motion: Option<Motion>,1649 motion: Option<Motion>,
1519 primitives: &mut Vec<Primitive<'a>>,1650 primitives: &mut Vec<Primitive<'a>>,
1520 ) {1651 ) {
1521 if !primitives.is_empty() {1652 if !primitives.is_empty() {
1522 layers.push(Layer::Primitives(Primitives {1653 layers.push(Layer::Primitives(Primitives {
1523 clip,1654 clip,
1655 round,
1524 motion,1656 motion,
1525 primitives: std::mem::take(primitives),1657 primitives: std::mem::take(primitives),
1526 }));1658 }));
...@@ -1529,11 +1661,15 @@ impl Ui {...@@ -1529,11 +1661,15 @@ impl Ui {
1529 for item in &self.display {1661 for item in &self.display {
1530 match item {1662 match item {
1531 Display::Clip(next) => {1663 Display::Clip(next) => {
1532 flush(&mut layers, clip, motion, &mut primitives);1664 flush(&mut layers, clip, round, motion, &mut primitives);
1533 clip = *next;1665 clip = *next;
1534 }1666 }
1667 Display::Round(next) => {
1668 flush(&mut layers, clip, round, motion, &mut primitives);
1669 round = *next;
1670 }
1535 Display::Motion(next) => {1671 Display::Motion(next) => {
1536 flush(&mut layers, clip, motion, &mut primitives);1672 flush(&mut layers, clip, round, motion, &mut primitives);
1537 motion = *next;1673 motion = *next;
1538 }1674 }
1539 Display::Rect {1675 Display::Rect {
...@@ -1617,7 +1753,7 @@ impl Ui {...@@ -1617,7 +1753,7 @@ impl Ui {
1617 ink: *color,1753 ink: *color,
1618 }),1754 }),
1619 Display::Custom { id, rect } => {1755 Display::Custom { id, rect } => {
1620 flush(&mut layers, clip, motion, &mut primitives);1756 flush(&mut layers, clip, round, motion, &mut primitives);
1621 layers.push(Layer::Custom {1757 layers.push(Layer::Custom {
1622 id: *id,1758 id: *id,
1623 rect: *rect,1759 rect: *rect,
...@@ -1625,7 +1761,7 @@ impl Ui {...@@ -1625,7 +1761,7 @@ impl Ui {
1625 }1761 }
1626 }1762 }
1627 }1763 }
1628 flush(&mut layers, clip, motion, &mut primitives);1764 flush(&mut layers, clip, round, motion, &mut primitives);
1629 layers1765 layers
1630 }1766 }
16311767
...@@ -1648,6 +1784,10 @@ impl Ui {...@@ -1648,6 +1784,10 @@ impl Ui {
1648 pub(crate) fn grab(&mut self, id: Id) -> &mut f32 {1784 pub(crate) fn grab(&mut self, id: Id) -> &mut f32 {
1649 &mut self.states.entry(id).or_default().grab1785 &mut self.states.entry(id).or_default().grab
1650 }1786 }
1787
1788 pub(crate) fn held(&mut self, id: Id) -> &mut Option<(ScrollerPart, Instant)> {
1789 &mut self.states.entry(id).or_default().held
1790 }
1651}1791}
16521792
1653impl Built {1793impl Built {
...@@ -1699,6 +1839,9 @@ impl Built {...@@ -1699,6 +1839,9 @@ impl Built {
1699 open: 1.0,1839 open: 1.0,
1700 motion: None,1840 motion: None,
1701 marks: Vec::new(),1841 marks: Vec::new(),
1842 fold: spec.fold,
1843 fade: spec.fade,
1844 hidden: false,
1702 computed: [0.0; 2],1845 computed: [0.0; 2],
1703 relative: [0.0; 2],1846 relative: [0.0; 2],
1704 rect: [0.0; 4],1847 rect: [0.0; 4],
crates/ui/src/list.rs+5-5
...@@ -189,18 +189,18 @@ pub fn list<R: Rows>(...@@ -189,18 +189,18 @@ pub fn list<R: Rows>(
189 let scroll = state.scroll;189 let scroll = state.scroll;
190 ui.animating |= scroll != state.target;190 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 };
192 ui.open_as(195 ui.open_as(
193 id,196 id,
194 Spec {197 Spec {
195 flags: spec.flags | Flags::SCROLL | Flags::CLIP,198 flags: spec.flags | Flags::SCROLL | Flags::CLIP,
199 pad: [spec.pad[0] + gutter, spec.pad[1]],
196 ..spec200 ..spec
197 },201 },
198 );202 );
199 // Rows leave the scrollbar a gutter.203 let width = fill();
200 let width = match rect {
201 Some(rect) if most > 0.0 => px(rect[2] - rect[0] - GUTTER),
202 _ => fill(),
203 };
204 let in_view = row_at(scroll)..(row_at(scroll + view) + 1).min(len);204 let in_view = row_at(scroll)..(row_at(scroll + view) + 1).min(len);
205 let mut shown = Vec::with_capacity(in_view.len());205 let mut shown = Vec::with_capacity(in_view.len());
206 for index in in_view {206 for index in in_view {
crates/ui/src/popup.rs+47-41
...@@ -209,24 +209,15 @@ pub fn tooltip(ui: &mut Ui, title: &str, keys: &str, description: Option<&str>)...@@ -209,24 +209,15 @@ pub fn tooltip(ui: &mut Ui, title: &str, keys: &str, description: Option<&str>)
209}209}
210210
211/// Builds popup `id` as a command palette across the top of the window while it is open,211/// Builds popup `id` as a command palette across the top of the window while it is open,
212/// listing `commands` that match what is typed, most relevant first. Returns the index of212/// listing `items` that match what is typed, most relevant first, under a filter field
213/// the command chosen.213/// showing `placeholder` while empty. Returns the index of the item chosen.
214pub fn palette(ui: &mut Ui, id: Id, commands: &[Item]) -> Option<usize> {214pub fn palette(ui: &mut Ui, id: Id, items: &[Item], placeholder: &str) -> Option<usize> {
215 if !ui.popup_open(id) {215 if !ui.popup_open(id) {
216 return None;216 return None;
217 }217 }
218 let window = ui.rect(Id::ROOT).unwrap_or_default();218 let window = ui.rect(Id::ROOT).unwrap_or_default();
219 let width = PALETTE.min(window[2] - 8.0 * PAD).max(NARROWEST);219 let width = PALETTE.min(window[2] - 8.0 * PAD).max(NARROWEST);
220 let [left, top] = [(window[2] - width) / 2.0, window[3] / 8.0];220 choose(ui, id, Anchor::Top, items, Some(placeholder), width, ROW)
221 choose(
222 ui,
223 id,
224 Anchor::Below([left, top, left, top]),
225 commands,
226 Some("Search commands"),
227 width,
228 ROW,
229 )
230}221}
231222
232fn choose(223fn choose(
...@@ -694,26 +685,42 @@ pub fn table_picker(ui: &mut Ui, id: Id, anchor: Anchor, size: [usize; 2]) -> Op...@@ -694,26 +685,42 @@ pub fn table_picker(ui: &mut Ui, id: Id, anchor: Anchor, size: [usize; 2]) -> Op
694 None685 None
695}686}
696687
697/// Builds popup `id` as a gallery beside `anchor` while it is open: `groups` of cells, each688/// A gallery's run of `cells` under `heading`, in rows of `columns` cells `size` large.
698/// a heading and a count, in rows of `columns` cells `size` large, with cell `current`689#[derive(Clone, Copy, Debug)]
699/// outlined. `cell` builds a cell's contents from its index, counted through the groups.690pub struct Group<'a> {
700/// Returns the index of the cell chosen.691 pub heading: &'a str,
701#[allow(clippy::too_many_arguments)]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.
702pub fn gallery(700pub fn gallery(
703 ui: &mut Ui,701 ui: &mut Ui,
704 id: Id,702 id: Id,
705 anchor: Anchor,703 anchor: Anchor,
706 groups: &[(&str, usize)],704 groups: &[Group],
707 columns: usize,705 current: &[usize],
708 size: [f32; 2],
709 current: Option<usize>,
710 mut cell: impl FnMut(&mut Ui, usize),706 mut cell: impl FnMut(&mut Ui, usize),
711) -> Option<usize> {707) -> Option<usize> {
712 if !ui.popup_open(id) {708 if !ui.popup_open(id) {
713 return None;709 return None;
714 }710 }
715 let cell_id = |index: usize| id.child(("cell", index));711 let cell_id = |index: usize| id.child(("cell", index));
716 let count: usize = groups.iter().map(|(_, count)| count).sum();712 let count: usize = groups.iter().map(|group| group.cells).sum();
713 // The columns of the group holding cell `at`.
714 let columns = |at: usize| {
715 let mut start = 0;
716 groups
717 .iter()
718 .find(|group| {
719 start += group.cells;
720 at < start
721 })
722 .map_or(1, |group| group.columns)
723 };
717 let keys = navigation(724 let keys = navigation(
718 ui,725 ui,
719 &[id],726 &[id],
...@@ -737,7 +744,7 @@ pub fn gallery(...@@ -737,7 +744,7 @@ pub fn gallery(
737 }744 }
738 }745 }
739 for key in keys {746 for key in keys {
740 let at = highlight.or(current).unwrap_or(0);747 let at = highlight.or(current.first().copied()).unwrap_or(0);
741 highlight = Some(match key {748 highlight = Some(match key {
742 NamedKey::Enter => {749 NamedKey::Enter => {
743 chosen = chosen.or(highlight);750 chosen = chosen.or(highlight);
...@@ -745,8 +752,8 @@ pub fn gallery(...@@ -745,8 +752,8 @@ pub fn gallery(
745 }752 }
746 NamedKey::ArrowLeft => at.saturating_sub(1),753 NamedKey::ArrowLeft => at.saturating_sub(1),
747 NamedKey::ArrowRight => (at + 1).min(count - 1),754 NamedKey::ArrowRight => (at + 1).min(count - 1),
748 NamedKey::ArrowUp => at.saturating_sub(columns),755 NamedKey::ArrowUp => at.saturating_sub(columns(at)),
749 _ => (at + columns).min(count - 1),756 _ => (at + columns(at)).min(count - 1),
750 });757 });
751 }758 }
752 if let Some(index) = chosen {759 if let Some(index) = chosen {
...@@ -754,19 +761,18 @@ pub fn gallery(...@@ -754,19 +761,18 @@ pub fn gallery(
754 return Some(index);761 return Some(index);
755 }762 }
756 let theme = ui.theme.clone();763 let theme = ui.theme.clone();
757 surface(764 let width = groups
758 ui,765 .iter()
759 id,766 .map(|group| group.columns as f32 * group.size[0])
760 anchor,767 .fold(0.0, f32::max);
761 columns as f32 * size[0] + 2.0 * ui.theme.menu().pad,768 surface(ui, id, anchor, width + 2.0 * ui.theme.menu().pad);
762 );
763 let mut index = 0;769 let mut index = 0;
764 for (group, (heading, cells)) in groups.iter().enumerate() {770 for (number, group) in groups.iter().enumerate() {
765 ui.leaf(771 ui.leaf(
766 ("heading", group),772 ("heading", number),
767 Spec {773 Spec {
768 size: [fill(), px(MENU_ROW)],774 size: [fill(), px(MENU_ROW)],
769 text: Some(heading),775 text: Some(group.heading),
770 font_size: Some(theme.font_size - 2.0),776 font_size: Some(theme.font_size - 2.0),
771 bold: true,777 bold: true,
772 color: Some(theme.text_dim),778 color: Some(theme.text_dim),
...@@ -774,23 +780,23 @@ pub fn gallery(...@@ -774,23 +780,23 @@ pub fn gallery(
774 ..Spec::default()780 ..Spec::default()
775 },781 },
776 );782 );
777 for row in 0..cells.div_ceil(columns) {783 for row in 0..group.cells.div_ceil(group.columns) {
778 ui.open(784 ui.open(
779 ("row", group, row),785 ("row", number, row),
780 Spec {786 Spec {
781 size: [fill(), px(size[1])],787 size: [fill(), px(group.size[1])],
782 ..Spec::default()788 ..Spec::default()
783 },789 },
784 );790 );
785 for _ in 0..columns.min(cells - row * columns) {791 for _ in 0..group.columns.min(group.cells - row * group.columns) {
786 ui.open_as(792 ui.open_as(
787 cell_id(index),793 cell_id(index),
788 Spec {794 Spec {
789 flags: Flags::CLICKABLE,795 flags: Flags::CLICKABLE,
790 axis: Axis::Y,796 axis: Axis::Y,
791 size: [px(size[0]), px(size[1])],797 size: [px(group.size[0]), px(group.size[1])],
792 fill: (highlight == Some(index)).then(|| theme.hover()),798 fill: (highlight == Some(index)).then(|| theme.hover()),
793 border: (current == Some(index)).then_some(theme.accent),799 border: current.contains(&index).then_some(theme.accent),
794 radius: 4.0,800 radius: 4.0,
795 pad: [PAD, PAD],801 pad: [PAD, PAD],
796 ..Spec::default()802 ..Spec::default()
crates/ui/src/shell.rs+71-24
...@@ -4,7 +4,8 @@...@@ -4,7 +4,8 @@
4use draw::{PathStyle, edit::SelectionUnit};4use draw::{PathStyle, edit::SelectionUnit};
55
6use crate::{6use crate::{
7 Anchor, Corner, Display, Flags, Id, Section, Shape, Signal, Spec, Ui, children, fill, mix, px,7 Anchor, Corner, Display, Extent, Flags, Id, Section, Shape, Signal, Spec, Ui, children, fill,
8 mix, px,
8};9};
9use std::hash::Hash;10use std::hash::Hash;
1011
...@@ -18,34 +19,34 @@ pub const TOOL: f32 = 22.0;...@@ -18,34 +19,34 @@ pub const TOOL: f32 = 22.0;
18const ARROW: f32 = 10.0;19const ARROW: f32 = 10.0;
19/// Where a section tab's label starts inside it.20/// Where a section tab's label starts inside it.
20pub const TAB_PAD: f32 = 10.0;21pub 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
22/// Section tabs in a row `height` tall, as OneNote draws them: each leans over the next26/// Section tabs in a row `height` tall, as OneNote draws them: each leans over the next
23/// at 45°, and the open one lies on top in `section`'s colours, rising to meet the frame27/// at 45°, and the open one lies on top in `section`'s colours, rising to meet the frame
24/// below as its outline and shadow fade in. `tabs` are names and section colours; `lit`28/// below as its outline and shadow fade in. `tabs` are names and section colours; `lit`
25/// shows as hovered, as a tab something is dragged onto. A `dragged` tab follows the29/// shows as hovered, as a tab something is dragged onto. A `dragged` tab follows the
26/// pointer, lifted over the others, which slide aside to open the gap it would land in;30/// pointer, lifted over the others, which slide aside to open the gap it would land in;
27/// each eases to its place once let go.31/// each eases to its place once let go, and near the row's ends the row scrolls on.
32///
33/// The row is as wide as the tabs, and gives up all of that where its own row lacks room.
34/// Then the tabs scroll sideways under the wheel, either way it turns, fading out at the
35/// ends they are cut at into the row's `fill`, and the open tab scrolls into view as it
36/// opens.
28#[allow(clippy::too_many_arguments)]37#[allow(clippy::too_many_arguments)]
29pub fn section_tabs(38pub fn section_tabs(
30 ui: &mut Ui,39 ui: &mut Ui,
31 part: impl Hash,40 row: Id,
32 tabs: &[(&str, [f32; 4])],41 tabs: &[(&str, [f32; 4])],
33 active: usize,42 active: usize,
34 lit: Option<usize>,43 lit: Option<usize>,
35 dragged: Option<Dragged>,44 dragged: Option<Dragged>,
36 section: &Section,45 section: &Section,
37 height: f32,46 height: f32,
47 fill: [f32; 4],
38) -> Tabs {48) -> Tabs {
39 let theme = ui.theme.clone();49 let theme = ui.theme.clone();
40 ui.open(
41 part,
42 Spec {
43 flags: Flags::CLIP,
44 size: [fill(), px(height)],
45 ..Spec::default()
46 },
47 );
48 let [low, tallest] = [height - 6.0, height - 2.0];
49 // Room for the first tab's outline and shadow, which the row would clip.50 // Room for the first tab's outline and shadow, which the row would clip.
50 let mut left = crate::SHADOW[0];51 let mut left = crate::SHADOW[0];
51 let placed: Vec<_> = tabs52 let placed: Vec<_> = tabs
...@@ -56,14 +57,65 @@ pub fn section_tabs(...@@ -56,14 +57,65 @@ pub fn section_tabs(
56 (left - width, width)57 (left - width, width)
57 })58 })
58 .collect();59 .collect();
60 // The last tab's slant and shadow reach past its box.
61 let wide = left + lean(height) + crate::SHADOW[0];
62 let view = ui.rect(row).map_or(wide, |rect| rect[2] - rect[0]);
63 let most = (wide - view).max(0.0);
64 let [scroll, shown] = [row.child("scroll"), row.child("shown")];
65 let held_value = |ui: &Ui, id: Id| ui.states.get(&id).and_then(|state| state.tween);
66 let mut target = held_value(ui, scroll).map_or(0.0, |[_, target]| target);
67 for event in ui.signal(row).events {
68 if let crate::Event::Wheel([x, y]) = event {
69 target -= x + y;
70 }
71 }
72 if held_value(ui, shown).is_none_or(|[_, was]| was != active as f32)
73 && let Some((x, width)) = placed.get(active)
74 {
75 target = target.min(*x).max(x + width + lean(height) - view);
76 }
77 ui.hold(shown, active as f32);
78 // A tab dragged near either end scrolls the row on, as long as it is held there.
79 let start = dragged.and_then(|dragged| {
80 let start = dragged.start?;
81 let width = placed[dragged.index].1;
82 let push = (start - EDGE).min(0.0) + (start + width - view + EDGE).max(0.0);
83 if push != 0.0 && (target > 0.0 || push > 0.0) && (target < most || push < 0.0) {
84 target += push * ui.dt * 8.0;
85 ui.animating = true;
86 }
87 Some(start)
88 });
89 let target = ui.hold(scroll, target.clamp(0.0, most));
90 let offset = ui.animate(row.child("offset"), target);
91 let fade = |cut: bool| if cut { EDGE } else { 0.0 };
92 ui.open_as(
93 row,
94 Spec {
95 flags: Flags::CLIP | Flags::SCROLL,
96 size: [
97 Extent {
98 size: crate::Size::Pixels(wide),
99 strictness: 0.0,
100 },
101 px(height),
102 ],
103 fill: Some(fill),
104 fade: [fade(offset > 0.5), fade(offset < most - 0.5)],
105 ..Spec::default()
106 },
107 );
108 let [low, tallest] = [height - 6.0, height - 2.0];
59 let (mut clicked, mut context, mut held, mut renamed) = (None, None, None, None);109 let (mut clicked, mut context, mut held, mut renamed) = (None, None, None, None);
60 let spans: Vec<[f32; 2]> = placed.iter().map(|(x, width)| [*x, *width]).collect();110 let spans: Vec<[f32; 2]> = placed.iter().map(|(x, width)| [*x, *width]).collect();
111 // The dragged tab's place along the tabs, however far they are scrolled.
112 let start = start.map(|start| start + offset);
61 let slot = dragged.and_then(|dragged| {113 let slot = dragged.and_then(|dragged| {
62 let width = placed[dragged.index].1;114 let width = placed[dragged.index].1;
63 Some(crate::drop_slot(115 Some(crate::drop_slot(
64 &spans,116 &spans,
65 dragged.index,117 dragged.index,
66 dragged.start? + width / 2.0,118 start? + width / 2.0,
67 ))119 ))
68 });120 });
69 let lifted = dragged.map(|dragged| dragged.index);121 let lifted = dragged.map(|dragged| dragged.index);
...@@ -79,14 +131,9 @@ pub fn section_tabs(...@@ -79,14 +131,9 @@ pub fn section_tabs(
79 let (x, width) = placed[index];131 let (x, width) = placed[index];
80 // Keyed by name, so a tab eases from where it stood when the tabs are reordered.132 // Keyed by name, so a tab eases from where it stood when the tabs are reordered.
81 let key = ui.id(("x", name));133 let key = ui.id(("x", name));
82 let x = match (dragged, slot) {134 let x = match (dragged, slot, start) {
83 (135 (_, _, Some(start)) if lifted == Some(index) => ui.hold(key, start),
84 Some(Dragged {136 (Some(dragged), Some(slot), _) => ui.animate(
85 start: Some(start), ..
86 }),
87 _,
88 ) if lifted == Some(index) => ui.hold(key, start),
89 (Some(dragged), Some(slot)) => ui.animate(
90 key,137 key,
91 x + crate::slide(index, dragged.index, slot, placed[dragged.index].1),138 x + crate::slide(index, dragged.index, slot, placed[dragged.index].1),
92 ),139 ),
...@@ -110,7 +157,7 @@ pub fn section_tabs(...@@ -110,7 +157,7 @@ pub fn section_tabs(
110 Spec {157 Spec {
111 flags: Flags::CLICKABLE | Flags::FLOAT,158 flags: Flags::CLICKABLE | Flags::FLOAT,
112 size: [px(width), px(tall)],159 size: [px(width), px(tall)],
113 position: [x, height - tall],160 position: [x - offset, height - tall],
114 text: Some(name),161 text: Some(name),
115 color: Some(mix(mix(theme.ink, fill, 0.2), theme.ink, open)),162 color: Some(mix(mix(theme.ink, fill, 0.2), theme.ink, open)),
116 fill: Some(if lit == Some(index) {163 fill: Some(if lit == Some(index) {
...@@ -409,13 +456,13 @@ pub fn menu_button(...@@ -409,13 +456,13 @@ pub fn menu_button(
409}456}
410457
411/// A drop-down box `width` wide showing `text`.458/// 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 {
413 let theme = ui.theme.clone();460 let theme = ui.theme.clone();
414 let id = ui.open(461 let id = ui.open(
415 part,462 part,
416 Spec {463 Spec {
417 flags: Flags::CLICKABLE,464 flags: Flags::CLICKABLE,
418 size: [px(width), px(TOOL)],465 size: [width.into(), px(TOOL)],
419 fill: Some(theme.base),466 fill: Some(theme.base),
420 hover_border: Some(theme.accent),467 hover_border: Some(theme.accent),
421 border: Some(theme.chip),468 border: Some(theme.chip),
crates/ui/src/tests.rs+340-30
...@@ -127,6 +127,146 @@ fn children_sum_with_gaps_and_padding() {...@@ -127,6 +127,146 @@ fn children_sum_with_gaps_and_padding() {
127 assert_eq!(ui.rect(second.unwrap()), Some([4.0, 18.0, 34.0, 38.0]));127 assert_eq!(ui.rect(second.unwrap()), Some([4.0, 18.0, 34.0, 38.0]));
128}128}
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
130fn button_frame(ui: &mut Ui) -> Signal {270fn button_frame(ui: &mut Ui) -> Signal {
131 let mut signal = Signal::default();271 let mut signal = Signal::default();
132 frame(ui, |ui| {272 frame(ui, |ui| {
...@@ -709,6 +849,86 @@ fn scrollbar_thumbs_track_the_offset_and_drags_reach_both_ends() {...@@ -709,6 +849,86 @@ fn scrollbar_thumbs_track_the_offset_and_drags_reach_both_ends() {
709 assert_eq!(offset, 0.0);849 assert_eq!(offset, 0.0);
710}850}
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
712fn items() -> Vec<popup::Item<'static>> {932fn items() -> Vec<popup::Item<'static>> {
713 let item = |text| popup::Item {933 let item = |text| popup::Item {
714 text,934 text,
...@@ -1071,13 +1291,15 @@ fn nothing_matching_leaves_nothing_to_choose() {...@@ -1071,13 +1291,15 @@ fn nothing_matching_leaves_nothing_to_choose() {
1071}1291}
10721292
1073#[test]1293#[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() {
1075 let mut ui = Ui::new(Theme::dark(), DOUBLE_CLICK);1295 let mut ui = Ui::new(Theme::dark(), DOUBLE_CLICK);
1076 let palette = Id::ROOT.child("palette");1296 let palette = Id::ROOT.child("palette");
1077 let commands = items();1297 let commands = items();
1078 let build = |ui: &mut Ui| {1298 let build = |ui: &mut Ui| {
1079 let mut chosen = None;1299 let mut chosen = None;
1080 frame(ui, |ui| chosen = popup::palette(ui, palette, &commands));1300 frame(ui, |ui| {
1301 chosen = popup::palette(ui, palette, &commands, "Search commands")
1302 });
1081 chosen1303 chosen
1082 };1304 };
1083 build(&mut ui);1305 build(&mut ui);
...@@ -1087,6 +1309,7 @@ fn the_palette_centres_across_the_window_and_chooses_the_best_match() {...@@ -1087,6 +1309,7 @@ fn the_palette_centres_across_the_window_and_chooses_the_best_match() {
1087 }1309 }
1088 let rect = ui.rect(palette).unwrap();1310 let rect = ui.rect(palette).unwrap();
1089 assert_eq!(rect[0] + rect[2], 400.0, "centred");1311 assert_eq!(rect[0] + rect[2], 400.0, "centred");
1312 assert_eq!(rect[1], 300.0 / 8.0, "an eighth down");
1090 ui.event(typed("a"));1313 ui.event(typed("a"));
1091 ui.event(key(NamedKey::Enter));1314 ui.event(key(NamedKey::Enter));
1092 assert_eq!(1315 assert_eq!(
...@@ -1748,7 +1971,7 @@ fn popups_ease_open_and_closed_then_stop_asking_for_frames() {...@@ -1748,7 +1971,7 @@ fn popups_ease_open_and_closed_then_stop_asking_for_frames() {
1748 build(ui);1971 build(ui);
1749 });1972 });
1750 let first = motion(&ui).unwrap();1973 let first = motion(&ui).unwrap();
1751 assert!(first.opacity < 0.1 && first.zoom < 0.96 && first.tilt > 0.3);1974 assert!(first.opacity < 0.1 && first.zoom < 0.96 && first.tilt > 0.15);
1752 assert_eq!(1975 assert_eq!(
1753 first.pivot,1976 first.pivot,
1754 [200.0, 125.0],1977 [200.0, 125.0],
...@@ -1895,7 +2118,7 @@ fn every_control_in_an_open_dialog_takes_clicks_where_it_rests() {...@@ -1895,7 +2118,7 @@ fn every_control_in_an_open_dialog_takes_clicks_where_it_rests() {
1895}2118}
18962119
1897#[test]2120#[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() {
1899 let mut ui = Ui::new(Theme::light(), DOUBLE_CLICK);2122 let mut ui = Ui::new(Theme::light(), DOUBLE_CLICK);
1900 let fonts = fonts();2123 let fonts = fonts();
1901 let combo = Id::ROOT.child("combo");2124 let combo = Id::ROOT.child("combo");
...@@ -1915,25 +2138,45 @@ fn a_combo_s_field_holds_its_place_as_its_list_opens_and_its_rows_take_clicks()...@@ -1915,25 +2138,45 @@ fn a_combo_s_field_holds_its_place_as_its_list_opens_and_its_rows_take_clicks()
1915 let [box_rect, first] = [ui.rect(combo).unwrap(), ui.rect(field).unwrap()];2138 let [box_rect, first] = [ui.rect(combo).unwrap(), ui.rect(field).unwrap()];
1916 assert_eq!(first[..2], box_rect[..2], "the field starts on the box");2139 assert_eq!(first[..2], box_rect[..2], "the field starts on the box");
1917 assert!(first[2] - first[0] < box_rect[2] - box_rect[0] + 20.0);2140 assert!(first[2] - first[0] < box_rect[2] - box_rect[0] + 20.0);
1918 let moving: Vec<_> = ui2141 let shown = ui.rect(menu_id()).unwrap();
1919 .layers()2142 let row = menu_id().child("rows").child(3_u64);
2143 let opening = ui.rect(row).unwrap();
2144 assert!(
2145 opening[2] > shown[2],
2146 "the rows lie out past the popup as it opens"
2147 );
2148 let layers = ui.layers();
2149 let moving: Vec<_> = layers
1920 .iter()2150 .iter()
1921 .filter_map(|layer| match layer {2151 .filter_map(|layer| match layer {
1922 Layer::Primitives(layer) => Some(layer.motion.is_some()),2152 Layer::Primitives(layer) => Some(layer),
1923 Layer::Custom { .. } => None,2153 Layer::Custom { .. } => None,
1924 })2154 })
2155 .skip_while(|layer| layer.motion.is_none())
1925 .collect();2156 .collect();
1926 let panel = moving.iter().position(|moving| *moving).unwrap();2157 let split = moving
2158 .iter()
2159 .position(|layer| layer.motion.is_none())
2160 .unwrap();
2161 assert!(
2162 moving[split..].iter().all(|layer| layer.motion.is_none()),
2163 "the field paints over the popup, which appears as one"
2164 );
1927 assert!(2165 assert!(
1928 moving[panel..].contains(&false),2166 moving[..split]
1929 "the field paints without the popup's motion"2167 .iter()
2168 .filter(|layer| layer.round.is_some())
2169 .all(|layer| layer.round == Some((shown, ui.theme.menu().radius))),
2170 "the rows show only inside the popup's outline"
1930 );2171 );
2172 drop(layers);
1931 for _ in 0..20 {2173 for _ in 0..20 {
1932 build(&mut ui);2174 build(&mut ui);
1933 }2175 }
1934 assert!(!ui.wants_frame());2176 assert!(!ui.wants_frame());
1935 let [panel, last] = [ui.rect(menu_id()).unwrap(), ui.rect(field).unwrap()];2177 let [panel, last] = [ui.rect(menu_id()).unwrap(), ui.rect(field).unwrap()];
1936 assert_eq!(last[2], panel[2] - 4.0, "the field widens across the popup");2178 assert_eq!(last[2], panel[2] - 4.0, "the field widens across the popup");
2179 assert_eq!(ui.rect(row), Some(opening), "the rows never move");
1937 click_box(&mut ui, menu_id().child("rows").child(3_u64));2180 click_box(&mut ui, menu_id().child("rows").child(3_u64));
1938 assert_eq!(build(&mut ui), Some(3));2181 assert_eq!(build(&mut ui), Some(3));
1939}2182}
...@@ -1955,6 +2198,8 @@ fn a_popup_over_a_box_by_the_window_s_edge_widens_away_from_it() {...@@ -1955,6 +2198,8 @@ fn a_popup_over_a_box_by_the_window_s_edge_widens_away_from_it() {
1955 let edges =2198 let edges =
1956 |ui: &Ui| [ui.rect(field).unwrap(), ui.rect(menu_id()).unwrap()].map(|rect| rect[2]);2199 |ui: &Ui| [ui.rect(field).unwrap(), ui.rect(menu_id()).unwrap()].map(|rect| rect[2]);
1957 let first = ui.rect(field).unwrap();2200 let first = ui.rect(field).unwrap();
2201 let row = menu_id().child("rows").child(3_u64);
2202 let opening = ui.rect(row).unwrap();
1958 assert_eq!(2203 assert_eq!(
1959 edges(&ui),2204 edges(&ui),
1960 [390.0, 394.0],2205 [390.0, 394.0],
...@@ -1969,6 +2214,7 @@ fn a_popup_over_a_box_by_the_window_s_edge_widens_away_from_it() {...@@ -1969,6 +2214,7 @@ fn a_popup_over_a_box_by_the_window_s_edge_widens_away_from_it() {
1969 ui.rect(field).unwrap()[0] < first[0] - 30.0,2214 ui.rect(field).unwrap()[0] < first[0] - 30.0,
1970 "the field widens leftward"2215 "the field widens leftward"
1971 );2216 );
2217 assert_eq!(ui.rect(row), Some(opening), "the rows never move");
1972}2218}
19732219
1974fn built(ui: &Ui, id: Id) -> &Built {2220fn built(ui: &Ui, id: Id) -> &Built {
...@@ -2113,29 +2359,25 @@ fn a_menu_button_opens_its_menu_with_the_icons_under_its_own() {...@@ -2113,29 +2359,25 @@ fn a_menu_button_opens_its_menu_with_the_icons_under_its_own() {
2113fn galleries_choose_across_their_groups_by_keys_and_clicks() {2359fn galleries_choose_across_their_groups_by_keys_and_clicks() {
2114 let mut ui = Ui::new(Theme::dark(), DOUBLE_CLICK);2360 let mut ui = Ui::new(Theme::dark(), DOUBLE_CLICK);
2115 let gallery = Id::ROOT.child("gallery");2361 let gallery = Id::ROOT.child("gallery");
2116 let groups = [("Recent", 2), ("Library", 5)];2362 let groups = [("Recent", 2), ("Library", 5)].map(|(heading, cells)| popup::Group {
2363 heading,
2364 cells,
2365 columns: 3,
2366 size: [30.0, 30.0],
2367 });
2117 let build = |ui: &mut Ui| {2368 let build = |ui: &mut Ui| {
2118 let mut chosen = None;2369 let mut chosen = None;
2119 frame(ui, |ui| {2370 frame(ui, |ui| {
2120 chosen = popup::gallery(2371 chosen = popup::gallery(ui, gallery, BELOW, &groups, &[3], |ui, index| {
2121 ui,2372 ui.leaf(
2122 gallery,2373 "label",
2123 BELOW,2374 Spec {
2124 &groups,2375 size: [fill(), fill()],
2125 3,2376 text: Some(["a", "b", "c", "d", "e", "f", "g"][index]),
2126 [30.0, 30.0],2377 ..Spec::default()
2127 Some(3),2378 },
2128 |ui, index| {2379 );
2129 ui.leaf(2380 });
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 );
2139 });2381 });
2140 chosen2382 chosen
2141 };2383 };
...@@ -2206,3 +2448,71 @@ fn desktop_menus_cut_or_fade_without_growing() {...@@ -2206,3 +2448,71 @@ fn desktop_menus_cut_or_fade_without_growing() {
2206 assert!((closing.opacity - (1.0 - 0.096 / 0.6_f32).powi(4)).abs() < 0.01);2448 assert!((closing.opacity - (1.0 - 0.096 / 0.6_f32).powi(4)).abs() < 0.01);
2207 assert!(ui.wants_frame());2449 assert!(ui.wants_frame());
2208}2450}
2451
2452/// A row 200 wide of twelve section tabs with `active` open; returns the row's id.
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 @@...@@ -1,10 +1,11 @@
1use crate::{Axis, Event, Flags, Id, Signal, Spec, Ui, fit, px};1use crate::{Axis, Event, Flags, Id, Signal, Spec, Ui, fit, px};
2use draw::RasterImage;
2use draw::edit::{self, Command, Movement};3use draw::edit::{self, Command, Movement};
3use parley::{4use parley::{
4 Affinity,5 Affinity,
5 editing::{Cursor, Selection},6 editing::{Cursor, Selection},
6};7};
7use std::hash::Hash;8use std::{hash::Hash, time::Duration};
8use winit::{9use winit::{
9 event::Ime,10 event::Ime,
10 keyboard::{Key, ModifiersState, NamedKey},11 keyboard::{Key, ModifiersState, NamedKey},
...@@ -75,9 +76,56 @@ pub fn check_box(ui: &mut Ui, part: impl Hash, label: &str, checked: bool) -> Si...@@ -75,9 +76,56 @@ pub fn check_box(ui: &mut Ui, part: impl Hash, label: &str, checked: bool) -> Si
75 signal76 signal
76}77}
7778
78/// An overlay scrollbar along the far edge of the current box, for content whose scroll79/// Scrollers the platform paints, fixed along the content's edges, as Mac OS X 10.6's are.
79/// offset ranges over `range` while `view` of it shows, all in one unit. Returns the offset80pub struct Scrollers {
80/// a drag of its thumb chose.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.
81pub fn scrollbar(129pub fn scrollbar(
82 ui: &mut Ui,130 ui: &mut Ui,
83 part: impl Hash,131 part: impl Hash,
...@@ -87,6 +135,9 @@ pub fn scrollbar(...@@ -87,6 +135,9 @@ pub fn scrollbar(
87 view: f32,135 view: f32,
88 color: [f32; 4],136 color: [f32; 4],
89) -> Option<f32> {137) -> Option<f32> {
138 if ui.scrollers.is_some() {
139 return system_scrollbar(ui, part, axis, offset, range, view);
140 }
90 let along = usize::from(axis == Axis::Y);141 let along = usize::from(axis == Axis::Y);
91 let rect = ui.rect(ui.current())?;142 let rect = ui.rect(ui.current())?;
92 let length = rect[along + 2] - rect[along];143 let length = rect[along + 2] - rect[along];
...@@ -133,6 +184,115 @@ pub fn scrollbar(...@@ -133,6 +184,115 @@ pub fn scrollbar(
133 chosen184 chosen
134}185}
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
136/// A single-line field editing `text`, showing `placeholder` while empty. Presses, drags296/// A single-line field editing `text`, showing `placeholder` while empty. Presses, drags
137/// and keys select and edit as on the page: a double press selects a word, a triple press297/// and keys select and edit as on the page: a double press selects a word, a triple press
138/// everything, and dragging extends by that unit.298/// everything, and dragging extends by that unit.
platform/snow-leopard/console/input.c+42-10
...@@ -1,5 +1,7 @@...@@ -1,5 +1,7 @@
1// Posts input to the logged-in session as the HID system would, for driving the app1// Posts input to the logged-in session as the HID system would, for driving the app
2// over ssh: `input click X Y`, `input type TEXT`, `input key KEYCODE [command]`.2// over ssh: `input click X Y`, `input double X Y`, `input drag X Y X2 Y2`,
3// `input scroll X Y LINES`, `input type TEXT`,
4// `input key KEYCODE [command] [shift] [option] [control]`.
3// Points are the display's, from its top left.5// Points are the display's, from its top left.
4#include <ApplicationServices/ApplicationServices.h>6#include <ApplicationServices/ApplicationServices.h>
5#include <stdlib.h>7#include <stdlib.h>
...@@ -12,8 +14,15 @@ static void post(CGEventRef event) {...@@ -12,8 +14,15 @@ static void post(CGEventRef event) {
12 usleep(20000);14 usleep(20000);
13}15}
1416
15static void mouse(CGEventType type, CGPoint at) {17static void mouse(CGEventType type, CGPoint at, int clicks) {
16 post(CGEventCreateMouseEvent(NULL, type, at, kCGMouseButtonLeft));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);
17}26}
1827
19static void key(CGKeyCode code, CGEventFlags flags, const UniChar *text, UniCharCount length) {28static void key(CGKeyCode code, CGEventFlags flags, const UniChar *text, UniCharCount length) {
...@@ -26,11 +35,27 @@ static void key(CGKeyCode code, CGEventFlags flags, const UniChar *text, UniChar...@@ -26,11 +35,27 @@ static void key(CGKeyCode code, CGEventFlags flags, const UniChar *text, UniChar
26}35}
2736
28int main(int argc, char **argv) {37int main(int argc, char **argv) {
29 if (argc == 4 && !strcmp(argv[1], "click")) {38 if ((argc == 4 && !strcmp(argv[1], "click")) || (argc == 4 && !strcmp(argv[1], "double"))) {
39 CGPoint at = CGPointMake(atof(argv[2]), atof(argv[3]));
40 mouse(kCGEventMouseMoved, at, 0);
41 click(at, 1);
42 if (!strcmp(argv[1], "double")) click(at, 2);
43 } else if (argc == 6 && !strcmp(argv[1], "drag")) {
44 CGPoint from = CGPointMake(atof(argv[2]), atof(argv[3]));
45 CGPoint to = CGPointMake(atof(argv[4]), atof(argv[5]));
46 mouse(kCGEventMouseMoved, from, 0);
47 mouse(kCGEventLeftMouseDown, from, 1);
48 for (int step = 1; step <= 10; step++) {
49 CGPoint at = CGPointMake(from.x + (to.x - from.x) * step / 10, from.y + (to.y - from.y) * step / 10);
50 mouse(kCGEventLeftMouseDragged, at, 1);
51 }
52 mouse(kCGEventLeftMouseUp, to, 1);
53 } else if (argc == 5 && !strcmp(argv[1], "scroll")) {
30 CGPoint at = CGPointMake(atof(argv[2]), atof(argv[3]));54 CGPoint at = CGPointMake(atof(argv[2]), atof(argv[3]));
31 mouse(kCGEventMouseMoved, at);55 mouse(kCGEventMouseMoved, at, 0);
32 mouse(kCGEventLeftMouseDown, at);56 CGEventRef event = CGEventCreateScrollWheelEvent(NULL, kCGScrollEventUnitLine, 1, atoi(argv[4]));
33 mouse(kCGEventLeftMouseUp, at);57 CGEventSetLocation(event, at);
58 post(event);
34 } else if (argc == 3 && !strcmp(argv[1], "type")) {59 } else if (argc == 3 && !strcmp(argv[1], "type")) {
35 CFStringRef text = CFStringCreateWithCString(NULL, argv[2], kCFStringEncodingUTF8);60 CFStringRef text = CFStringCreateWithCString(NULL, argv[2], kCFStringEncodingUTF8);
36 for (CFIndex i = 0; i < CFStringGetLength(text); i++) {61 for (CFIndex i = 0; i < CFStringGetLength(text); i++) {
...@@ -38,10 +63,17 @@ int main(int argc, char **argv) {...@@ -38,10 +63,17 @@ int main(int argc, char **argv) {
38 key(0, 0, &c, 1);63 key(0, 0, &c, 1);
39 }64 }
40 CFRelease(text);65 CFRelease(text);
41 } else if ((argc == 3 || argc == 4) && !strcmp(argv[1], "key")) {66 } else if (argc >= 3 && !strcmp(argv[1], "key")) {
42 key(atoi(argv[2]), argc == 4 && !strcmp(argv[3], "command") ? kCGEventFlagMaskCommand : 0, NULL, 0);67 CGEventFlags flags = 0;
68 for (int i = 3; i < argc; i++) {
69 if (!strcmp(argv[i], "command")) flags |= kCGEventFlagMaskCommand;
70 else if (!strcmp(argv[i], "shift")) flags |= kCGEventFlagMaskShift;
71 else if (!strcmp(argv[i], "option")) flags |= kCGEventFlagMaskAlternate;
72 else if (!strcmp(argv[i], "control")) flags |= kCGEventFlagMaskControl;
73 }
74 key(atoi(argv[2]), flags, NULL, 0);
43 } else {75 } else {
44 fprintf(stderr, "usage: input click X Y | input type TEXT | input key KEYCODE [command]\n");76 fprintf(stderr, "usage: input click|double X Y | drag X Y X2 Y2 | scroll X Y LINES | type TEXT | key KEYCODE [command] [shift] [option] [control]\n");
45 return 2;77 return 2;
46 }78 }
47 return 0;79 return 0;
platform/snow-leopard/remote.sh+3-2
...@@ -9,7 +9,8 @@...@@ -9,7 +9,8 @@
9# remote.sh stop NAME end what start started (a bundle by its executable)9# remote.sh stop NAME end what start started (a bundle by its executable)
10# remote.sh screen LOCAL.png wake the display and capture it (screencapture10# remote.sh screen LOCAL.png wake the display and capture it (screencapture
11# writes nothing from ssh)11# writes nothing from ssh)
12# remote.sh input ARGS... click X Y | type TEXT | key KEYCODE [command]12# remote.sh input ARGS... click|double X Y | drag X Y X2 Y2 | scroll X Y LINES
13# | type TEXT | key KEYCODE [command|shift|option|control]...
13# remote.sh selectors BINARY selector-like names BINARY holds that nothing on14# remote.sh selectors BINARY selector-like names BINARY holds that nothing on
14# 10.6 implements15# 10.6 implements
15# remote.sh mirror PATH... copy repo paths to the same absolute paths there, so16# remote.sh mirror PATH... copy repo paths to the same absolute paths there, so
...@@ -119,7 +120,7 @@ sh)...@@ -119,7 +120,7 @@ sh)
119 ssh "$host" "$@"120 ssh "$host" "$@"
120 ;;121 ;;
121*)122*)
122 sed -n '2,20s/^# \{0,1\}//p' "$0" >&2123 sed -n '2,21s/^# \{0,1\}//p' "$0" >&2
123 exit 2124 exit 2
124 ;;125 ;;
125esac126esac
platform/snow-leopard/rt/appkit.c-1
...@@ -112,7 +112,6 @@ __attribute__((constructor)) static void polyfill(void) {...@@ -112,7 +112,6 @@ __attribute__((constructor)) static void polyfill(void) {
112 add("NSWindow", 0, "setTabbingIdentifier:", ignore, "v@:@");112 add("NSWindow", 0, "setTabbingIdentifier:", ignore, "v@:@");
113 add("NSWindow", 1, "setAllowsAutomaticWindowTabbing:", ignore, "v@:c");113 add("NSWindow", 1, "setAllowsAutomaticWindowTabbing:", ignore, "v@:c");
114 add("NSWindow", 0, "setTitlebarAppearsTransparent:", ignore, "v@:c");114 add("NSWindow", 0, "setTitlebarAppearsTransparent:", ignore, "v@:c");
115 add("NSWindow", 0, "setTitleVisibility:", ignore, "v@:q");
116 add("NSView", 0, "setWantsBestResolutionOpenGLSurface:", ignore, "v@:c");115 add("NSView", 0, "setWantsBestResolutionOpenGLSurface:", ignore, "v@:c");
117 add("NSView", 0, "setAccessibilityLabel:", set_accessibility_label, "v@:@");116 add("NSView", 0, "setAccessibilityLabel:", set_accessibility_label, "v@:@");
118 add("NSDatePicker", 0, "setPresentsCalendarOverlay:", ignore, "v@:c");117 add("NSDatePicker", 0, "setPresentsCalendarOverlay:", ignore, "v@:c");
readme.md+2-5
...@@ -10,8 +10,7 @@ feels more like a standard text editor software as textboxes automatically...@@ -10,8 +10,7 @@ feels more like a standard text editor software as textboxes automatically
10create and resize as you would expect. In addition, Snowbound supports10create and resize as you would expect. In addition, Snowbound supports
11pen and drawing tools [(WIP)](https://shale.paperclover.net/snowbound/issues/21), recording audio and video11pen and drawing tools [(WIP)](https://shale.paperclover.net/snowbound/issues/21), recording audio and video
12[(WIP)](https://shale.paperclover.net/snowbound/issues/36), revision12[(WIP)](https://shale.paperclover.net/snowbound/issues/36), revision
13history[(WIP)](https://shale.paperclover.net/snowbound/issues/37), multi-machine13history, multi-machine live collaboration, and much more.
14live collaboration, and much more.
1514
16<!-- Regenerate from the sample notebook (edit it freely in OneNote or Snowbound):15<!-- Regenerate from the sample notebook (edit it freely in OneNote or Snowbound):
17python3 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/screenshot16python3 tools/canvas/build_macos.py --release && d=$(mktemp -d) && cp -R docs/sample-notebook/Personal "$d" && target/Snowbound.app/Contents/MacOS/Snowbound --notebook "$d/Personal" --cache "$d/cache" --settings "$d/settings.json" --screenshot docs/screenshot
...@@ -23,9 +22,7 @@ python3 tools/canvas/build_macos.py --release && d=$(mktemp -d) && cp -R docs/sa...@@ -23,9 +22,7 @@ python3 tools/canvas/build_macos.py --release && d=$(mktemp -d) && cp -R docs/sa
2322
24Snowbound implements the file and sync protocol used in 2010 Microsoft OneNote,23Snowbound implements the file and sync protocol used in 2010 Microsoft OneNote,
25so notebooks are fully compatible, including collaboration features. It supports24so notebooks are fully compatible, including collaboration features. It supports
26OS X 10.6 Snow Leopard25OS X 10.6 Snow Leopard, all the way to modern macOS, Linux, Windows
27[(WIP)](https://shale.paperclover.net/snowbound/issues/13), all the way to
28modern macOS, Linux, Windows
29[(WIP)](https://shale.paperclover.net/snowbound/issues/11), and fully capable26[(WIP)](https://shale.paperclover.net/snowbound/issues/11), and fully capable
30iOS[(WIP)](https://shale.paperclover.net/snowbound/issues/36) and27iOS[(WIP)](https://shale.paperclover.net/snowbound/issues/36) and
31Android[(WIP)](https://shale.paperclover.net/snowbound/issues/24) apps.28Android[(WIP)](https://shale.paperclover.net/snowbound/issues/24) apps.
tools/TESTING.md+11
...@@ -49,6 +49,17 @@ leaves `soak/*.log` ending in the command that replays it; crash inputs stay in...@@ -49,6 +49,17 @@ leaves `soak/*.log` ending in the command that replays it; crash inputs stay in
49toolchains, `cargo install cargo-fuzz`, and `build-essential pkg-config49toolchains, `cargo install cargo-fuzz`, and `build-essential pkg-config
50libfontconfig-dev`; copy the checkout and run the script under `tmux`.50libfontconfig-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
52## Private and native verification63## Private and native verification
5364
54Private notebooks stay outside versioned fixtures. Materialize a copy before65Private 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...@@ -6,7 +6,7 @@ crates.io (the version winit resolves to in `Cargo.lock`), copied without its
6`[patch.crates-io]` here.6`[patch.crates-io]` here.
77
8`Cargo.toml` gains a `[lints.rust]` table silencing a lint newer than the8`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 source9release, since Cargo does not cap a path dependency's warnings. The first source
10change drops the 1 px border line under the header bar, so the title10change drops the 1 px border line under the header bar, so the title
11bar flows into Snowbound's toolbar as it does in a GTK 4 app:11bar 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:...@@ -28,7 +28,31 @@ bar flows into Snowbound's toolbar as it does in a GTK 4 app:
28 }28 }
29```29```
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
31To bump: copy the new release's `src/`, `Cargo.toml`, `LICENSE`, `README.md`55To bump: copy the new release's `src/`, `Cargo.toml`, `LICENSE`, `README.md`
32and `CHANGELOG.md` over this folder, reapply the diff, and update the version56and `CHANGELOG.md` over this folder, reapply the three changes, and update the version
33above. The patch applies only while the version here satisfies winit's57above. The patch applies only while the version here satisfies winit's
34requirement; `cargo tree -i sctk-adwaita` shows the path source when it does.58requirement; `cargo tree -i sctk-adwaita` shows the path source when it does.
vendor/sctk-adwaita/src/lib.rs+200-11
...@@ -1,6 +1,7 @@...@@ -1,6 +1,7 @@
1use std::error::Error;1use std::error::Error;
2use std::mem;2use std::mem;
3use std::num::NonZeroU32;3use std::num::NonZeroU32;
4use std::sync::atomic::{AtomicBool, Ordering};
4use std::sync::Arc;5use std::sync::Arc;
5use std::time::Duration;6use std::time::Duration;
67
...@@ -49,6 +50,42 @@ use wl_typed::WlTyped;...@@ -49,6 +50,42 @@ use wl_typed::WlTyped;
49/// XXX this is not result, so `must_use` when needed.50/// XXX this is not result, so `must_use` when needed.
50type SkiaResult = Option<()>;51type 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
52/// A simple set of decorations89/// A simple set of decorations
53#[derive(Debug)]90#[derive(Debug)]
54pub struct AdwaitaFrame<State> {91pub struct AdwaitaFrame<State> {
...@@ -106,6 +143,7 @@ where...@@ -106,6 +143,7 @@ where
106 let base_surface = WlTyped::wrap::<State>(base_surface.wl_surface().clone());143 let base_surface = WlTyped::wrap::<State>(base_surface.wl_surface().clone());
107144
108 let pool = SlotPool::new(1, shm)?;145 let pool = SlotPool::new(1, shm)?;
146 FRAMED.store(true, Ordering::Relaxed);
109147
110 let decorations = Some(DecorationParts::new(148 let decorations = Some(DecorationParts::new(
111 &base_surface,149 &base_surface,
...@@ -226,12 +264,16 @@ where...@@ -226,12 +264,16 @@ where
226 true264 true
227 };265 };
228 let border_paint = colors.border_paint();266 let border_paint = colors.border_paint();
267 let libadwaita = libadwaita_edge() && !self.state.intersects(WindowState::TILED);
268 let window = (
269 decorations.header().surface_rect.width,
270 decorations.side_height(),
271 );
229272
230 // Draw the borders.273 // Draw the borders.
231 for (idx, part) in decorations274 for (idx, part) in decorations.parts().filter(|(idx, part)| {
232 .parts()275 (*idx == DecorationParts::HEADER || draw_borders) && part.surface_rect.height > 0
233 .filter(|(idx, _)| *idx == DecorationParts::HEADER || draw_borders)276 }) {
234 {
235 let scale = self.scale_factor;277 let scale = self.scale_factor;
236278
237 let mut rect = part.surface_rect;279 let mut rect = part.surface_rect;
...@@ -240,10 +282,28 @@ where...@@ -240,10 +282,28 @@ where
240 // start. To achieve that, we enlargen the width of the header by282 // start. To achieve that, we enlargen the width of the header by
241 // 2 * `VISIBLE_BORDER_SIZE`, and move `x` by `VISIBLE_BORDER_SIZE`283 // 2 * `VISIBLE_BORDER_SIZE`, and move `x` by `VISIBLE_BORDER_SIZE`
242 // to the left.284 // to the left.
243 if idx == DecorationParts::HEADER && draw_borders {285 if idx == DecorationParts::HEADER && draw_borders && !libadwaita {
244 rect.width += 2 * VISIBLE_BORDER_SIZE;286 rect.width += 2 * VISIBLE_BORDER_SIZE;
245 rect.x -= VISIBLE_BORDER_SIZE as i32;287 rect.x -= VISIBLE_BORDER_SIZE as i32;
246 }288 }
289 // libadwaita's outline lies inside the body, and its bottom corners are cut from
290 // it: the sides overlap the body by the outline, the bottom by the radius.
291 let (inset, reach) = match idx {
292 _ if !libadwaita || !draw_borders => (0, 0),
293 DecorationParts::LEFT | DecorationParts::RIGHT => (1, 0),
294 DecorationParts::BOTTOM => (0, WINDOW_RADIUS as u32),
295 _ => (0, 0),
296 };
297 if idx == DecorationParts::RIGHT {
298 rect.x -= inset as i32;
299 }
300 rect.width += inset;
301 rect.y -= reach as i32;
302 rect.height += reach;
303 // Without a header the body's top corners are cut too, and the top reaches over them.
304 if libadwaita && draw_borders && idx == DecorationParts::TOP {
305 rect.height += WINDOW_RADIUS as u32;
306 }
247307
248 rect.width *= scale;308 rect.width *= scale;
249 rect.height *= scale;309 rect.height *= scale;
...@@ -265,7 +325,23 @@ where...@@ -265,7 +325,23 @@ where
265 // do invisible borders to enlarge the input zone.325 // do invisible borders to enlarge the input zone.
266 pixmap.fill(Color::TRANSPARENT);326 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) {
269 self.shadow.draw(345 self.shadow.draw(
270 &mut pixmap,346 &mut pixmap,
271 scale,347 scale,
...@@ -292,6 +368,7 @@ where...@@ -292,6 +368,7 @@ where
292 self.mouse.location,368 self.mouse.location,
293 );369 );
294 }370 }
371 _ if edge => {}
295 border => {372 border => {
296 // The visible border is one pt.373 // The visible border is one pt.
297 let visible_border_size = VISIBLE_BORDER_SIZE * scale;374 let visible_border_size = VISIBLE_BORDER_SIZE * scale;
...@@ -318,8 +395,16 @@ where...@@ -318,8 +395,16 @@ where
318 (rect.height - y) as f32,395 (rect.height - y) as f32,
319 )396 )
320 }397 }
321 // We draw small visible border only bellow the window surface, no need to398 // Without a header, the top has its border too.
322 // handle `TOP`.399 DecorationParts::TOP => {
400 let x = (rect.x.unsigned_abs() * scale) - visible_border_size;
401 Rect::from_xywh(
402 x as f32,
403 (rect.height - visible_border_size) as f32,
404 (rect.width - 2 * x) as f32,
405 visible_border_size as f32,
406 )
407 }
323 DecorationParts::BOTTOM => {408 DecorationParts::BOTTOM => {
324 let x = (rect.x.unsigned_abs() * scale) - visible_border_size;409 let x = (rect.x.unsigned_abs() * scale) - visible_border_size;
325 Rect::from_xywh(410 Rect::from_xywh(
...@@ -339,6 +424,10 @@ where...@@ -339,6 +424,10 @@ where
339 }424 }
340 };425 };
341426
427 if edge {
428 edge_layer(&mut pixmap, true);
429 }
430
342 if should_sync {431 if should_sync {
343 part.subsurface.set_sync();432 part.subsurface.set_sync();
344 } else {433 } else {
...@@ -359,8 +448,13 @@ where...@@ -359,8 +448,13 @@ where
359 if let Some(input_rect) = part.input_rect {448 if let Some(input_rect) = part.input_rect {
360 let input_region = Region::new(&*self.compositor).ok()?;449 let input_region = Region::new(&*self.compositor).ok()?;
361 input_region.add(450 input_region.add(
362 input_rect.x,451 input_rect.x
363 input_rect.y,452 + if idx == DecorationParts::RIGHT {
453 inset as i32
454 } else {
455 0
456 },
457 input_rect.y + reach as i32,
364 input_rect.width as i32,458 input_rect.width as i32,
365 input_rect.height as i32,459 input_rect.height as i32,
366 );460 );
...@@ -685,6 +779,8 @@ fn draw_headerbar_bg(...@@ -685,6 +779,8 @@ fn draw_headerbar_bg(
685779
686 let radius = if state.intersects(WindowState::MAXIMIZED | WindowState::TILED) {780 let radius = if state.intersects(WindowState::MAXIMIZED | WindowState::TILED) {
687 0.781 0.
782 } else if libadwaita_edge() {
783 WINDOW_RADIUS * scale
688 } else {784 } else {
689 CORNER_RADIUS as f32 * scale785 CORNER_RADIUS as f32 * scale
690 };786 };
...@@ -702,6 +798,99 @@ fn draw_headerbar_bg(...@@ -702,6 +798,99 @@ fn draw_headerbar_bg(
702 Some(())798 Some(())
703}799}
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
705fn rounded_headerbar_shape(x: f32, y: f32, width: f32, height: f32, radius: f32) -> Option<Path> {894fn rounded_headerbar_shape(x: f32, y: f32, width: f32, height: f32, radius: f32) -> Option<Path> {
706 // https://stackoverflow.com/a/27863181895 // https://stackoverflow.com/a/27863181
707 let cubic_bezier_circle = 0.552_284_8 * radius;896 let cubic_bezier_circle = 0.552_284_8 * radius;
...@@ -765,7 +954,7 @@ fn rounded_headerbar_shape(x: f32, y: f32, width: f32, height: f32, radius: f32)...@@ -765,7 +954,7 @@ fn rounded_headerbar_shape(x: f32, y: f32, width: f32, height: f32, radius: f32)
765954
766// returns horizontal margin, logical points955// returns horizontal margin, logical points
767fn get_margin_h_lp(state: &WindowState) -> f32 {956fn get_margin_h_lp(state: &WindowState) -> f32 {
768 if state.intersects(WindowState::MAXIMIZED | WindowState::TILED) {957 if state.intersects(WindowState::MAXIMIZED | WindowState::TILED) || libadwaita_edge() {
769 0.958 0.
770 } else {959 } else {
771 VISIBLE_BORDER_SIZE as f32960 VISIBLE_BORDER_SIZE as f32
vendor/sctk-adwaita/src/theme.rs+1-1
...@@ -6,7 +6,7 @@ pub(crate) const RESIZE_HANDLE_SIZE: u32 = 12;...@@ -6,7 +6,7 @@ pub(crate) const RESIZE_HANDLE_SIZE: u32 = 12;
6// https://gitlab.gnome.org/GNOME/gtk/-/blob/1bf88f1d81043fd99740e2f91e56ade7ede7303b/gtk/gtkwindow.c#L1666// https://gitlab.gnome.org/GNOME/gtk/-/blob/1bf88f1d81043fd99740e2f91e56ade7ede7303b/gtk/gtkwindow.c#L166
7pub(crate) const RESIZE_HANDLE_CORNER_SIZE: u32 = 24;7pub(crate) const RESIZE_HANDLE_CORNER_SIZE: u32 = 24;
8pub(crate) const BORDER_SIZE: u32 = crate::shadow::SHADOW_SIZE + VISIBLE_BORDER_SIZE;8pub(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;
10pub(crate) const CORNER_RADIUS: u32 = 10;10pub(crate) const CORNER_RADIUS: u32 = 10;
11pub(crate) const VISIBLE_BORDER_SIZE: u32 = 1;11pub(crate) const VISIBLE_BORDER_SIZE: u32 = 1;
1212