diff --git a/arc/platforms.md b/arc/platforms.md index 1acb4b564951137639b95bf4450ceb63fe97faa4..60ab0848199c82f66a7c3e2a7ba8e80145b410f2 100644 --- a/arc/platforms.md +++ b/arc/platforms.md @@ -29,8 +29,13 @@ keyboard, the toolbar and the macOS menu bar all run commands from it. ### macOS -- The title bar is transparent. The app draws its own around AppKit's traffic - lights, as part of the same frame as the rest of the chrome. Under the whole +- The toolbar's row is the title bar. An empty `NSToolbar`, unified compact + from macOS 11, with the title hidden makes the title bar the row's height, + and AppKit places the traffic lights in it; a press in the row's gaps drags + the window. The title is still set, for the Window menu, Mission Control and + VoiceOver. `--screenshot` paints the lights where the hidden window's AppKit + put them. The app draws the row as part of the same frame as the rest of the + chrome. Under the whole window lies AppKit's title bar material, an `NSVisualEffectView`, and the chrome is drawn transparent over it, so the title bar, toolbar, tab row and 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. ### Linux - X11 and Wayland. Title bars come from the window manager, or on Wayland from - the compositor where it offers server-side decorations and a client-side - frame otherwise. The app draws its own window controls only where no frame - could be made. + the compositor where it offers server-side decorations, and the toolbar's + row lies beneath them, as in Dolphin and Kate. Elsewhere on Wayland, as on + GNOME, the row is the title bar, as a GTK 4 header bar holding tools: winit's + Adwaita frame keeps its shadow, corners and resize edges but has no header, + and the row ends in the window's buttons in the order GNOME's `button-layout` + lists them. Under that frame on GNOME the window erases its bottom + corners to transparent pixels and the frame draws libadwaita's window edge + round it: radius, shadows and outline. KWin rounds Breeze's corners itself. - The toolbar and the rest of the chrome continue the title bar's fill, focused and not: on KDE the colour scheme's header colours from `kdeglobals`, as KWin paints its title bars, and on GNOME winit's Adwaita diff --git a/arc/ui.md b/arc/ui.md index a553d585df2a643383f6986f94e4f912685efe58..4287b45b2d1028c9ef760e7538ed0b517c7a739d 100644 --- a/arc/ui.md +++ b/arc/ui.md @@ -23,9 +23,10 @@ quickly and never feel like they're waiting on an animation. Animated values ease exponentially toward their targets with a short half-life. That is frame-rate independent, a retargeted animation continues smoothly from where it is, and it settles fast. Popups open and close on short timed curves, -slow enough to follow: a menu grows out of the pointer or its button as it +slow enough to follow: a menu swings out of the pointer or its button as it fades in, a combo's field widens into its list, and a dialog swings up into -place over a dimmed window, as Windows opens a window. On GNOME and KDE, menus instead +place over a dimmed window, as Windows opens a window; the command palette swings +in the same way near the window's top. On GNOME and KDE, menus instead look and move as the desktop's own (see [platforms](platforms.md)). ## Why not native widgets @@ -62,7 +63,7 @@ frame N route input ─── against frame N-1's layout (hover, press, focus, drags, wheel) build ───────── app code declares boxes; each reads its Signal (clicked, dragging, events…) solve layout ── per axis: pixels · label size · fraction of an ancestor · sum of children - overflow is shared out by each box's strictness + overflow taken from space, then by folding groups, then by strictness paint ───────── Layers: primitives under a clip, or a Custom box the host paints after paint ─── work the frame asked for (page requests, saving) runs, then asks for a frame ``` @@ -75,8 +76,17 @@ frame N it is being declared. It sounds odd and is invisible in practice. - **Layout is solved after building**, per axis. A box can be sized in fixed pixels, by its label, as a fraction of an ancestor, or by its children. When - siblings overflow, each gives up space according to its *strictness*. That - one knob covers most of what flexbox is usually needed for. + siblings overflow, space sized from an ancestor gives way first. Then a row + *folds* its groups, boxes with a full and a folded form (`Spec::fold`), by + priority. Only then do boxes give up room by their *strictness*, the least + strict first. That one knob covers most of what flexbox is usually needed + for. +- **The toolbar is one row that never overflows.** Both forms of every group + are built each frame and the solver picks, so a group folds in the frame the + window narrows, with no widths remembered or worked out by the app. A folded + group is a dropdown of the same command-table rows, and a gallery it lists + opens under it, since a folded-away box takes its group's rectangle. The + section tabs scroll sideways past the row's room, fading out where cut. - **Nothing runs while nothing changes.** The kit reports whether it wants another frame (queued input, an animation still settling) and when a timed 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. ordinary ones. - **Batches** merge consecutive primitives that share a texture and clip, without reordering paint. +- **A popup in motion is one picture.** Layers sharing a motion draw + offscreen together, then fade and lean back as a whole, so nothing beneath + or within shows through a row. A popup's contents clip to its rounded + outline, and one opening over a combo lays its rows out at their final + width while only the outline widens. - **Caches are bounded.** The glyph atlas and image cache have fixed budgets. Eviction keeps everything the current frame needs and rebuilds after pressure. Images are filtered in linear light with premultiplied alpha. diff --git a/corpus/page-background/README.md b/corpus/page-background/README.md new file mode 100644 index 0000000000000000000000000000000000000000..ebf4088f7a77eee2effcad6dea6e302366d59088 --- /dev/null +++ b/corpus/page-background/README.md @@ -0,0 +1,13 @@ +# Page background + +`candidate` is `backgrounds_go_on_and_off_pages_that_have_content` in +`crates/snowbound/src/manage.rs` (`SNOWBOUND_BACKGROUND_EXPORT`), on a copy of +`rule-lines/native/notebook`: the Page Color menu's ops give the ruled "Standard" page +Ivy's art (`manage::art_ops`), give "Wide" the art and take it away again, and colour +"SmallGrid" `0x00c8d8e8`, a COLORREF outside OneNote's menu. + +`cold` is its fresh OneNote 2010 read: "Standard" keeps its rule lines under one +background image (`backgroundImage="true"`), "Wide" keeps its lines and no image, and +"SmallGrid" reads back `color="#E8D8C8"` and shows it under its grid. `read/page-006.png` +shows OneNote drawing the art over the rule lines. `tools/test_page_background.py` +checks this without a VM. diff --git a/corpus/page-background/candidate/Open Notebook.onetoc2 b/corpus/page-background/candidate/Open Notebook.onetoc2 new file mode 100644 index 0000000000000000000000000000000000000000..2d2785048e719aaea688d47f540628bf86baaf99 Binary files /dev/null and b/corpus/page-background/candidate/Open Notebook.onetoc2 differ diff --git a/corpus/page-background/candidate/Rules.one b/corpus/page-background/candidate/Rules.one new file mode 100644 index 0000000000000000000000000000000000000000..b3cac5439567e52d91afb41e0aea8bfe756f673d Binary files /dev/null and b/corpus/page-background/candidate/Rules.one differ diff --git a/corpus/page-background/cold/commands.jsonl b/corpus/page-background/cold/commands.jsonl new file mode 100644 index 0000000000000000000000000000000000000000..078755456d9b165dd24331a220e48eef3cdfdbea --- /dev/null +++ b/corpus/page-background/cold/commands.jsonl @@ -0,0 +1,3 @@ +{"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} +{"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} +{"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} diff --git a/corpus/page-background/cold/machine.json b/corpus/page-background/cold/machine.json new file mode 100644 index 0000000000000000000000000000000000000000..e58b873f2d473eca43130194d68fecdd79deb49b --- /dev/null +++ b/corpus/page-background/cold/machine.json @@ -0,0 +1 @@ +{"name": "m6-5ef70348", "hostname": "ONE-M6-5EF70348"} diff --git a/corpus/page-background/cold/read/environment.json b/corpus/page-background/cold/read/environment.json new file mode 100644 index 0000000000000000000000000000000000000000..fccbb69c546565860b9d41302c324d6405a6f1fd --- /dev/null +++ b/corpus/page-background/cold/read/environment.json @@ -0,0 +1,7 @@ +{ + "powershell": "5.1.14409.1005", + "schema": "xs2010", + "hostname": "ONE-M6-5EF70348", + "cold": true, + "onenote": "14.0.4763.1000" +} diff --git a/corpus/page-background/cold/read/hierarchy.xml b/corpus/page-background/cold/read/hierarchy.xml new file mode 100644 index 0000000000000000000000000000000000000000..2480abbaf9351670fbea32ca881eda9ec2b47ce7 --- /dev/null +++ b/corpus/page-background/cold/read/hierarchy.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/page-background/cold/read/page-000.navigation.json b/corpus/page-background/cold/read/page-000.navigation.json new file mode 100644 index 0000000000000000000000000000000000000000..7824d62a06fb14853ccd8fca71946931cbe8e9ec --- /dev/null +++ b/corpus/page-background/cold/read/page-000.navigation.json @@ -0,0 +1,13 @@ +{ + "exit": 0, + "stdout": "", + "stderr": "", + "w": 800, + "h": 600, + "error": null, + "win": { + "title": "None - Microsoft OneNote", + "class": "Framework::CFrame", + "dialog": false + } +} \ No newline at end of file diff --git a/corpus/page-background/cold/read/page-000.png b/corpus/page-background/cold/read/page-000.png new file mode 100644 index 0000000000000000000000000000000000000000..6d53a16941a97c24ef8e89388659739401287191 Binary files /dev/null and b/corpus/page-background/cold/read/page-000.png differ diff --git a/corpus/page-background/cold/read/page-000.xml b/corpus/page-background/cold/read/page-000.xml new file mode 100644 index 0000000000000000000000000000000000000000..12cf428804b54db3214d4d440d9a26a08090c293 --- /dev/null +++ b/corpus/page-background/cold/read/page-000.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/page-background/cold/read/page-001.navigation.json b/corpus/page-background/cold/read/page-001.navigation.json new file mode 100644 index 0000000000000000000000000000000000000000..1d051ab18621c4450d424efbbd3056b504248b1b --- /dev/null +++ b/corpus/page-background/cold/read/page-001.navigation.json @@ -0,0 +1,13 @@ +{ + "exit": 0, + "stdout": "", + "stderr": "", + "w": 800, + "h": 600, + "error": null, + "win": { + "title": "College - Microsoft OneNote", + "class": "Framework::CFrame", + "dialog": false + } +} \ No newline at end of file diff --git a/corpus/page-background/cold/read/page-001.png b/corpus/page-background/cold/read/page-001.png new file mode 100644 index 0000000000000000000000000000000000000000..86f49f9425b6b98a8ed88aa8c1675759632059e3 Binary files /dev/null and b/corpus/page-background/cold/read/page-001.png differ diff --git a/corpus/page-background/cold/read/page-001.xml b/corpus/page-background/cold/read/page-001.xml new file mode 100644 index 0000000000000000000000000000000000000000..3578db1b2139354625912e9473a87ebbed2eff28 --- /dev/null +++ b/corpus/page-background/cold/read/page-001.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/page-background/cold/read/page-002.navigation.json b/corpus/page-background/cold/read/page-002.navigation.json new file mode 100644 index 0000000000000000000000000000000000000000..909744712d947f9080464d99bb701cd7de4071c8 --- /dev/null +++ b/corpus/page-background/cold/read/page-002.navigation.json @@ -0,0 +1,13 @@ +{ + "exit": 0, + "stdout": "", + "stderr": "", + "w": 800, + "h": 600, + "error": null, + "win": { + "title": "VeryLargeGrid - Microsoft OneNote", + "class": "Framework::CFrame", + "dialog": false + } +} \ No newline at end of file diff --git a/corpus/page-background/cold/read/page-002.png b/corpus/page-background/cold/read/page-002.png new file mode 100644 index 0000000000000000000000000000000000000000..75a53d42b8eb63e08cdc6d28336d940273b7e2f4 Binary files /dev/null and b/corpus/page-background/cold/read/page-002.png differ diff --git a/corpus/page-background/cold/read/page-002.xml b/corpus/page-background/cold/read/page-002.xml new file mode 100644 index 0000000000000000000000000000000000000000..7b7b9f465790ce5737d5f211006ff058494757be --- /dev/null +++ b/corpus/page-background/cold/read/page-002.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/page-background/cold/read/page-003.navigation.json b/corpus/page-background/cold/read/page-003.navigation.json new file mode 100644 index 0000000000000000000000000000000000000000..7ccafd7fd59c95368f51d575f411c3fb815d3863 --- /dev/null +++ b/corpus/page-background/cold/read/page-003.navigation.json @@ -0,0 +1,13 @@ +{ + "exit": 0, + "stdout": "", + "stderr": "", + "w": 800, + "h": 600, + "error": null, + "win": { + "title": "MediumGrid - Microsoft OneNote", + "class": "Framework::CFrame", + "dialog": false + } +} \ No newline at end of file diff --git a/corpus/page-background/cold/read/page-003.png b/corpus/page-background/cold/read/page-003.png new file mode 100644 index 0000000000000000000000000000000000000000..4adfda1220e6a91319553962aa0b842b9c535abe Binary files /dev/null and b/corpus/page-background/cold/read/page-003.png differ diff --git a/corpus/page-background/cold/read/page-003.xml b/corpus/page-background/cold/read/page-003.xml new file mode 100644 index 0000000000000000000000000000000000000000..3544c37d82f5ce4bbe4d70bdc5ac292060eed29b --- /dev/null +++ b/corpus/page-background/cold/read/page-003.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/page-background/cold/read/page-004.navigation.json b/corpus/page-background/cold/read/page-004.navigation.json new file mode 100644 index 0000000000000000000000000000000000000000..007050b6d7a6917406f9ab04289d337fab5d8d43 --- /dev/null +++ b/corpus/page-background/cold/read/page-004.navigation.json @@ -0,0 +1,13 @@ +{ + "exit": 0, + "stdout": "", + "stderr": "", + "w": 800, + "h": 600, + "error": null, + "win": { + "title": "Wide - Microsoft OneNote", + "class": "Framework::CFrame", + "dialog": false + } +} \ No newline at end of file diff --git a/corpus/page-background/cold/read/page-004.png b/corpus/page-background/cold/read/page-004.png new file mode 100644 index 0000000000000000000000000000000000000000..d70d67d33fff88c810540c9cc6048c0da36b0d47 Binary files /dev/null and b/corpus/page-background/cold/read/page-004.png differ diff --git a/corpus/page-background/cold/read/page-004.xml b/corpus/page-background/cold/read/page-004.xml new file mode 100644 index 0000000000000000000000000000000000000000..cd5eb9a8d6fd489b97c26548fb5c08c58b3d5e71 --- /dev/null +++ b/corpus/page-background/cold/read/page-004.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/page-background/cold/read/page-005.navigation.json b/corpus/page-background/cold/read/page-005.navigation.json new file mode 100644 index 0000000000000000000000000000000000000000..7f25097d7917248dd57c3d10c5466fadaaa84a95 --- /dev/null +++ b/corpus/page-background/cold/read/page-005.navigation.json @@ -0,0 +1,13 @@ +{ + "exit": 0, + "stdout": "", + "stderr": "", + "w": 800, + "h": 600, + "error": null, + "win": { + "title": "Narrow - Microsoft OneNote", + "class": "Framework::CFrame", + "dialog": false + } +} \ No newline at end of file diff --git a/corpus/page-background/cold/read/page-005.png b/corpus/page-background/cold/read/page-005.png new file mode 100644 index 0000000000000000000000000000000000000000..0ff75c9f29678987fa5562d0c36b6d9438c63975 Binary files /dev/null and b/corpus/page-background/cold/read/page-005.png differ diff --git a/corpus/page-background/cold/read/page-005.xml b/corpus/page-background/cold/read/page-005.xml new file mode 100644 index 0000000000000000000000000000000000000000..a50c40d773c0bc67794d26ec2a6f57a39c985c87 --- /dev/null +++ b/corpus/page-background/cold/read/page-005.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/page-background/cold/read/page-006.navigation.json b/corpus/page-background/cold/read/page-006.navigation.json new file mode 100644 index 0000000000000000000000000000000000000000..74f74505c909f96ff84679b418cd721b7be7459f --- /dev/null +++ b/corpus/page-background/cold/read/page-006.navigation.json @@ -0,0 +1,13 @@ +{ + "exit": 0, + "stdout": "", + "stderr": "", + "w": 800, + "h": 600, + "error": null, + "win": { + "title": "Standard - Microsoft OneNote", + "class": "Framework::CFrame", + "dialog": false + } +} \ No newline at end of file diff --git a/corpus/page-background/cold/read/page-006.png b/corpus/page-background/cold/read/page-006.png new file mode 100644 index 0000000000000000000000000000000000000000..b529348ff769e5ea373a48ffec8425339c59d681 Binary files /dev/null and b/corpus/page-background/cold/read/page-006.png differ diff --git a/corpus/page-background/cold/read/page-006.xml b/corpus/page-background/cold/read/page-006.xml new file mode 100644 index 0000000000000000000000000000000000000000..3d7a8ca5f93aef9d28145f04b73701ba123c7855 --- /dev/null +++ b/corpus/page-background/cold/read/page-006.xml @@ -0,0 +1,353 @@ + +/9j/4AAQSkZJRgABAgAAAQABAAD//gAYU25vd2JvdW5kIHRlbXBsYXRlIGl2ef/AABEIAxgA2AMB +EQACEQEDEQH/2wBDAAMCAgMCAgMDAwMEAwMEBQgFBQQEBQoHBwYIDAoMDAsKCwsNDhIQDQ4RDgsL +EBYQERMUFRUVDA8XGBYUGBIUFRT/2wBDAQMEBAUEBQkFBQkUDQsNFBQUFBQUFBQUFBQUFBQUFBQU +FBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBT/xAAfAAABBQEBAQEBAQAAAAAAAAAAAQID +BAUGBwgJCgv/xAC1EAACAQMDAgQDBQUEBAAAAX0BAgMABBEFEiExQQYTUWEHInEUMoGRoQgjQrHB +FVLR8CQzYnKCCQoWFxgZGiUmJygpKjQ1Njc4OTpDREVGR0hJSlNUVVZXWFlaY2RlZmdoaWpzdHV2 +d3h5eoOEhYaHiImKkpOUlZaXmJmaoqOkpaanqKmqsrO0tba3uLm6wsPExcbHyMnK0tPU1dbX2Nna +4eLj5OXm5+jp6vHy8/T19vf4+fr/xAAfAQADAQEBAQEBAQEBAAAAAAAAAQIDBAUGBwgJCgv/xAC1 +EQACAQIEBAMEBwUEBAABAncAAQIDEQQFITEGEkFRB2FxEyIygQgUQpGhscEJIzNS8BVictEKFiQ0 +4SXxFxgZGiYnKCkqNTY3ODk6Q0RFRkdISUpTVFVWV1hZWmNkZWZnaGlqc3R1dnd4eXqCg4SFhoeI +iYqSk5SVlpeYmZqio6Slpqeoqaqys7S1tre4ubrCw8TFxsfIycrS09TV1tfY2dri4+Tl5ufo6ery +8/T19vf4+fr/2gAMAwEAAhEDEQA/AP1ToAKAOS8f+KbvwbFYamkX2nThL5V3EB8wDfdcH2wRjoc1 +5GYYqpg+SqleN7NfkzGtV9jDnauup0el6nbazp8F7aSCW2nUOjjuK9KlVhXgqkHdM1TUldbHK/Cf +/kW7r/sIXH/odeblf8F/4pfmcGB/hv1Z2leuegFABQAUAFABQAUAFABQAUAFABQAUAFABQAUAFAB +QAUAFABQAUAFABQBW1HTrbVrGazu4lntpl2PG3QisqtKFaDpzV0xNKSszmNP0uP4X+ENUZLh7y1t +vMuIUkGCgxwme/PfjrXlU6X9l4ao+bmSu1/l95lCEcPTstlf/Mz/AIM3Dt4GM03+ta6mZsjHzFuf +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 diff --git a/corpus/page-background/cold/read/page-007.navigation.json b/corpus/page-background/cold/read/page-007.navigation.json new file mode 100644 index 0000000000000000000000000000000000000000..6ea1c2e7cfb4aec222874c087cbbaf51b80cb3c7 --- /dev/null +++ b/corpus/page-background/cold/read/page-007.navigation.json @@ -0,0 +1,13 @@ +{ + "exit": 0, + "stdout": "", + "stderr": "", + "w": 800, + "h": 600, + "error": null, + "win": { + "title": "LargeGrid - Microsoft OneNote", + "class": "Framework::CFrame", + "dialog": false + } +} \ No newline at end of file diff --git a/corpus/page-background/cold/read/page-007.png b/corpus/page-background/cold/read/page-007.png new file mode 100644 index 0000000000000000000000000000000000000000..16bcfdc5106ef59235784c52de70b3b2e78111cb Binary files /dev/null and b/corpus/page-background/cold/read/page-007.png differ diff --git a/corpus/page-background/cold/read/page-007.xml b/corpus/page-background/cold/read/page-007.xml new file mode 100644 index 0000000000000000000000000000000000000000..f6255c7de82987f898fd5b3afa20bdb00fac5dd9 --- /dev/null +++ b/corpus/page-background/cold/read/page-007.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/page-background/cold/read/page-008.navigation.json b/corpus/page-background/cold/read/page-008.navigation.json new file mode 100644 index 0000000000000000000000000000000000000000..d2a29b6a0602e35842b0605919ee79aa85216a62 --- /dev/null +++ b/corpus/page-background/cold/read/page-008.navigation.json @@ -0,0 +1,13 @@ +{ + "exit": 0, + "stdout": "", + "stderr": "", + "w": 800, + "h": 600, + "error": null, + "win": { + "title": "ColorRed - Microsoft OneNote", + "class": "Framework::CFrame", + "dialog": false + } +} \ No newline at end of file diff --git a/corpus/page-background/cold/read/page-008.png b/corpus/page-background/cold/read/page-008.png new file mode 100644 index 0000000000000000000000000000000000000000..9b309ee670f3838fcab2d007a7549ba9921aeec7 Binary files /dev/null and b/corpus/page-background/cold/read/page-008.png differ diff --git a/corpus/page-background/cold/read/page-008.xml b/corpus/page-background/cold/read/page-008.xml new file mode 100644 index 0000000000000000000000000000000000000000..2d6b4f741bc5f03ec29303610f43fbb8d56de2c1 --- /dev/null +++ b/corpus/page-background/cold/read/page-008.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/page-background/cold/read/page-009.navigation.json b/corpus/page-background/cold/read/page-009.navigation.json new file mode 100644 index 0000000000000000000000000000000000000000..3cda85f0889301dcc34bd7f3046be58554002323 --- /dev/null +++ b/corpus/page-background/cold/read/page-009.navigation.json @@ -0,0 +1,13 @@ +{ + "exit": 0, + "stdout": "", + "stderr": "", + "w": 800, + "h": 600, + "error": null, + "win": { + "title": "SmallGrid - Microsoft OneNote", + "class": "Framework::CFrame", + "dialog": false + } +} \ No newline at end of file diff --git a/corpus/page-background/cold/read/page-009.png b/corpus/page-background/cold/read/page-009.png new file mode 100644 index 0000000000000000000000000000000000000000..07af1f35fa610f3eb4fb07e679391ccc07846f74 Binary files /dev/null and b/corpus/page-background/cold/read/page-009.png differ diff --git a/corpus/page-background/cold/read/page-009.xml b/corpus/page-background/cold/read/page-009.xml new file mode 100644 index 0000000000000000000000000000000000000000..cdff2258459a79cf71abc3a86be1f028e65dbe19 --- /dev/null +++ b/corpus/page-background/cold/read/page-009.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/page-background/cold/read/page-010.navigation.json b/corpus/page-background/cold/read/page-010.navigation.json new file mode 100644 index 0000000000000000000000000000000000000000..5c585338bca1397dd69f5878808de906cd5ff0ae --- /dev/null +++ b/corpus/page-background/cold/read/page-010.navigation.json @@ -0,0 +1,13 @@ +{ + "exit": 0, + "stdout": "", + "stderr": "", + "w": 800, + "h": 600, + "error": null, + "win": { + "title": "Hidden - Microsoft OneNote", + "class": "Framework::CFrame", + "dialog": false + } +} \ No newline at end of file diff --git a/corpus/page-background/cold/read/page-010.png b/corpus/page-background/cold/read/page-010.png new file mode 100644 index 0000000000000000000000000000000000000000..756322df669e4169f0910aadc989f3608b11c603 Binary files /dev/null and b/corpus/page-background/cold/read/page-010.png differ diff --git a/corpus/page-background/cold/read/page-010.xml b/corpus/page-background/cold/read/page-010.xml new file mode 100644 index 0000000000000000000000000000000000000000..b22513a98ff771e9450f944bcccc1dc1a187e55e --- /dev/null +++ b/corpus/page-background/cold/read/page-010.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/page-background/cold/read/payloads.json b/corpus/page-background/cold/read/payloads.json new file mode 100644 index 0000000000000000000000000000000000000000..1ce91a413d6fff4d58765187b76756f0e97ae843 --- /dev/null +++ b/corpus/page-background/cold/read/payloads.json @@ -0,0 +1,3 @@ +[ + +] \ No newline at end of file diff --git a/corpus/page-background/cold/run.json b/corpus/page-background/cold/run.json new file mode 100644 index 0000000000000000000000000000000000000000..2e035ab67362f60cf49d3ae690d4fca77cef7237 --- /dev/null +++ b/corpus/page-background/cold/run.json @@ -0,0 +1,18 @@ +{ + "notebook": "/private/tmp/snowbound-page-background/candidate", + "expected_pages": 11, + "author": null, + "author_timeout_seconds": 600, + "inspect": false, + "collect_notebook": false, + "base": { + "file": "win7-office-base.qcow2", + "format": "qcow2", + "sha256": "a1a4f8fab782ee14885ff801ca2f6347c208fdcfc3513637096f314315c89346", + "virtual_size": 68719476736 + }, + "scripts": { + "cold.ps1": "c177fc72ae6c2634d186f5671a880de20b09f9716532c37c69cb13aa15c7e331", + "read.ps1": "04013bfcccee40a17e2a225f9b9e96f40a3a8daad9e350609654f8eda3ccbb41" + } +} diff --git a/corpus/page-background/cold/scripts/cold.ps1 b/corpus/page-background/cold/scripts/cold.ps1 new file mode 100644 index 0000000000000000000000000000000000000000..a0b99e7aeb86f4dc10032537800501a52ad65b4f --- /dev/null +++ b/corpus/page-background/cold/scripts/cold.ps1 @@ -0,0 +1,27 @@ +param([Parameter(Mandatory=$true)][string]$Root, [string]$CloneHost = '') +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' +$root = [IO.Path]::GetFullPath($Root).TrimEnd('\') +if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') { + throw 'Choose a run directly below C:\one-tests\runs.' +} +if (Get-Process ONENOTE -ErrorAction SilentlyContinue) { throw 'Close OneNote before resetting its test cache.' } +$key = 'HKCU:\Software\Microsoft\Office\14.0\OneNote' +if ($CloneHost) { + if ($CloneHost -notmatch '^ONE-[A-Z0-9-]+$' -or [Environment]::MachineName -ne $CloneHost) { + throw 'The disposable clone hostname does not match this machine.' + } + New-Item "$key\Options\Paths" -Force | Out-Null + New-ItemProperty "$key\Options\Paths" -Name UnfiledNotesSection -PropertyType ExpandString -Value 'C:\one-tests\Loose.one' -Force | Out-Null +} elseif ((Get-ItemProperty "$key\Options\Paths").UnfiledNotesSection -ne 'C:\one-tests\Loose.one' -or + -not (Test-Path 'C:\one-tests\profile-original-cache')) { + throw 'Park the personal OneNote profile before resetting the test cache.' +} +$cache = Join-Path $env:LOCALAPPDATA 'Microsoft\OneNote\14.0' +$parked = Join-Path 'C:\one-tests\caches' ([IO.Path]::GetFileName($root)) +if (Test-Path $parked) { throw 'Choose a new run; its parked cache already exists.' } +New-Item -ItemType Directory -Path 'C:\one-tests\caches' -Force | Out-Null +if (Test-Path $cache) { Move-Item -LiteralPath $cache -Destination $parked } +if (Test-Path "$key\OpenNotebooks") { Remove-Item "$key\OpenNotebooks" -Recurse } +New-Item "$key\OpenNotebooks" | Out-Null +New-ItemProperty "$key\OpenNotebooks" -Name '1' -PropertyType String -Value "$root\notebook" | Out-Null diff --git a/corpus/page-background/cold/scripts/read.ps1 b/corpus/page-background/cold/scripts/read.ps1 new file mode 100644 index 0000000000000000000000000000000000000000..476094ab1e0c47077988a6461db2080cf208642b --- /dev/null +++ b/corpus/page-background/cold/scripts/read.ps1 @@ -0,0 +1,142 @@ +param( + [Parameter(Mandatory=$true)][string]$Root, + [int]$ExpectedPages = -1, + [switch]$UseCurrentCache, + [switch]$Pdf, + [switch]$KeepOpen, + [string]$CloneHost = '' +) +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' +$root = [IO.Path]::GetFullPath($Root).TrimEnd('\') +if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') { + throw 'Choose a run directly below C:\one-tests\runs.' +} +$notebook = Join-Path $root 'notebook' +$output = Join-Path $root 'read' +if (Test-Path $output) { throw 'Choose a new read destination.' } +if ($UseCurrentCache) { + if ((Get-ItemProperty 'HKCU:\Software\Microsoft\Office\14.0\OneNote\Options\Paths').UnfiledNotesSection -ne 'C:\one-tests\Loose.one') { + throw 'Park the personal OneNote profile before reading test notebooks.' + } +} else { + & "$PSScriptRoot\cold-current.ps1" -Root $root -CloneHost $CloneHost +} +New-Item -ItemType Directory -Path $output | Out-Null +$app = New-Object -ComObject OneNote.Application +$notebookId = '' +$failure = $null +try { + $app.OpenHierarchy($notebook, '', [ref]$notebookId, 0) + $process = Get-Process ONENOTE + @{ hostname = [Environment]::MachineName; onenote = $process.MainModule.FileVersionInfo.FileVersion; + powershell = $PSVersionTable.PSVersion.ToString(); schema = 'xs2010'; cold = (-not $UseCurrentCache.IsPresent) } | + ConvertTo-Json | Set-Content (Join-Path $output 'environment.json') -Encoding UTF8 + $sections = @() + foreach ($file in @(Get-ChildItem $notebook -Recurse | Where-Object { $_.Extension -eq '.one' })) { + $id = '' + $app.OpenHierarchy($file.FullName, '', [ref]$id, 0) + $sections += $id + } + $deadline = [DateTime]::UtcNow.AddSeconds(300) + $previous = '' + $lastChange = '' + $stableSince = [DateTime]::UtcNow + $settled = $false + do { + $pages = @{} + foreach ($section in $sections) { + $hierarchy = '' + $app.GetHierarchy($section, 4, [ref]$hierarchy, 1) + [xml]$xml = $hierarchy + foreach ($node in $xml.SelectNodes('//*[@path]')) { + if (-not $node.GetAttribute('path').StartsWith("$notebook\", [StringComparison]::OrdinalIgnoreCase)) { + throw 'OneNote opened a section outside the copied notebook.' + } + } + foreach ($node in $xml.SelectNodes('//*[local-name()="Page"]')) { + $id = $node.GetAttribute('ID') + $content = '' + $app.GetPageContent($id, [ref]$content, 1, 1) + $pages[$id] = $content + } + } + $signature = [String]::Join('|', @($pages.Keys | Sort-Object | ForEach-Object { $_ + $pages[$_] })) + if ($signature -ne $previous) { + $lastChange = $previous + $previous = $signature + $stableSince = [DateTime]::UtcNow + } + if ((($ExpectedPages -ge 0 -and $pages.Count -eq $ExpectedPages) -or + ($ExpectedPages -lt 0 -and $pages.Count -gt 0)) -and + ([DateTime]::UtcNow - $stableSince).TotalSeconds -ge 2) { $settled = $true; break } + Start-Sleep -Milliseconds 250 + } while ([DateTime]::UtcNow -lt $deadline) + if (-not $settled -or ($ExpectedPages -ge 0 -and $pages.Count -ne $ExpectedPages) -or ($ExpectedPages -lt 0 -and $pages.Count -eq 0)) { + [IO.File]::WriteAllText((Join-Path $output 'previous-signature.txt'), $lastChange, [Text.Encoding]::UTF8) + $index = 0 + foreach ($id in @($pages.Keys | Sort-Object)) { + [IO.File]::WriteAllText((Join-Path $output ('unsettled-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8) + $index++ + } + $hierarchy = '' + $app.GetHierarchy($notebookId, 4, [ref]$hierarchy, 1) + [IO.File]::WriteAllText((Join-Path $output 'unsettled-hierarchy.xml'), $hierarchy, [Text.Encoding]::UTF8) + throw "Expected $ExpectedPages stable pages; OneNote returned $($pages.Count), settled=$settled." + } + $index = 0 + $payloads = @() + foreach ($id in @($pages.Keys | Sort-Object)) { + [IO.File]::WriteAllText((Join-Path $output ('page-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8) + if ($Pdf) { + $pdfPath = Join-Path $output ('page-{0:d3}.pdf' -f $index) + $app.NavigateTo($id, '', $false) + $app.Publish($id, $pdfPath, 3, '') + if (-not (Test-Path $pdfPath) -or (Get-Item $pdfPath).Length -eq 0) { throw 'OneNote did not publish the page PDF.' } + } + [xml]$page = $pages[$id] + foreach ($file in $page.SelectNodes('//*[local-name()="InsertedFile" or local-name()="MediaFile"]')) { + $bytes = [IO.File]::ReadAllBytes($file.GetAttribute('pathCache')) + $hash = [BitConverter]::ToString([Security.Cryptography.SHA256]::Create().ComputeHash($bytes)).Replace('-', '').ToLowerInvariant() + [IO.File]::WriteAllBytes((Join-Path $output ($hash + '.attachment')), $bytes) + $payloads += @{ page = $id; object = $file.ParentNode.GetAttribute('objectID'); + kind = $file.LocalName; name = $file.GetAttribute('preferredName'); + sha256 = $hash; bytes = $bytes.Length } + } + $index++ + } + [IO.File]::WriteAllText((Join-Path $output 'payloads.json'), (ConvertTo-Json -InputObject $payloads -Depth 4), [Text.Encoding]::UTF8) + $all = '' + $app.GetHierarchy($notebookId, 4, [ref]$all, 1) + [xml]$finalTree = $all + $finalIds = @($finalTree.SelectNodes('//*[local-name()="Page"]') | ForEach-Object { $_.GetAttribute('ID') } | Sort-Object -Unique) + if ($finalIds.Count -ne $pages.Count -or @($finalIds | Where-Object { -not $pages.ContainsKey($_) }).Count -ne 0) { + throw 'The notebook hierarchy changed while collecting page evidence; repeat the cold read.' + } + [IO.File]::WriteAllText((Join-Path $output 'hierarchy.xml'), $all, [Text.Encoding]::UTF8) + Write-Output "Read $($sections.Count) sections and $($pages.Count) pages." +} catch { + $failure = $_ + [IO.File]::WriteAllText((Join-Path $output 'failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8) + throw +} finally { + try { + try { + if ($notebookId -and $CloneHost) { $app.SyncHierarchy($notebookId) } + if ($notebookId -and -not $KeepOpen -and (-not $UseCurrentCache -or $CloneHost)) { + $app.CloseNotebook($notebookId, $false) + } + } catch { + if ($null -eq $failure) { throw } + [IO.File]::WriteAllText((Join-Path $output 'cleanup-failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8) + } + } finally { + [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($app) + } + $app = $null + [GC]::Collect() + [GC]::WaitForPendingFinalizers() + if (-not $UseCurrentCache -and -not $CloneHost) { + Get-Process ONENOTE -ErrorAction SilentlyContinue | Wait-Process -Timeout 10 + } +} diff --git a/corpus/page-background/cold/source.json b/corpus/page-background/cold/source.json new file mode 100644 index 0000000000000000000000000000000000000000..d05a2062f5d643ca05e92a4d82d237e2bc8e322a --- /dev/null +++ b/corpus/page-background/cold/source.json @@ -0,0 +1,14 @@ +[ + { + "path": "Open Notebook.onetoc2", + "bytes": 4808, + "sha256": "26e13bc41400333df9b3b6ebc182ea592bdc8a4bd0d3c33c0773e832965add13", + "mtime_ns": 1790669768851191042 + }, + { + "path": "Rules.one", + "bytes": 132704, + "sha256": "a513e56274ae794cf558da34dca7189225159d7eb6ab7d2256531fd7a2a55b44", + "mtime_ns": 1790671178905073494 + } +] diff --git a/corpus/page-background/cold/teardown.json b/corpus/page-background/cold/teardown.json new file mode 100644 index 0000000000000000000000000000000000000000..05a47793de40f322e745c4c4183727e3590ecf70 --- /dev/null +++ b/corpus/page-background/cold/teardown.json @@ -0,0 +1 @@ +{"absent": true} diff --git a/corpus/rule-lines/README.md b/corpus/rule-lines/README.md new file mode 100644 index 0000000000000000000000000000000000000000..7bb9931540f21c5337c2c9f2092040a582a71820 --- /dev/null +++ b/corpus/rule-lines/README.md @@ -0,0 +1,25 @@ +# Rule lines + +`native` is OneNote 2010's View, Rule Lines, set through its menu on the VM on eleven +pages of a notebook the COM API made, each titled for what it was given: None; Narrow +(ruled, then set back to None); College, Standard and Wide Ruled; Small, Medium, Large and +Very Large Grid; ColorRed (Standard, then Rule Line Color, Red); Hidden (Standard, then +Rule Line Color, ``, on a Teal page). MediumGrid was then given Rule Line Color, +Purple. `read` is its cold read in a fresh clone. + +The page node stores six properties MS-ONE does not document, in this order: the +horizontal lines' colour (`0x14001cd5`), kind (`0x14001cd3`: 2 ruled, 1 grid) and spacing +(`0x14001cd4`, half inches), then the vertical lines' kind (`0x14001cd6`: 3 a margin line, +1 grid), spacing (`0x14001cd7`, 0 for a margin line) and colour (`0x14001cd8`). Colours +are 0xAARRGGBB with alpha 0xFF, unlike the page colour's COLORREF; the menu's `` +colour is white. None removes all six. OneNote draws lines 1/96 inch wide: horizontal ones +every spacing from the margin origin (36, 14.4) down across the page, grid lines through +it either way down the whole page, and a margin line 1/96 inch left of it. +`colors.txt` is each Rule Line Color as the page XML reports it, in the menu's order. + +`candidate` is `rule_lines_store_what_onenote_stores` in `crates/onestore/src/op/tests.rs` +(`ONESTORE_RULE_LINES_EXPORT`): on OneNote's file, the `RuleLines` op gives None Wide +Ruled, removes College's, turns Standard into a Small Grid and MediumGrid into Narrow +Ruled in Red, and gives a created page, "Created", a Very Large Grid. `cold` is its fresh +OneNote 2010 read: every page reads back with the lines written, and `read/page-*.png` +shows them. `tools/test_rule_lines.py` checks both without a VM. diff --git a/corpus/rule-lines/candidate/Open Notebook.onetoc2 b/corpus/rule-lines/candidate/Open Notebook.onetoc2 new file mode 100644 index 0000000000000000000000000000000000000000..2d2785048e719aaea688d47f540628bf86baaf99 Binary files /dev/null and b/corpus/rule-lines/candidate/Open Notebook.onetoc2 differ diff --git a/corpus/rule-lines/candidate/Rules.one b/corpus/rule-lines/candidate/Rules.one new file mode 100644 index 0000000000000000000000000000000000000000..7dc34ca30264afddb2c7ba621ac0db0de62bca82 Binary files /dev/null and b/corpus/rule-lines/candidate/Rules.one differ diff --git a/corpus/rule-lines/cold/commands.jsonl b/corpus/rule-lines/cold/commands.jsonl new file mode 100644 index 0000000000000000000000000000000000000000..a57fb25a9ae5ee623cd7c84427a096998598f81e --- /dev/null +++ b/corpus/rule-lines/cold/commands.jsonl @@ -0,0 +1,3 @@ +{"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} +{"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} +{"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} diff --git a/corpus/rule-lines/cold/machine.json b/corpus/rule-lines/cold/machine.json new file mode 100644 index 0000000000000000000000000000000000000000..ec2b821899d7763bac4e7011be60a2a03b8ec1de --- /dev/null +++ b/corpus/rule-lines/cold/machine.json @@ -0,0 +1 @@ +{"name": "m6-8f5199a7", "hostname": "ONE-M6-8F5199A7"} diff --git a/corpus/rule-lines/cold/read/environment.json b/corpus/rule-lines/cold/read/environment.json new file mode 100644 index 0000000000000000000000000000000000000000..055ec1c4451571d914c2b975fee4511c3718f4f9 --- /dev/null +++ b/corpus/rule-lines/cold/read/environment.json @@ -0,0 +1,7 @@ +{ + "powershell": "5.1.14409.1005", + "schema": "xs2010", + "hostname": "ONE-M6-8F5199A7", + "cold": true, + "onenote": "14.0.4763.1000" +} diff --git a/corpus/rule-lines/cold/read/hierarchy.xml b/corpus/rule-lines/cold/read/hierarchy.xml new file mode 100644 index 0000000000000000000000000000000000000000..30c09acf806fadc2496ade5d6f57238bcc31870b --- /dev/null +++ b/corpus/rule-lines/cold/read/hierarchy.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/rule-lines/cold/read/page-000.navigation.json b/corpus/rule-lines/cold/read/page-000.navigation.json new file mode 100644 index 0000000000000000000000000000000000000000..3cda85f0889301dcc34bd7f3046be58554002323 --- /dev/null +++ b/corpus/rule-lines/cold/read/page-000.navigation.json @@ -0,0 +1,13 @@ +{ + "exit": 0, + "stdout": "", + "stderr": "", + "w": 800, + "h": 600, + "error": null, + "win": { + "title": "SmallGrid - Microsoft OneNote", + "class": "Framework::CFrame", + "dialog": false + } +} \ No newline at end of file diff --git a/corpus/rule-lines/cold/read/page-000.png b/corpus/rule-lines/cold/read/page-000.png new file mode 100644 index 0000000000000000000000000000000000000000..de61174ee173c0657bcdbfb25d916b91aaff71cf Binary files /dev/null and b/corpus/rule-lines/cold/read/page-000.png differ diff --git a/corpus/rule-lines/cold/read/page-000.xml b/corpus/rule-lines/cold/read/page-000.xml new file mode 100644 index 0000000000000000000000000000000000000000..124a8520ad7e73038f4554ea05cc0df3b45408e4 --- /dev/null +++ b/corpus/rule-lines/cold/read/page-000.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/rule-lines/cold/read/page-001.navigation.json b/corpus/rule-lines/cold/read/page-001.navigation.json new file mode 100644 index 0000000000000000000000000000000000000000..5c585338bca1397dd69f5878808de906cd5ff0ae --- /dev/null +++ b/corpus/rule-lines/cold/read/page-001.navigation.json @@ -0,0 +1,13 @@ +{ + "exit": 0, + "stdout": "", + "stderr": "", + "w": 800, + "h": 600, + "error": null, + "win": { + "title": "Hidden - Microsoft OneNote", + "class": "Framework::CFrame", + "dialog": false + } +} \ No newline at end of file diff --git a/corpus/rule-lines/cold/read/page-001.png b/corpus/rule-lines/cold/read/page-001.png new file mode 100644 index 0000000000000000000000000000000000000000..6d0adc8c83aab4d6bb05017c6de76f3f6c09b40f Binary files /dev/null and b/corpus/rule-lines/cold/read/page-001.png differ diff --git a/corpus/rule-lines/cold/read/page-001.xml b/corpus/rule-lines/cold/read/page-001.xml new file mode 100644 index 0000000000000000000000000000000000000000..2f0641e053667cf0990e23808fa93f286280a218 --- /dev/null +++ b/corpus/rule-lines/cold/read/page-001.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/rule-lines/cold/read/page-002.navigation.json b/corpus/rule-lines/cold/read/page-002.navigation.json new file mode 100644 index 0000000000000000000000000000000000000000..6ea1c2e7cfb4aec222874c087cbbaf51b80cb3c7 --- /dev/null +++ b/corpus/rule-lines/cold/read/page-002.navigation.json @@ -0,0 +1,13 @@ +{ + "exit": 0, + "stdout": "", + "stderr": "", + "w": 800, + "h": 600, + "error": null, + "win": { + "title": "LargeGrid - Microsoft OneNote", + "class": "Framework::CFrame", + "dialog": false + } +} \ No newline at end of file diff --git a/corpus/rule-lines/cold/read/page-002.png b/corpus/rule-lines/cold/read/page-002.png new file mode 100644 index 0000000000000000000000000000000000000000..2f014d8b6bdcdc95b2f2d134d93e3a5b1df29d50 Binary files /dev/null and b/corpus/rule-lines/cold/read/page-002.png differ diff --git a/corpus/rule-lines/cold/read/page-002.xml b/corpus/rule-lines/cold/read/page-002.xml new file mode 100644 index 0000000000000000000000000000000000000000..198a53ae38d8e74f392370cb79c361e95d01369c --- /dev/null +++ b/corpus/rule-lines/cold/read/page-002.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/rule-lines/cold/read/page-003.navigation.json b/corpus/rule-lines/cold/read/page-003.navigation.json new file mode 100644 index 0000000000000000000000000000000000000000..74f74505c909f96ff84679b418cd721b7be7459f --- /dev/null +++ b/corpus/rule-lines/cold/read/page-003.navigation.json @@ -0,0 +1,13 @@ +{ + "exit": 0, + "stdout": "", + "stderr": "", + "w": 800, + "h": 600, + "error": null, + "win": { + "title": "Standard - Microsoft OneNote", + "class": "Framework::CFrame", + "dialog": false + } +} \ No newline at end of file diff --git a/corpus/rule-lines/cold/read/page-003.png b/corpus/rule-lines/cold/read/page-003.png new file mode 100644 index 0000000000000000000000000000000000000000..5f45c8f6ec22ab27524d71d7d1cd9d491d6bafb2 Binary files /dev/null and b/corpus/rule-lines/cold/read/page-003.png differ diff --git a/corpus/rule-lines/cold/read/page-003.xml b/corpus/rule-lines/cold/read/page-003.xml new file mode 100644 index 0000000000000000000000000000000000000000..418545dd328f46d2f41f5c5b87cf50b0a3e71f3d --- /dev/null +++ b/corpus/rule-lines/cold/read/page-003.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/rule-lines/cold/read/page-004.navigation.json b/corpus/rule-lines/cold/read/page-004.navigation.json new file mode 100644 index 0000000000000000000000000000000000000000..d2a29b6a0602e35842b0605919ee79aa85216a62 --- /dev/null +++ b/corpus/rule-lines/cold/read/page-004.navigation.json @@ -0,0 +1,13 @@ +{ + "exit": 0, + "stdout": "", + "stderr": "", + "w": 800, + "h": 600, + "error": null, + "win": { + "title": "ColorRed - Microsoft OneNote", + "class": "Framework::CFrame", + "dialog": false + } +} \ No newline at end of file diff --git a/corpus/rule-lines/cold/read/page-004.png b/corpus/rule-lines/cold/read/page-004.png new file mode 100644 index 0000000000000000000000000000000000000000..3d9891788240a977e303162e14dcf90e93aa1486 Binary files /dev/null and b/corpus/rule-lines/cold/read/page-004.png differ diff --git a/corpus/rule-lines/cold/read/page-004.xml b/corpus/rule-lines/cold/read/page-004.xml new file mode 100644 index 0000000000000000000000000000000000000000..ecba02cc6a08f4d1de4024aa208512e6710a51d1 --- /dev/null +++ b/corpus/rule-lines/cold/read/page-004.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/rule-lines/cold/read/page-005.navigation.json b/corpus/rule-lines/cold/read/page-005.navigation.json new file mode 100644 index 0000000000000000000000000000000000000000..5b9f18d07155f053a73f448d8ba8208522d981e4 --- /dev/null +++ b/corpus/rule-lines/cold/read/page-005.navigation.json @@ -0,0 +1,13 @@ +{ + "exit": 0, + "stdout": "", + "stderr": "", + "w": 800, + "h": 600, + "error": null, + "win": { + "title": "Created - Microsoft OneNote", + "class": "Framework::CFrame", + "dialog": false + } +} \ No newline at end of file diff --git a/corpus/rule-lines/cold/read/page-005.png b/corpus/rule-lines/cold/read/page-005.png new file mode 100644 index 0000000000000000000000000000000000000000..67bae3e09fdfa200beb1057810936a06df39686c Binary files /dev/null and b/corpus/rule-lines/cold/read/page-005.png differ diff --git a/corpus/rule-lines/cold/read/page-005.xml b/corpus/rule-lines/cold/read/page-005.xml new file mode 100644 index 0000000000000000000000000000000000000000..bacea2ec47fbf7cfb4e0aded8093ab717354bee6 --- /dev/null +++ b/corpus/rule-lines/cold/read/page-005.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/rule-lines/cold/read/page-006.navigation.json b/corpus/rule-lines/cold/read/page-006.navigation.json new file mode 100644 index 0000000000000000000000000000000000000000..909744712d947f9080464d99bb701cd7de4071c8 --- /dev/null +++ b/corpus/rule-lines/cold/read/page-006.navigation.json @@ -0,0 +1,13 @@ +{ + "exit": 0, + "stdout": "", + "stderr": "", + "w": 800, + "h": 600, + "error": null, + "win": { + "title": "VeryLargeGrid - Microsoft OneNote", + "class": "Framework::CFrame", + "dialog": false + } +} \ No newline at end of file diff --git a/corpus/rule-lines/cold/read/page-006.png b/corpus/rule-lines/cold/read/page-006.png new file mode 100644 index 0000000000000000000000000000000000000000..515fc107961c0917c33e70ccf878b0006f80feab Binary files /dev/null and b/corpus/rule-lines/cold/read/page-006.png differ diff --git a/corpus/rule-lines/cold/read/page-006.xml b/corpus/rule-lines/cold/read/page-006.xml new file mode 100644 index 0000000000000000000000000000000000000000..b80b7adb0c14e0439f4f33662a31c723a4c2f242 --- /dev/null +++ b/corpus/rule-lines/cold/read/page-006.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/rule-lines/cold/read/page-007.navigation.json b/corpus/rule-lines/cold/read/page-007.navigation.json new file mode 100644 index 0000000000000000000000000000000000000000..7ccafd7fd59c95368f51d575f411c3fb815d3863 --- /dev/null +++ b/corpus/rule-lines/cold/read/page-007.navigation.json @@ -0,0 +1,13 @@ +{ + "exit": 0, + "stdout": "", + "stderr": "", + "w": 800, + "h": 600, + "error": null, + "win": { + "title": "MediumGrid - Microsoft OneNote", + "class": "Framework::CFrame", + "dialog": false + } +} \ No newline at end of file diff --git a/corpus/rule-lines/cold/read/page-007.png b/corpus/rule-lines/cold/read/page-007.png new file mode 100644 index 0000000000000000000000000000000000000000..2c261c2238293fc4d220516d5509ed4265ef278a Binary files /dev/null and b/corpus/rule-lines/cold/read/page-007.png differ diff --git a/corpus/rule-lines/cold/read/page-007.xml b/corpus/rule-lines/cold/read/page-007.xml new file mode 100644 index 0000000000000000000000000000000000000000..17aa5a9eb4cbd1d3086ac854e1bfd01aad0554da --- /dev/null +++ b/corpus/rule-lines/cold/read/page-007.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/rule-lines/cold/read/page-008.navigation.json b/corpus/rule-lines/cold/read/page-008.navigation.json new file mode 100644 index 0000000000000000000000000000000000000000..007050b6d7a6917406f9ab04289d337fab5d8d43 --- /dev/null +++ b/corpus/rule-lines/cold/read/page-008.navigation.json @@ -0,0 +1,13 @@ +{ + "exit": 0, + "stdout": "", + "stderr": "", + "w": 800, + "h": 600, + "error": null, + "win": { + "title": "Wide - Microsoft OneNote", + "class": "Framework::CFrame", + "dialog": false + } +} \ No newline at end of file diff --git a/corpus/rule-lines/cold/read/page-008.png b/corpus/rule-lines/cold/read/page-008.png new file mode 100644 index 0000000000000000000000000000000000000000..2c0d0e6e7fe757c5e25514d342dbef2fbae4773e Binary files /dev/null and b/corpus/rule-lines/cold/read/page-008.png differ diff --git a/corpus/rule-lines/cold/read/page-008.xml b/corpus/rule-lines/cold/read/page-008.xml new file mode 100644 index 0000000000000000000000000000000000000000..3f623bae42d02d171ac17f4d03be05500d4beffd --- /dev/null +++ b/corpus/rule-lines/cold/read/page-008.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/rule-lines/cold/read/page-009.navigation.json b/corpus/rule-lines/cold/read/page-009.navigation.json new file mode 100644 index 0000000000000000000000000000000000000000..7f25097d7917248dd57c3d10c5466fadaaa84a95 --- /dev/null +++ b/corpus/rule-lines/cold/read/page-009.navigation.json @@ -0,0 +1,13 @@ +{ + "exit": 0, + "stdout": "", + "stderr": "", + "w": 800, + "h": 600, + "error": null, + "win": { + "title": "Narrow - Microsoft OneNote", + "class": "Framework::CFrame", + "dialog": false + } +} \ No newline at end of file diff --git a/corpus/rule-lines/cold/read/page-009.png b/corpus/rule-lines/cold/read/page-009.png new file mode 100644 index 0000000000000000000000000000000000000000..33c5cf4c63329d3e6279ee4868044541bfd1dc43 Binary files /dev/null and b/corpus/rule-lines/cold/read/page-009.png differ diff --git a/corpus/rule-lines/cold/read/page-009.xml b/corpus/rule-lines/cold/read/page-009.xml new file mode 100644 index 0000000000000000000000000000000000000000..78108f6435b4c7ffd252bcd117126111fd5863a0 --- /dev/null +++ b/corpus/rule-lines/cold/read/page-009.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/rule-lines/cold/read/page-010.navigation.json b/corpus/rule-lines/cold/read/page-010.navigation.json new file mode 100644 index 0000000000000000000000000000000000000000..7824d62a06fb14853ccd8fca71946931cbe8e9ec --- /dev/null +++ b/corpus/rule-lines/cold/read/page-010.navigation.json @@ -0,0 +1,13 @@ +{ + "exit": 0, + "stdout": "", + "stderr": "", + "w": 800, + "h": 600, + "error": null, + "win": { + "title": "None - Microsoft OneNote", + "class": "Framework::CFrame", + "dialog": false + } +} \ No newline at end of file diff --git a/corpus/rule-lines/cold/read/page-010.png b/corpus/rule-lines/cold/read/page-010.png new file mode 100644 index 0000000000000000000000000000000000000000..13265708543cedff3b9df7dd32b3a1075a1a2f0a Binary files /dev/null and b/corpus/rule-lines/cold/read/page-010.png differ diff --git a/corpus/rule-lines/cold/read/page-010.xml b/corpus/rule-lines/cold/read/page-010.xml new file mode 100644 index 0000000000000000000000000000000000000000..091b229025781813c5258cc08d2eb45c60b6a441 --- /dev/null +++ b/corpus/rule-lines/cold/read/page-010.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/rule-lines/cold/read/page-011.navigation.json b/corpus/rule-lines/cold/read/page-011.navigation.json new file mode 100644 index 0000000000000000000000000000000000000000..1d051ab18621c4450d424efbbd3056b504248b1b --- /dev/null +++ b/corpus/rule-lines/cold/read/page-011.navigation.json @@ -0,0 +1,13 @@ +{ + "exit": 0, + "stdout": "", + "stderr": "", + "w": 800, + "h": 600, + "error": null, + "win": { + "title": "College - Microsoft OneNote", + "class": "Framework::CFrame", + "dialog": false + } +} \ No newline at end of file diff --git a/corpus/rule-lines/cold/read/page-011.png b/corpus/rule-lines/cold/read/page-011.png new file mode 100644 index 0000000000000000000000000000000000000000..c55381d98a16b6012d27d1aef4b5e1087eb368e3 Binary files /dev/null and b/corpus/rule-lines/cold/read/page-011.png differ diff --git a/corpus/rule-lines/cold/read/page-011.xml b/corpus/rule-lines/cold/read/page-011.xml new file mode 100644 index 0000000000000000000000000000000000000000..5851c2a885556a4940bc0f935017c462715e00e1 --- /dev/null +++ b/corpus/rule-lines/cold/read/page-011.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/rule-lines/cold/read/payloads.json b/corpus/rule-lines/cold/read/payloads.json new file mode 100644 index 0000000000000000000000000000000000000000..1ce91a413d6fff4d58765187b76756f0e97ae843 --- /dev/null +++ b/corpus/rule-lines/cold/read/payloads.json @@ -0,0 +1,3 @@ +[ + +] \ No newline at end of file diff --git a/corpus/rule-lines/cold/run.json b/corpus/rule-lines/cold/run.json new file mode 100644 index 0000000000000000000000000000000000000000..d315390697e479b8177e1eedb2399f6dc5dad6e2 --- /dev/null +++ b/corpus/rule-lines/cold/run.json @@ -0,0 +1,18 @@ +{ + "notebook": "/private/tmp/snowbound-rule-lines/candidate", + "expected_pages": 12, + "author": null, + "author_timeout_seconds": 600, + "inspect": false, + "collect_notebook": false, + "base": { + "file": "win7-office-base.qcow2", + "format": "qcow2", + "sha256": "a1a4f8fab782ee14885ff801ca2f6347c208fdcfc3513637096f314315c89346", + "virtual_size": 68719476736 + }, + "scripts": { + "cold.ps1": "c177fc72ae6c2634d186f5671a880de20b09f9716532c37c69cb13aa15c7e331", + "read.ps1": "04013bfcccee40a17e2a225f9b9e96f40a3a8daad9e350609654f8eda3ccbb41" + } +} diff --git a/corpus/rule-lines/cold/scripts/cold.ps1 b/corpus/rule-lines/cold/scripts/cold.ps1 new file mode 100644 index 0000000000000000000000000000000000000000..a0b99e7aeb86f4dc10032537800501a52ad65b4f --- /dev/null +++ b/corpus/rule-lines/cold/scripts/cold.ps1 @@ -0,0 +1,27 @@ +param([Parameter(Mandatory=$true)][string]$Root, [string]$CloneHost = '') +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' +$root = [IO.Path]::GetFullPath($Root).TrimEnd('\') +if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') { + throw 'Choose a run directly below C:\one-tests\runs.' +} +if (Get-Process ONENOTE -ErrorAction SilentlyContinue) { throw 'Close OneNote before resetting its test cache.' } +$key = 'HKCU:\Software\Microsoft\Office\14.0\OneNote' +if ($CloneHost) { + if ($CloneHost -notmatch '^ONE-[A-Z0-9-]+$' -or [Environment]::MachineName -ne $CloneHost) { + throw 'The disposable clone hostname does not match this machine.' + } + New-Item "$key\Options\Paths" -Force | Out-Null + New-ItemProperty "$key\Options\Paths" -Name UnfiledNotesSection -PropertyType ExpandString -Value 'C:\one-tests\Loose.one' -Force | Out-Null +} elseif ((Get-ItemProperty "$key\Options\Paths").UnfiledNotesSection -ne 'C:\one-tests\Loose.one' -or + -not (Test-Path 'C:\one-tests\profile-original-cache')) { + throw 'Park the personal OneNote profile before resetting the test cache.' +} +$cache = Join-Path $env:LOCALAPPDATA 'Microsoft\OneNote\14.0' +$parked = Join-Path 'C:\one-tests\caches' ([IO.Path]::GetFileName($root)) +if (Test-Path $parked) { throw 'Choose a new run; its parked cache already exists.' } +New-Item -ItemType Directory -Path 'C:\one-tests\caches' -Force | Out-Null +if (Test-Path $cache) { Move-Item -LiteralPath $cache -Destination $parked } +if (Test-Path "$key\OpenNotebooks") { Remove-Item "$key\OpenNotebooks" -Recurse } +New-Item "$key\OpenNotebooks" | Out-Null +New-ItemProperty "$key\OpenNotebooks" -Name '1' -PropertyType String -Value "$root\notebook" | Out-Null diff --git a/corpus/rule-lines/cold/scripts/read.ps1 b/corpus/rule-lines/cold/scripts/read.ps1 new file mode 100644 index 0000000000000000000000000000000000000000..476094ab1e0c47077988a6461db2080cf208642b --- /dev/null +++ b/corpus/rule-lines/cold/scripts/read.ps1 @@ -0,0 +1,142 @@ +param( + [Parameter(Mandatory=$true)][string]$Root, + [int]$ExpectedPages = -1, + [switch]$UseCurrentCache, + [switch]$Pdf, + [switch]$KeepOpen, + [string]$CloneHost = '' +) +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' +$root = [IO.Path]::GetFullPath($Root).TrimEnd('\') +if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') { + throw 'Choose a run directly below C:\one-tests\runs.' +} +$notebook = Join-Path $root 'notebook' +$output = Join-Path $root 'read' +if (Test-Path $output) { throw 'Choose a new read destination.' } +if ($UseCurrentCache) { + if ((Get-ItemProperty 'HKCU:\Software\Microsoft\Office\14.0\OneNote\Options\Paths').UnfiledNotesSection -ne 'C:\one-tests\Loose.one') { + throw 'Park the personal OneNote profile before reading test notebooks.' + } +} else { + & "$PSScriptRoot\cold-current.ps1" -Root $root -CloneHost $CloneHost +} +New-Item -ItemType Directory -Path $output | Out-Null +$app = New-Object -ComObject OneNote.Application +$notebookId = '' +$failure = $null +try { + $app.OpenHierarchy($notebook, '', [ref]$notebookId, 0) + $process = Get-Process ONENOTE + @{ hostname = [Environment]::MachineName; onenote = $process.MainModule.FileVersionInfo.FileVersion; + powershell = $PSVersionTable.PSVersion.ToString(); schema = 'xs2010'; cold = (-not $UseCurrentCache.IsPresent) } | + ConvertTo-Json | Set-Content (Join-Path $output 'environment.json') -Encoding UTF8 + $sections = @() + foreach ($file in @(Get-ChildItem $notebook -Recurse | Where-Object { $_.Extension -eq '.one' })) { + $id = '' + $app.OpenHierarchy($file.FullName, '', [ref]$id, 0) + $sections += $id + } + $deadline = [DateTime]::UtcNow.AddSeconds(300) + $previous = '' + $lastChange = '' + $stableSince = [DateTime]::UtcNow + $settled = $false + do { + $pages = @{} + foreach ($section in $sections) { + $hierarchy = '' + $app.GetHierarchy($section, 4, [ref]$hierarchy, 1) + [xml]$xml = $hierarchy + foreach ($node in $xml.SelectNodes('//*[@path]')) { + if (-not $node.GetAttribute('path').StartsWith("$notebook\", [StringComparison]::OrdinalIgnoreCase)) { + throw 'OneNote opened a section outside the copied notebook.' + } + } + foreach ($node in $xml.SelectNodes('//*[local-name()="Page"]')) { + $id = $node.GetAttribute('ID') + $content = '' + $app.GetPageContent($id, [ref]$content, 1, 1) + $pages[$id] = $content + } + } + $signature = [String]::Join('|', @($pages.Keys | Sort-Object | ForEach-Object { $_ + $pages[$_] })) + if ($signature -ne $previous) { + $lastChange = $previous + $previous = $signature + $stableSince = [DateTime]::UtcNow + } + if ((($ExpectedPages -ge 0 -and $pages.Count -eq $ExpectedPages) -or + ($ExpectedPages -lt 0 -and $pages.Count -gt 0)) -and + ([DateTime]::UtcNow - $stableSince).TotalSeconds -ge 2) { $settled = $true; break } + Start-Sleep -Milliseconds 250 + } while ([DateTime]::UtcNow -lt $deadline) + if (-not $settled -or ($ExpectedPages -ge 0 -and $pages.Count -ne $ExpectedPages) -or ($ExpectedPages -lt 0 -and $pages.Count -eq 0)) { + [IO.File]::WriteAllText((Join-Path $output 'previous-signature.txt'), $lastChange, [Text.Encoding]::UTF8) + $index = 0 + foreach ($id in @($pages.Keys | Sort-Object)) { + [IO.File]::WriteAllText((Join-Path $output ('unsettled-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8) + $index++ + } + $hierarchy = '' + $app.GetHierarchy($notebookId, 4, [ref]$hierarchy, 1) + [IO.File]::WriteAllText((Join-Path $output 'unsettled-hierarchy.xml'), $hierarchy, [Text.Encoding]::UTF8) + throw "Expected $ExpectedPages stable pages; OneNote returned $($pages.Count), settled=$settled." + } + $index = 0 + $payloads = @() + foreach ($id in @($pages.Keys | Sort-Object)) { + [IO.File]::WriteAllText((Join-Path $output ('page-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8) + if ($Pdf) { + $pdfPath = Join-Path $output ('page-{0:d3}.pdf' -f $index) + $app.NavigateTo($id, '', $false) + $app.Publish($id, $pdfPath, 3, '') + if (-not (Test-Path $pdfPath) -or (Get-Item $pdfPath).Length -eq 0) { throw 'OneNote did not publish the page PDF.' } + } + [xml]$page = $pages[$id] + foreach ($file in $page.SelectNodes('//*[local-name()="InsertedFile" or local-name()="MediaFile"]')) { + $bytes = [IO.File]::ReadAllBytes($file.GetAttribute('pathCache')) + $hash = [BitConverter]::ToString([Security.Cryptography.SHA256]::Create().ComputeHash($bytes)).Replace('-', '').ToLowerInvariant() + [IO.File]::WriteAllBytes((Join-Path $output ($hash + '.attachment')), $bytes) + $payloads += @{ page = $id; object = $file.ParentNode.GetAttribute('objectID'); + kind = $file.LocalName; name = $file.GetAttribute('preferredName'); + sha256 = $hash; bytes = $bytes.Length } + } + $index++ + } + [IO.File]::WriteAllText((Join-Path $output 'payloads.json'), (ConvertTo-Json -InputObject $payloads -Depth 4), [Text.Encoding]::UTF8) + $all = '' + $app.GetHierarchy($notebookId, 4, [ref]$all, 1) + [xml]$finalTree = $all + $finalIds = @($finalTree.SelectNodes('//*[local-name()="Page"]') | ForEach-Object { $_.GetAttribute('ID') } | Sort-Object -Unique) + if ($finalIds.Count -ne $pages.Count -or @($finalIds | Where-Object { -not $pages.ContainsKey($_) }).Count -ne 0) { + throw 'The notebook hierarchy changed while collecting page evidence; repeat the cold read.' + } + [IO.File]::WriteAllText((Join-Path $output 'hierarchy.xml'), $all, [Text.Encoding]::UTF8) + Write-Output "Read $($sections.Count) sections and $($pages.Count) pages." +} catch { + $failure = $_ + [IO.File]::WriteAllText((Join-Path $output 'failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8) + throw +} finally { + try { + try { + if ($notebookId -and $CloneHost) { $app.SyncHierarchy($notebookId) } + if ($notebookId -and -not $KeepOpen -and (-not $UseCurrentCache -or $CloneHost)) { + $app.CloseNotebook($notebookId, $false) + } + } catch { + if ($null -eq $failure) { throw } + [IO.File]::WriteAllText((Join-Path $output 'cleanup-failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8) + } + } finally { + [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($app) + } + $app = $null + [GC]::Collect() + [GC]::WaitForPendingFinalizers() + if (-not $UseCurrentCache -and -not $CloneHost) { + Get-Process ONENOTE -ErrorAction SilentlyContinue | Wait-Process -Timeout 10 + } +} diff --git a/corpus/rule-lines/cold/source.json b/corpus/rule-lines/cold/source.json new file mode 100644 index 0000000000000000000000000000000000000000..13632db263c2a5049d67291facf40b885f23f14b --- /dev/null +++ b/corpus/rule-lines/cold/source.json @@ -0,0 +1,14 @@ +[ + { + "path": "Open Notebook.onetoc2", + "bytes": 4808, + "sha256": "26e13bc41400333df9b3b6ebc182ea592bdc8a4bd0d3c33c0773e832965add13", + "mtime_ns": 1790669621631553306 + }, + { + "path": "Rules.one", + "bytes": 93264, + "sha256": "3ab2a2abcef76f4e008e349560caa63a907e15a61e71d080dd3e764c4aca34c1", + "mtime_ns": 1790669717172032242 + } +] diff --git a/corpus/rule-lines/cold/teardown.json b/corpus/rule-lines/cold/teardown.json new file mode 100644 index 0000000000000000000000000000000000000000..05a47793de40f322e745c4c4183727e3590ecf70 --- /dev/null +++ b/corpus/rule-lines/cold/teardown.json @@ -0,0 +1 @@ +{"absent": true} diff --git a/corpus/rule-lines/native/colors.txt b/corpus/rule-lines/native/colors.txt new file mode 100644 index 0000000000000000000000000000000000000000..5e40850773c4b91ce2f69a01f543994cdf6a2d0b --- /dev/null +++ b/corpus/rule-lines/native/colors.txt @@ -0,0 +1,20 @@ +none #FFFFFF +Light Blue #CAEBFD +Blue #D1E5FC +Yellow #FFF2AE +Green #D9E2C7 +Red #FFD4D6 +Purple #E0C9FF +Cyan #BBE8DB +Orange #F5DFB4 +Magenta #F5D3E8 +Blue Mist #BED3E2 +Purple Mist #C5BACD +Tan #F1E3C2 +Lemon #F9FBA9 +Apple #CFF1BF +Teal #94D7DF +Red Chalk #D6ACAC +Silver #C8C8D3 + +[exited with code 0] diff --git a/corpus/rule-lines/native/commands.jsonl b/corpus/rule-lines/native/commands.jsonl new file mode 100644 index 0000000000000000000000000000000000000000..f6306365d143bbae3d5a07444aec5eb8380a6caf --- /dev/null +++ b/corpus/rule-lines/native/commands.jsonl @@ -0,0 +1,3 @@ +{"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} +{"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} +{"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} diff --git a/corpus/rule-lines/native/machine.json b/corpus/rule-lines/native/machine.json new file mode 100644 index 0000000000000000000000000000000000000000..0e6ceda6fd4916a255a2a8fe184daa15efde0d04 --- /dev/null +++ b/corpus/rule-lines/native/machine.json @@ -0,0 +1 @@ +{"name": "m6-4e581665", "hostname": "ONE-M6-4E581665"} diff --git a/corpus/rule-lines/native/notebook/Open Notebook.onetoc2 b/corpus/rule-lines/native/notebook/Open Notebook.onetoc2 new file mode 100644 index 0000000000000000000000000000000000000000..2d2785048e719aaea688d47f540628bf86baaf99 Binary files /dev/null and b/corpus/rule-lines/native/notebook/Open Notebook.onetoc2 differ diff --git a/corpus/rule-lines/native/notebook/Rules.one b/corpus/rule-lines/native/notebook/Rules.one new file mode 100644 index 0000000000000000000000000000000000000000..34b827282dfe81dbbee916a11083169bc6c32eec Binary files /dev/null and b/corpus/rule-lines/native/notebook/Rules.one differ diff --git a/corpus/rule-lines/native/read/environment.json b/corpus/rule-lines/native/read/environment.json new file mode 100644 index 0000000000000000000000000000000000000000..fa14e9d48787948cdd11bcd302d49048bbde3852 --- /dev/null +++ b/corpus/rule-lines/native/read/environment.json @@ -0,0 +1,7 @@ +{ + "powershell": "5.1.14409.1005", + "schema": "xs2010", + "hostname": "ONE-M6-4E581665", + "cold": true, + "onenote": "14.0.4763.1000" +} diff --git a/corpus/rule-lines/native/read/hierarchy.xml b/corpus/rule-lines/native/read/hierarchy.xml new file mode 100644 index 0000000000000000000000000000000000000000..13e0022d58f6d5bd520e0d1f6a59d07a11bb6cbc --- /dev/null +++ b/corpus/rule-lines/native/read/hierarchy.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/rule-lines/native/read/page-000.navigation.json b/corpus/rule-lines/native/read/page-000.navigation.json new file mode 100644 index 0000000000000000000000000000000000000000..74f74505c909f96ff84679b418cd721b7be7459f --- /dev/null +++ b/corpus/rule-lines/native/read/page-000.navigation.json @@ -0,0 +1,13 @@ +{ + "exit": 0, + "stdout": "", + "stderr": "", + "w": 800, + "h": 600, + "error": null, + "win": { + "title": "Standard - Microsoft OneNote", + "class": "Framework::CFrame", + "dialog": false + } +} \ No newline at end of file diff --git a/corpus/rule-lines/native/read/page-000.png b/corpus/rule-lines/native/read/page-000.png new file mode 100644 index 0000000000000000000000000000000000000000..a3a1d429e9398f0bb0543f820fbf314a02a32104 Binary files /dev/null and b/corpus/rule-lines/native/read/page-000.png differ diff --git a/corpus/rule-lines/native/read/page-000.xml b/corpus/rule-lines/native/read/page-000.xml new file mode 100644 index 0000000000000000000000000000000000000000..a0df7113ab49f1a57435b0fc8b08e8b63de2dcfb --- /dev/null +++ b/corpus/rule-lines/native/read/page-000.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/rule-lines/native/read/page-001.navigation.json b/corpus/rule-lines/native/read/page-001.navigation.json new file mode 100644 index 0000000000000000000000000000000000000000..6ea1c2e7cfb4aec222874c087cbbaf51b80cb3c7 --- /dev/null +++ b/corpus/rule-lines/native/read/page-001.navigation.json @@ -0,0 +1,13 @@ +{ + "exit": 0, + "stdout": "", + "stderr": "", + "w": 800, + "h": 600, + "error": null, + "win": { + "title": "LargeGrid - Microsoft OneNote", + "class": "Framework::CFrame", + "dialog": false + } +} \ No newline at end of file diff --git a/corpus/rule-lines/native/read/page-001.png b/corpus/rule-lines/native/read/page-001.png new file mode 100644 index 0000000000000000000000000000000000000000..883f096ccb28b9bdb9d4739ce19b95e9839faf84 Binary files /dev/null and b/corpus/rule-lines/native/read/page-001.png differ diff --git a/corpus/rule-lines/native/read/page-001.xml b/corpus/rule-lines/native/read/page-001.xml new file mode 100644 index 0000000000000000000000000000000000000000..8244fb2b57cdd120640d46f3e2d36b092cd27819 --- /dev/null +++ b/corpus/rule-lines/native/read/page-001.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/rule-lines/native/read/page-002.navigation.json b/corpus/rule-lines/native/read/page-002.navigation.json new file mode 100644 index 0000000000000000000000000000000000000000..d2a29b6a0602e35842b0605919ee79aa85216a62 --- /dev/null +++ b/corpus/rule-lines/native/read/page-002.navigation.json @@ -0,0 +1,13 @@ +{ + "exit": 0, + "stdout": "", + "stderr": "", + "w": 800, + "h": 600, + "error": null, + "win": { + "title": "ColorRed - Microsoft OneNote", + "class": "Framework::CFrame", + "dialog": false + } +} \ No newline at end of file diff --git a/corpus/rule-lines/native/read/page-002.png b/corpus/rule-lines/native/read/page-002.png new file mode 100644 index 0000000000000000000000000000000000000000..4fd3402c8f4412c13661cc76347ce0905e9138f3 Binary files /dev/null and b/corpus/rule-lines/native/read/page-002.png differ diff --git a/corpus/rule-lines/native/read/page-002.xml b/corpus/rule-lines/native/read/page-002.xml new file mode 100644 index 0000000000000000000000000000000000000000..f6560ac65325e720b8bfa8db9c880a088520dc85 --- /dev/null +++ b/corpus/rule-lines/native/read/page-002.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/rule-lines/native/read/page-003.navigation.json b/corpus/rule-lines/native/read/page-003.navigation.json new file mode 100644 index 0000000000000000000000000000000000000000..3cda85f0889301dcc34bd7f3046be58554002323 --- /dev/null +++ b/corpus/rule-lines/native/read/page-003.navigation.json @@ -0,0 +1,13 @@ +{ + "exit": 0, + "stdout": "", + "stderr": "", + "w": 800, + "h": 600, + "error": null, + "win": { + "title": "SmallGrid - Microsoft OneNote", + "class": "Framework::CFrame", + "dialog": false + } +} \ No newline at end of file diff --git a/corpus/rule-lines/native/read/page-003.png b/corpus/rule-lines/native/read/page-003.png new file mode 100644 index 0000000000000000000000000000000000000000..4053718977f0cffa04829949d7b296d91094e80c Binary files /dev/null and b/corpus/rule-lines/native/read/page-003.png differ diff --git a/corpus/rule-lines/native/read/page-003.xml b/corpus/rule-lines/native/read/page-003.xml new file mode 100644 index 0000000000000000000000000000000000000000..65ad5a4d6d7e7b19d14273f7b1cce442e8af56a4 --- /dev/null +++ b/corpus/rule-lines/native/read/page-003.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/rule-lines/native/read/page-004.navigation.json b/corpus/rule-lines/native/read/page-004.navigation.json new file mode 100644 index 0000000000000000000000000000000000000000..5c585338bca1397dd69f5878808de906cd5ff0ae --- /dev/null +++ b/corpus/rule-lines/native/read/page-004.navigation.json @@ -0,0 +1,13 @@ +{ + "exit": 0, + "stdout": "", + "stderr": "", + "w": 800, + "h": 600, + "error": null, + "win": { + "title": "Hidden - Microsoft OneNote", + "class": "Framework::CFrame", + "dialog": false + } +} \ No newline at end of file diff --git a/corpus/rule-lines/native/read/page-004.png b/corpus/rule-lines/native/read/page-004.png new file mode 100644 index 0000000000000000000000000000000000000000..c84a8ecdc67d7095fa031637f0b94d4f8157fd41 Binary files /dev/null and b/corpus/rule-lines/native/read/page-004.png differ diff --git a/corpus/rule-lines/native/read/page-004.xml b/corpus/rule-lines/native/read/page-004.xml new file mode 100644 index 0000000000000000000000000000000000000000..406270390b698e7a377e388277c99ec489afcde0 --- /dev/null +++ b/corpus/rule-lines/native/read/page-004.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/rule-lines/native/read/page-005.navigation.json b/corpus/rule-lines/native/read/page-005.navigation.json new file mode 100644 index 0000000000000000000000000000000000000000..1d051ab18621c4450d424efbbd3056b504248b1b --- /dev/null +++ b/corpus/rule-lines/native/read/page-005.navigation.json @@ -0,0 +1,13 @@ +{ + "exit": 0, + "stdout": "", + "stderr": "", + "w": 800, + "h": 600, + "error": null, + "win": { + "title": "College - Microsoft OneNote", + "class": "Framework::CFrame", + "dialog": false + } +} \ No newline at end of file diff --git a/corpus/rule-lines/native/read/page-005.png b/corpus/rule-lines/native/read/page-005.png new file mode 100644 index 0000000000000000000000000000000000000000..15a17cd81784b75f9473751cb317482311282411 Binary files /dev/null and b/corpus/rule-lines/native/read/page-005.png differ diff --git a/corpus/rule-lines/native/read/page-005.xml b/corpus/rule-lines/native/read/page-005.xml new file mode 100644 index 0000000000000000000000000000000000000000..f6ee073a558c8cbf2b5a52afa8b92953c5409d6b --- /dev/null +++ b/corpus/rule-lines/native/read/page-005.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/rule-lines/native/read/page-006.navigation.json b/corpus/rule-lines/native/read/page-006.navigation.json new file mode 100644 index 0000000000000000000000000000000000000000..7824d62a06fb14853ccd8fca71946931cbe8e9ec --- /dev/null +++ b/corpus/rule-lines/native/read/page-006.navigation.json @@ -0,0 +1,13 @@ +{ + "exit": 0, + "stdout": "", + "stderr": "", + "w": 800, + "h": 600, + "error": null, + "win": { + "title": "None - Microsoft OneNote", + "class": "Framework::CFrame", + "dialog": false + } +} \ No newline at end of file diff --git a/corpus/rule-lines/native/read/page-006.png b/corpus/rule-lines/native/read/page-006.png new file mode 100644 index 0000000000000000000000000000000000000000..49f2ff077f49645759f31067633c716ae08fb6b3 Binary files /dev/null and b/corpus/rule-lines/native/read/page-006.png differ diff --git a/corpus/rule-lines/native/read/page-006.xml b/corpus/rule-lines/native/read/page-006.xml new file mode 100644 index 0000000000000000000000000000000000000000..b68025658b6b35362b22b0b1ab2c4fb99410f067 --- /dev/null +++ b/corpus/rule-lines/native/read/page-006.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/rule-lines/native/read/page-007.navigation.json b/corpus/rule-lines/native/read/page-007.navigation.json new file mode 100644 index 0000000000000000000000000000000000000000..909744712d947f9080464d99bb701cd7de4071c8 --- /dev/null +++ b/corpus/rule-lines/native/read/page-007.navigation.json @@ -0,0 +1,13 @@ +{ + "exit": 0, + "stdout": "", + "stderr": "", + "w": 800, + "h": 600, + "error": null, + "win": { + "title": "VeryLargeGrid - Microsoft OneNote", + "class": "Framework::CFrame", + "dialog": false + } +} \ No newline at end of file diff --git a/corpus/rule-lines/native/read/page-007.png b/corpus/rule-lines/native/read/page-007.png new file mode 100644 index 0000000000000000000000000000000000000000..680352c4729c2852b98aebc3f80d2ca780109e78 Binary files /dev/null and b/corpus/rule-lines/native/read/page-007.png differ diff --git a/corpus/rule-lines/native/read/page-007.xml b/corpus/rule-lines/native/read/page-007.xml new file mode 100644 index 0000000000000000000000000000000000000000..ef82b34ec9eb3e7fc176d78f4fae978fd42bd00e --- /dev/null +++ b/corpus/rule-lines/native/read/page-007.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/rule-lines/native/read/page-008.navigation.json b/corpus/rule-lines/native/read/page-008.navigation.json new file mode 100644 index 0000000000000000000000000000000000000000..007050b6d7a6917406f9ab04289d337fab5d8d43 --- /dev/null +++ b/corpus/rule-lines/native/read/page-008.navigation.json @@ -0,0 +1,13 @@ +{ + "exit": 0, + "stdout": "", + "stderr": "", + "w": 800, + "h": 600, + "error": null, + "win": { + "title": "Wide - Microsoft OneNote", + "class": "Framework::CFrame", + "dialog": false + } +} \ No newline at end of file diff --git a/corpus/rule-lines/native/read/page-008.png b/corpus/rule-lines/native/read/page-008.png new file mode 100644 index 0000000000000000000000000000000000000000..b5904a87db31d072e8310400fc91a8dcef1d4e72 Binary files /dev/null and b/corpus/rule-lines/native/read/page-008.png differ diff --git a/corpus/rule-lines/native/read/page-008.xml b/corpus/rule-lines/native/read/page-008.xml new file mode 100644 index 0000000000000000000000000000000000000000..a2caacb0e3949c4305deb42f97dfb66dafc4f5ed --- /dev/null +++ b/corpus/rule-lines/native/read/page-008.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/rule-lines/native/read/page-009.navigation.json b/corpus/rule-lines/native/read/page-009.navigation.json new file mode 100644 index 0000000000000000000000000000000000000000..7ccafd7fd59c95368f51d575f411c3fb815d3863 --- /dev/null +++ b/corpus/rule-lines/native/read/page-009.navigation.json @@ -0,0 +1,13 @@ +{ + "exit": 0, + "stdout": "", + "stderr": "", + "w": 800, + "h": 600, + "error": null, + "win": { + "title": "MediumGrid - Microsoft OneNote", + "class": "Framework::CFrame", + "dialog": false + } +} \ No newline at end of file diff --git a/corpus/rule-lines/native/read/page-009.png b/corpus/rule-lines/native/read/page-009.png new file mode 100644 index 0000000000000000000000000000000000000000..ec1bda13876e081cd3b5a3c8eb5b755a75ba35a7 Binary files /dev/null and b/corpus/rule-lines/native/read/page-009.png differ diff --git a/corpus/rule-lines/native/read/page-009.xml b/corpus/rule-lines/native/read/page-009.xml new file mode 100644 index 0000000000000000000000000000000000000000..5901aa0663735b816f64d1659fd95e5b751eb58b --- /dev/null +++ b/corpus/rule-lines/native/read/page-009.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/rule-lines/native/read/page-010.navigation.json b/corpus/rule-lines/native/read/page-010.navigation.json new file mode 100644 index 0000000000000000000000000000000000000000..7f25097d7917248dd57c3d10c5466fadaaa84a95 --- /dev/null +++ b/corpus/rule-lines/native/read/page-010.navigation.json @@ -0,0 +1,13 @@ +{ + "exit": 0, + "stdout": "", + "stderr": "", + "w": 800, + "h": 600, + "error": null, + "win": { + "title": "Narrow - Microsoft OneNote", + "class": "Framework::CFrame", + "dialog": false + } +} \ No newline at end of file diff --git a/corpus/rule-lines/native/read/page-010.png b/corpus/rule-lines/native/read/page-010.png new file mode 100644 index 0000000000000000000000000000000000000000..d3f54fd498c8db04015a5c997dc146f1d32f4094 Binary files /dev/null and b/corpus/rule-lines/native/read/page-010.png differ diff --git a/corpus/rule-lines/native/read/page-010.xml b/corpus/rule-lines/native/read/page-010.xml new file mode 100644 index 0000000000000000000000000000000000000000..06126160d1b0c06656c34e906fa57d3731d51d6e --- /dev/null +++ b/corpus/rule-lines/native/read/page-010.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/rule-lines/native/read/payloads.json b/corpus/rule-lines/native/read/payloads.json new file mode 100644 index 0000000000000000000000000000000000000000..1ce91a413d6fff4d58765187b76756f0e97ae843 --- /dev/null +++ b/corpus/rule-lines/native/read/payloads.json @@ -0,0 +1,3 @@ +[ + +] \ No newline at end of file diff --git a/corpus/rule-lines/native/run.json b/corpus/rule-lines/native/run.json new file mode 100644 index 0000000000000000000000000000000000000000..c65f7ec6157d71a321b709bf2895cb796d08d02d --- /dev/null +++ b/corpus/rule-lines/native/run.json @@ -0,0 +1,18 @@ +{ + "notebook": "/private/tmp/snowbound-rule-lines/lab/native-notebook", + "expected_pages": 11, + "author": null, + "author_timeout_seconds": 600, + "inspect": false, + "collect_notebook": true, + "base": { + "file": "win7-office-base.qcow2", + "format": "qcow2", + "sha256": "a1a4f8fab782ee14885ff801ca2f6347c208fdcfc3513637096f314315c89346", + "virtual_size": 68719476736 + }, + "scripts": { + "cold.ps1": "c177fc72ae6c2634d186f5671a880de20b09f9716532c37c69cb13aa15c7e331", + "read.ps1": "04013bfcccee40a17e2a225f9b9e96f40a3a8daad9e350609654f8eda3ccbb41" + } +} diff --git a/corpus/rule-lines/native/scripts/cold.ps1 b/corpus/rule-lines/native/scripts/cold.ps1 new file mode 100644 index 0000000000000000000000000000000000000000..a0b99e7aeb86f4dc10032537800501a52ad65b4f --- /dev/null +++ b/corpus/rule-lines/native/scripts/cold.ps1 @@ -0,0 +1,27 @@ +param([Parameter(Mandatory=$true)][string]$Root, [string]$CloneHost = '') +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' +$root = [IO.Path]::GetFullPath($Root).TrimEnd('\') +if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') { + throw 'Choose a run directly below C:\one-tests\runs.' +} +if (Get-Process ONENOTE -ErrorAction SilentlyContinue) { throw 'Close OneNote before resetting its test cache.' } +$key = 'HKCU:\Software\Microsoft\Office\14.0\OneNote' +if ($CloneHost) { + if ($CloneHost -notmatch '^ONE-[A-Z0-9-]+$' -or [Environment]::MachineName -ne $CloneHost) { + throw 'The disposable clone hostname does not match this machine.' + } + New-Item "$key\Options\Paths" -Force | Out-Null + New-ItemProperty "$key\Options\Paths" -Name UnfiledNotesSection -PropertyType ExpandString -Value 'C:\one-tests\Loose.one' -Force | Out-Null +} elseif ((Get-ItemProperty "$key\Options\Paths").UnfiledNotesSection -ne 'C:\one-tests\Loose.one' -or + -not (Test-Path 'C:\one-tests\profile-original-cache')) { + throw 'Park the personal OneNote profile before resetting the test cache.' +} +$cache = Join-Path $env:LOCALAPPDATA 'Microsoft\OneNote\14.0' +$parked = Join-Path 'C:\one-tests\caches' ([IO.Path]::GetFileName($root)) +if (Test-Path $parked) { throw 'Choose a new run; its parked cache already exists.' } +New-Item -ItemType Directory -Path 'C:\one-tests\caches' -Force | Out-Null +if (Test-Path $cache) { Move-Item -LiteralPath $cache -Destination $parked } +if (Test-Path "$key\OpenNotebooks") { Remove-Item "$key\OpenNotebooks" -Recurse } +New-Item "$key\OpenNotebooks" | Out-Null +New-ItemProperty "$key\OpenNotebooks" -Name '1' -PropertyType String -Value "$root\notebook" | Out-Null diff --git a/corpus/rule-lines/native/scripts/read.ps1 b/corpus/rule-lines/native/scripts/read.ps1 new file mode 100644 index 0000000000000000000000000000000000000000..476094ab1e0c47077988a6461db2080cf208642b --- /dev/null +++ b/corpus/rule-lines/native/scripts/read.ps1 @@ -0,0 +1,142 @@ +param( + [Parameter(Mandatory=$true)][string]$Root, + [int]$ExpectedPages = -1, + [switch]$UseCurrentCache, + [switch]$Pdf, + [switch]$KeepOpen, + [string]$CloneHost = '' +) +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' +$root = [IO.Path]::GetFullPath($Root).TrimEnd('\') +if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') { + throw 'Choose a run directly below C:\one-tests\runs.' +} +$notebook = Join-Path $root 'notebook' +$output = Join-Path $root 'read' +if (Test-Path $output) { throw 'Choose a new read destination.' } +if ($UseCurrentCache) { + if ((Get-ItemProperty 'HKCU:\Software\Microsoft\Office\14.0\OneNote\Options\Paths').UnfiledNotesSection -ne 'C:\one-tests\Loose.one') { + throw 'Park the personal OneNote profile before reading test notebooks.' + } +} else { + & "$PSScriptRoot\cold-current.ps1" -Root $root -CloneHost $CloneHost +} +New-Item -ItemType Directory -Path $output | Out-Null +$app = New-Object -ComObject OneNote.Application +$notebookId = '' +$failure = $null +try { + $app.OpenHierarchy($notebook, '', [ref]$notebookId, 0) + $process = Get-Process ONENOTE + @{ hostname = [Environment]::MachineName; onenote = $process.MainModule.FileVersionInfo.FileVersion; + powershell = $PSVersionTable.PSVersion.ToString(); schema = 'xs2010'; cold = (-not $UseCurrentCache.IsPresent) } | + ConvertTo-Json | Set-Content (Join-Path $output 'environment.json') -Encoding UTF8 + $sections = @() + foreach ($file in @(Get-ChildItem $notebook -Recurse | Where-Object { $_.Extension -eq '.one' })) { + $id = '' + $app.OpenHierarchy($file.FullName, '', [ref]$id, 0) + $sections += $id + } + $deadline = [DateTime]::UtcNow.AddSeconds(300) + $previous = '' + $lastChange = '' + $stableSince = [DateTime]::UtcNow + $settled = $false + do { + $pages = @{} + foreach ($section in $sections) { + $hierarchy = '' + $app.GetHierarchy($section, 4, [ref]$hierarchy, 1) + [xml]$xml = $hierarchy + foreach ($node in $xml.SelectNodes('//*[@path]')) { + if (-not $node.GetAttribute('path').StartsWith("$notebook\", [StringComparison]::OrdinalIgnoreCase)) { + throw 'OneNote opened a section outside the copied notebook.' + } + } + foreach ($node in $xml.SelectNodes('//*[local-name()="Page"]')) { + $id = $node.GetAttribute('ID') + $content = '' + $app.GetPageContent($id, [ref]$content, 1, 1) + $pages[$id] = $content + } + } + $signature = [String]::Join('|', @($pages.Keys | Sort-Object | ForEach-Object { $_ + $pages[$_] })) + if ($signature -ne $previous) { + $lastChange = $previous + $previous = $signature + $stableSince = [DateTime]::UtcNow + } + if ((($ExpectedPages -ge 0 -and $pages.Count -eq $ExpectedPages) -or + ($ExpectedPages -lt 0 -and $pages.Count -gt 0)) -and + ([DateTime]::UtcNow - $stableSince).TotalSeconds -ge 2) { $settled = $true; break } + Start-Sleep -Milliseconds 250 + } while ([DateTime]::UtcNow -lt $deadline) + if (-not $settled -or ($ExpectedPages -ge 0 -and $pages.Count -ne $ExpectedPages) -or ($ExpectedPages -lt 0 -and $pages.Count -eq 0)) { + [IO.File]::WriteAllText((Join-Path $output 'previous-signature.txt'), $lastChange, [Text.Encoding]::UTF8) + $index = 0 + foreach ($id in @($pages.Keys | Sort-Object)) { + [IO.File]::WriteAllText((Join-Path $output ('unsettled-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8) + $index++ + } + $hierarchy = '' + $app.GetHierarchy($notebookId, 4, [ref]$hierarchy, 1) + [IO.File]::WriteAllText((Join-Path $output 'unsettled-hierarchy.xml'), $hierarchy, [Text.Encoding]::UTF8) + throw "Expected $ExpectedPages stable pages; OneNote returned $($pages.Count), settled=$settled." + } + $index = 0 + $payloads = @() + foreach ($id in @($pages.Keys | Sort-Object)) { + [IO.File]::WriteAllText((Join-Path $output ('page-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8) + if ($Pdf) { + $pdfPath = Join-Path $output ('page-{0:d3}.pdf' -f $index) + $app.NavigateTo($id, '', $false) + $app.Publish($id, $pdfPath, 3, '') + if (-not (Test-Path $pdfPath) -or (Get-Item $pdfPath).Length -eq 0) { throw 'OneNote did not publish the page PDF.' } + } + [xml]$page = $pages[$id] + foreach ($file in $page.SelectNodes('//*[local-name()="InsertedFile" or local-name()="MediaFile"]')) { + $bytes = [IO.File]::ReadAllBytes($file.GetAttribute('pathCache')) + $hash = [BitConverter]::ToString([Security.Cryptography.SHA256]::Create().ComputeHash($bytes)).Replace('-', '').ToLowerInvariant() + [IO.File]::WriteAllBytes((Join-Path $output ($hash + '.attachment')), $bytes) + $payloads += @{ page = $id; object = $file.ParentNode.GetAttribute('objectID'); + kind = $file.LocalName; name = $file.GetAttribute('preferredName'); + sha256 = $hash; bytes = $bytes.Length } + } + $index++ + } + [IO.File]::WriteAllText((Join-Path $output 'payloads.json'), (ConvertTo-Json -InputObject $payloads -Depth 4), [Text.Encoding]::UTF8) + $all = '' + $app.GetHierarchy($notebookId, 4, [ref]$all, 1) + [xml]$finalTree = $all + $finalIds = @($finalTree.SelectNodes('//*[local-name()="Page"]') | ForEach-Object { $_.GetAttribute('ID') } | Sort-Object -Unique) + if ($finalIds.Count -ne $pages.Count -or @($finalIds | Where-Object { -not $pages.ContainsKey($_) }).Count -ne 0) { + throw 'The notebook hierarchy changed while collecting page evidence; repeat the cold read.' + } + [IO.File]::WriteAllText((Join-Path $output 'hierarchy.xml'), $all, [Text.Encoding]::UTF8) + Write-Output "Read $($sections.Count) sections and $($pages.Count) pages." +} catch { + $failure = $_ + [IO.File]::WriteAllText((Join-Path $output 'failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8) + throw +} finally { + try { + try { + if ($notebookId -and $CloneHost) { $app.SyncHierarchy($notebookId) } + if ($notebookId -and -not $KeepOpen -and (-not $UseCurrentCache -or $CloneHost)) { + $app.CloseNotebook($notebookId, $false) + } + } catch { + if ($null -eq $failure) { throw } + [IO.File]::WriteAllText((Join-Path $output 'cleanup-failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8) + } + } finally { + [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($app) + } + $app = $null + [GC]::Collect() + [GC]::WaitForPendingFinalizers() + if (-not $UseCurrentCache -and -not $CloneHost) { + Get-Process ONENOTE -ErrorAction SilentlyContinue | Wait-Process -Timeout 10 + } +} diff --git a/corpus/rule-lines/native/source.json b/corpus/rule-lines/native/source.json new file mode 100644 index 0000000000000000000000000000000000000000..55e0e1c2a1eb579021f1209cbef55aded7ba18bb --- /dev/null +++ b/corpus/rule-lines/native/source.json @@ -0,0 +1,14 @@ +[ + { + "path": "Open Notebook.onetoc2", + "bytes": 4808, + "sha256": "26e13bc41400333df9b3b6ebc182ea592bdc8a4bd0d3c33c0773e832965add13", + "mtime_ns": 1790669502093308352 + }, + { + "path": "Rules.one", + "bytes": 88432, + "sha256": "76da013db8a7015b9cb864dd354cb0bb184c9ca3b5e76b755b1143c830ca39b1", + "mtime_ns": 1790669501695822834 + } +] diff --git a/corpus/rule-lines/native/teardown.json b/corpus/rule-lines/native/teardown.json new file mode 100644 index 0000000000000000000000000000000000000000..05a47793de40f322e745c4c4183727e3590ecf70 --- /dev/null +++ b/corpus/rule-lines/native/teardown.json @@ -0,0 +1 @@ +{"absent": true} diff --git a/crates/canvas/assets/tags/blog.svg b/crates/canvas/assets/tags/blog.svg new file mode 100644 index 0000000000000000000000000000000000000000..b982d25e89fb2b8e29aed8f69ba13e6014cc222b --- /dev/null +++ b/crates/canvas/assets/tags/blog.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/crates/canvas/assets/tags/book.svg b/crates/canvas/assets/tags/book.svg new file mode 100644 index 0000000000000000000000000000000000000000..15f5e3ed5ea86e0573e15c48db878446198f43e8 --- /dev/null +++ b/crates/canvas/assets/tags/book.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/crates/canvas/assets/tags/box-small.svg b/crates/canvas/assets/tags/box-small.svg new file mode 100644 index 0000000000000000000000000000000000000000..17ed0f99c5a134e131cf29b3392fb1940423c000 --- /dev/null +++ b/crates/canvas/assets/tags/box-small.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/crates/canvas/assets/tags/check-small.svg b/crates/canvas/assets/tags/check-small.svg new file mode 100644 index 0000000000000000000000000000000000000000..85254d3e1bd50de3007c6fbc96174fd048ee8943 --- /dev/null +++ b/crates/canvas/assets/tags/check-small.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/crates/canvas/assets/tags/email.svg b/crates/canvas/assets/tags/email.svg new file mode 100644 index 0000000000000000000000000000000000000000..2e3378b2e99d99468a2c7d7805ca5ccbd94cfdd3 --- /dev/null +++ b/crates/canvas/assets/tags/email.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/crates/canvas/assets/tags/idea.svg b/crates/canvas/assets/tags/idea.svg new file mode 100644 index 0000000000000000000000000000000000000000..f210d6a6d1949443a7448ed32697ef03393d917d --- /dev/null +++ b/crates/canvas/assets/tags/idea.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/crates/canvas/assets/tags/mark-arrow.svg b/crates/canvas/assets/tags/mark-arrow.svg new file mode 100644 index 0000000000000000000000000000000000000000..97c2c3c44577ef5dad304ca3244098182964baa4 --- /dev/null +++ b/crates/canvas/assets/tags/mark-arrow.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/crates/canvas/assets/tags/mark-client.svg b/crates/canvas/assets/tags/mark-client.svg new file mode 100644 index 0000000000000000000000000000000000000000..b562e9082ec9bf2d0e6f6dad91a46658025f3f36 --- /dev/null +++ b/crates/canvas/assets/tags/mark-client.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/crates/canvas/assets/tags/mark-manager.svg b/crates/canvas/assets/tags/mark-manager.svg new file mode 100644 index 0000000000000000000000000000000000000000..c7b1b3e74d3f5454151a4bd08edcabb2f54a4dee --- /dev/null +++ b/crates/canvas/assets/tags/mark-manager.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/crates/canvas/assets/tags/mark-one.svg b/crates/canvas/assets/tags/mark-one.svg new file mode 100644 index 0000000000000000000000000000000000000000..f95d8aa3643bd45b9b71e6c9d4756543526d9c00 --- /dev/null +++ b/crates/canvas/assets/tags/mark-one.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/crates/canvas/assets/tags/mark-person.svg b/crates/canvas/assets/tags/mark-person.svg new file mode 100644 index 0000000000000000000000000000000000000000..1f2d6f0affa1495cf724a9825b126c6d175e5a81 --- /dev/null +++ b/crates/canvas/assets/tags/mark-person.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/crates/canvas/assets/tags/mark-two.svg b/crates/canvas/assets/tags/mark-two.svg new file mode 100644 index 0000000000000000000000000000000000000000..43c86f79bd98b9127b769d353f8089e1f89d1294 --- /dev/null +++ b/crates/canvas/assets/tags/mark-two.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/crates/canvas/assets/tags/movie.svg b/crates/canvas/assets/tags/movie.svg new file mode 100644 index 0000000000000000000000000000000000000000..1dcbbb38994002babdf78dc8ca0c5dd3866d340e --- /dev/null +++ b/crates/canvas/assets/tags/movie.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/crates/canvas/assets/tags/password.svg b/crates/canvas/assets/tags/password.svg new file mode 100644 index 0000000000000000000000000000000000000000..b32cbb4abd2d8df60314e36c08cec7e6f3fedbe8 --- /dev/null +++ b/crates/canvas/assets/tags/password.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/crates/canvas/assets/tags/web.svg b/crates/canvas/assets/tags/web.svg new file mode 100644 index 0000000000000000000000000000000000000000..716ae57eeebe22fe7d39e0790c5ff80b24c23007 --- /dev/null +++ b/crates/canvas/assets/tags/web.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/crates/canvas/src/editor.rs b/crates/canvas/src/editor.rs index 3a65e3bfdc75bb4e44bf269b6ee07125e2118e42..eb296741af44f2ac8598b4089186579b11ed943d 100644 --- a/crates/canvas/src/editor.rs +++ b/crates/canvas/src/editor.rs @@ -31,6 +31,7 @@ mod format; mod link; mod ops; pub(crate) mod page; +mod space; mod table; pub use format::{ Alignment, BULLET_LIBRARY, FormatState, Formatting, ListStyle, NUMBER_LIBRARY, NoteTag, Toggle, @@ -126,6 +127,7 @@ struct PageHeader { created: Option, margin_origin: [f32; 2], color: Option, + rule_lines: Option, areas: Vec, } @@ -754,6 +756,11 @@ enum RestoreFocus { enum History { Date(Box), + /// The page's colour and rule lines before a change to either. + Paper { + color: Option, + rule_lines: Option, + }, Draft { outlines: Box<[TextOutline; 2]>, index: usize, @@ -765,10 +772,25 @@ enum History { outline: onestore::ExGuid, change: Box, }, + /// A page object's position: an outline, picture or ink drawing. Position { - outline: onestore::ExGuid, + object: onestore::ExGuid, position: [Option; 2], }, + /// Parts `outline`'s paragraphs from root node `at` into outline `part` at `position`. + Split { + outline: ExGuid, + at: usize, + part: ExGuid, + position: [f32; 2], + }, + /// Returns `part`'s paragraphs to the end of `outline` and removes it: each root of + /// `part` under the parent it had, the outline when `None`. + Join { + outline: ExGuid, + part: ExGuid, + parents: Vec<(ExGuid, Option)>, + }, Layout { outline: ExGuid, layout: onestore::document::Layout, @@ -797,8 +819,12 @@ enum History { selection: Selection, focus: RestoreFocus, }, - /// One undo step of edits to OneNote's page selection, undone last first. - Group(Vec), + /// One undo step of several edits, undone last first; `page` when they edited OneNote's + /// page selection, which undoing selects again. + Group { + entries: Vec, + page: bool, + }, } impl CanvasEditor { @@ -876,6 +902,7 @@ impl CanvasEditor { created: page.created, margin_origin: page.margin_origin, color: page.color, + rule_lines: page.rule_lines, areas, }; let mut ids = BTreeSet::new(); @@ -1019,6 +1046,7 @@ impl CanvasEditor { let gone = |focus: &RestoreFocus| matches!(focus, RestoreFocus::Outline(id) if !fresh.outlines.iter().any(|o| o.id == *id)); let reaches_one = |history: &History| match history { History::Date(_) => date_changed, + History::Paper { .. } => false, History::Image { .. } | History::Picture { .. } => objects_changed, History::Draft { outlines, .. } => changed.contains(&outlines[0].id), History::Text { outline, change } => { @@ -1028,16 +1056,18 @@ impl CanvasEditor { .iter() .any(|placement| objects_changed || changed.contains(&placement.id)) } - History::Position { outline, .. } | History::Layout { outline, .. } => { - changed.contains(outline) + History::Position { object, .. } => changed.contains(object) || objects_changed, + History::Layout { outline, .. } => changed.contains(outline), + History::Split { outline, part, .. } | History::Join { outline, part, .. } => { + changed.contains(outline) || changed.contains(part) || objects_changed } History::Remove { outline, focus } => changed.contains(outline) || gone(focus), History::Insert { outline, focus, .. } => changed.contains(&outline.id) || gone(focus), History::Restore { source, focus, .. } => changed.contains(&source.id) || gone(focus), - History::Group(_) => unreachable!("groups do not nest"), + History::Group { .. } => unreachable!("groups do not nest"), }; let reaches = |history: &History| match history { - History::Group(entries) => entries.iter().any(reaches_one), + History::Group { entries, .. } => entries.iter().any(reaches_one), history => reaches_one(history), }; fresh.undo = std::mem::take(&mut self.undo) @@ -1143,6 +1173,7 @@ impl CanvasEditor { .or(self.header.created), margin_origin: self.header.margin_origin, color: self.header.color, + rule_lines: self.header.rule_lines, objects, definitions, }) @@ -1215,6 +1246,11 @@ impl CanvasEditor { self.header.color } + /// The page's rule lines (View, Rule Lines). + pub fn rule_lines(&self) -> Option { + self.header.rule_lines + } + /// Where the page's first body line starts, in points, under its title. pub fn body_start(&self) -> Option<[f32; 2]> { let title = self @@ -1419,6 +1455,44 @@ impl CanvasEditor { Ok(true) } + /// Gives the page `color` (View, Page Color) and `rule_lines` (View, Rule Lines) as one + /// undo step; false when it had them. + pub fn set_paper( + &mut self, + color: Option, + rule_lines: Option, + ) -> bool { + if (color, rule_lines) == (self.header.color, self.header.rule_lines) { + return false; + } + self.finish_composition(); + let before = self.paper(color, rule_lines); + self.undo.push(before); + self.redo.clear(); + true + } + + /// Shows `color` and `rule_lines`, recording the ops that store them; the history entry + /// restoring what the page had. + fn paper( + &mut self, + color: Option, + rule_lines: Option, + ) -> History { + let mut ops = Vec::new(); + if color != self.header.color { + ops.push(PageOp::Color(color)); + } + if rule_lines != self.header.rule_lines { + ops.push(PageOp::RuleLines(rule_lines)); + } + self.record(Ok(ops)); + History::Paper { + color: std::mem::replace(&mut self.header.color, color), + rule_lines: std::mem::replace(&mut self.header.rule_lines, rule_lines), + } + } + /// Stored content; arrow-created paragraphs appear only in `visible_outlines`. pub fn outlines(&self) -> &[TextOutline] { &self.outlines @@ -1665,7 +1739,7 @@ impl CanvasEditor { let layout = &mut self.outlines[index].layout; let previous = [layout.x, layout.y]; self.undo.push(History::Position { - outline: id, + object: id, position: previous, }); self.redo.clear(); @@ -2145,7 +2219,10 @@ impl CanvasEditor { let result = edit(self); let entries = self.undo.split_off(depth); if !entries.is_empty() { - self.undo.push(History::Group(entries)); + self.undo.push(History::Group { + entries, + page: true, + }); } result } @@ -3212,10 +3289,9 @@ impl CanvasEditor { }; match self.apply_history(engine, history) { Ok(inverse) => { - let group = matches!(inverse, History::Group(_)); + let page = matches!(inverse, History::Group { page: true, .. }); self.redo.push(inverse); - // Undoing an edit to the page selection selects the page again. - if group { + if page { self.select_page()?; } Ok(true) @@ -3255,6 +3331,7 @@ impl CanvasEditor { .date .as_ref() .is_some_and(|current| current.source().id == date.source().id), + History::Paper { .. } => true, History::Draft { outlines, index, .. } => { @@ -3265,11 +3342,22 @@ impl CanvasEditor { .position(|item| item.id == outlines[0].id) .is_none_or(|found| found == *index) } - History::Text { outline, .. } - | History::Position { outline, .. } - | History::Layout { outline, .. } => { + History::Text { outline, .. } | History::Layout { outline, .. } => { self.outlines.iter().any(|item| item.id == *outline) } + History::Position { object, .. } => self.object_layout_mut(*object).is_some(), + History::Split { + outline, at, part, .. + } => { + self.outlines + .iter() + .find(|item| item.id == *outline) + .is_some_and(|item| (1..item.document.nodes().len()).contains(at)) + && !self.outlines.iter().any(|item| item.id == *part) + } + History::Join { outline, part, .. } => [outline, part] + .iter() + .all(|id| self.outlines.iter().any(|item| item.id == **id)), History::Image { image, .. } => self.image(*image).is_some(), History::Picture { index, @@ -3290,7 +3378,7 @@ impl CanvasEditor { History::Insert { index, .. } | History::Restore { index, .. } => { *index <= self.outlines.len() } - History::Group(_) => true, + History::Group { .. } => true, }; if !valid { return Err((history, EditError::InvalidRange.into())); @@ -3301,6 +3389,7 @@ impl CanvasEditor { self.record(Ok(self.date_ops())); History::Date(Box::new(shown)) } + History::Paper { color, rule_lines } => self.paper(color, rule_lines), History::Draft { outlines, index, @@ -3387,25 +3476,67 @@ impl CanvasEditor { change: Box::new(inverse), } } - History::Position { outline, position } => { - let index = self - .outlines - .iter() - .position(|item| item.id == outline) - .unwrap(); - let layout = &mut self.outlines[index].layout; + History::Position { object, position } => { + let layout = self.object_layout_mut(object).unwrap(); let previous = [layout.x, layout.y]; [layout.x, layout.y] = position; - self.active = Focus::Outline(index); + if let Some(index) = self.outlines.iter().position(|item| item.id == object) { + self.active = Focus::Outline(index); + } self.record(self.placement_ops(&Placement { - id: outline, + id: object, position: previous, })); History::Position { - outline, + object, position: previous, } } + History::Split { + outline, + at, + part, + position, + } => match self.split_outline(engine, outline, at, part, position) { + Ok(parents) => History::Join { + outline, + part, + parents, + }, + Err(error) => { + return Err(( + History::Split { + outline, + at, + part, + position, + }, + error, + )); + } + }, + History::Join { + outline, + part, + parents, + } => match self.join_outline(engine, outline, part, &parents) { + Ok((at, position)) => History::Split { + outline, + at, + part, + position, + }, + Err(error) => { + return Err(( + History::Join { + outline, + part, + parents, + }, + error, + )); + } + }, History::Layout { outline, layout } => { let index = self .outlines @@ -3554,18 +3685,21 @@ impl CanvasEditor { self.active = Focus::Outline(index); History::Remove { outline: id, focus } } - History::Group(mut entries) => { + History::Group { mut entries, page } => { let mut inverses = Vec::with_capacity(entries.len()); while let Some(entry) = entries.pop() { match self.apply_history(engine, entry) { Ok(inverse) => inverses.push(inverse), Err((entry, error)) => { entries.push(entry); - return Err((History::Group(entries), error)); + return Err((History::Group { entries, page }, error)); } } } - History::Group(inverses) + History::Group { + entries: inverses, + page, + } } }; self.preferred_x = None; @@ -5445,6 +5579,7 @@ mod tests { created: None, margin_origin: [36.0, 14.4], color: None, + rule_lines: None, definitions: BTreeMap::new(), objects: vec![PageObject::Image(background), PageObject::Image(picture)], }, @@ -5569,6 +5704,7 @@ mod tests { created: None, margin_origin: [36.0, 14.4], color: None, + rule_lines: None, definitions: BTreeMap::new(), objects, }, @@ -5627,6 +5763,7 @@ mod tests { created: None, margin_origin: [36.0, 14.4], color: None, + rule_lines: None, definitions: BTreeMap::new(), objects: vec![PageObject::Outline(source)], }, @@ -5767,6 +5904,7 @@ mod tests { created: None, margin_origin: [36.0, 14.4], color: None, + rule_lines: None, definitions: BTreeMap::new(), objects: vec![ PageObject::Outline(mixed), @@ -5858,6 +5996,7 @@ mod tests { created: None, margin_origin: [36.0, 14.4], color: None, + rule_lines: None, definitions: BTreeMap::new(), objects, }, diff --git a/crates/canvas/src/editor/format.rs b/crates/canvas/src/editor/format.rs index 017ffa75775bc9c860c5219426eae7d65e69ad7b..222e2181b7edf55fea5428592cf22c015e157614 100644 --- a/crates/canvas/src/editor/format.rs +++ b/crates/canvas/src/editor/format.rs @@ -1895,13 +1895,19 @@ mod tests { |editor: &CanvasEditor| editor.active_outline().shaped.paragraphs[1].tags[0].icon; assert_eq!( icon(&editor), - crate::outline::TagIcon::CheckBox { checked: false } + crate::outline::TagIcon::CheckBox { + checked: false, + mark: None + } ); editor.format(&mut engine, Formatting::Check).unwrap(); assert_eq!(text_tags(&editor, 1), [("To Do".into(), 1, true)]); assert_eq!( icon(&editor), - crate::outline::TagIcon::CheckBox { checked: true } + crate::outline::TagIcon::CheckBox { + checked: true, + mark: None + } ); editor.select([at(1, 0); 2].into()).unwrap(); editor.format(&mut engine, Formatting::Check).unwrap(); @@ -1976,7 +1982,10 @@ mod tests { assert_eq!( icons[..7], [ - TagIcon::CheckBox { checked: false }, + TagIcon::CheckBox { + checked: false, + mark: None + }, TagIcon::Star, TagIcon::Question, TagIcon::Highlight, diff --git a/crates/canvas/src/editor/ops.rs b/crates/canvas/src/editor/ops.rs index bc90efe1505d5de4288c21320a2d805d557d8429..3b12789442eaaf253b8183ca31427b8fcb319613 100644 --- a/crates/canvas/src/editor/ops.rs +++ b/crates/canvas/src/editor/ops.rs @@ -173,7 +173,7 @@ fn lower( /// An empty paragraph keeping a container from emptying while its paragraphs are removed /// and inserted again; the lowering removes it after. -fn anchor() -> Result { +pub(super) fn anchor() -> Result { crate::document::node( Paragraph::new(String::new(), Format::default()), Format::default(), @@ -327,6 +327,9 @@ impl CanvasEditor { for object in &self.objects { match object { page::Content::Image(image) if image.id == placement.id => return picture(image), + page::Content::Ink(ink) if ink.id == placement.id => { + return layout_ops(ink.id, &old, &moved(&ink.layout)); + } page::Content::Outline { source, .. } if source.id == placement.id => { return layout_ops(source.id, &old, &moved(&source.layout)); } @@ -352,29 +355,7 @@ impl CanvasEditor { match (old, new) { (None, None) => Ok(Vec::new()), (Some(_), None) => Ok(vec![PageOp::Delete { object: id }]), - (None, Some(new)) => { - let outline = new.snapshot(); - let page = |objects| Page { - title: String::new(), - identity: None, - created: None, - margin_origin: [0.0; 2], - color: None, - objects, - definitions: named(&self.definitions, &[&outline.paragraphs]), - }; - let mut ops = op::lower_page( - &page(Vec::new()), - &page(vec![PageObject::Outline(outline.clone())]), - )?; - let successor = self.successor(id); - for op in &mut ops { - if let PageOp::Add { before, .. } = op { - *before = successor; - } - } - Ok(ops) - } + (None, Some(new)) => self.added(new.snapshot()), (Some(old), Some(new)) => { let (a, b) = (old.document.nodes(), new.document.nodes()); let prefix = a.iter().zip(b).take_while(|(x, y)| x == y).count(); @@ -397,6 +378,31 @@ impl CanvasEditor { } } + /// Ops adding `outline`, new to the stored page, in its place in paint order. + pub(super) fn added(&self, outline: Outline) -> Lowered { + let page = |objects| Page { + title: String::new(), + identity: None, + created: None, + margin_origin: [0.0; 2], + color: None, + rule_lines: None, + objects, + definitions: named(&self.definitions, &[&outline.paragraphs]), + }; + let mut ops = op::lower_page( + &page(Vec::new()), + &page(vec![PageObject::Outline(outline.clone())]), + )?; + let successor = self.successor(outline.id); + for op in &mut ops { + if let PageOp::Add { before, .. } = op { + *before = successor; + } + } + Ok(ops) + } + /// The page's date as the editor shows it. pub(super) fn date_ops(&self) -> Vec { let Some(date) = &self.date else { diff --git a/crates/canvas/src/editor/page.rs b/crates/canvas/src/editor/page.rs index bc45ba4267550f186b35a88b473eced13df66a53..a90a91a003cc6ed0f9ec986a51272bdf818f231d 100644 --- a/crates/canvas/src/editor/page.rs +++ b/crates/canvas/src/editor/page.rs @@ -76,9 +76,9 @@ impl Content { match self { Self::Outline { source, .. } => Some((source.id, &mut source.layout)), Self::Image(source) => Some((source.id, &mut source.layout)), + Self::Ink(source) => Some((source.id, &mut source.layout)), Self::ReadOnly(object) => Some((object.source.id(), object.source.layout_mut())), - // Strokes carry their own page coordinates, so a layout move would not move them. - Self::Date { .. } | Self::Ink(_) | Self::Editable(_) => None, + Self::Date { .. } | Self::Editable(_) => None, } } } @@ -357,8 +357,19 @@ pub(crate) fn build( }) } -/// The painted extent of every stroke, pen included, as `[x0, y0, x1, y1]` page points. +/// The painted extent of a page's ink drawing, pen included, as `[x0, y0, x1, y1]` page +/// points. pub(crate) fn ink_bounds(ink: &Ink) -> Option<[f32; 4]> { + let [x, y] = ink_offset(ink); + stroke_bounds(ink).map(|[x0, y0, x1, y1]| [x0 + x, y0 + y, x1 + x, y1 + y]) +} + +pub(crate) fn ink_offset(ink: &Ink) -> [f32; 2] { + [ink.layout.x.unwrap_or(0.0), ink.layout.y.unwrap_or(0.0)] +} + +/// The painted extent of every stroke before the drawing's offset. +fn stroke_bounds(ink: &Ink) -> Option<[f32; 4]> { ink.strokes .iter() .flat_map(|stroke| { @@ -368,7 +379,7 @@ pub(crate) fn ink_bounds(ink: &Ink) -> Option<[f32; 4]> { .iter() .map(move |[x, y]| [x - r, y - r, x + r, y + r]) }) - .chain(ink.groups.iter().filter_map(ink_bounds)) + .chain(ink.groups.iter().filter_map(stroke_bounds)) .reduce(|a, b| { [ a[0].min(b[0]), diff --git a/crates/canvas/src/editor/space.rs b/crates/canvas/src/editor/space.rs new file mode 100644 index 0000000000000000000000000000000000000000..5e4a9c13aa74998baa34899594c8f3fba80b2d6c --- /dev/null +++ b/crates/canvas/src/editor/space.rs @@ -0,0 +1,336 @@ +//! OneNote 2010's Insert Space: what lies past a line across the page moves along one axis, +//! and a body outline the line crosses parts between its paragraphs. + +use super::*; + +/// Something Insert Space moves. +enum Piece { + /// A body outline, picture or ink drawing, placed anew. + Object(ExGuid), + /// The root nodes of a body outline from `at` on. + Tail { outline: ExGuid, at: usize }, +} + +impl CanvasEditor { + /// What starts at or past `line` along `axis` (0 for x, 1 for y) with its extent as + /// `[x0, y0, x1, y1]` page points, and the farthest end of what starts before the line. + fn spaced(&self, axis: usize, line: f32) -> (Vec<(Piece, [f32; 4])>, f32) { + let mut moved = Vec::new(); + let mut floor = f32::NEG_INFINITY; + let mut stays = |rect: [f32; 4]| { + if rect[axis] < line { + floor = floor.max(rect[axis + 2]); + } + }; + for outline in &self.outlines { + let bounds = outline.bounds(); + let rect = [bounds.x0, bounds.y0, bounds.x1, bounds.y1].map(|value| value as f32); + if outline.title { + stays(rect); + continue; + } + if rect[axis] >= line { + moved.push((Piece::Object(outline.id), rect)); + continue; + } + let top = outline.origin()[1]; + let spans: Vec> = outline.shaped.spans().collect(); + let at = (axis == 1) + .then(|| { + spans + .iter() + .position(|span| span.is_some_and(|[start, _]| top + start >= line)) + }) + .flatten(); + let Some(at) = at else { + stays(rect); + continue; + }; + let [start, _] = spans[at].unwrap(); + moved.push(( + Piece::Tail { + outline: outline.id, + at, + }, + [rect[0], top + start, rect[2], rect[3]], + )); + let end = spans[..at] + .iter() + .flatten() + .map(|[_, end]| *end) + .fold(0.0, f32::max); + stays([rect[0], rect[1], rect[2], top + end]); + } + for content in &self.objects { + let (id, rect) = match content { + page::Content::Outline { + source, + layout, + below_title: None, + } => { + let [x, y] = [source.layout.x, source.layout.y].map(|v| v.unwrap_or(0.0)); + (source.id, [x, y, x + layout.size[0], y + layout.size[1]]) + } + page::Content::Image(image) => { + let (origin, size) = self.image_placement(image.id).unwrap_or_default(); + let rect = [ + origin[0], + origin[1], + origin[0] + size[0], + origin[1] + size[1], + ]; + (image.id, rect) + } + page::Content::Ink(ink) => match page::ink_bounds(ink) { + Some(rect) => (ink.id, rect), + None => continue, + }, + page::Content::ReadOnly(object) + if matches!(object.source, PageObject::Outline(_) | PageObject::Image(_)) => + { + (object.source.id(), object.rect()) + } + page::Content::ReadOnly(object) => { + stays(object.rect()); + continue; + } + page::Content::Outline { .. } | page::Content::Date { .. } => continue, + page::Content::Editable(_) => continue, + }; + if rect[axis] >= line { + moved.push((Piece::Object(id), rect)); + } else { + stays(rect); + } + } + (moved, floor) + } + + /// How far back from `line` Insert Space can take the page along `axis`: never over what + /// stays before the line, so at most zero. + pub fn space_limit(&self, axis: usize, line: f32) -> f32 { + (self.spaced(axis, line).1 - line).min(0.0) + } + + /// The extents, as `[x0, y0, x1, y1]` page points, of what Insert Space at `line` along + /// `axis` moves. + pub fn space_preview(&self, axis: usize, line: f32) -> Vec<[f32; 4]> { + self.spaced(axis, line) + .0 + .into_iter() + .map(|(_, rect)| rect) + .collect() + } + + /// Moves what starts at or past `line` along `axis` by `delta`, taking no more space back + /// than `space_limit` allows, as one undo step. False when nothing moves. + pub fn insert_space( + &mut self, + engine: &mut TextEngine, + axis: usize, + line: f32, + delta: f32, + ) -> Result { + if !line.is_finite() || !delta.is_finite() || axis > 1 { + return Err(EditError::InvalidRange.into()); + } + let (moved, floor) = self.spaced(axis, line); + let delta = delta.max((floor - line).min(0.0)); + if delta == 0.0 || moved.is_empty() { + return Ok(false); + } + self.finish_composition(); + self.whole = None; + if let Focus::Draft { index, .. } = self.active { + self.active = Focus::Outline(index); + } + let shift = |mut position: [f32; 2]| { + position[axis] += delta; + position + }; + let depth = self.undo.len(); + for (piece, rect) in moved { + let result = match piece { + Piece::Object(id) => { + let layout = self.object_layout_mut(id).unwrap(); + // Undo restores an unset coordinate as the zero it stands for, as ops + // cannot unset one. + let origin = [layout.x, layout.y].map(|v| v.unwrap_or(0.0)); + let previous = origin.map(Some); + [layout.x, layout.y] = shift(origin).map(Some); + self.undo.push(History::Position { + object: id, + position: previous, + }); + self.record(self.placement_ops(&Placement { + id, + position: previous, + })); + Ok(()) + } + Piece::Tail { outline, at } => { + let part = onestore::page::text::new_id()?; + self.split_outline(engine, outline, at, part, shift([rect[0], rect[1]])) + .map(|parents| { + self.undo.push(History::Join { + outline, + part, + parents, + }) + }) + } + }; + if let Err(error) = result { + self.group(depth); + return Err(error); + } + } + self.group(depth); + self.redo.clear(); + self.preferred_x = None; + Ok(true) + } + + /// Makes the history entries past `depth` one undo step that leaves the selection alone. + fn group(&mut self, depth: usize) { + let entries = self.undo.split_off(depth); + if !entries.is_empty() { + self.undo.push(History::Group { + entries, + page: false, + }); + } + } + + /// Parts outline `id`'s root nodes from `at` into new outline `part` at `position`, which + /// takes the rest of the outline's layout; returns each root `part` then holds with the + /// parent it had. + pub(super) fn split_outline( + &mut self, + engine: &mut TextEngine, + id: ExGuid, + at: usize, + part: ExGuid, + position: [f32; 2], + ) -> Result)>, EditorError> { + let index = self + .outlines + .iter() + .position(|outline| outline.id == id) + .ok_or(EditError::InvalidRange)?; + let mut kept = self.outlines[index].snapshot(); + let mut tail = kept.paragraphs.split_off(at); + let head: BTreeSet = kept.paragraphs.iter().map(|node| node.id).collect(); + let mut parents = Vec::new(); + for node in &mut tail { + if node.parent.is_none_or(|parent| head.contains(&parent)) { + parents.push((node.id, node.parent.take())); + } + } + let [x, y] = position.map(Some); + let moved = Outline { + id: part, + layout: onestore::document::Layout { + x, + y, + max_height: None, + ..kept.layout.clone() + }, + paragraphs: tail, + ..kept.clone() + }; + let head = TextOutline::from_outline(engine, &kept, &self.definitions)?; + let tail = TextOutline::from_outline(engine, &moved, &self.definitions)?; + self.outlines[index] = head; + self.outlines.insert(index + 1, tail); + match &mut self.active { + Focus::Outline(active) | Focus::Caret { index: active, .. } if *active > index => { + *active += 1 + } + _ => {} + } + if let Some(slot) = self + .objects + .iter() + .position(|object| matches!(object, page::Content::Editable(slot) if *slot == id)) + { + self.objects.insert(slot + 1, page::Content::Editable(part)); + } + let anchor = ops::anchor()?; + let lowered = self + .added(Outline { + paragraphs: vec![anchor.clone()], + ..moved + }) + .map(|mut added| { + added.extend(parents.iter().map(|(root, _)| PageOp::Move { + object: *root, + parent: Some(part), + before: Some(anchor.id), + })); + added.push(PageOp::Delete { object: anchor.id }); + added + }); + self.record(lowered); + Ok(parents) + } + + /// Returns outline `part`'s root nodes to the end of outline `id`, each under its parent + /// in `parents`, and removes `part`; returns where `split_outline` would part them again. + pub(super) fn join_outline( + &mut self, + engine: &mut TextEngine, + id: ExGuid, + part: ExGuid, + parents: &[(ExGuid, Option)], + ) -> Result<(usize, [f32; 2]), EditorError> { + let find = |id| self.outlines.iter().position(|outline| outline.id == id); + let (Some(index), Some(from)) = (find(id), find(part)) else { + return Err(EditError::InvalidRange.into()); + }; + let mut joined = self.outlines[index].snapshot(); + let at = joined.paragraphs.len(); + let position = self.outlines[from].origin(); + let parents: BTreeMap> = parents.iter().copied().collect(); + joined + .paragraphs + .extend(self.outlines[from].document.nodes().iter().map(|node| { + let mut node = node.clone(); + if let Some(parent) = parents.get(&node.id) { + node.parent = *parent; + } + node + })); + let roots: Vec = self.outlines[from] + .document + .nodes() + .iter() + .filter(|node| node.parent.is_none()) + .map(|node| node.id) + .collect(); + self.outlines[index] = TextOutline::from_outline(engine, &joined, &self.definitions)?; + self.outlines.remove(from); + match &mut self.active { + Focus::Outline(active) if *active == from => { + *active = index - usize::from(index > from) + } + Focus::Outline(active) | Focus::Caret { index: active, .. } if *active > from => { + *active -= 1 + } + _ => {} + } + self.objects + .retain(|object| !matches!(object, page::Content::Editable(slot) if *slot == part)); + // Moving its last paragraph out removes the emptied outline. + let ops = roots + .into_iter() + .map(|root| PageOp::Move { + object: root, + parent: Some(parents.get(&root).copied().flatten().unwrap_or(id)), + before: None, + }) + .collect(); + self.record(Ok(ops)); + Ok((at, position)) + } +} diff --git a/crates/canvas/src/gpu/mod.rs b/crates/canvas/src/gpu/mod.rs index 69b412d1d79fac5c01be84f9b1255ec03740b5cb..34357767a6e35a2022fc831c57e1a7cc93af6743 100644 --- a/crates/canvas/src/gpu/mod.rs +++ b/crates/canvas/src/gpu/mod.rs @@ -4,7 +4,7 @@ mod profile; use crate::{ layout::{TextBrush, TextLayout}, - outline::TagIcon, + outline::{BoxMark, TagIcon}, }; use draw::{GlyphRun, Glyphs, Layer, Primitive, RenderError}; use parley::PositionedLayoutItem; @@ -33,6 +33,7 @@ impl Viewport { origin: self.origin, clip: None, backdrop: None, + round: None, motion: None, primitives, } @@ -70,7 +71,7 @@ impl Paper { /// A fill OneNote draws on white paper, moved onto this paper: as far from it in /// OKLab lightness as from white, in the same hue and chroma, so text keeps its /// contrast on it. - pub(crate) fn tint(&self, light: [f32; 4]) -> [f32; 4] { + pub fn tint(&self, light: [f32; 4]) -> [f32; 4] { let [lightness, a, b] = draw::oklab(light); let [paper, ..] = draw::oklab(self.color); let distance = 1.0 - lightness; @@ -153,13 +154,82 @@ const RED_SQUARE: &str = include_str!("../../assets/tags/red-square.svg"); const YELLOW_SQUARE: &str = include_str!("../../assets/tags/yellow-square.svg"); const BLUE_SQUARE: &str = include_str!("../../assets/tags/blue-square.svg"); const FLAG: &str = include_str!("../../assets/tags/flag.svg"); +const IDEA: &str = include_str!("../../assets/tags/idea.svg"); +const PASSWORD: &str = include_str!("../../assets/tags/password.svg"); +const MOVIE: &str = include_str!("../../assets/tags/movie.svg"); +const BOOK: &str = include_str!("../../assets/tags/book.svg"); +const WEB: &str = include_str!("../../assets/tags/web.svg"); +const BLOG: &str = include_str!("../../assets/tags/blog.svg"); +const EMAIL: &str = include_str!("../../assets/tags/email.svg"); +const BOX_SMALL: &str = include_str!("../../assets/tags/box-small.svg"); +const CHECK_SMALL: &str = include_str!("../../assets/tags/check-small.svg"); +const MARK_PERSON: &str = include_str!("../../assets/tags/mark-person.svg"); +const MARK_MANAGER: &str = include_str!("../../assets/tags/mark-manager.svg"); +const MARK_ARROW: &str = include_str!("../../assets/tags/mark-arrow.svg"); +const MARK_ONE: &str = include_str!("../../assets/tags/mark-one.svg"); +const MARK_TWO: &str = include_str!("../../assets/tags/mark-two.svg"); +const MARK_CLIENT: &str = include_str!("../../assets/tags/mark-client.svg"); const TAG: &str = include_str!("../../assets/tags/tag.svg"); /// The tag's artwork, drawn in order. pub fn tag_sources(icon: TagIcon) -> &'static [&'static str] { match icon { - TagIcon::CheckBox { checked: false } => &[CHECKBOX], - TagIcon::CheckBox { checked: true } => &[CHECKBOX, CHECKMARK], + TagIcon::CheckBox { + checked: false, + mark: None, + } => &[CHECKBOX], + TagIcon::CheckBox { + checked: true, + mark: None, + } => &[CHECKBOX, CHECKMARK], + TagIcon::CheckBox { + checked: false, + mark: Some(BoxMark::Person), + } => &[BOX_SMALL, MARK_PERSON], + TagIcon::CheckBox { + checked: true, + mark: Some(BoxMark::Person), + } => &[BOX_SMALL, CHECK_SMALL, MARK_PERSON], + TagIcon::CheckBox { + checked: false, + mark: Some(BoxMark::Manager), + } => &[BOX_SMALL, MARK_MANAGER], + TagIcon::CheckBox { + checked: true, + mark: Some(BoxMark::Manager), + } => &[BOX_SMALL, CHECK_SMALL, MARK_MANAGER], + TagIcon::CheckBox { + checked: false, + mark: Some(BoxMark::Arrow), + } => &[BOX_SMALL, MARK_ARROW], + TagIcon::CheckBox { + checked: true, + mark: Some(BoxMark::Arrow), + } => &[BOX_SMALL, CHECK_SMALL, MARK_ARROW], + TagIcon::CheckBox { + checked: false, + mark: Some(BoxMark::One), + } => &[BOX_SMALL, MARK_ONE], + TagIcon::CheckBox { + checked: true, + mark: Some(BoxMark::One), + } => &[BOX_SMALL, CHECK_SMALL, MARK_ONE], + TagIcon::CheckBox { + checked: false, + mark: Some(BoxMark::Two), + } => &[BOX_SMALL, MARK_TWO], + TagIcon::CheckBox { + checked: true, + mark: Some(BoxMark::Two), + } => &[BOX_SMALL, CHECK_SMALL, MARK_TWO], + TagIcon::CheckBox { + checked: false, + mark: Some(BoxMark::Client), + } => &[BOX_SMALL, MARK_CLIENT], + TagIcon::CheckBox { + checked: true, + mark: Some(BoxMark::Client), + } => &[BOX_SMALL, CHECK_SMALL, MARK_CLIENT], TagIcon::Star => &[STAR], TagIcon::Question => &[QUESTION], TagIcon::Highlight => &[HIGHLIGHT], @@ -172,6 +242,13 @@ pub fn tag_sources(icon: TagIcon) -> &'static [&'static str] { TagIcon::YellowSquare => &[YELLOW_SQUARE], TagIcon::BlueSquare => &[BLUE_SQUARE], TagIcon::Flag => &[FLAG], + TagIcon::Idea => &[IDEA], + TagIcon::Password => &[PASSWORD], + TagIcon::Movie => &[MOVIE], + TagIcon::Book => &[BOOK], + TagIcon::Web => &[WEB], + TagIcon::Blog => &[BLOG], + TagIcon::Email => &[EMAIL], TagIcon::Other => &[TAG], } } @@ -288,8 +365,14 @@ mod tests { }); let mut renderer = draw::Renderer::new(device, queue, wgpu::TextureFormat::Rgba8UnormSrgb); let icons = [ - TagIcon::CheckBox { checked: false }, - TagIcon::CheckBox { checked: true }, + TagIcon::CheckBox { + checked: false, + mark: None, + }, + TagIcon::CheckBox { + checked: true, + mark: None, + }, TagIcon::Star, TagIcon::Question, TagIcon::Highlight, diff --git a/crates/canvas/src/gpu/page.rs b/crates/canvas/src/gpu/page.rs index 13ff11522c8255cb0d721d31a8aa345aea7a4c89..3d1f632f8db485567f5015efcf87e0a46aa20552 100644 --- a/crates/canvas/src/gpu/page.rs +++ b/crates/canvas/src/gpu/page.rs @@ -733,7 +733,8 @@ impl PageScene { continue; } Content::Ink(ink) => { - append_ink(ink, offset, primitives); + let [x, y] = crate::editor::page::ink_offset(ink); + append_ink(ink, [offset[0] + x, offset[1] + y], primitives); continue; } }; @@ -1086,6 +1087,7 @@ mod tests { title: "Header".into(), margin_origin: [36.0, 14.4], color: None, + rule_lines: None, definitions: BTreeMap::new(), objects: vec![PageObject::Title(Title { date: Some(fields[1].id), @@ -1528,6 +1530,7 @@ mod tests { title: "Header".into(), margin_origin: [36.0, 14.4], color: None, + rule_lines: None, definitions: BTreeMap::new(), objects: vec![PageObject::Title(Title { date: None, @@ -1603,6 +1606,7 @@ mod tests { title: String::new(), margin_origin: [0.0; 2], color: None, + rule_lines: None, definitions: BTreeMap::new(), objects, }; @@ -1686,6 +1690,7 @@ mod tests { title: String::new(), margin_origin: [36.0, 14.0], color: None, + rule_lines: None, objects: vec![unsupported, PageObject::Outline(editable)], definitions: BTreeMap::from([ ( @@ -1773,6 +1778,7 @@ mod tests { title: String::new(), margin_origin: [0.0; 2], color: None, + rule_lines: None, definitions: BTreeMap::new(), objects: vec![ PageObject::Outline(Outline { @@ -1953,6 +1959,7 @@ mod tests { title: String::new(), margin_origin: [0.0; 2], color: None, + rule_lines: None, definitions: BTreeMap::new(), objects: vec![ outline(2.0, "first"), @@ -2127,6 +2134,7 @@ mod tests { title: String::new(), margin_origin: [0.0; 2], color: None, + rule_lines: None, definitions: BTreeMap::new(), objects: vec![PageObject::Ink(ink.clone())], }; @@ -2187,6 +2195,7 @@ mod tests { title: String::new(), margin_origin: [0.0; 2], color: None, + rule_lines: None, definitions: BTreeMap::new(), objects: vec![PageObject::Image(Image { size: None, @@ -2273,6 +2282,7 @@ mod tests { title: String::new(), margin_origin: [0.0; 2], color: None, + rule_lines: None, definitions: BTreeMap::new(), objects: vec![PageObject::Image(background), PageObject::Image(picture)], }; @@ -2341,6 +2351,7 @@ mod tests { title: String::new(), margin_origin: [0.0; 2], color: None, + rule_lines: None, definitions: BTreeMap::new(), objects: pictures.iter().cloned().map(PageObject::Image).collect(), }; diff --git a/crates/canvas/src/gpu/page/background.rs b/crates/canvas/src/gpu/page/background.rs index bcc2b16de694a12e407d267c374fcaaee663bb59..31c77ad6e93123ce1bcf8bd1ed9386f053b7fc5d 100644 --- a/crates/canvas/src/gpu/page/background.rs +++ b/crates/canvas/src/gpu/page/background.rs @@ -910,6 +910,7 @@ mod tests { title: String::new(), margin_origin: [0.0; 2], color: None, + rule_lines: None, definitions: BTreeMap::new(), objects: vec![PageObject::Image(background)], }; diff --git a/crates/canvas/src/interaction/accessibility.rs b/crates/canvas/src/interaction/accessibility.rs index 33338090166679e6390f59929e3eb36fa1a3cacf..985ce87b8ad0437c98feec39ce977577cc8136f1 100644 --- a/crates/canvas/src/interaction/accessibility.rs +++ b/crates/canvas/src/interaction/accessibility.rs @@ -609,6 +609,13 @@ fn runs_of(shaped: &ParagraphLayout, breaks: bool) -> Result TagIcon::YellowSquare => "Project B", TagIcon::BlueSquare => "Project C", TagIcon::Flag => "Outlook task", + TagIcon::Idea => "Idea", + TagIcon::Password => "Password", + TagIcon::Movie => "Movie to see", + TagIcon::Book => "Book to read", + TagIcon::Web => "Web site to visit", + TagIcon::Blog => "Remember for blog", + TagIcon::Email => "Send in email", TagIcon::Other => "Tag", }; let label = if tag.label.is_empty() { @@ -617,8 +624,8 @@ fn runs_of(shaped: &ParagraphLayout, breaks: bool) -> Result &tag.label }; let state = match tag.icon { - TagIcon::CheckBox { checked: true } => ", completed", - TagIcon::CheckBox { checked: false } => ", incomplete", + TagIcon::CheckBox { checked: true, .. } => ", completed", + TagIcon::CheckBox { checked: false, .. } => ", incomplete", _ => "", }; format!( @@ -739,6 +746,7 @@ pub(super) mod tests { created: None, margin_origin: [36.0, 14.4], color: None, + rule_lines: None, definitions: Default::default(), objects: vec![ PageObject::Title(Title { @@ -832,6 +840,7 @@ pub(super) mod tests { title: String::new(), margin_origin: [0.0; 2], color: None, + rule_lines: None, definitions: Default::default(), objects: vec![PageObject::Unsupported(Unsupported { id: Default::default(), diff --git a/crates/canvas/src/interaction/mod.rs b/crates/canvas/src/interaction/mod.rs index 18d38cc29ee20fe59c5e26c831ef70adfe66be40..cd5c7b6c9629a60c9b377173e75c9280b3253083 100644 --- a/crates/canvas/src/interaction/mod.rs +++ b/crates/canvas/src/interaction/mod.rs @@ -6,6 +6,7 @@ pub mod accessibility; #[cfg(test)] mod profile; mod scroll; +mod space; pub use scroll::Scroll; #[cfg(test)] @@ -42,6 +43,10 @@ pub enum Cursor { NsResize, NwseResize, NeswResize, + /// Insert Space's line across the page. + RowResize, + /// Insert Space's line down the page. + ColResize, } /// Work only the host can do, asked for by an event. @@ -151,6 +156,8 @@ enum Drag { press: [f32; 2], pending_press: Option<[f32; 2]>, }, + /// Insert Space from a press on `line` along `axis`, 0 for x. + Space { axis: usize, line: f32 }, } #[derive(Clone, Copy)] @@ -205,6 +212,8 @@ pub struct PageView { pointer: [f32; 2], pointer_inside: bool, drag: Option, + /// Insert Space waits for its press: its line follows the pointer. + space: bool, object_focus: Option, modifiers: Modifiers, focused: bool, @@ -269,6 +278,7 @@ impl PageView { pointer: [0.0; 2], pointer_inside: false, drag: None, + space: false, object_focus: None, modifiers: Modifiers::default(), focused: true, @@ -287,6 +297,7 @@ impl PageView { self.editor = editor; self.scene = scene; self.drag = None; + self.space = false; self.object_focus = None; self.found.clear(); } @@ -349,6 +360,32 @@ impl PageView { self.object_focus } + /// Starts OneNote's Insert Space: a line follows the pointer, across the page or, within + /// half an inch of the view's left or right edge, down it; dragging from it moves what + /// lies past it, and Escape cancels. + pub fn insert_space(&mut self) -> Response { + self.space = true; + self.drag = None; + Response::redraw() + } + + /// Whether Insert Space waits for or follows a drag. + pub fn inserting_space(&self) -> bool { + self.space || matches!(self.drag, Some(Drag::Space { .. })) + } + + /// The axis Insert Space's line moves the page along, 0 for x, while it shows. + fn space_axis(&self) -> Option { + if let Some(Drag::Space { axis, .. }) = self.drag { + return Some(axis); + } + let reach = 48.0 * self.display_scale; + let x = self.pointer[0]; + self.space.then_some(usize::from( + x >= reach && x <= self.viewport.size[0] as f32 - reach, + )) + } + /// Text input goes to the editor unless an object holds focus. pub fn accepts_text(&self) -> bool { self.object_focus.is_none() @@ -622,6 +659,12 @@ impl PageView { self.moved() } + /// Scales the view by `factor` about the pointer, as a trackpad pinch does. + pub fn pinch(&mut self, factor: f32) -> Result { + self.zoom_about(factor, self.pointer); + self.moved() + } + /// Scales the view by `factor`, keeping the document point under `anchor` in place. fn zoom_about(&mut self, factor: f32, anchor: [f32; 2]) { let point = self.viewport.document_point(anchor); @@ -721,7 +764,7 @@ impl PageView { .map(|(id, origin, size)| PointerFeedback::Image(id, origin, size)) }) .or_else(|| { - if !self.pointer_inside { + if !self.pointer_inside || self.space { return None; } match page_hit_test( @@ -737,7 +780,18 @@ impl PageView { } }), }; - page_primitives( + let [left, top] = self.viewport.document_point([0.0; 2]); + let [right, bottom] = self + .viewport + .document_point(self.viewport.size.map(|side| side as f32)); + let mut primitives = rule_primitives( + self.editor.rule_lines(), + self.editor.margin_origin(), + [left, top, right, bottom], + self.pixel(), + colors.paper, + ); + primitives.extend(page_primitives( &self.editor, self.scene.as_ref(), preview, @@ -746,7 +800,12 @@ impl PageView { caret: if self.focused && !matches!( self.drag, - Some(Drag::Outline { .. } | Drag::Resize { .. } | Drag::Image { .. }) + Some( + Drag::Outline { .. } + | Drag::Resize { .. } + | Drag::Image { .. } + | Drag::Space { .. } + ) ) { self.caret } else { @@ -760,11 +819,36 @@ impl PageView { chrome: !self.touch || self.focused, found: &self.found, }, - ) + )?); + if let Some(axis) = self + .space_axis() + .filter(|_| self.pointer_inside || self.drag.is_some()) + { + let point = self.viewport.document_point(self.pointer)[axis]; + let (line, to, moved) = match self.drag { + Some(Drag::Space { line, .. }) => { + let delta = (point - line).max(self.editor.space_limit(axis, line)); + (line, line + delta, self.editor.space_preview(axis, line)) + } + _ => (point, point, Vec::new()), + }; + space::append( + axis, + [line, to], + &moved, + [left, top, right, bottom], + self.pixel(), + &mut primitives, + ); + } + Ok(primitives) } /// The pointer shape at the pointer's position. pub fn cursor(&self) -> Cursor { + if let Some(axis) = self.space_axis() { + return [Cursor::ColResize, Cursor::RowResize][axis]; + } let hit = self.hit_test(self.viewport.document_point(self.pointer)); match (&self.drag, hit) { (Some(Drag::Image { handle, .. }), _) => handle_cursor(*handle), @@ -816,6 +900,7 @@ impl PageView { self.focused = focused; if !focused { self.drag = None; + self.space = false; } self.changed() } @@ -864,6 +949,7 @@ impl PageView { } self.changed() } + Some(Drag::Space { .. }) => Ok(Response::redraw()), Some(Drag::Resize { outline, grab }) => { let point = self.viewport.document_point(self.pointer); let width = (point[0] - self.editor.active_outline().origin()[0] - *grab).max(36.0); @@ -886,6 +972,15 @@ impl PageView { /// A primary-button press at the pointer's position, at `now` for click counting. pub fn pointer_pressed(&mut self, now: Instant) -> Result { let point = self.viewport.document_point(self.pointer); + if let Some(axis) = self.space_axis().filter(|_| self.space) { + self.space = false; + self.set_object_focus(None); + self.drag = Some(Drag::Space { + axis, + line: point[axis], + }); + return Ok(Response::redraw()); + } let unit = self .clicks .press(now, self.pointer, 4.0 * self.display_scale); @@ -987,6 +1082,13 @@ impl PageView { } pub fn pointer_released(&mut self) -> Result { + if let Some(Drag::Space { axis, line }) = self.drag { + self.drag = None; + let delta = self.viewport.document_point(self.pointer)[axis] - line; + self.editor + .insert_space(&mut self.engine, axis, line, delta)?; + return self.changed(); + } // As in OneNote, an outline or picture dropped outside the view stays where it was. let inside = (0..2).all(|axis| (0.0..self.viewport.size[axis] as f32).contains(&self.pointer[axis])); @@ -1302,6 +1404,18 @@ impl PageView { self.changed() } + /// Gives the page a colour (COLORREF) and rule lines, as one undo step. + pub fn set_paper( + &mut self, + color: Option, + rule_lines: Option, + ) -> Result { + if !self.editor.set_paper(color, rule_lines) { + return Ok(Response::default()); + } + self.changed() + } + /// Gives keyboard focus to a read-only object, as an assistive technology asks. pub fn focus_read_only(&mut self, index: usize) -> Result { self.set_object_focus(Some(ObjectFocus::ReadOnly(index))); @@ -1355,9 +1469,16 @@ impl PageView { } if matches!( self.drag, - Some(Drag::Outline { .. } | Drag::Resize { .. } | Drag::Image { .. }) - ) { + Some( + Drag::Outline { .. } + | Drag::Resize { .. } + | Drag::Image { .. } + | Drag::Space { .. } + ) + ) || self.space + { self.drag = None; + self.space = false; if key == &Key::Named(NamedKey::Escape) { return self.changed(); } @@ -1712,6 +1833,54 @@ fn page_hit( .find_map(pass) } +/// The page's rule lines over `visible`, a document rectangle, as OneNote 2010 draws them: +/// 1/96 inch wide, or a device pixel where that is wider, horizontal lines from the margin +/// origin down, and a grid's vertical lines through it or a margin line 1/96 inch left of it. +fn rule_primitives( + lines: Option, + [x, y]: [f32; 2], + [left, top, right, bottom]: [f32; 4], + pixel: f32, + paper: Paper, +) -> Vec> { + use onestore::page::VerticalRule; + let Some(lines) = lines else { + return Vec::new(); + }; + let width = pixel.max(0.75); + // Stored spacings are in half inches; nothing draws closer than a device pixel apart. + let step = |spacing: f32| (spacing * 36.0).max(pixel); + let color = |color| paper.tint(crate::gpu::colorref(color)); + let horizontal = step(lines.spacing); + let first = ((top - y) / horizontal).ceil().max(0.0); + let mut primitives: Vec<_> = (first as u32..) + .map(|index| y + index as f32 * horizontal) + .take_while(|at| *at <= bottom + width) + .map(|at| Primitive::Rect { + rect: [left, at - width / 2.0, right, at + width / 2.0], + color: color(lines.color), + }) + .collect(); + let vertical = |at: f32, rule_color| Primitive::Rect { + rect: [at - width / 2.0, top, at + width / 2.0, bottom], + color: color(rule_color), + }; + match lines.vertical { + VerticalRule::Margin(rule_color) => primitives.push(vertical(x - 0.75, rule_color)), + VerticalRule::Grid { spacing, color } => { + let spacing = step(spacing); + let first = ((left - x) / spacing).floor(); + primitives.extend( + (0..) + .map(|index| x + (first + index as f32) * spacing) + .take_while(|at| *at <= right + width) + .map(|at| vertical(at, color)), + ); + } + } + primitives +} + fn page_primitives<'a>( editor: &'a CanvasEditor, scene: Option<&'a (PageScene, [f32; 2])>, diff --git a/crates/canvas/src/interaction/space.rs b/crates/canvas/src/interaction/space.rs new file mode 100644 index 0000000000000000000000000000000000000000..b44ebe47016e598f0278cb7fc97e6dd39957849e --- /dev/null +++ b/crates/canvas/src/interaction/space.rs @@ -0,0 +1,94 @@ +//! Insert Space's feedback, as OneNote 2010 draws it: lines across the view where the drag +//! began and where it is, an arrow between them in the middle of the view, and a tint where +//! what moves lands. + +use crate::gpu::colorref; +use draw::Primitive; + +/// Appends the feedback for lines at `from` and `to` along `axis` (0 for x), with `moved` the +/// extents of what moves, over `view`, the page's visible rectangle; `pixel` is page units +/// per pixel. +pub(super) fn append( + axis: usize, + [from, to]: [f32; 2], + moved: &[[f32; 4]], + view: [f32; 4], + pixel: f32, + primitives: &mut Vec>, +) { + let across = 1 - axis; + let rect = |along: [f32; 2], side: [f32; 2]| { + let [a0, a1] = [along[0].min(along[1]), along[0].max(along[1])]; + let [s0, s1] = side; + if axis == 1 { + [s0, a0, s1, a1] + } else { + [a0, s0, a1, s1] + } + }; + let point = |along: f32, side: f32| { + let mut point = [side; 2]; + point[axis] = along; + point + }; + let delta = to - from; + let mut tint = colorref(0x00f7e4cc); + tint[3] = 0.6; + for extent in moved { + let mut extent = *extent; + extent[axis] += delta; + extent[axis + 2] += delta; + primitives.push(Primitive::Rect { + rect: extent, + color: tint, + }); + } + if delta.abs() >= pixel { + let (fill, edge) = (colorref(0x00fff2e5), colorref(0x00e8a460)); + let middle = (view[across] + view[across + 2]) / 2.0; + let [shaft, head] = [36.5 * pixel, 73.0 * pixel]; + let length = (31.0 * pixel).min(delta.abs()); + let base = to - length * delta.signum(); + primitives.push(Primitive::Rect { + rect: rect([from, base], [middle - shaft, middle + shaft]), + color: fill, + }); + let rows = (length / pixel).ceil().max(1.0); + for row in 0..rows as u32 { + let row = row as f32; + let half = head * (1.0 - (row + 0.5) / rows); + let near = base + delta.signum() * row * pixel; + let far = base + delta.signum() * ((row + 1.0) * pixel).min(length); + primitives.push(Primitive::Rect { + rect: rect([near, far], [middle - half, middle + half]), + color: fill, + }); + } + let segments = [ + (point(from, middle - shaft), point(base, middle - shaft)), + (point(from, middle + shaft), point(base, middle + shaft)), + (point(base, middle - shaft), point(base, middle - head)), + (point(base, middle + shaft), point(base, middle + head)), + (point(base, middle - head), point(to, middle)), + (point(base, middle + head), point(to, middle)), + ]; + for (from, to) in segments { + primitives.push(Primitive::Segment { + from, + to, + width: pixel, + round: false, + color: edge, + }); + } + } + for at in [from, to] { + primitives.push(Primitive::Rect { + rect: rect( + [at - pixel / 2.0, at + pixel / 2.0], + [view[across], view[across + 2]], + ), + color: colorref(0x00df862d), + }); + } +} diff --git a/crates/canvas/src/interaction/tests.rs b/crates/canvas/src/interaction/tests.rs index 268d66f67f3648d663dd8c1a679539cbbd44ddc8..57f461e852be7c992b982e25a044dd97e19eab62 100644 --- a/crates/canvas/src/interaction/tests.rs +++ b/crates/canvas/src/interaction/tests.rs @@ -52,6 +52,7 @@ fn date_buttons_keep_accessibility_identity_and_match_mouse_hits_after_reflow() created: Some(1), margin_origin: [36.0, 14.4], color: None, + rule_lines: None, definitions: Default::default(), objects: vec![onestore::page::PageObject::Title(onestore::page::Title { id: onestore::ExGuid::default(), @@ -584,6 +585,7 @@ fn extension_hits_yield_to_objects_and_keep_their_source_coordinate_frame() { created: None, margin_origin: [0.0; 2], color: None, + rule_lines: None, definitions: Default::default(), objects: vec![onestore::page::PageObject::Outline(source)], }; @@ -707,6 +709,7 @@ fn a_picture_in_an_outline_takes_the_click_over_its_text() { created: None, margin_origin: [36.0, 14.4], color: None, + rule_lines: None, definitions: Default::default(), objects: vec![PageObject::Outline(source)], }, @@ -1219,6 +1222,7 @@ fn overlapping_objects_follow_paint_order_through_creation_movement_and_undo() { title: String::new(), margin_origin: [0.0; 2], color: None, + rule_lines: None, definitions: Default::default(), objects, }; @@ -1416,6 +1420,7 @@ fn read_only_focus_retires_text_overlays_and_draws_a_scaled_focus_border() { title: String::new(), margin_origin: [0.0; 2], color: None, + rule_lines: None, definitions: Default::default(), objects: vec![PageObject::Unsupported(Unsupported { id: Default::default(), @@ -1531,6 +1536,7 @@ fn picture_view() -> (PageView, onestore::ExGuid) { created: None, margin_origin: [36.0, 14.4], color: None, + rule_lines: None, definitions: Default::default(), objects: vec![PageObject::Outline(source)], }, @@ -1568,6 +1574,7 @@ fn a_page_outline_emptied_by_backspace_still_draws() { created: None, margin_origin: [36.0, 14.4], color: None, + rule_lines: None, definitions: Default::default(), objects, }; @@ -2018,6 +2025,7 @@ fn page_keys_scroll_or_carry_the_caret_as_the_platform_does() { created: None, margin_origin: [36.0, 14.4], color: None, + rule_lines: None, definitions: Default::default(), objects: vec![onestore::page::PageObject::Outline(outline.snapshot())], }, @@ -2109,6 +2117,7 @@ fn page_selection_view() -> (PageView, [onestore::ExGuid; 4]) { created: None, margin_origin: [36.0, 14.4], color: None, + rule_lines: None, definitions: Default::default(), objects: std::iter::once(onestore::page::PageObject::Title(onestore::page::Title { id: onestore::page::text::new_id().unwrap(), @@ -2425,3 +2434,194 @@ fn the_page_selection_does_not_return_with_its_text_selection() { view.editor.select(whole).unwrap(); assert_eq!(view.editor.whole(), None); } + +#[test] +fn pinch_zooms_about_the_pointer_within_range() { + let mut engine = TextEngine::default(); + let editor = CanvasEditor::new( + &mut engine, + TextDocument::new(vec![Paragraph::new("text".into(), Format::default())]).unwrap(), + 240.0, + ) + .unwrap(); + let mut view = PageView::new( + editor, + engine, + None, + [800, 600], + 1.0, + Duration::from_millis(500), + ); + // The host's own scrolling leaves the anchor unclamped, as iOS's does. + view.host_viewport = true; + let pointer = [300.0, 200.0]; + let _ = view.pointer_moved(pointer).unwrap(); + let under = view.viewport.document_point(pointer); + let zoom = view.zoom(); + assert!(view.pinch(1.5).unwrap().moved); + assert!((view.zoom() / zoom - 1.5).abs() < 1e-5); + let after = view.viewport.document_point(pointer); + assert!((after[0] - under[0]).abs() < 1e-3 && (after[1] - under[1]).abs() < 1e-3); + let _ = view.pinch(100.0).unwrap(); + assert!((view.zoom() - 4.0).abs() < 1e-5); +} + +fn texts(view: &PageView) -> Vec<(String, [f32; 2])> { + view.editor + .outlines() + .iter() + .filter(|outline| !outline.title) + .map(|outline| (outline.shown_text(), outline.origin())) + .collect() +} + +/// Drags Insert Space from `from` to `to`, view points at 1:1. +fn insert_space(view: &mut PageView, from: [f32; 2], to: [f32; 2]) { + let _ = view.insert_space(); + let _ = view.pointer_moved(from).unwrap(); + let _ = view.pointer_pressed(Instant::now()).unwrap(); + let _ = view.pointer_moved(to).unwrap(); + let _ = view.pointer_released().unwrap(); +} + +/// As OneNote 2010's Insert Space (lab, 2026-09-29): what starts at or below the line moves +/// by the drag, an outline it crosses parts between paragraphs into a new outline below, the +/// title stays, and one undo step puts everything back. +#[test] +fn insert_space_moves_what_lies_below_and_parts_a_crossed_outline() { + let (mut view, [alpha, ..]) = page_selection_view(); + let stored = texts(&view); + let title = view.editor.outlines()[0].origin(); + let alpha_outline = view + .editor + .outlines() + .iter() + .find(|o| o.id == alpha) + .unwrap(); + let second = alpha_outline.origin()[1] + alpha_outline.shaped().paragraphs[1].origin[1]; + let _ = view.insert_space(); + let _ = view.pointer_moved([400.0, second]).unwrap(); + assert!(view.inserting_space()); + assert_eq!(view.cursor(), Cursor::RowResize); + let _ = view.pointer_pressed(Instant::now()).unwrap(); + let _ = view.pointer_moved([400.0, second + 50.0]).unwrap(); + // Nothing moves until the drag ends; its lines, arrow and landing tints show. + assert_eq!(texts(&view), stored); + assert!(view.primitives(COLORS).is_ok()); + let _ = view.pointer_released().unwrap(); + assert!(!view.inserting_space()); + let moved = texts(&view); + let expected: Vec<(String, [f32; 2])> = vec![ + ("Alpha one".into(), stored[0].1), + ("Alpha two".into(), [stored[0].1[0], second + 50.0]), + ("Beta".into(), [300.0, 230.0]), + ("Gamma long line of text".into(), [90.0, 350.0]), + ("Delta".into(), [380.0, 72.0]), + ]; + assert_eq!(moved, expected); + assert_eq!(view.editor.outlines()[0].origin(), title); + assert!(!view.editor.take_ops().unwrap().is_empty()); + let _ = view.undo(false).unwrap(); + assert_eq!(texts(&view), stored); + assert_eq!(view.editor.whole(), None); + let _ = view.undo(true).unwrap(); + assert_eq!(texts(&view), expected); +} + +/// Within half an inch of the view's side the line runs down the page and moves what starts +/// right of it; taking space back stops at the end of what stays before the line. +#[test] +fn insert_space_runs_down_the_page_near_its_side_and_takes_back_only_space() { + let (mut view, _) = page_selection_view(); + let stored = texts(&view); + let _ = view.insert_space(); + let _ = view.pointer_moved([20.0, 200.0]).unwrap(); + assert_eq!(view.cursor(), Cursor::ColResize); + let _ = view.pointer_moved([790.0, 200.0]).unwrap(); + assert_eq!(view.cursor(), Cursor::ColResize); + insert_space(&mut view, [20.0, 200.0], [60.0, 200.0]); + let right: Vec<_> = stored + .iter() + .map(|(text, [x, y])| (text.clone(), [x + 40.0, *y])) + .collect(); + assert_eq!(texts(&view), right); + let _ = view.undo(false).unwrap(); + // Undo scrolls to what it restores; the drags below are in page points. + view.viewport.origin = [0.0; 2]; + + // Beta ends at its line's bottom; Gamma, below the line at 250, comes up to it and no + // further. + let beta = view.editor.outlines()[2].bounds().y1 as f32; + insert_space(&mut view, [400.0, 250.0], [400.0, 20.0]); + let gamma = texts(&view)[2].1[1]; + assert!((gamma - (300.0 - (250.0 - beta))).abs() < 1e-3, "{gamma}"); + // A line with something stationary across it cannot take space back. + let _ = view.undo(false).unwrap(); + view.viewport.origin = [0.0; 2]; + insert_space(&mut view, [400.0, 185.0], [400.0, 20.0]); + assert_eq!(texts(&view), stored); +} + +#[test] +fn escape_cancels_insert_space() { + let (mut view, _) = page_selection_view(); + let stored = texts(&view); + let _ = view.editor.take_ops().unwrap(); + let _ = view.insert_space(); + let _ = view.pointer_moved([400.0, 150.0]).unwrap(); + let _ = view.key(&Key::Named(NamedKey::Escape), None).unwrap(); + assert!(!view.inserting_space()); + let _ = view.insert_space(); + let _ = view.pointer_pressed(Instant::now()).unwrap(); + let _ = view.pointer_moved([400.0, 250.0]).unwrap(); + let _ = view.key(&Key::Named(NamedKey::Escape), None).unwrap(); + let _ = view.pointer_released().unwrap(); + assert!(!view.inserting_space()); + assert_eq!(texts(&view), stored); + assert!(view.editor.take_ops().unwrap().is_empty()); +} + +/// Rule lines draw as OneNote 2010 draws them: horizontal lines from the margin origin +/// down, a margin line 1/96 inch left of it, and grid lines through it either way. +#[test] +fn rule_lines_start_at_the_margin_origin() { + // Each line's axis and the centre across it, to a hundredth of a point. + let lines = |lines| { + super::rule_primitives( + Some(lines), + [36.0, 14.4], + [0.0, 0.0, 100.0, 100.0], + 0.5, + Paper::WHITE, + ) + .into_iter() + .map(|primitive| match primitive { + Primitive::Rect { + rect: [x0, y0, x1, y1], + .. + } => { + let vertical = y1 - y0 > x1 - x0; + let center = if vertical { x0 + x1 } else { y0 + y1 } / 2.0; + (vertical, (center * 100.0).round() / 100.0) + } + _ => panic!("rule lines are rectangles"), + }) + .collect::>() + }; + let [(_, standard), (_, small)] = [ + crate::template::RULE_LINES[2], + crate::template::RULE_LINES[4], + ]; + assert_eq!( + lines(standard), + [(false, 14.4), (false, 47.52), (false, 80.64), (true, 35.25)] + ); + let vertical: Vec = lines(small) + .into_iter() + .filter_map(|(vertical, at)| vertical.then_some(at)) + .collect(); + assert_eq!( + vertical, + [0.0, 12.0, 24.0, 36.0, 48.0, 60.0, 72.0, 84.0, 96.0] + ); +} diff --git a/crates/canvas/src/outline.rs b/crates/canvas/src/outline.rs index dc52272a8ef26a878618054f036ffe627b39df19..bd618c4f7e39e7696886f475f06d0eee6ec9237d 100644 --- a/crates/canvas/src/outline.rs +++ b/crates/canvas/src/outline.rs @@ -213,6 +213,8 @@ pub struct ParagraphLayout { pub enum TagIcon { CheckBox { checked: bool, + /// The badge on the small box of OneNote's check box variants. + mark: Option, }, Star, Question, @@ -227,10 +229,28 @@ pub enum TagIcon { BlueSquare, /// An Outlook task flag. Flag, + Idea, + Password, + Movie, + Book, + Web, + Blog, + Email, /// A tag symbol without artwork of its own. Other, } +/// What OneNote draws on a check box variant's small box. +#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq)] +pub enum BoxMark { + Person, + Manager, + Arrow, + One, + Two, + Client, +} + impl TagIcon { /// How the page draws tag symbol `shape`, `checked` where it is a check box; none for /// shape 0, which marks the text instead. @@ -238,7 +258,21 @@ impl TagIcon { Some(match shape { 0 => return None, // OneNote's check boxes, plain and with a person, arrow, number or bulb. - 3 | 8 | 12 | 28 | 71 | 94 | 95 => Self::CheckBox { checked }, + 3 => Self::CheckBox { + checked, + mark: None, + }, + 8 | 12 | 28 | 71 | 94 | 95 => Self::CheckBox { + checked, + mark: Some(match shape { + 8 => BoxMark::Client, + 12 => BoxMark::Arrow, + 28 => BoxMark::One, + 71 => BoxMark::Two, + 94 => BoxMark::Person, + _ => BoxMark::Manager, + }), + }, 13 => Self::Star, 15 => Self::Question, 17 => Self::Exclamation, @@ -250,6 +284,13 @@ impl TagIcon { 101 => Self::YellowSquare, 102 => Self::BlueSquare, 121 => Self::Music, + 21 => Self::Idea, + 131 => Self::Password, + 122 => Self::Movie, + 132 => Self::Book, + 125 => Self::Web, + 24 => Self::Blog, + 106 => Self::Email, _ => Self::Other, }) } @@ -1214,6 +1255,19 @@ impl OutlineLayout { .flatten() } + /// Where each root node's flow starts and ends in the outline, or `None` while a collapsed + /// parent hides it. + pub(crate) fn spans(&self) -> impl Iterator> + '_ { + let mut state = (0.0, None); + self.blocks.iter().map(move |block| { + let span = block + .metrics + .map(|[before, after, _]| [top(&mut state, [before, after]), block.state.0 as f32]); + state = block.state; + span + }) + } + /// The paragraphs, tables and objects of root nodes `range`. pub(crate) fn pieces(&self, range: Range) -> [Range; 3] { let first = |root: usize| { @@ -2039,7 +2093,13 @@ mod tests { extra_set: 0, }); for (shape, expected) in [ - (3, TagIcon::CheckBox { checked: true }), + ( + 3, + TagIcon::CheckBox { + checked: true, + mark: None, + }, + ), (13, TagIcon::Star), (15, TagIcon::Question), (17, TagIcon::Exclamation), diff --git a/crates/canvas/src/search.rs b/crates/canvas/src/search.rs index 7a15ec1f7fef0e1b69b75c5ade7cf9f34bf91446..db1a939df1695f98265f9bda7b9843ab24c41dad 100644 --- a/crates/canvas/src/search.rs +++ b/crates/canvas/src/search.rs @@ -6,6 +6,7 @@ use crate::document::TextPosition; use crate::editor::{CanvasEditor, Selection}; use icu_normalizer::DecomposingNormalizerBorrowed; use onestore::ExGuid; +use onestore::document::Kind; use onestore::page::{Page, PageObject, PageParagraph, ParagraphContent, text::Paragraph}; use std::ops::Range; @@ -176,32 +177,96 @@ pub fn shown(paragraph: &Paragraph) -> String { .collect() } -/// A page's text outside its title, a paragraph to a line: outlines in page order, tables +/// Calls `visit` with each text paragraph of the page's outlines, in page order, tables /// cell by cell. -pub fn page_text(page: &Page) -> String { - fn walk(paragraphs: &[PageParagraph], out: &mut Vec) { +fn text_paragraphs<'a>(page: &'a Page, mut visit: impl FnMut(&'a PageParagraph, &'a Paragraph)) { + fn walk<'a>( + paragraphs: &'a [PageParagraph], + visit: &mut impl FnMut(&'a PageParagraph, &'a Paragraph), + ) { for paragraph in paragraphs { match ¶graph.content { - ParagraphContent::Text(text) => out.push(shown(&text.text)), + ParagraphContent::Text(text) => visit(paragraph, &text.text), ParagraphContent::Table(table) => { for cell in table.rows.iter().flat_map(|row| &row.cells) { - walk(&cell.paragraphs, out); + walk(&cell.paragraphs, visit); } } _ => {} } } } - let mut out = Vec::new(); for object in &page.objects { if let PageObject::Outline(outline) = object { - walk(&outline.paragraphs, &mut out); + walk(&outline.paragraphs, &mut visit); } } +} + +/// A page's text outside its title, a paragraph to a line. +pub fn page_text(page: &Page) -> String { + let mut out = Vec::new(); + text_paragraphs(page, |_, text| out.push(shown(text))); out.retain(|line| !line.trim().is_empty()); out.join("\n") } +/// A tagged paragraph, as OneNote's Tags Summary lists it: once for each of its tags. +#[derive(Clone, Debug, PartialEq)] +pub struct Tagged { + pub section: String, + pub space: ExGuid, + /// The page's title. + pub title: String, + pub paragraph: ExGuid, + /// The tag's name, as its definition stores it. + pub name: String, + /// The tag's symbol, as its definition stores it. + pub shape: u16, + /// A check box tag is checked. + pub checked: bool, + pub text: String, + /// When the tag was applied, in Time32, or the page's modification time where unknown. + pub created: u64, +} + +/// The page's tagged paragraphs, oldest tag first on each, as OneNote lists them; a tag +/// without a definition, as an Outlook task's, is left out. +fn tagged(section: &str, space: ExGuid, page: &Page, modified: u64) -> Vec { + let mut out = Vec::new(); + text_paragraphs(page, |paragraph, text| { + let ParagraphContent::Text(object) = ¶graph.content else { + return; + }; + // Stored newest first. + for tag in paragraph.tags.iter().chain(&object.tags).rev() { + let Some(Kind::TagDefinition { + label: Some(name), + shape, + .. + }) = tag + .definition + .and_then(|id| page.definitions.get(&id)) + .map(|definition| &definition.kind) + else { + continue; + }; + out.push(Tagged { + section: section.to_owned(), + space, + title: page.title.clone(), + paragraph: paragraph.id, + name: name.to_string(), + shape: shape.unwrap_or(0), + checked: tag.status & 1 != 0, + text: shown(text), + created: tag.created.map_or(modified, u64::from), + }); + } + }); + out +} + /// A page as search knows it. pub struct Entry { /// The section's key, which the host chooses. @@ -213,6 +278,7 @@ pub struct Entry { text: String, folded_title: String, folded_text: String, + tagged: Vec, } impl Entry { @@ -227,6 +293,7 @@ impl Entry { title: page.title.clone(), modified, text, + tagged: tagged(section, space, page, modified), } } } @@ -237,6 +304,9 @@ pub struct Found { pub section: String, pub space: ExGuid, pub title: String, + pub modified: u64, + /// The page's place in the index, which keeps each section's pages in order. + pub order: usize, /// Every word is in the title, as OneNote's "Title contains" lists it. pub in_title: bool, /// Byte ranges of the title the words match. @@ -261,6 +331,11 @@ impl Index { self.entries.is_empty() } + /// Every page, each section's in order. + pub fn entries(&self) -> &[Entry] { + &self.entries + } + /// Adds `entry`, replacing the page it names. pub fn set(&mut self, entry: Entry) { match self @@ -291,29 +366,32 @@ impl Index { if query.is_empty() { return Vec::new(); } - let mut found: Vec<(&Entry, bool)> = self + let mut found: Vec<(usize, &Entry, bool)> = self .entries .iter() - .filter(|entry| scope(&entry.section)) - .filter_map(|entry| { + .enumerate() + .filter(|(_, entry)| scope(&entry.section)) + .filter_map(|(order, entry)| { let in_title = query.all_in(&entry.folded_title); let matches = in_title || query.terms.iter().all(|term| { starts_word(term, &entry.folded_title) || starts_word(term, &entry.folded_text) }); - matches.then_some((entry, in_title)) + matches.then_some((order, entry, in_title)) }) .collect(); - found.sort_by_key(|(entry, in_title)| (!in_title, std::cmp::Reverse(entry.modified))); + found.sort_by_key(|(_, entry, in_title)| (!in_title, std::cmp::Reverse(entry.modified))); found .into_iter() - .map(|(entry, in_title)| { + .map(|(order, entry, in_title)| { let (snippet, snippet_hits) = snippet(entry, query); Found { section: entry.section.clone(), space: entry.space, title: entry.title.clone(), + modified: entry.modified, + order, in_title, title_hits: query.find(&entry.title), snippet, @@ -322,6 +400,16 @@ impl Index { }) .collect() } + + /// The tagged paragraphs of the pages whose section and page `scope` accepts, in page + /// order. + pub fn tagged(&self, scope: impl Fn(&Entry) -> bool) -> Vec { + self.entries + .iter() + .filter(|entry| scope(entry)) + .flat_map(|entry| entry.tagged.iter().cloned()) + .collect() + } } /// 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 { } matches } + +/// The whole of paragraph `id` on the page `editor` shows, as a tag summary selects it. +pub fn paragraph_match(editor: &CanvasEditor, id: ExGuid) -> Option { + editor.outlines().iter().find_map(|outline| { + let document = outline.document(); + let index = document.text_nodes().position(|node| node.id == id)?; + let paragraph = document.paragraph(index)?; + let end = paragraph.utf16_offset(paragraph.text().len()).ok()?; + let at = |offset| TextPosition { + paragraph: index, + offset, + }; + Some((outline.id, [at(0), at(end)].into())) + }) +} diff --git a/crates/canvas/src/template.rs b/crates/canvas/src/template.rs index de51d86a97fb279450d2be6dcafafd768556dd6d..51a071667f2245783b7a9c62c09351fa56c69540 100644 --- a/crates/canvas/src/template.rs +++ b/crates/canvas/src/template.rs @@ -1,6 +1,8 @@ //! The page templates Snowbound offers: OneNote 2010's background art, recreated -//! (`gpu::page::template_picture`), placed where OneNote's templates place it, and plain -//! page colours. +//! (`gpu::page::template_picture`), placed where OneNote's templates place it, plain +//! page colours and rule lines. + +use onestore::page::{RuleLines, VerticalRule}; /// One background picture of a template: the recreated art's name, and the picture's /// 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)] = &[ ("Teal", 0xf2f9d4), ]; +/// OneNote 2010's View, Rule Lines presets in its menu order, in its default light blue +/// with a red margin line +/// (`corpus/rule-lines/native`). +pub const RULE_LINES: &[(&str, RuleLines)] = &[ + ("Narrow Ruled", ruled(0x3ebe_f8d2)), + ("College Ruled", ruled(0x3f28_f5c3)), + ("Standard Ruled", ruled(0x3f6b_851f)), + ("Wide Ruled", ruled(0x3fa6_6666)), + ("Small Grid", grid(0x3eaa_aaab)), + ("Medium Grid", grid(0x3f49_930c)), + ("Large Grid", grid(0x3f97_2e49)), + ("Very Large Grid", grid(0x3fc9_930c)), +]; + +const RULE_COLOR: u32 = 0xfdebca; + +/// Lines `spacing` apart, the bits of the half inches OneNote stores. +const fn ruled(spacing: u32) -> RuleLines { + RuleLines { + spacing: f32::from_bits(spacing), + color: RULE_COLOR, + vertical: VerticalRule::Margin(0x5050ff), + } +} + +const fn grid(spacing: u32) -> RuleLines { + RuleLines { + spacing: f32::from_bits(spacing), + color: RULE_COLOR, + vertical: VerticalRule::Grid { + spacing: f32::from_bits(spacing), + color: RULE_COLOR, + }, + } +} + /// The template named `name`. pub fn find(name: &str) -> Option<&'static Template> { TEMPLATES.iter().find(|template| template.name == name) diff --git a/crates/canvas/tests/ops_differential.rs b/crates/canvas/tests/ops_differential.rs index 0e63ca88888a93b105c2abf1cf6282a314fc983c..c84e8a72c1082d1cc81ea015cfde0138d81ef782 100644 --- a/crates/canvas/tests/ops_differential.rs +++ b/crates/canvas/tests/ops_differential.rs @@ -95,11 +95,14 @@ enum Kind { PageType, PageMove, PageBold, + InsertSpace, + RemoveSpace, + InsertSpaceRight, Undo, Redo, } -const KINDS: [Kind; 52] = [ +const KINDS: [Kind; 55] = [ Kind::Type, Kind::TypeEnd, Kind::Compose, @@ -150,6 +153,9 @@ const KINDS: [Kind; 52] = [ Kind::PageType, Kind::PageMove, Kind::PageBold, + Kind::InsertSpace, + Kind::RemoveSpace, + Kind::InsertSpaceRight, Kind::Undo, Kind::Redo, ]; @@ -438,6 +444,24 @@ fn perform( .format(engine, Formatting::Toggle(Toggle::Bold)) .is_ok() } + Kind::InsertSpace | Kind::RemoveSpace => { + // A line at a paragraph's top parts the outline there, or moves all of it. + let outline = editor.active_outline(); + let paragraphs = &outline.shaped().paragraphs; + let line = outline.origin()[1] + + paragraphs[random.below(paragraphs.len())].origin[1] + + [0.0, 0.5][random.below(2)]; + let delta = if kind == Kind::InsertSpace { + 36.0 + } else { + -200.0 + }; + editor.insert_space(engine, 1, line, delta).unwrap_or(false) + } + Kind::InsertSpaceRight => { + let line = editor.active_outline().origin()[0] - random.below(40) as f32; + editor.insert_space(engine, 0, line, 45.0).unwrap_or(false) + } Kind::Undo => editor.undo(engine).unwrap_or(false), Kind::Redo => editor.redo(engine).unwrap_or(false), } @@ -812,4 +836,12 @@ fn editor_ops_store_the_editor_s_page() { println!("{report}"); let differs = totals[4]; assert_eq!(differs, 0, "{report}"); + // Undoing stores what the edit it undoes stored, so storage never refuses it. + for kind in [Kind::Undo, Kind::Redo] { + assert_eq!( + per_kind.get(&kind).map_or(0, |counts| counts[1]), + 0, + "{report}" + ); + } } diff --git a/crates/canvas/tests/search.rs b/crates/canvas/tests/search.rs index 00387b54b3f73f645221f7d4318fbff6884ac4bf..3e0a7d500e02680d3524a2cba651c323c5d69b91 100644 --- a/crates/canvas/tests/search.rs +++ b/crates/canvas/tests/search.rs @@ -5,7 +5,7 @@ use canvas::{ document::TextDocument, editor::CanvasEditor, layout::TextEngine, - search::{Entry, Index, Query, fold, page_matches}, + search::{Entry, Index, Query, fold, page_matches, paragraph_match}, }; use onestore::{ ExGuid, RevisionIndex, Store, @@ -214,3 +214,47 @@ fn words_match_word_starts_and_pages_rank_by_title_then_recency() { assert_eq!(order(&index, "cafe"), ["Café list"]); assert_eq!(index.len(), 2); } + +/// OneNote's tag gallery (`corpus/structural-probe`): a paragraph under each default tag, +/// listed once for each tag with its stored name, and selected whole from the summary. +#[test] +fn tags_summary_lists_each_tagged_paragraph() { + let file = + Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus/structural-probe/tag-gallery.one"); + let bytes = std::fs::read(file).unwrap(); + let store = Store::parse(&bytes).unwrap(); + let index = RevisionIndex::parse(&store).unwrap(); + let document = Document::parse(&index).unwrap(); + let (space, _) = document.pages().unwrap()[0]; + let page = Page::from_space(&document, space).unwrap(); + let mut index = Index::default(); + index.set(Entry::new("gallery", space, &page, 7)); + let tagged = index.tagged(|_| true); + let mut names: Vec<&str> = tagged.iter().map(|tagged| tagged.name.as_str()).collect(); + names.sort_unstable(); + // The probe's T24 and T25 also carry Call back, one entry under each tag. + assert_eq!(tagged.len(), names.len()); + names.dedup(); + let mut labels = canvas::editor::NoteTag::ALL.map(|tag| tag.label()); + labels.sort_unstable(); + assert_eq!(names, labels); + assert!(tagged.iter().all(|tagged| tagged.space == space)); + assert!(index.tagged(|entry| entry.section != "gallery").is_empty()); + let mut engine = TextEngine::default(); + let editor = CanvasEditor::from_page(page, &mut engine).unwrap(); + let (outline, selection) = paragraph_match(&editor, tagged[0].paragraph).unwrap(); + let outline = editor + .outlines() + .iter() + .find(|candidate| candidate.id == outline) + .unwrap(); + let [start, end] = selection.positions; + let text = outline + .document() + .paragraphs() + .nth(start.paragraph) + .unwrap(); + assert_eq!(start.offset, 0); + assert_eq!(end.offset as usize, text.text().encode_utf16().count()); + assert_eq!(canvas::search::shown(text), tagged[0].text); +} diff --git a/crates/draw/src/draw.glsl b/crates/draw/src/draw.glsl index 304fa963bc7a6d2db4760c914736c86b30d152dd..7a3c78e353a45165032e858645b3fe254107e77f 100644 --- a/crates/draw/src/draw.glsl +++ b/crates/draw/src/draw.glsl @@ -8,12 +8,16 @@ attribute vec4 color; attribute vec2 local; attribute vec4 shape; attribute float stroke; +attribute vec2 clip_local; +attribute vec3 clip; #endif varying vec2 v_uv; varying vec4 v_color; varying vec2 v_local; varying vec4 v_shape; varying float v_stroke; +varying vec2 v_clip_local; +varying vec3 v_clip; #ifdef VERTEX void main() { @@ -22,6 +26,8 @@ void main() { v_local = local; v_shape = shape; v_stroke = stroke; + v_clip_local = clip_local; + v_clip = clip; gl_Position = vec4(position, 0.0, 1.0); } #else @@ -97,6 +103,11 @@ void main() { } color *= coverage; } + if (v_clip.x > 0.0) { + vec2 q = abs(v_clip_local) - v_clip.xy + v_clip.z; + float distance = length(max(q, vec2(0.0))) + min(max(q.x, q.y), 0.0) - v_clip.z; + color *= clamp(0.5 - distance, 0.0, 1.0); + } gl_FragColor = color; } #endif diff --git a/crates/draw/src/draw.wgsl b/crates/draw/src/draw.wgsl index 46e482110fb6f54c8f07e38c10edb9db36351f91..2704536ae512dc0196d9d79b68b75f8cadd2aab9 100644 --- a/crates/draw/src/draw.wgsl +++ b/crates/draw/src/draw.wgsl @@ -5,6 +5,8 @@ struct Vertex { @location(2) local: vec2, @location(3) @interpolate(flat) shape: vec4, @location(4) @interpolate(flat) stroke: f32, + @location(5) clip_local: vec2, + @location(6) @interpolate(flat) clip: vec3, } @group(0) @binding(0) var atlas: texture_2d; @@ -13,8 +15,9 @@ struct Vertex { @vertex fn vertex(@location(0) position: vec2, @location(1) uv: vec2, @location(2) color: vec4, @location(3) local: vec2, - @location(4) shape: vec4, @location(5) stroke: f32) -> Vertex { - return Vertex(vec4(position, 0.0, 1.0), uv, color, local, shape, stroke); + @location(4) shape: vec4, @location(5) stroke: f32, + @location(6) clip_local: vec2, @location(7) clip: vec3) -> Vertex { + return Vertex(vec4(position, 0.0, 1.0), uv, color, local, shape, stroke, clip_local, clip); } fn ellipse_arc(angle: f32, radius: vec2) -> f32 { @@ -83,5 +86,10 @@ fn fragment(input: Vertex) -> @location(0) vec4 { } color *= coverage; } + if input.clip.x > 0.0 { + let q = abs(input.clip_local) - input.clip.xy + input.clip.z; + let distance = length(max(q, vec2(0.0))) + min(max(q.x, q.y), 0.0) - input.clip.z; + color *= clamp(0.5 - distance, 0.0, 1.0); + } return color; } diff --git a/crates/draw/src/render.rs b/crates/draw/src/render.rs index d80710faf7b46426d73d81802cc37cc65328589b..3c0d0e85049c41f957dbce7c0a0235d801b2e41a 100644 --- a/crates/draw/src/render.rs +++ b/crates/draw/src/render.rs @@ -170,6 +170,12 @@ struct Batch { scissor: [u32; 4], } +/// Batches that paint offscreen as one, then onto the target by `composite`'s vertices. +struct Group { + batches: Range, + composite: Range, +} + /// How a batch meets what lies beneath it. #[derive(Clone, Copy, PartialEq)] enum Blend { @@ -189,6 +195,10 @@ struct Vertex { local: [f32; 2], shape: [f32; 4], stroke: f32, + /// The position from the middle of the rounded rectangle the vertex paints within, and + /// that rectangle's half size and corner radius; a zero half size paints everywhere. + clip_local: [f32; 2], + clip: [f32; 3], } #[derive(Hash, PartialEq, Eq)] @@ -244,6 +254,10 @@ pub struct Layer<'a> { /// The colour behind the layer: on a dark one, text in dark colours of its own is /// lifted to stay legible against what lies behind it, as OneNote's dark page does. pub backdrop: Option<[f32; 4]>, + /// Device bounds and corner radius of a rounded rectangle the layer paints only inside. + pub round: Option<([f32; 4], f32)>, + /// Consecutive layers with the same motion appear as one: painted together offscreen, + /// then faded and leaned back as a whole, so none shows through another. pub motion: Option, pub primitives: &'a [Primitive<'a>], } @@ -260,28 +274,16 @@ pub struct Motion { } impl Motion { - fn apply(&self, vertex: &mut Vertex, size: [u32; 2], blend: Blend) { - match blend { - Blend::Image(_) => vertex.color = vertex.color.map(|v| v * self.opacity), - Blend::Over | Blend::Erase => vertex.color[3] *= self.opacity, - } - if self.tilt == 0.0 { - return; - } - let [width, height] = size.map(|v| v as f32); + /// Where device point `[x, y]` on a target `height` tall shows, turned. + fn project(&self, [x, y]: [f32; 2], height: f32) -> [f32; 2] { // Seen from twice the target's height away, so a small turn reads as depth. let distance = 2.0 * height; - let [x, y] = [ - (vertex.position[0] + 1.0) * width / 2.0, - (1.0 - vertex.position[1]) * height / 2.0, - ]; let below = y - self.pivot[1]; let near = distance / (distance + below * self.tilt.sin()); - let [x, y] = [ + [ self.pivot[0] + (x - self.pivot[0]) * near, self.pivot[1] + below * self.tilt.cos() * near, - ]; - vertex.position = [x * 2.0 / width - 1.0, 1.0 - y * 2.0 / height]; + ] } } @@ -292,7 +294,7 @@ struct Space { scale: f32, origin: [f32; 2], backdrop: Option<[f32; 4]>, - motion: Option, + round: Option<([f32; 4], f32)>, } impl Space { @@ -458,6 +460,9 @@ pub struct Renderer { glyphs: HashMap>, images: HashMap, batches: Vec, + groups: Vec, + /// Batches before this one take no more primitives. + barrier: usize, scaler: ScaleContext, pen: [u32; 2], row_height: u32, @@ -479,6 +484,8 @@ impl Renderer { glyphs: HashMap::new(), images: HashMap::new(), batches: Vec::new(), + groups: Vec::new(), + barrier: 0, scaler: ScaleContext::with_max_entries(32), pen: [1, 0], row_height: 1, @@ -557,7 +564,7 @@ impl Renderer { scale: layer.scale, origin: layer.origin, backdrop: layer.backdrop, - motion: layer.motion, + round: layer.round, }; for primitive in layer.primitives { if let Primitive::Image { image, rect } = primitive @@ -574,6 +581,8 @@ impl Renderer { for attempt in 0..2 { self.vertices.clear(); self.batches.clear(); + self.groups.clear(); + self.barrier = 0; match self.prepare(size, layers, &active_images) { Err(RenderError::AtlasFull) if attempt == 0 => self.clear_glyph_cache(), result => { @@ -582,7 +591,7 @@ impl Renderer { } } } - self.submit(target, clear); + self.submit(target, size, clear); Ok(()) } @@ -592,13 +601,26 @@ impl Renderer { layers: &[Layer<'_>], active_images: &HashSet, ) -> Result<(), RenderError> { + // The motion the layers painted since `start` share, where they paint as a group. + let mut group: Option<(Motion, usize)> = None; for layer in layers { + let motion = layer + .motion + .filter(|motion| motion.opacity < 1.0 || motion.tilt != 0.0); + if motion != group.map(|(motion, _)| motion) { + if let Some((motion, start)) = group { + self.group(motion, start, size)?; + } + // A group's batches never merge with those outside it. + self.barrier = self.batches.len(); + group = motion.map(|motion| (motion, self.batches.len())); + } let space = Space { size, scale: layer.scale, origin: layer.origin, backdrop: layer.backdrop, - motion: layer.motion, + round: layer.round, }; let bounds = match layer.clip { Some(clip) => space.scissor(clip), @@ -611,6 +633,67 @@ impl Renderer { self.primitive(space, bounds, primitive, active_images)?; } } + if let Some((motion, start)) = group { + self.group(motion, start, size)?; + } + Ok(()) + } + + /// Makes the batches from `start` a group appearing by `motion`: the vertices that + /// paint its offscreen picture over the target, in strips so the turn stays in + /// perspective across the picture. + fn group(&mut self, motion: Motion, start: usize, size: [u32; 2]) -> Result<(), RenderError> { + let batches = start..self.batches.len(); + if batches.is_empty() { + return Ok(()); + } + let [width, height] = size.map(|side| side as f32); + let [mut left, mut top, mut right, mut bottom] = [width, height, 0.0, 0.0]; + let first = self.batches[start].vertices.start as usize; + for vertex in &self.vertices[first..] { + let [x, y] = [ + (vertex.position[0] + 1.0) * width / 2.0, + (1.0 - vertex.position[1]) * height / 2.0, + ]; + [left, top] = [left.min(x), top.min(y)]; + [right, bottom] = [right.max(x), bottom.max(y)]; + } + let [left, top] = [left.max(0.0).floor(), top.max(0.0).floor()]; + let [right, bottom] = [right.min(width).ceil(), bottom.min(height).ceil()]; + let strips = if motion.tilt == 0.0 { 1 } else { 32 }; + if self.vertices.len() + 6 * strips > MAX_VERTICES { + return Err(RenderError::FrameTooLarge); + } + let from = self.vertices.len() as u32; + let corner = |x: f32, y: f32| { + let [shown_x, shown_y] = motion.project([x, y], height); + let v = y / height; + Vertex { + position: [shown_x * 2.0 / width - 1.0, 1.0 - shown_y * 2.0 / height], + uv: [x / width, if backend::FLIPPED { 1.0 - v } else { v }], + color: [motion.opacity; 4], + local: [0.0; 2], + shape: [0.0; 4], + stroke: 0.0, + clip_local: [0.0; 2], + clip: [0.0; 3], + } + }; + for strip in 0..strips { + let [y0, y1] = + [strip, strip + 1].map(|edge| top + (bottom - top) * edge as f32 / strips as f32); + let [a, b, c, d] = [ + corner(left, y0), + corner(left, y1), + corner(right, y1), + corner(right, y0), + ]; + self.vertices.extend([a, b, c, a, c, d]); + } + self.groups.push(Group { + batches, + composite: from..self.vertices.len() as u32, + }); Ok(()) } @@ -714,13 +797,21 @@ impl Renderer { } } let end = self.vertices.len() as u32; - if let Some(motion) = space.motion { + if let Some((rect, radius)) = space.round { + let [width, height] = space.size.map(|side| side as f32); + let half = [(rect[2] - rect[0]) / 2.0, (rect[3] - rect[1]) / 2.0]; + let middle = [rect[0] + half[0], rect[1] + half[1]]; for vertex in &mut self.vertices[start as usize..] { - motion.apply(vertex, space.size, blend); + vertex.clip_local = [ + (vertex.position[0] + 1.0) * width / 2.0 - middle[0], + (1.0 - vertex.position[1]) * height / 2.0 - middle[1], + ]; + vertex.clip = [half[0], half[1], radius.min(half[0]).min(half[1])]; } } if start != end { - if let Some(last) = self.batches.last_mut() + if self.batches.len() > self.barrier + && let Some(last) = self.batches.last_mut() && last.blend == blend && last.scissor == scissor { @@ -1232,6 +1323,8 @@ impl Renderer { local, shape: [half[0], half[1], corner, corner], stroke: 0.0, + clip_local: [0.0; 2], + clip: [0.0; 3], }); } Ok(()) @@ -1283,6 +1376,8 @@ impl Renderer { local, shape: [0.0; 4], stroke: 0.0, + clip_local: [0.0; 2], + clip: [0.0; 3], }); } Ok(()) @@ -1715,11 +1810,66 @@ mod tests { origin: [24.0; 2], clip: None, backdrop: None, + round: None, motion: None, primitives, }] } + #[test] + #[ignore = "requires a native GPU adapter"] + fn layers_sharing_a_motion_fade_as_one_inside_their_rounded_clip() { + let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle()); + let adapter = pollster::block_on(instance.request_adapter(&Default::default())).unwrap(); + let (device, queue) = + pollster::block_on(adapter.request_device(&Default::default())).unwrap(); + let target = Target::new(&device); + let mut renderer = Renderer::new(device, queue, wgpu::TextureFormat::Rgba8UnormSrgb); + let rect = |rect, color| [Primitive::Rect { rect, color }]; + let [blue, red, black] = [ + rect([0.0, 0.0, 100.0, 100.0], [0.0, 0.0, 1.0, 1.0]), + rect([50.0, 0.0, 150.0, 100.0], [1.0, 0.0, 0.0, 1.0]), + rect([200.0, 0.0, 300.0, 100.0], [0.0, 0.0, 0.0, 1.0]), + ]; + let half = Motion { + opacity: 0.5, + tilt: 0.0, + pivot: [0.0; 2], + }; + let layer = |primitives, motion, round| Layer { + scale: 1.0, + origin: [0.0; 2], + clip: None, + backdrop: None, + round, + motion, + primitives, + }; + target + .draw( + &mut renderer, + &[ + layer(&blue, Some(half), None), + layer(&red, Some(half), None), + layer(&black, None, Some(([200.0, 0.0, 300.0, 100.0], 30.0))), + ], + ) + .unwrap(); + let capture = target.capture(&renderer); + let pixel = |x: usize, y: usize| &capture[(y * 512 + x) * 4..(y * 512 + x) * 4 + 4]; + let [overlap, alone] = [pixel(75, 50), pixel(25, 50)]; + assert!( + overlap[0] == 255 && overlap[1] == overlap[2] && overlap[1] < 255, + "the red covers the blue before both fade: {overlap:?}" + ); + assert!( + alone[2] == 255 && alone[0] == alone[1] && alone[0] < 255, + "{alone:?}" + ); + assert_eq!(pixel(202, 2), [255; 4], "outside the rounded corner"); + assert_eq!(pixel(250, 50), [0, 0, 0, 255]); + } + #[test] #[ignore = "requires a native GPU adapter"] fn eviction_and_repaint_reproduce_pixels() { @@ -2119,6 +2269,7 @@ mod tests { origin: [0.0; 2], clip: None, backdrop: None, + round: None, motion: None, primitives: &primitives, }], @@ -2191,6 +2342,7 @@ mod tests { origin: [0.0; 2], clip: None, backdrop: None, + round: None, motion: None, primitives: &primitives, }], @@ -2236,6 +2388,7 @@ mod tests { origin: [0.0; 2], clip: None, backdrop: None, + round: None, motion: None, primitives: &primitives, }], @@ -2288,6 +2441,7 @@ mod tests { origin: [0.0; 2], clip: Some([100.0, 50.0, 200.5, 80.0]), backdrop: None, + round: None, motion: None, primitives: &fill, }, @@ -2296,6 +2450,7 @@ mod tests { origin: [300.0, 10.0], clip: None, backdrop: None, + round: None, motion: None, primitives: &chrome, }, @@ -2324,6 +2479,7 @@ mod tests { origin: [0.0; 2], clip: None, backdrop: None, + round: None, motion: None, primitives: &fill, }] diff --git a/crates/draw/src/render/gl.rs b/crates/draw/src/render/gl.rs index bd758ded1214bd2a1f5b8d8d80700ba27ee664d0..7cbb7046e637f40affcd391c0ed40c3740406847 100644 --- a/crates/draw/src/render/gl.rs +++ b/crates/draw/src/render/gl.rs @@ -38,6 +38,7 @@ const CLAMP_TO_EDGE: GLint = 0x812F; const TEXTURE0: GLenum = 0x84C0; const ARRAY_BUFFER: GLenum = 0x8892; const STREAM_DRAW: GLenum = 0x88E0; +const STATIC_DRAW: GLenum = 0x88E4; const FRAGMENT_SHADER: GLenum = 0x8B30; const VERTEX_SHADER: GLenum = 0x8B31; const COMPILE_STATUS: GLenum = 0x8B81; @@ -103,6 +104,7 @@ unsafe extern "C" { fn glBindBuffer(target: GLenum, buffer: GLuint); fn glBufferData(target: GLenum, size: isize, data: *const c_void, usage: GLenum); fn glEnableVertexAttribArray(index: GLuint); + fn glDisableVertexAttribArray(index: GLuint); fn glVertexAttribPointer( index: GLuint, size: GLint, @@ -132,6 +134,7 @@ unsafe extern "C" { fn glUseProgram(program: GLuint); fn glGetUniformLocation(program: GLuint, name: *const c_char) -> GLint; fn glUniform1i(location: GLint, value: GLint); + fn glUniform2f(location: GLint, x: f32, y: f32); fn glGenFramebuffersEXT(n: GLsizei, framebuffers: *mut GLuint); fn glDeleteFramebuffersEXT(n: GLsizei, framebuffers: *const GLuint); @@ -158,6 +161,9 @@ unsafe extern "C" { ); } +/// Blending a premultiplied picture over what lies beneath. +const PREMULTIPLIED: [GLenum; 4] = [ONE, ONE_MINUS_SRC_ALPHA, ONE, ONE_MINUS_SRC_ALPHA]; + /// An sRGB RGBA texture, deleted with its value. pub(super) struct Image { name: GLuint, @@ -283,65 +289,96 @@ impl Drop for Target { } } +/// Whether an offscreen picture's rows run bottom first. +pub(super) const FLIPPED: bool = true; + pub(super) struct Gpu { program: GLuint, buffer: GLuint, + /// `translucent.glsl`, and the triangle it covers a window with. + translucent: GLuint, + triangle: GLuint, atlas: Image, max_texture: u32, + /// Offscreen pictures for groups, the target's size. + groups: Vec, } /// Each attribute's name, component count and offset, bound to its index in the shader. -const ATTRIBUTES: [(&CStr, GLint, usize); 6] = [ +const ATTRIBUTES: [(&CStr, GLint, usize); 8] = [ (c"position", 2, offset_of!(Vertex, position)), (c"uv", 2, offset_of!(Vertex, uv)), (c"color", 4, offset_of!(Vertex, color)), (c"local", 2, offset_of!(Vertex, local)), (c"shape", 4, offset_of!(Vertex, shape)), (c"stroke", 1, offset_of!(Vertex, stroke)), + (c"clip_local", 2, offset_of!(Vertex, clip_local)), + (c"clip", 3, offset_of!(Vertex, clip)), ]; impl Renderer { /// Draws with the OpenGL context current on this thread, which must stay current /// whenever the renderer or a `Target` is used. pub fn new() -> Result { - let source = include_str!("../draw.glsl"); - let program = unsafe { - let vertex = compile( - VERTEX_SHADER, - &format!("#version 120\n#define VERTEX\n{source}"), - )?; - let fragment = compile(FRAGMENT_SHADER, &format!("#version 120\n{source}"))?; - let program = glCreateProgram(); - glAttachShader(program, vertex); - glAttachShader(program, fragment); - for (index, (name, _, _)) in ATTRIBUTES.iter().enumerate() { - glBindAttribLocation(program, index as GLuint, name.as_ptr()); - } - glLinkProgram(program); - let mut linked = 0; - glGetProgramiv(program, LINK_STATUS, &mut linked); - if linked == 0 { - return Err(format!( - "Linking the draw shader failed: {}", - log(program, glGetProgramiv, glGetProgramInfoLog) - )); - } - program - }; - let mut buffer = 0; + let names: Vec<_> = ATTRIBUTES.iter().map(|(name, _, _)| *name).collect(); + let draw = unsafe { program(include_str!("../draw.glsl"), &names)? }; + let translucent = unsafe { program(include_str!("translucent.glsl"), &[c"position"])? }; + let [mut buffer, mut triangle] = [0; 2]; let mut max_texture = 0; unsafe { glGenBuffers(1, &mut buffer); + glGenBuffers(1, &mut triangle); + glBindBuffer(ARRAY_BUFFER, triangle); + let corners: [f32; 6] = [-1.0, -1.0, 3.0, -1.0, -1.0, 3.0]; + glBufferData( + ARRAY_BUFFER, + size_of_val(&corners) as isize, + corners.as_ptr().cast(), + STATIC_DRAW, + ); glGetIntegerv(MAX_TEXTURE_SIZE, &mut max_texture); } Ok(Self::with_gpu(Gpu { - program, + program: draw, buffer, + translucent, + triangle, atlas: Image::new([ATLAS_SIZE; 2], NEAREST, None), max_texture: max_texture as u32, + groups: Vec::new(), })) } + /// Copies `target` to the context's window, the same size, premultiplying each pixel + /// again in sRGB, as a window server compositing a transparent surface needs. + pub fn present_translucent(&self, target: &Target) { + let [width, height] = target.size().map(|side| side as f32); + let program = self.gpu.translucent; + unsafe { + glBindFramebufferEXT(FRAMEBUFFER, 0); + glDisable(FRAMEBUFFER_SRGB); + glDisable(BLEND); + glDisable(SCISSOR_TEST); + glViewport(0, 0, width as GLsizei, height as GLsizei); + glUseProgram(program); + glUniform1i(glGetUniformLocation(program, c"frame".as_ptr()), 0); + glUniform2f( + glGetUniformLocation(program, c"size".as_ptr()), + width, + height, + ); + glActiveTexture(TEXTURE0); + glBindTexture(TEXTURE_2D, target.texture.name); + glBindBuffer(ARRAY_BUFFER, self.gpu.triangle); + for index in 1..ATTRIBUTES.len() { + glDisableVertexAttribArray(index as GLuint); + } + glEnableVertexAttribArray(0); + glVertexAttribPointer(0, 2, FLOAT, 0, 0, ptr::null()); + glDrawArrays(TRIANGLES, 0, 3); + } + } + /// The widest and tallest texture the driver takes, in pixels. pub fn max_texture_dimension(&self) -> u32 { self.gpu.max_texture @@ -368,17 +405,68 @@ impl Renderer { Image::new(image.size, LINEAR, Some(image.pixels())) } - /// Clears `target` to linear `clear` and draws the prepared batches. - pub(super) fn submit(&mut self, target: &Target, clear: [f32; 4]) { - let [width, height] = target.size().map(|side| side as GLsizei); + /// Clears `target`, `size` device pixels, to linear `clear` and draws the prepared + /// batches, each group's offscreen first. + pub(super) fn submit(&mut self, target: &Target, size: [u32; 2], clear: [f32; 4]) { + if self + .gpu + .groups + .first() + .is_some_and(|picture| picture.size() != size) + { + self.gpu.groups.clear(); + } + while self.gpu.groups.len() < self.groups.len() { + match Target::new(size) { + Ok(picture) => unsafe { + // Filtered, so a picture leaning back stays smooth. + glBindTexture(TEXTURE_2D, picture.texture.name); + glTexParameteri(TEXTURE_2D, TEXTURE_MIN_FILTER, LINEAR); + glTexParameteri(TEXTURE_2D, TEXTURE_MAG_FILTER, LINEAR); + self.gpu.groups.push(picture); + }, + Err(error) => { + eprintln!("{error}"); + return; + } + } + } + let [width, height] = size.map(|side| side as GLsizei); let gpu = &self.gpu; - unsafe { + let begin = |target: &Target, clear: [f32; 4]| unsafe { glBindFramebufferEXT(FRAMEBUFFER, target.framebuffer); - glEnable(FRAMEBUFFER_SRGB); - glViewport(0, 0, width, height); glDisable(SCISSOR_TEST); glClearColor(clear[0], clear[1], clear[2], clear[3]); glClear(COLOR_BUFFER_BIT); + glEnable(SCISSOR_TEST); + }; + let draw = |batches: &[Batch]| unsafe { + for batch in batches { + let [x, y, w, h] = batch.scissor.map(|value| value as GLint); + glScissor(x, height - y - h, w, h); + let (texture, [src_rgb, dst_rgb, src_alpha, dst_alpha]) = match batch.blend { + Blend::Over => ( + gpu.atlas.name, + [SRC_ALPHA, ONE_MINUS_SRC_ALPHA, ONE, ONE_MINUS_SRC_ALPHA], + ), + Blend::Image(id) => (self.images[&id].texture.name, PREMULTIPLIED), + Blend::Erase => ( + gpu.atlas.name, + [ZERO, ONE_MINUS_SRC_ALPHA, ZERO, ONE_MINUS_SRC_ALPHA], + ), + }; + glBlendFuncSeparate(src_rgb, dst_rgb, src_alpha, dst_alpha); + glBindTexture(TEXTURE_2D, texture); + glDrawArrays( + TRIANGLES, + batch.vertices.start as GLint, + batch.vertices.len() as GLsizei, + ); + } + }; + unsafe { + glEnable(FRAMEBUFFER_SRGB); + glViewport(0, 0, width, height); glUseProgram(gpu.program); glUniform1i(glGetUniformLocation(gpu.program, c"atlas".as_ptr()), 0); glActiveTexture(TEXTURE0); @@ -401,32 +489,26 @@ impl Renderer { ); } glEnable(BLEND); - glEnable(SCISSOR_TEST); - for batch in &self.batches { - let [x, y, w, h] = batch.scissor.map(|value| value as GLint); - glScissor(x, height - y - h, w, h); - let (texture, [src_rgb, dst_rgb, src_alpha, dst_alpha]) = match batch.blend { - Blend::Over => ( - &gpu.atlas, - [SRC_ALPHA, ONE_MINUS_SRC_ALPHA, ONE, ONE_MINUS_SRC_ALPHA], - ), - Blend::Image(id) => ( - &self.images[&id].texture, - [ONE, ONE_MINUS_SRC_ALPHA, ONE, ONE_MINUS_SRC_ALPHA], - ), - Blend::Erase => ( - &gpu.atlas, - [ZERO, ONE_MINUS_SRC_ALPHA, ZERO, ONE_MINUS_SRC_ALPHA], - ), - }; + for (group, picture) in self.groups.iter().zip(&gpu.groups) { + begin(picture, [0.0; 4]); + draw(&self.batches[group.batches.clone()]); + } + begin(target, clear); + let mut next = 0; + for (group, picture) in self.groups.iter().zip(&gpu.groups) { + draw(&self.batches[next..group.batches.start]); + glScissor(0, 0, width, height); + let [src_rgb, dst_rgb, src_alpha, dst_alpha] = PREMULTIPLIED; glBlendFuncSeparate(src_rgb, dst_rgb, src_alpha, dst_alpha); - glBindTexture(TEXTURE_2D, texture.name); + glBindTexture(TEXTURE_2D, picture.texture.name); glDrawArrays( TRIANGLES, - batch.vertices.start as GLint, - batch.vertices.len() as GLsizei, + group.composite.start as GLint, + group.composite.len() as GLsizei, ); + next = group.batches.end; } + draw(&self.batches[next..]); glDisable(SCISSOR_TEST); glDisable(BLEND); glBindFramebufferEXT(FRAMEBUFFER, 0); @@ -434,6 +516,33 @@ impl Renderer { } } +/// A program from `source`'s two stages, its attributes bound in order. +unsafe fn program(source: &str, attributes: &[&CStr]) -> Result { + unsafe { + let vertex = compile( + VERTEX_SHADER, + &format!("#version 120\n#define VERTEX\n{source}"), + )?; + let fragment = compile(FRAGMENT_SHADER, &format!("#version 120\n{source}"))?; + let program = glCreateProgram(); + glAttachShader(program, vertex); + glAttachShader(program, fragment); + for (index, name) in attributes.iter().enumerate() { + glBindAttribLocation(program, index as GLuint, name.as_ptr()); + } + glLinkProgram(program); + let mut linked = 0; + glGetProgramiv(program, LINK_STATUS, &mut linked); + if linked == 0 { + return Err(format!( + "Linking a shader failed: {}", + log(program, glGetProgramiv, glGetProgramInfoLog) + )); + } + Ok(program) + } +} + unsafe fn compile(kind: GLenum, source: &str) -> Result { unsafe { let shader = glCreateShader(kind); @@ -444,7 +553,7 @@ unsafe fn compile(kind: GLenum, source: &str) -> Result { glGetShaderiv(shader, COMPILE_STATUS, &mut compiled); if compiled == 0 { return Err(format!( - "Compiling the draw shader failed: {}", + "Compiling a shader failed: {}", log(shader, glGetShaderiv, glGetShaderInfoLog) )); } diff --git a/crates/draw/src/render/translucent.glsl b/crates/draw/src/render/translucent.glsl new file mode 100644 index 0000000000000000000000000000000000000000..8a237a94cfe1d68c3f7a3027b57f68617069eb3e --- /dev/null +++ b/crates/draw/src/render/translucent.glsl @@ -0,0 +1,22 @@ +// GLSL 1.20 twin of translucent.wgsl: the frame, blended in linear light, premultiplied +// again in sRGB for a window server that composites that way. +#ifdef VERTEX +attribute vec2 position; + +void main() { + gl_Position = vec4(position, 0.0, 1.0); +} +#else +uniform sampler2D frame; +uniform vec2 size; + +vec3 encode(vec3 linear) { + return mix(1.055 * pow(linear, vec3(1.0 / 2.4)) - 0.055, linear * 12.92, + vec3(lessThanEqual(linear, vec3(0.0031308)))); +} + +void main() { + vec4 pixel = texture2D(frame, gl_FragCoord.xy / size); + gl_FragColor = pixel.a == 0.0 ? vec4(0.0) : vec4(encode(pixel.rgb / pixel.a) * pixel.a, pixel.a); +} +#endif diff --git a/crates/draw/src/render/webgpu.rs b/crates/draw/src/render/webgpu.rs index 53fe78bde98dcb7e76089c2be856be2af72aa76a..3ca92bf521955e457b57b910a93c7b590e3913a2 100644 --- a/crates/draw/src/render/webgpu.rs +++ b/crates/draw/src/render/webgpu.rs @@ -6,6 +6,9 @@ pub type Target = wgpu::TextureView; pub(super) type Image = wgpu::BindGroup; +/// Whether an offscreen picture's rows run bottom first. +pub(super) const FLIPPED: bool = false; + pub(super) struct Gpu { pipeline: wgpu::RenderPipeline, image_pipeline: wgpu::RenderPipeline, @@ -14,6 +17,10 @@ pub(super) struct Gpu { bind_group: wgpu::BindGroup, image_sampler: wgpu::Sampler, vertex_buffer: wgpu::Buffer, + format: wgpu::TextureFormat, + /// Offscreen pictures for groups, the target's size, and how each is sampled. + groups: Vec<(wgpu::TextureView, wgpu::BindGroup)>, + groups_size: [u32; 2], } impl Renderer { @@ -56,7 +63,7 @@ impl Renderer { buffers: &[Some(wgpu::VertexBufferLayout { array_stride: size_of::() as u64, step_mode: wgpu::VertexStepMode::Vertex, - attributes: &wgpu::vertex_attr_array![0 => Float32x2, 1 => Float32x2, 2 => Float32x4, 3 => Float32x2, 4 => Float32x4, 5 => Float32], + attributes: &wgpu::vertex_attr_array![0 => Float32x2, 1 => Float32x2, 2 => Float32x4, 3 => Float32x2, 4 => Float32x4, 5 => Float32, 6 => Float32x2, 7 => Float32x3], })], }, fragment: Some(wgpu::FragmentState { @@ -148,6 +155,9 @@ impl Renderer { bind_group, image_sampler, vertex_buffer, + format, + groups: Vec::new(), + groups_size: [0; 2], }, ) } @@ -225,29 +235,58 @@ impl Renderer { }) } - /// Clears `target` to linear `clear` and draws the prepared batches. - pub(super) fn submit(&mut self, target: &Target, clear: [f32; 4]) { + /// An offscreen picture `size` large for each group, kept for later frames. + fn group_pictures(&mut self, size: [u32; 2]) { + let gpu = &mut self.gpu; + if gpu.groups_size != size { + gpu.groups.clear(); + gpu.groups_size = size; + } + while gpu.groups.len() < self.groups.len() { + let texture = self.device.create_texture(&wgpu::TextureDescriptor { + label: Some("Draw group"), + size: wgpu::Extent3d { + width: size[0], + height: size[1], + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: gpu.format, + usage: wgpu::TextureUsages::RENDER_ATTACHMENT + | wgpu::TextureUsages::TEXTURE_BINDING, + view_formats: &[], + }); + let view = texture.create_view(&Default::default()); + let binding = self.device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("Draw group"), + layout: &gpu.pipeline.get_bind_group_layout(0), + entries: &[ + wgpu::BindGroupEntry { + binding: 0, + resource: wgpu::BindingResource::TextureView(&view), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: wgpu::BindingResource::Sampler(&gpu.image_sampler), + }, + ], + }); + gpu.groups.push((view, binding)); + } + } + + /// Clears `target`, `size` device pixels, to linear `clear` and draws the prepared + /// batches, each group's offscreen first. + pub(super) fn submit(&mut self, target: &Target, size: [u32; 2], clear: [f32; 4]) { + self.group_pictures(size); let gpu = &self.gpu; self.queue .write_buffer(&gpu.vertex_buffer, 0, bytemuck::cast_slice(&self.vertices)); let mut encoder = self.device.create_command_encoder(&Default::default()); - { - let [r, g, b, a] = clear.map(f64::from); - let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { - label: Some("Draw"), - color_attachments: &[Some(wgpu::RenderPassColorAttachment { - view: target, - depth_slice: None, - resolve_target: None, - ops: wgpu::Operations { - load: wgpu::LoadOp::Clear(wgpu::Color { r, g, b, a }), - store: wgpu::StoreOp::Store, - }, - })], - ..Default::default() - }); - pass.set_vertex_buffer(0, gpu.vertex_buffer.slice(..)); - for batch in &self.batches { + let draw = |pass: &mut wgpu::RenderPass, batches: &[Batch]| { + for batch in batches { let [x, y, width, height] = batch.scissor; pass.set_scissor_rect(x, y, width, height); let (pipeline, binding) = match batch.blend { @@ -259,7 +298,41 @@ impl Renderer { pass.set_bind_group(0, binding, &[]); pass.draw(batch.vertices.clone(), 0..1); } + }; + let begin = |encoder: &mut wgpu::CommandEncoder, view, clear: [f32; 4]| { + let [r, g, b, a] = clear.map(f64::from); + let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("Draw"), + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + view, + depth_slice: None, + resolve_target: None, + ops: wgpu::Operations { + load: wgpu::LoadOp::Clear(wgpu::Color { r, g, b, a }), + store: wgpu::StoreOp::Store, + }, + })], + ..Default::default() + }); + pass.set_vertex_buffer(0, gpu.vertex_buffer.slice(..)); + pass.forget_lifetime() + }; + for (group, (view, _)) in self.groups.iter().zip(&gpu.groups) { + let mut pass = begin(&mut encoder, view, [0.0; 4]); + draw(&mut pass, &self.batches[group.batches.clone()]); } + let mut pass = begin(&mut encoder, target, clear); + let mut next = 0; + for (group, (_, binding)) in self.groups.iter().zip(&gpu.groups) { + draw(&mut pass, &self.batches[next..group.batches.start]); + pass.set_scissor_rect(0, 0, size[0], size[1]); + pass.set_pipeline(&gpu.image_pipeline); + pass.set_bind_group(0, binding, &[]); + pass.draw(group.composite.clone(), 0..1); + next = group.batches.end; + } + draw(&mut pass, &self.batches[next..]); + drop(pass); self.queue.submit([encoder.finish()]); } } diff --git a/crates/notebook/examples/keystroke_probe.rs b/crates/notebook/examples/keystroke_probe.rs index d0579cd7c47d4792a6888875e22b1a5cecafc4d5..c45738831aa25a2046eb1bf7d1fb0df216e49473 100644 --- a/crates/notebook/examples/keystroke_probe.rs +++ b/crates/notebook/examples/keystroke_probe.rs @@ -136,6 +136,7 @@ fn main() -> Result<(), Box> { created: None, margin_origin: [0.0; 2], color: None, + rule_lines: None, objects: vec![PageObject::Outline(Outline { id: new_id()?, title: false, diff --git a/crates/notebook/examples/open_probe.rs b/crates/notebook/examples/open_probe.rs index d3370a13769574a67c332621068a8b3520e5cd34..dd7db91cbe429a8f1a23e77dad6a72f7a91a3364 100644 --- a/crates/notebook/examples/open_probe.rs +++ b/crates/notebook/examples/open_probe.rs @@ -45,6 +45,7 @@ fn generated(count: usize) -> Result, Box> { created: None, margin_origin: [0.0; 2], color: None, + rule_lines: None, objects: vec![PageObject::Outline(Outline { id: new_id()?, title: false, diff --git a/crates/notebook/examples/save_probe.rs b/crates/notebook/examples/save_probe.rs index 6020ced7fe383cb16bb0df7a32a740613cf93674..bcd4563af8cab94d082f6a44a91cc76e0df08a24 100644 --- a/crates/notebook/examples/save_probe.rs +++ b/crates/notebook/examples/save_probe.rs @@ -51,6 +51,7 @@ fn main() -> Result<(), Box> { created: None, margin_origin: [0.0; 2], color: None, + rule_lines: None, objects: vec![PageObject::Outline(Outline { id: new_id()?, title: false, diff --git a/crates/notebook/src/merge.rs b/crates/notebook/src/merge.rs index c817e8945c7ecef4b8f809d6c58960cfd9ec6782..2d7d924db748fd6073d4faa8ab30195fab5f0ee9 100644 --- a/crates/notebook/src/merge.rs +++ b/crates/notebook/src/merge.rs @@ -153,7 +153,7 @@ fn named(op: &PageOp) -> Vec { | PageOp::Equation { text, .. } | PageOp::Split { text, .. } => vec![*text], PageOp::Date { fields, .. } => fields.iter().map(|(text, _)| *text).collect(), - PageOp::Color(_) => Vec::new(), + PageOp::Color(_) | PageOp::RuleLines(_) => Vec::new(), PageOp::Join { left, right } => vec![*left, *right], PageOp::Insert { container, .. } => vec![*container], PageOp::Move { object, .. } @@ -655,8 +655,11 @@ impl Diff { self.region(text)?; } } - // A page's colour is the latest one set, whichever side set it. - PageOp::Insert { .. } | PageOp::Add { .. } | PageOp::Color(_) => {} + // A page's colour and rule lines are the latest set, whichever side set them. + PageOp::Insert { .. } + | PageOp::Add { .. } + | PageOp::Color(_) + | PageOp::RuleLines(_) => {} PageOp::Move { object, parent, @@ -692,9 +695,10 @@ impl Diff { // What the edit sets, as each side has it. let part = |index: &Index| { index.nodes.get(object).map(|node| match (&node.own, edit) { - (Own::Outline { layout, .. }, OutlineEdit::Position { .. }) => { - (layout.x, layout.y, None, None) - } + ( + Own::Outline { layout, .. } | Own::Ink(Ink { layout, .. }), + OutlineEdit::Position { .. }, + ) => (layout.x, layout.y, None, None), (Own::Outline { layout, .. }, OutlineEdit::Width { .. }) => ( layout.max_width, layout.reserved_width, diff --git a/crates/onestore/README.md b/crates/onestore/README.md index 219631190dc9b188078a9a8fa80c21c075b06cfd..926c384d1f5cfea1ae8fe2dd3c82b4b6f848e3e1 100644 --- a/crates/onestore/README.md +++ b/crates/onestore/README.md @@ -46,7 +46,7 @@ harness also accepts `--client-profile release`. | `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 | | `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 | | `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 | -| `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` | +| `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` | | `protected::UnlockedSection` (optional feature) | Own decoded buffers for explicit known-password inspection; clear those buffers on drop; derived document strings/exports remain caller-owned | | `create_section` | Create one page containing one plain-text paragraph and an author, including Unicode | | `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 identity and creation time, as a page moved to the recycle bin keeps them. The page has no body outlines or applied template; `create_empty_section` makes a section for such pages, and `PageOp::Color` sets or clears a page's colour (`0x14001d2a` on the page node, -absent for "No color"). +absent for "No color"), and `PageOp::RuleLines` its rule lines (six properties on the page +node, absent for None; [rule-lines](../../corpus/rule-lines/README.md)). Retain the intent to preserve its page, title and space identities; existing identities require reconciliation before retry. Body insertion and title edits use those identities through page ops. [Native page-creation fixtures](../../corpus/page-lifecycle/creation/README.md) diff --git a/crates/onestore/src/op/apply.rs b/crates/onestore/src/op/apply.rs index a5292c09b170ee1efdd2601e506d2f3d07ceb789..819ca1c0a0dff81edbd7678660725afa9bc76b10 100644 --- a/crates/onestore/src/op/apply.rs +++ b/crates/onestore/src/op/apply.rs @@ -638,6 +638,26 @@ impl<'a> Writer<'_, 'a> { } Ok(BTreeMap::from([(node, object)])) }), + PageOp::RuleLines(lines) => self.write(|page| { + let [node] = crate::active::manifest_pages(&page.view)[..] else { + return Err(invalid("The page has no page node to rule")); + }; + let mut object = + crate::write::PropertyObject::from_object(&page.live.revision.objects[&node])?; + // OneNote's "None" removes the properties. + match lines { + Some(lines) => { + let properties = lines.properties(); + let values: Vec<_> = properties + .iter() + .map(|(id, value)| (*id, &value[..])) + .collect(); + object.set(&values)? + } + None => object.remove(&crate::page::RULE_LINES)?, + } + Ok(BTreeMap::from([(node, object)])) + }), PageOp::Insert { container, before, diff --git a/crates/onestore/src/op/lower.rs b/crates/onestore/src/op/lower.rs index befc6740028540750b1624f9b54ecf54819332b0..8e451030e1e86ec086cb5234163deb2d348cf222 100644 --- a/crates/onestore/src/op/lower.rs +++ b/crates/onestore/src/op/lower.rs @@ -45,6 +45,7 @@ pub fn lower( created: None, margin_origin: [0.0; 2], color: None, + rule_lines: None, objects: vec![PageObject::Outline(Outline { id: container, title: false, @@ -1424,8 +1425,25 @@ impl Lowering { } fn strokes(&mut self, stored: &crate::page::Ink, ink: &crate::page::Ink) -> Result<(), Error> { - if stored.layout != ink.layout || stored.groups != ink.groups { - return Err(invalid("Ink position and groups stay as stored")); + let placed = |layout: &crate::document::Layout| crate::document::Layout { + x: None, + y: None, + ..layout.clone() + }; + if placed(&stored.layout) != placed(&ink.layout) || stored.groups != ink.groups { + return Err(invalid("Ink size and groups stay as stored")); + } + if (stored.layout.x, stored.layout.y) != (ink.layout.x, ink.layout.y) { + let (Some(x), Some(y)) = (ink.layout.x, ink.layout.y) else { + return Err(invalid("An ink position needs both coordinates")); + }; + self.emit(PageOp::Outline { + object: ink.id, + edit: crate::OutlineEdit::Position { x, y }, + })?; + if stored.strokes == ink.strokes { + return Ok(()); + } } let mut add = Vec::new(); for stroke in &ink.strokes { @@ -1569,6 +1587,9 @@ impl Lowering { if before.color != after.color { self.emit(PageOp::Color(after.color))?; } + if before.rule_lines != after.rule_lines { + self.emit(PageOp::RuleLines(after.rule_lines))?; + } if before.created == after.created && shown { return Ok(()); } diff --git a/crates/onestore/src/op/mod.rs b/crates/onestore/src/op/mod.rs index 14d00eb55180e548a797a1b34a2a5e7baf9b58ff..b5542666a19497bbc9d18b3b2cd8fc1505540ef5 100644 --- a/crates/onestore/src/op/mod.rs +++ b/crates/onestore/src/op/mod.rs @@ -88,6 +88,8 @@ pub enum PageOp { }, /// The page's colour (View, Page Color), COLORREF; `None` is OneNote's "No color". Color(Option), + /// The page's rule lines (View, Rule Lines); `None` is OneNote's "None". + RuleLines(Option), /// Inserts paragraphs before a direct child of `container`, or last. Children follow /// their parents in `paragraphs`; levels are absolute. Text keeps its spans' formats; /// lists, tags, styles and collapse state are set by their own ops. diff --git a/crates/onestore/src/op/model.rs b/crates/onestore/src/op/model.rs index 130e957b0fe48b37c374193a76e54549954227ec..3948e5213b25a11b472450b12bbaa8f2e26c7100 100644 --- a/crates/onestore/src/op/model.rs +++ b/crates/onestore/src/op/model.rs @@ -627,6 +627,7 @@ fn interpret(page: &mut Page, op: &PageOp) -> Result<(), Error> { page.created = Some(*created); } PageOp::Color(color) => page.color = *color, + PageOp::RuleLines(lines) => page.rule_lines = *lines, PageOp::Text { text, range, with } => { let object = text_mut(page, *text)?; object.text = replace_text(&object.text, range.clone(), with)?; @@ -923,9 +924,12 @@ fn interpret(page: &mut Page, op: &PageOp) -> Result<(), Error> { PageOp::Outline { object, edit } => match edit { OutlineEdit::Collapsed(value) => paragraph_mut(page, *object)?.collapsed = *value, OutlineEdit::Position { x, y } => { - let outline = outline_mut(page, *object)?; - outline.layout.x = Some(stored(*x)); - outline.layout.y = Some(stored(*y)); + let layout = match page.objects.iter_mut().find(|o| o.id() == *object) { + Some(PageObject::Ink(ink)) => &mut ink.layout, + _ => &mut outline_mut(page, *object)?.layout, + }; + layout.x = Some(stored(*x)); + layout.y = Some(stored(*y)); retitle(page, None); } OutlineEdit::Width { points, user_set } => { diff --git a/crates/onestore/src/op/tests.rs b/crates/onestore/src/op/tests.rs index 398576c7095b0fd4b2b489396836bac1f33da432..95488f28ebfdedfdfcff7882bcfada67eefdb74d 100644 --- a/crates/onestore/src/op/tests.rs +++ b/crates/onestore/src/op/tests.rs @@ -8,7 +8,8 @@ use crate::{ document::{Document, Format, Kind, Layout}, page::{ Attachment, Definition, Image, Ink, InkStroke, Outline, PageObject, PageParagraph, - ParagraphContent, Table, TableCell, TableColumn, TableRow, TextObject, text::new_id, + ParagraphContent, Table, TableCell, TableColumn, TableRow, TextObject, VerticalRule, + text::new_id, }, write::GUIDS, }; @@ -352,11 +353,12 @@ pub(super) enum Family { AttachmentRename, NewInk, InkStrokes, + InkPosition, Link, Equation, } -pub(super) const FAMILIES: [Family; 33] = [ +pub(super) const FAMILIES: [Family; 34] = [ Family::Text, Family::Format, Family::Insert, @@ -388,6 +390,7 @@ pub(super) const FAMILIES: [Family; 33] = [ Family::AttachmentRename, Family::NewInk, Family::InkStrokes, + Family::InkPosition, Family::Link, Family::Equation, ]; @@ -1223,6 +1226,17 @@ pub(super) fn mutate(page: &mut Page, rng: &mut Rng, family: Family) -> bool { } true } + Family::InkPosition => { + let Some(ink) = page.objects.iter_mut().find_map(|o| match o { + PageObject::Ink(ink) => Some(ink), + _ => None, + }) else { + return false; + }; + ink.layout.x = Some((rng.next() % 40) as f32 - 20.0); + ink.layout.y = Some((rng.next() % 90) as f32); + true + } } } @@ -2769,3 +2783,177 @@ fn a_page_date_stores_what_onenote_stores() { .unwrap(); } } + +/// OneNote 2010's View, Rule Lines (`corpus/rule-lines/native`): each page is titled for +/// what its menu gave it, and "Narrow" was ruled, then set back to None. +const RULED: &[u8] = include_bytes!("../../../../corpus/rule-lines/native/notebook/Rules.one"); + +/// The rule-line properties on `space`'s page node, in stored order. +fn rule_properties(image: &[u8], space: ExGuid) -> Vec<(u32, Vec)> { + let store = Store::parse(image).unwrap(); + let index = RevisionIndex::parse(&store).unwrap(); + let revision = index.resolve(space, index.active(space).unwrap()).unwrap(); + let document = Document::parse(&index).unwrap(); + let [node] = crate::active::manifest_pages(document.active(space).unwrap())[..] else { + panic!("one page node") + }; + let crate::ObjectData::Properties(bytes) = revision.objects[&node].data else { + panic!("a page node's properties") + }; + crate::PropertySets::parse(bytes).unwrap().sets[0] + .iter() + .filter(|property| crate::page::RULE_LINES.contains(&property.id)) + .map(|property| match property.value { + crate::Value::Bytes(bytes) => (property.id, bytes.to_vec()), + _ => panic!("rule lines are scalars"), + }) + .collect() +} + +/// Rule lines read as OneNote's page XML reports them, and the `RuleLines` op stores the +/// properties OneNote stores, in its order, or removes them for None. +#[test] +fn rule_lines_store_what_onenote_stores() { + let apply = |section: &mut Section, op| { + section.apply( + "Author", + &Edit { + at: AT, + ops: vec![op], + }, + ) + }; + let titled = |image: &[u8], title: &str| { + *pages(image) + .iter() + .find(|space| read(image, **space).title == title) + .unwrap() + }; + let lines = |title| read(RULED, titled(RULED, title)).rule_lines; + let blue = 0xfdebca; + assert_eq!(lines("None"), None); + assert_eq!(lines("Narrow"), None); + // The page XML reports these spacings in points. + for (title, spacing, vertical) in [ + ("College", 23.76, None), + ("Standard", 33.12, None), + ("Wide", 46.8, None), + ("SmallGrid", 12.0, Some(blue)), + ("MediumGrid", 28.3464, Some(0xffc9e0)), + ("LargeGrid", 42.5196, Some(blue)), + ("VeryLargeGrid", 56.6928, Some(blue)), + ] { + let lines = lines(title).unwrap(); + assert!((lines.spacing * 36.0 - spacing).abs() < 1e-3, "{title}"); + assert_eq!(lines.color, vertical.unwrap_or(blue), "{title}"); + assert_eq!( + lines.vertical, + match vertical { + Some(color) => VerticalRule::Grid { + spacing: lines.spacing, + color, + }, + None => VerticalRule::Margin(0x5050ff), + }, + "{title}" + ); + } + assert_eq!(lines("ColorRed").unwrap().color, 0xd6d4ff); + // "Hidden" is Standard in the menu's "" colour, white, on a Teal page. + assert_eq!(lines("Hidden").unwrap().color, 0xffffff); + + // Each page's lines given to the unruled page store what OneNote stored for them. + let arena = Arena::default(); + let bare = titled(RULED, "None"); + for title in [ + "College", + "Standard", + "Wide", + "SmallGrid", + "MediumGrid", + "LargeGrid", + "VeryLargeGrid", + "ColorRed", + "Hidden", + ] { + let mut section = Section::open(&arena, RULED.to_vec()).unwrap(); + let op = PageOp::RuleLines(lines(title)); + let mut predicted = section.page(bare).unwrap(); + model::apply(&mut predicted, &op).unwrap(); + apply(&mut section, Op::Page { space: bare, op }).unwrap(); + section.seal().unwrap(); + let image = section.image(); + assert_eq!(read(&image, bare), predicted, "{title}"); + assert_eq!( + rule_properties(&image, bare), + rule_properties(RULED, titled(RULED, title)), + "{title}" + ); + } + + // The candidate: rules added, changed between ruled and grid, and removed on OneNote's + // pages, and a created page ruled. + let mut section = Section::open(&arena, RULED.to_vec()).unwrap(); + let creation = crate::PageCreation::new(None, Some("Created"), "Author").unwrap(); + apply( + &mut section, + Op::Section(SectionOp::Create(creation.clone())), + ) + .unwrap(); + let wide = lines("Wide"); + let red_narrow = crate::page::RuleLines { + spacing: f32::from_bits(0x3ebe_f8d2), + color: 0xd6d4ff, + vertical: VerticalRule::Margin(0x5050ff), + }; + let edits = [ + (bare, wide), + (titled(RULED, "College"), None), + (titled(RULED, "Standard"), lines("SmallGrid")), + (titled(RULED, "MediumGrid"), Some(red_narrow)), + (creation.space(), lines("VeryLargeGrid")), + ]; + for (space, lines) in edits { + apply( + &mut section, + Op::Page { + space, + op: PageOp::RuleLines(lines), + }, + ) + .unwrap(); + section.seal().unwrap(); + } + let image = section.image(); + RevisionIndex::parse(&Store::parse(&image).unwrap()) + .unwrap() + .validate_current() + .unwrap(); + for (space, lines) in edits { + assert_eq!(read(&image, space).rule_lines, lines); + } + assert!(rule_properties(&image, titled(&image, "College")).is_empty()); + + // The whole-page lowering reaches the same pages through the same op. + let before = read(RULED, bare); + let mut after = before.clone(); + after.rule_lines = wide; + assert_eq!( + lower_page(&before, &after).unwrap(), + [PageOp::RuleLines(wide)] + ); + + if let Some(directory) = std::env::var_os("ONESTORE_RULE_LINES_EXPORT") { + let directory = std::path::Path::new(&directory); + std::fs::create_dir_all(directory).unwrap(); + std::fs::write(directory.join("Rules.one"), &image).unwrap(); + std::fs::copy( + concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../corpus/rule-lines/native/notebook/Open Notebook.onetoc2" + ), + directory.join("Open Notebook.onetoc2"), + ) + .unwrap(); + } +} diff --git a/crates/onestore/src/outline.rs b/crates/onestore/src/outline.rs index 8486e6badb744a9c26e321c0a9e7ba6e8a4f8d1e..85e51698be4f74ba52823924ab2f41c4141d394c 100644 --- a/crates/onestore/src/outline.rs +++ b/crates/onestore/src/outline.rs @@ -13,7 +13,8 @@ use std::collections::{BTreeMap, BTreeSet}; #[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub enum OutlineEdit { - /// Moves an ordinary page outline to coordinates measured in points. + /// Moves an ordinary page outline to coordinates measured in points, or offsets a page's + /// ink drawing from its strokes' coordinates by that much, as OneNote 2010 moves one. /// Refreshes automatic page titles when the leading outline changes. Position { x: f32, y: f32 }, /// Changes an ordinary outline's maximum width in points, at least 36. @@ -63,7 +64,17 @@ impl OutlineEdit { return Err(invalid("Select a paragraph for its saved expansion state")); } } - _ => { + Self::Position { .. } => { + if !matches!(node.kind, Kind::Outline { .. } | Kind::Ink { .. }) + || parents.get(&object).map(Vec::as_slice) != Some(pages.as_slice()) + || !view.nodes[page].children.contains(&object) + { + return Err(invalid( + "Select an ordinary outline or ink drawing directly on the page", + )); + } + } + Self::Width { .. } => { if !matches!(node.kind, Kind::Outline { .. }) || parents.get(&object).map(Vec::as_slice) != Some(pages.as_slice()) || !view.nodes[page].children.contains(&object) diff --git a/crates/onestore/src/page/ink.rs b/crates/onestore/src/page/ink.rs index e2f6016f637058089b07212fae51d2a306c212a4..881a2222ddac97a8fdccae1d17a4a85596bfc483 100644 --- a/crates/onestore/src/page/ink.rs +++ b/crates/onestore/src/page/ink.rs @@ -27,6 +27,7 @@ const DIMENSION_Y: [u8; 16] = [ #[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] pub struct Ink { pub id: ExGuid, + /// A drawing's `x` and `y` offset every stroke point, as OneNote moves ink. pub layout: Layout, pub strokes: Vec, /// Nested ink containers, as newer OneNote versions group handwriting. @@ -36,7 +37,7 @@ pub struct Ink { #[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] pub struct InkStroke { pub id: ExGuid, - /// Absolute page coordinates in points. + /// Page coordinates in points, before the drawing's offset. pub points: Vec<[f32; 2]>, /// Pen width and height in points. pub width: f32, diff --git a/crates/onestore/src/page/mod.rs b/crates/onestore/src/page/mod.rs index 593424ca89729c08126cd447b87459330b5c6dbd..9006bcee87082032d060d61da2d1775baa8a3ad6 100644 --- a/crates/onestore/src/page/mod.rs +++ b/crates/onestore/src/page/mod.rs @@ -56,6 +56,94 @@ pub use text::Paragraph; /// MS-ONE, observed in OneNote 2010's pages (`corpus/notebook-management/native/page-color`). pub(crate) const PAGE_COLOR: u32 = 0x14001d2a; +/// The page node's rule lines (View, Rule Lines), as OneNote 2010 orders them; not in MS-ONE, +/// observed in its pages (`corpus/rule-lines/native`). Each direction stores a kind (1 grid, +/// 2 ruled, 3 margin line), a spacing in half inches and a colour as 0xAARRGGBB. +pub(crate) const RULE_LINES: [u32; 6] = [ + HORIZONTAL_COLOR, + HORIZONTAL_KIND, + HORIZONTAL_SPACING, + VERTICAL_KIND, + VERTICAL_SPACING, + VERTICAL_COLOR, +]; +const HORIZONTAL_KIND: u32 = 0x14001cd3; +const HORIZONTAL_SPACING: u32 = 0x14001cd4; +const HORIZONTAL_COLOR: u32 = 0x14001cd5; +const VERTICAL_KIND: u32 = 0x14001cd6; +const VERTICAL_SPACING: u32 = 0x14001cd7; +const VERTICAL_COLOR: u32 = 0x14001cd8; + +/// A page's rule lines (View, Rule Lines): horizontal lines every `spacing` from the margin +/// origin down, crossed by a margin line or a grid's vertical lines. Spacings are in half +/// inches, as stored, so OneNote's presets survive exactly. +#[derive(Clone, Copy, Debug, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct RuleLines { + pub spacing: f32, + /// COLORREF. + pub color: u32, + pub vertical: VerticalRule, +} + +#[derive(Clone, Copy, Debug, PartialEq, serde::Serialize, serde::Deserialize)] +pub enum VerticalRule { + /// One line just left of the margin origin, COLORREF. + Margin(u32), + /// Lines every `spacing` either side of the margin origin. + Grid { spacing: f32, color: u32 }, +} + +impl RuleLines { + /// The page node's properties for these lines, in `RULE_LINES` order. + pub(crate) fn properties(&self) -> [(u32, [u8; 4]); 6] { + let argb = |color: u32| { + let [red, green, blue, _] = color.to_le_bytes(); + [blue, green, red, 0xff] + }; + let (kinds, spacing, color) = match self.vertical { + VerticalRule::Margin(color) => ([2, 3], 0.0, color), + VerticalRule::Grid { spacing, color } => ([1, 1], spacing, color), + }; + [ + (HORIZONTAL_COLOR, argb(self.color)), + (HORIZONTAL_KIND, u32::to_le_bytes(kinds[0])), + (HORIZONTAL_SPACING, self.spacing.to_le_bytes()), + (VERTICAL_KIND, u32::to_le_bytes(kinds[1])), + (VERTICAL_SPACING, spacing.to_le_bytes()), + (VERTICAL_COLOR, argb(color)), + ] + } + + /// The lines a page node's `fields` store, if any are whole enough to draw. + fn read(fields: &[crate::document::Field<'_>]) -> Option { + let value = |id: u32| { + fields + .iter() + .find(|field| field.id == id) + .and_then(|field| match field.value { + FieldValue::Bytes(&[a, b, c, d]) => Some([a, b, c, d]), + _ => None, + }) + }; + let spacing = |id| value(id).map(f32::from_le_bytes); + let color = + |id| value(id).map(|[blue, green, red, _]| u32::from_le_bytes([red, green, blue, 0])); + value(HORIZONTAL_KIND)?; + let vertical = match value(VERTICAL_KIND).map(u32::from_le_bytes) { + Some(1) => VerticalRule::Grid { + spacing: spacing(VERTICAL_SPACING)?, + color: color(VERTICAL_COLOR)?, + }, + _ => VerticalRule::Margin(color(VERTICAL_COLOR)?), + }; + Some(Self { + spacing: spacing(HORIZONTAL_SPACING).filter(|spacing| *spacing > 0.0)?, + color: color(HORIZONTAL_COLOR)?, + vertical, + }) + } +} + /// The role of a title-outline paragraph that displays the page's creation date or time. #[derive(Clone, Copy, Debug, PartialEq, serde::Serialize, serde::Deserialize)] pub enum DateField { @@ -74,6 +162,8 @@ pub struct Page { /// The page's colour (View, Page Color), COLORREF; `None` is OneNote's "No color". #[serde(default)] pub color: Option, + #[serde(default)] + pub rule_lines: Option, pub objects: Vec, pub definitions: BTreeMap, } @@ -617,6 +707,10 @@ impl Page { _ => Err(invalid("The page color is damaged")), }) .transpose()?, + rule_lines: root + .extra + .first() + .and_then(|fields| RuleLines::read(fields)), objects: Vec::new(), definitions: BTreeMap::new(), }; diff --git a/crates/snowbound/Cargo.toml b/crates/snowbound/Cargo.toml index 9f9c14840888de0374a2236efefe3e40e9f418bd..34eade76ac9d16bc439e36851b4800d36cf41117 100644 --- a/crates/snowbound/Cargo.toml +++ b/crates/snowbound/Cargo.toml @@ -29,7 +29,7 @@ accesskit_winit = { version = "0.34.0", default-features = false, features = ["r [target.'cfg(target_os = "macos")'.dependencies] libc = "0.2" objc2 = "0.5.2" -objc2-app-kit = { version = "0.2.2", default-features = false, features = ["NSAlert", "NSButton", "NSCell", "NSControl", "NSView", "NSResponder", "NSWindow", "NSApplication", "NSEvent", "NSMenu", "NSMenuItem", "NSColor", "NSColorSpace", "NSDatePicker", "NSDatePickerCell", "NSOpenPanel", "NSPanel", "NSSavePanel"] } +objc2-app-kit = { version = "0.2.2", default-features = false, features = ["NSAlert", "NSButton", "NSCell", "NSControl", "NSView", "NSResponder", "NSWindow", "NSApplication", "NSEvent", "NSMenu", "NSMenuItem", "NSColor", "NSColorSpace", "NSDatePicker", "NSDatePickerCell", "NSOpenPanel", "NSPanel", "NSSavePanel", "NSWorkspace"] } objc2-foundation = { version = "0.2.2", default-features = false, features = ["NSString", "NSThread", "NSAttributedString", "NSDate", "NSCalendar", "NSDateFormatter", "NSGeometry", "NSLocale", "NSPathUtilities", "NSTimeZone", "NSURL", "NSArray"] } [target.'cfg(target_os = "linux")'.dependencies] diff --git a/crates/snowbound/assets/icons/full-page-view.svg b/crates/snowbound/assets/icons/full-page-view.svg deleted file mode 100644 index 0043934720d99b1ceb2f72c056d5d7a920e065b0..0000000000000000000000000000000000000000 --- a/crates/snowbound/assets/icons/full-page-view.svg +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - - - - - - - diff --git a/crates/snowbound/assets/icons/rule-lines.svg b/crates/snowbound/assets/icons/rule-lines.svg deleted file mode 100644 index e4f5a27a8d4574a9658b0fc30b61dca3ab65a99e..0000000000000000000000000000000000000000 --- a/crates/snowbound/assets/icons/rule-lines.svg +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - - - diff --git a/crates/snowbound/assets/icons/sync-busy.svg b/crates/snowbound/assets/icons/sync-busy.svg new file mode 100644 index 0000000000000000000000000000000000000000..2433d77c9c800aabf333ec780ec17ceaed4603fe --- /dev/null +++ b/crates/snowbound/assets/icons/sync-busy.svg @@ -0,0 +1,4 @@ + + + + diff --git a/crates/snowbound/assets/icons/sync-done.svg b/crates/snowbound/assets/icons/sync-done.svg new file mode 100644 index 0000000000000000000000000000000000000000..10b6c9321626d1e709a000831cdfb37ea581f098 --- /dev/null +++ b/crates/snowbound/assets/icons/sync-done.svg @@ -0,0 +1,4 @@ + + + + diff --git a/crates/snowbound/assets/icons/sync-error.svg b/crates/snowbound/assets/icons/sync-error.svg new file mode 100644 index 0000000000000000000000000000000000000000..68525b7274cd94fff9287abf752e742e909969e2 --- /dev/null +++ b/crates/snowbound/assets/icons/sync-error.svg @@ -0,0 +1,4 @@ + + + + diff --git a/crates/snowbound/assets/icons/sync-offline.svg b/crates/snowbound/assets/icons/sync-offline.svg new file mode 100644 index 0000000000000000000000000000000000000000..280bce17ea2b25e85f9dd2057c36526648a50782 --- /dev/null +++ b/crates/snowbound/assets/icons/sync-offline.svg @@ -0,0 +1,4 @@ + + + + diff --git a/crates/snowbound/src/aqua.rs b/crates/snowbound/src/aqua.rs new file mode 100644 index 0000000000000000000000000000000000000000..5f475e2829308318102b94322eff2981da57fb98 --- /dev/null +++ b/crates/snowbound/src/aqua.rs @@ -0,0 +1,324 @@ +//! Mac OS X 10.6's window, scrollers and resize grip. The window is textured, its title's +//! gradient running on through the toolbar's row. Its scrollers sit beside the content +//! rather than over it, so AppKit paints them here, into bitmaps the interface draws; it +//! resizes only from the grip, which the OpenGL surface covers, so the app draws that. + +use objc2::{ + ClassType, + declare::ClassBuilder, + msg_send, msg_send_id, + rc::{Allocated, Retained}, + runtime::{AnyClass, AnyObject, Bool, Sel}, + sel, +}; +use objc2_foundation::{MainThreadMarker, NSPoint, NSRect, NSSize}; +use std::{cell::Cell, collections::HashMap}; +use ui::{Axis, PaintedScroller, Scroller, ScrollerPart}; +use winit::{ + raw_window_handle::{HasWindowHandle, RawWindowHandle}, + window::{CursorIcon, Window}, +}; + +/// Whether the system predates 10.7, which moved scrollers over the content, gave +/// NSScroller its styles and let windows resize from any edge. +pub(crate) fn before_lion() -> bool { + let scroller = AnyClass::get("NSScroller").expect("AppKit is linked"); + let styled: bool = + unsafe { msg_send![scroller, respondsToSelector: sel!(preferredScrollerStyle)] }; + !styled +} + +/// Makes the window textured, AppKit's gradient running from the title through `row` points +/// of content below it, as 10.6's unified toolbars do. Where frames are transparent it +/// shows through. Returns whether the system draws windows so, which only 10.6 does here. +pub fn textured(window: &Window, row: f32) -> bool { + if !before_lion() { + return false; + } + let RawWindowHandle::AppKit(handle) = + window.window_handle().expect("Live AppKit window").as_raw() + else { + unreachable!() + }; + unsafe { + let view = &*handle.ns_view.as_ptr().cast::(); + let window: Retained = msg_send_id![view, window]; + let title: Retained = msg_send_id![&window, title]; + let mask: usize = msg_send![&window, styleMask]; + // NSTexturedBackgroundWindowMask. + let _: () = msg_send![&window, setStyleMask: mask | 1 << 8]; + // The style change leaves 10.6's title nil, which winit's getter can't return. + let _: () = msg_send![&window, setTitle: &*title]; + // NSMaxYEdge. + let _: () = + msg_send![&window, setAutorecalculatesContentBorderThickness: false, forEdge: 3usize]; + let _: () = msg_send![&window, setContentBorderThickness: f64::from(row), forEdge: 3usize]; + } + true +} + +thread_local! { + static DRAGS: Cell = const { Cell::new(false) }; +} + +/// Whether a press where the pointer is drags the window, which a textured window asks +/// its content of: presses on empty toolbar space do, and nowhere else. +pub fn set_window_drags(drags: bool) { + DRAGS.set(drags); +} + +/// `-mouseDownCanMoveWindow` for the content view of a textured window. +pub extern "C" fn window_drags(_: &AnyObject, _: Sel) -> Bool { + Bool::new(DRAGS.get()) +} + +/// The cursor for moving something: winit loads its move cursor from a folder of cursors +/// 10.6 doesn't have, so there it is the open hand. +pub fn move_cursor() -> CursorIcon { + if before_lion() { + CursorIcon::Grab + } else { + CursorIcon::Move + } +} + +/// NSScrollerKnob, NSScrollerKnobSlot, NSScrollerDecrementLine, NSScrollerIncrementLine. +const KNOB: usize = 2; +const SLOT: usize = 6; +const DECREMENT_LINE: usize = 4; +const INCREMENT_LINE: usize = 5; + +extern "C" fn yes(_: &AnyObject, _: Sel) -> Bool { + Bool::YES +} + +/// AppKit's scrollers, on systems whose scrollers sit beside the content. +pub fn scrollers() -> Option { + if !before_lion() { + return None; + } + MainThreadMarker::new().expect("Views belong to the main thread"); + let window_class = objc2_app_kit::NSWindow::class(); + // A scroller colours its knob by whether its window is key; these windows never show. + let mut key = ClassBuilder::new("SnowboundKeyWindow", window_class).expect("Unique class"); + unsafe { + key.add_method(sel!(isKeyWindow), yes as extern "C" fn(_, _) -> _); + key.add_method(sel!(isMainWindow), yes as extern "C" fn(_, _) -> _); + } + let key = key.register(); + let windows = [window_class, key].map(|class| unsafe { + let window: Allocated = msg_send_id![class, alloc]; + let window: Retained = msg_send_id![ + window, + initWithContentRect: NSRect::new(NSPoint::new(-10000.0, -10000.0), NSSize::new(16.0, 16.0)), + styleMask: 0usize, + backing: 2usize, + defer: true + ]; + let _: () = msg_send![&window, setReleasedWhenClosed: false]; + window + }); + let thickness: f64 = unsafe { msg_send![AnyClass::get("NSScroller").unwrap(), scrollerWidth] }; + let mut views: HashMap<(bool, bool), Retained> = HashMap::new(); + let mut painted: HashMap<[u32; 6], PaintedScroller> = HashMap::new(); + Some(ui::Scrollers { + thickness: thickness as f32, + paint: Box::new(move |request: &Scroller| { + let key = [ + u32::from(request.axis == Axis::Y), + request.length.to_bits(), + request.value.to_bits(), + request.proportion.to_bits(), + request.held.map_or(0, |part| part as u32 + 1), + u32::from(request.active), + ]; + if let Some(cached) = painted.get(&key) { + return cached.clone(); + } + let vertical = request.axis == Axis::Y; + let window = &windows[usize::from(request.active)]; + let view = views + .entry((request.active, vertical)) + .or_insert_with(|| unsafe { + let view: Allocated = + msg_send_id![AnyClass::get("NSScroller").unwrap(), alloc]; + let size = if vertical { + NSSize::new(thickness, 100.0) + } else { + NSSize::new(100.0, thickness) + }; + let view: Retained = + msg_send_id![view, initWithFrame: NSRect::new(NSPoint::new(0.0, 0.0), size)]; + let content: Retained = msg_send_id![window, contentView]; + let _: () = msg_send![&content, addSubview: &*view]; + view + }); + let result = unsafe { paint(window, view, request, thickness) }; + if painted.len() > 32 { + painted.clear(); + } + painted.insert(key, result.clone()); + result + }), + }) +} + +unsafe fn paint( + window: &AnyObject, + view: &AnyObject, + request: &Scroller, + thickness: f64, +) -> PaintedScroller { + let vertical = request.axis == Axis::Y; + let length = f64::from(request.length); + let size = if vertical { + NSSize::new(thickness, length) + } else { + NSSize::new(length, thickness) + }; + unsafe { + let _: () = msg_send![window, setContentSize: size]; + let _: () = msg_send![view, setFrame: NSRect::new(NSPoint::new(0.0, 0.0), size)]; + let _: () = msg_send![view, setEnabled: true]; + let _: () = msg_send![view, setKnobProportion: f64::from(request.proportion)]; + let _: () = msg_send![view, setDoubleValue: f64::from(request.value)]; + let bounds: NSRect = msg_send![view, bounds]; + let rep: Retained = + msg_send_id![view, bitmapImageRepForCachingDisplayInRect: bounds]; + let _: () = msg_send![view, cacheDisplayInRect: bounds, toBitmapImageRep: &*rep]; + // NSScrollerIncrementArrow, NSScrollerDecrementArrow. + let arrow = match request.held { + Some(ScrollerPart::Increment) => Some(0usize), + Some(ScrollerPart::Decrement) => Some(1usize), + _ => None, + }; + if let Some(arrow) = arrow { + let class = AnyClass::get("NSGraphicsContext").unwrap(); + let context: Retained = + msg_send_id![class, graphicsContextWithBitmapImageRep: &*rep]; + let _: () = msg_send![class, saveGraphicsState]; + let _: () = msg_send![class, setCurrentContext: &*context]; + let flipped: bool = msg_send![view, isFlipped]; + if flipped { + let transform: Retained = + msg_send_id![AnyClass::get("NSAffineTransform").unwrap(), transform]; + let _: () = msg_send![&transform, translateXBy: 0.0f64, yBy: size.height]; + let _: () = msg_send![&transform, scaleXBy: 1.0f64, yBy: -1.0f64]; + let _: () = msg_send![&transform, concat]; + } + let _: () = msg_send![view, drawArrow: arrow, highlight: true]; + let _: () = msg_send![class, restoreGraphicsState]; + } + let along = |part: usize| { + let rect: NSRect = msg_send![view, rectForPart: part]; + let (start, extent) = if vertical { + (rect.origin.y, rect.size.height) + } else { + (rect.origin.x, rect.size.width) + }; + [start as f32, (start + extent) as f32] + }; + PaintedScroller { + image: pixels(&rep), + knob: along(KNOB), + slot: along(SLOT), + decrement: along(DECREMENT_LINE), + increment: along(INCREMENT_LINE), + } + } +} + +/// An NSBitmapImageRep's pixels as premultiplied RGBA rows. +unsafe fn pixels(rep: &AnyObject) -> draw::RasterImage { + unsafe { + let width: isize = msg_send![rep, pixelsWide]; + let height: isize = msg_send![rep, pixelsHigh]; + let row: isize = msg_send![rep, bytesPerRow]; + let samples: isize = msg_send![rep, samplesPerPixel]; + // NSAlphaFirstBitmapFormat, NSAlphaNonpremultipliedBitmapFormat. + let format: usize = msg_send![rep, bitmapFormat]; + let data: *const u8 = msg_send![rep, bitmapData]; + let [width, height, row, samples] = [width, height, row, samples].map(|v| v as usize); + let bytes = std::slice::from_raw_parts(data, row * height); + let mut rgba = Vec::with_capacity(width * height * 4); + for line in bytes.chunks(row) { + for pixel in line[..width * samples].chunks(samples) { + let [r, g, b, a] = match (samples, format & 1 != 0) { + (4, true) => [pixel[1], pixel[2], pixel[3], pixel[0]], + (4, false) => [pixel[0], pixel[1], pixel[2], pixel[3]], + _ => [pixel[0], pixel[1], pixel[2], 255], + }; + let premultiply = |value: u8| (u16::from(value) * u16::from(a) / 255) as u8; + rgba.extend(if format & 2 != 0 { + [premultiply(r), premultiply(g), premultiply(b), a] + } else { + [r, g, b, a] + }); + } + } + draw::RasterImage::new([width as u32, height as u32], rgba) + .expect("A scroller's bitmap is a valid image") + } +} + +/// Where each pixel of the grip lies from the window's bottom-right corner, it takes a +/// diagonal's black at this opacity: a line, then a shade either side, as 10.6 draws it. +fn grip_alpha(diagonal: u32) -> u8 { + match diagonal % 4 { + 3 => 102, + 2 => 31, + 0 => 18, + _ => 0, + } +} + +/// 10.6's resize grip at the window's bottom-right corner, over everything the app draws, +/// where the OpenGL surface hides AppKit's own. AppKit still takes the drag. +pub fn resize_grip(ui: &mut ui::Ui, window: &Window, size: [f32; 2]) { + if !before_lion() { + return; + } + static HIDDEN: std::sync::Once = std::sync::Once::new(); + HIDDEN.call_once(|| { + let RawWindowHandle::AppKit(handle) = + window.window_handle().expect("Live AppKit window").as_raw() + else { + unreachable!() + }; + unsafe { + let view = &*handle.ns_view.as_ptr().cast::(); + let window: Retained = msg_send_id![view, window]; + let _: () = msg_send![&window, setShowsResizeIndicator: false]; + } + }); + thread_local! { + static GRIP: draw::RasterImage = { + // Three diagonals within 15 pixels of the corner, the last pixel row and column + // left clear. + let pixels = (0..15u32) + .flat_map(|y| (0..15u32).map(move |x| (x, y))) + .flat_map(|(x, y)| { + let alpha = if x < 14 && y < 14 && x + y >= 16 { + grip_alpha(x + y + 2) + } else { + 0 + }; + [0, 0, 0, alpha] + }) + .collect(); + draw::RasterImage::new([15, 15], pixels).expect("The grip is a valid image") + }; + } + GRIP.with(|grip| { + ui.leaf( + "resize grip", + ui::Spec { + flags: ui::Flags::FLOAT, + size: [ui::px(15.0), ui::px(15.0)], + position: [size[0] - 15.0, size[1] - 15.0], + image: Some(grip), + ..ui::Spec::default() + }, + ); + }); +} diff --git a/crates/snowbound/src/art.rs b/crates/snowbound/src/art.rs index 8d7e2165ec2a107e3f483a6d666cd672a3d6183e..9be17b1ef36889f6b7ee668193d237be5d5f2a48 100644 --- a/crates/snowbound/src/art.rs +++ b/crates/snowbound/src/art.rs @@ -53,16 +53,18 @@ pub const DATE_TIME: &[&str] = art!("icons/date-time"); pub const FIND_TAGS: &[&str] = art!("icons/find-tags"); pub const FORMAT_PAINTER: &[&str] = art!("icons/format-painter"); pub const FORWARD: &[&str] = art!("icons/forward"); -pub const FULL_PAGE_VIEW: &[&str] = art!("icons/full-page-view"); pub const INSERT_SPACE: &[&str] = art!("icons/insert-space"); pub const PAGE_COLOR: &[&str] = art!("icons/page-color"); pub const PASTE: &[&str] = art!("icons/paste"); pub const RECORD_AUDIO: &[&str] = art!("icons/record-audio"); pub const RECORD_VIDEO: &[&str] = art!("icons/record-video"); -pub const RULE_LINES: &[&str] = art!("icons/rule-lines"); pub const SCREEN_CLIPPING: &[&str] = art!("icons/screen-clipping"); pub const SPELLING: &[&str] = art!("icons/spelling"); pub const SYMBOL: &[&str] = art!("icons/symbol"); +pub const SYNC_BUSY: &[&str] = art!("icons/sync-busy"); +pub const SYNC_DONE: &[&str] = art!("icons/sync-done"); +pub const SYNC_ERROR: &[&str] = art!("icons/sync-error"); +pub const SYNC_OFFLINE: &[&str] = art!("icons/sync-offline"); pub const TAG_REMEMBER: &[&str] = art!("tags/remember"); pub const TAG_DEFINITION: &[&str] = art!("tags/definition"); diff --git a/crates/snowbound/src/background.rs b/crates/snowbound/src/background.rs new file mode 100644 index 0000000000000000000000000000000000000000..2a709d9618dfc0c17d75b25059e7ddf1a1faf274 --- /dev/null +++ b/crates/snowbound/src/background.rs @@ -0,0 +1,200 @@ +//! The Page Color menu: the page's colour, template art to lie behind it, and its rule +//! lines. + +use crate::{ + commands::Choice, + templates::{TILE, Thumbnails}, +}; +use canvas::{ + gpu::{Paper, colorref}, + template::{PAGE_COLORS, RULE_LINES}, +}; +use onestore::page::{RuleLines, VerticalRule}; +use ui::{Anchor, Flags, Id, Spec, Ui, fill, px}; + +const COLUMNS: usize = 9; +const SWATCH: [f32; 2] = [40.0, 32.0]; +const RULES: f32 = 40.0; +/// Points of page per point of a rule-line thumbnail. +const SHRINK: f32 = 4.0; +/// The art offered after None. +const ART: [&str; 5] = ["Ivy", "Purple Clouds", "Notebook", "Bamboo", "Blue Clouds"]; + +/// Builds popup `id` beside `anchor` while it is open, for a page on `paper` (its own colour +/// aside) coloured `color` and ruled with `rule_lines`. Returns the choice made. +#[allow(clippy::too_many_arguments)] +pub fn menu( + ui: &mut Ui, + id: Id, + anchor: Anchor, + thumbnails: &mut Thumbnails, + paper: Paper, + color: Option, + rule_lines: Option, +) -> Option { + let cells: Vec = std::iter::once(Choice::PageColor(None)) + .chain((0..PAGE_COLORS.len()).map(|index| Choice::PageColor(Some(index)))) + .chain(std::iter::once(Choice::Art(None))) + .chain(ART.map(|name| Choice::Art(Some(name)))) + .chain(std::iter::once(Choice::RuleLines(None))) + .chain((0..RULE_LINES.len()).map(|index| Choice::RuleLines(Some(index)))) + .collect(); + let groups = [ + ui::popup::Group { + heading: "Page Color", + cells: 1 + PAGE_COLORS.len(), + columns: COLUMNS, + size: SWATCH, + }, + ui::popup::Group { + heading: "Background", + cells: 1 + ART.len(), + columns: 3, + size: [TILE[0] + 8.0, TILE[1] + 8.0], + }, + ui::popup::Group { + heading: "Rule Lines", + cells: 1 + RULE_LINES.len(), + columns: COLUMNS, + size: [RULES; 2], + }, + ]; + let shown = [ + Choice::PageColor(color.map(|color| { + PAGE_COLORS + .iter() + .position(|(_, listed)| *listed == color) + .unwrap_or(usize::MAX) + })), + Choice::RuleLines(rule_lines.map(|lines| { + RULE_LINES + .iter() + .position(|(_, listed)| { + listed.spacing == lines.spacing + && matches!(listed.vertical, VerticalRule::Grid { .. }) + == matches!(lines.vertical, VerticalRule::Grid { .. }) + }) + .unwrap_or(usize::MAX) + })), + ]; + let current: Vec = shown + .iter() + .filter_map(|choice| cells.iter().position(|cell| cell == choice)) + .collect(); + let theme = ui.theme.clone(); + let scale = ui.scale(); + let dark = paper.ink[0] > paper.color[0]; + let chosen = ui::popup::gallery(ui, id, anchor, &groups, ¤t, |ui, index| { + match cells[index] { + Choice::PageColor(None) | Choice::Art(None) | Choice::RuleLines(None) => { + ui.leaf( + "none", + Spec { + size: [fill(), fill()], + text: Some("None"), + font_size: Some(theme.font_size - 2.0), + fill: Some(paper.color), + border: Some(theme.chip), + radius: 2.0, + center: true, + ..Spec::default() + }, + ); + } + Choice::PageColor(Some(index)) => { + let (name, color) = PAGE_COLORS[index]; + ui.leaf( + name, + Spec { + size: [fill(), fill()], + fill: Some(paper.colored(Some(color)).color), + border: Some(theme.chip), + radius: 2.0, + ..Spec::default() + }, + ); + } + Choice::Art(Some(name)) => { + ui.open( + name, + Spec { + flags: Flags::CLIP, + size: [px(TILE[0]), px(TILE[1])], + fill: Some(paper.color), + border: Some(theme.chip), + radius: 2.0, + ..Spec::default() + }, + ); + let art = canvas::template::find(name).map_or(&[][..], |template| { + thumbnails.art(template, paper, dark, scale) + }); + for (index, (image, [x, y, width, height])) in art.iter().enumerate() { + ui.leaf( + index, + Spec { + flags: Flags::FLOAT, + size: [px(*width), px(*height)], + position: [*x, *y], + image: Some(image), + ..Spec::default() + }, + ); + } + ui.close(); + } + Choice::RuleLines(Some(index)) => rule_thumbnail(ui, paper, RULE_LINES[index]), + _ => unreachable!("The menu offers colours, art and rule lines"), + } + })?; + Some(cells[chosen].clone()) +} + +/// A corner of a page ruled with `lines`, drawn a quarter size. +fn rule_thumbnail(ui: &mut Ui, paper: Paper, (name, lines): (&str, RuleLines)) { + let side = RULES - 8.0; + ui.open( + name, + Spec { + flags: Flags::CLIP, + size: [fill(), fill()], + fill: Some(paper.color), + border: Some(ui.theme.chip), + radius: 2.0, + ..Spec::default() + }, + ); + let line = |ui: &mut Ui, index: usize, rect: [f32; 4], color: u32| { + ui.leaf( + ("line", index), + Spec { + flags: Flags::FLOAT, + position: [rect[0], rect[1]], + size: [px(rect[2]), px(rect[3])], + fill: Some(paper.tint(colorref(color))), + ..Spec::default() + }, + ); + }; + let [x, y] = [36.0 / SHRINK, 14.4 / SHRINK]; + let step = |spacing: f32| spacing * 36.0 / SHRINK; + let mut index = 0; + let mut at = y; + while at < side { + line(ui, index, [0.0, at, side, 1.0], lines.color); + index += 1; + at += step(lines.spacing); + } + match lines.vertical { + VerticalRule::Margin(color) => line(ui, index, [x - 1.0, 0.0, 1.0, side], color), + VerticalRule::Grid { spacing, color } => { + let mut at = x % step(spacing); + while at < side { + line(ui, index, [at, 0.0, 1.0, side], color); + index += 1; + at += step(spacing); + } + } + } + ui.close(); +} diff --git a/crates/snowbound/src/commands.rs b/crates/snowbound/src/commands.rs index ceed601ca702cb39cfa1d52f35ba322151512888..b5bc521080fd18a501f43e880795f149893c4f8f 100644 --- a/crates/snowbound/src/commands.rs +++ b/crates/snowbound/src/commands.rs @@ -29,6 +29,8 @@ pub enum Id { SelectAll, Find, Search, + SearchResults, + CommandPalette, Back, Forward, ZoomIn, @@ -63,6 +65,7 @@ pub enum Id { ClearFormatting, Tag(NoteTag), RemoveTags, + FindTags, Help, } @@ -81,6 +84,10 @@ pub enum Choice { Table([usize; 2]), /// An index into `PAGE_COLORS`, or `None` for no colour. PageColor(Option), + /// An index into `RULE_LINES`, or `None` for none. + RuleLines(Option), + /// A template whose art becomes the page's background, or `None` for none. + Art(Option<&'static str>), } #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] @@ -225,6 +232,19 @@ pub const COMMANDS: &[Command] = &[ row!(Id::SelectAll, "Select All", &[cmd('a')], &[cmd('a')]), row!(Id::Find, "Find on This Page", &[cmd('f')], &[cmd('f')]), row!(Id::Search, "Search Notebooks", &[cmd('e')], &[cmd('e')]), + // OneNote for Mac searches all notebooks with Option-Command-F. + row!( + Id::SearchResults, + "Search Results Pane", + &[cmd('f').option()], + &[key('o').option()], + ), + row!( + Id::CommandPalette, + "Command Palette…", + &[cmd('p').shift()], + &[cmd('p').shift()] + ), // Option-Command-arrows move the caret; Xcode goes back with Control-Command. row!( Id::Back, @@ -252,7 +272,7 @@ pub const COMMANDS: &[Command] = &[ &[cmd('0')], &[cmd('0').option()] ), - row!(Id::Sidebar, "Sidebar", &[cmd('s').control()], NONE), + row!(Id::Sidebar, "Notebook List", &[cmd('s').control()], NONE), row!(Id::PageList, "Page List", NONE, NONE), row!(Id::DarkPages, "Dark Pages", NONE, NONE), // AppKit's Enter Full Screen takes Control-Command-F. @@ -393,6 +413,7 @@ pub const COMMANDS: &[Command] = &[ &[cmd('0').control()], &[cmd('0')] ), + row!(Id::FindTags, "Find Tags", NONE, NONE), row!(Id::Help, "Snowbound Help", NONE, NONE), ]; @@ -442,7 +463,12 @@ pub fn title(choice: Choice) -> String { .iter() .find(|(listed, _)| *listed == color) .map_or_else(String::new, |(_, name)| (*name).to_owned()), - Choice::Color(_) | Choice::List(_) | Choice::Table(_) | Choice::PageColor(_) => { + Choice::Color(_) + | Choice::List(_) + | Choice::Table(_) + | Choice::PageColor(_) + | Choice::RuleLines(_) + | Choice::Art(_) => { unreachable!("Only the toolbar offers these") } } @@ -626,10 +652,12 @@ impl State { Choice::Size(size) => return checked(format.font_size == Some(*size)), Choice::Highlight(_) | Choice::Color(_) | Choice::List(_) => return enabled(text), Choice::Table(_) => Id::Table, - Choice::PageColor(_) => Id::PageColor, + Choice::PageColor(_) | Choice::RuleLines(_) | Choice::Art(_) => Id::PageColor, }; match id { - Id::Settings | Id::NewNotebook | Id::OpenNotebook | Id::Help => enabled(!modal), + Id::Settings | Id::NewNotebook | Id::OpenNotebook | Id::CommandPalette | Id::Help => { + enabled(!modal) + } Id::CloseNotebook | Id::ShowNotebook => enabled(!modal && self.notebook().is_some()), Id::NewSection | Id::NewSectionGroup => enabled( !modal @@ -669,6 +697,14 @@ impl State { enabled: !modal && session.is_some(), checked: self.pages_open, }, + Id::SearchResults => Status { + enabled: !modal && session.is_some(), + checked: matches!(self.search.pane, Some(crate::pane::Pane::Search { .. })), + }, + Id::FindTags => Status { + enabled: !modal && session.is_some(), + checked: matches!(self.search.pane, Some(crate::pane::Pane::Tags { .. })), + }, Id::DarkPages => Status { enabled: !modal, checked: !self.light_pages, @@ -679,8 +715,12 @@ impl State { }, Id::PageColor => enabled(writable && session.is_some()), Id::ScreenClipping => enabled(text && cfg!(target_os = "macos")), - // Attach File awaits #23, Record Audio and Video #36, and Insert Space a drag mode. - Id::Attachment | Id::RecordAudio | Id::RecordVideo | Id::InsertSpace => enabled(false), + // Attach File awaits #23, Record Audio and Video #36. + Id::Attachment | Id::RecordAudio | Id::RecordVideo => enabled(false), + Id::InsertSpace => Status { + enabled: writable, + checked: self.view.inserting_space(), + }, Id::Table | Id::Picture | Id::Symbol @@ -776,7 +816,14 @@ impl State { } Choice::PageColor(color) => { let color = color.map(|index| canvas::template::PAGE_COLORS[index].1); - return self.color_page(color); + return self.paper_page(color, self.view.editor.rule_lines()); + } + Choice::Art(name) => { + return self.art_page(name.and_then(canvas::template::find)); + } + Choice::RuleLines(lines) => { + let lines = lines.map(|index| canvas::template::RULE_LINES[index].1); + return self.paper_page(self.view.editor.page_color(), lines); } }; let field = self.ui.focused().filter(|focus| *focus != page()); @@ -860,6 +907,14 @@ impl State { self.start_search(); return Ok(()); } + Id::SearchResults | Id::FindTags => { + self.toggle_pane(id == Id::FindTags); + return Ok(()); + } + Id::CommandPalette => { + self.ui.open_popup(crate::palette::id()); + return Ok(()); + } Id::Back | Id::Forward => { self.travel(id == Id::Forward); return Ok(()); @@ -917,7 +972,12 @@ impl State { self.save_settings(); return Ok(()); } - Id::Attachment | Id::RecordAudio | Id::RecordVideo | Id::InsertSpace => { + Id::Attachment | Id::RecordAudio | Id::RecordVideo => { + return Ok(()); + } + Id::InsertSpace => { + let response = self.view.insert_space(); + self.respond(response); return Ok(()); } Id::Link => { @@ -1125,8 +1185,8 @@ mod tests { fn toolbar_buttons_run_commands() { let source = include_str!("main.rs"); let start = source - .find(" fn toolbar(") - .expect("The toolbar is built in main.rs"); + .find(" fn tools(") + .expect("The toolbar's groups are built in main.rs"); let body = &source[start..]; let body = &body[..body[1..] .find("\n fn ") diff --git a/crates/snowbound/src/conflict_render.rs b/crates/snowbound/src/conflict_render.rs index 08074f5ff0a7cfde2ff8965d8b7171aba2f1fefa..be2fc6fed4d5115b0caac248ed405fc861d58db2 100644 --- a/crates/snowbound/src/conflict_render.rs +++ b/crates/snowbound/src/conflict_render.rs @@ -133,6 +133,7 @@ fn paint( ], clip: Some(rect.map(|value| value * SCALE)), backdrop: Some(ui.theme.paper), + round: None, motion: None, primitives: &primitives, }, diff --git a/crates/snowbound/src/library.rs b/crates/snowbound/src/library.rs index 1e690a1676a34de0855ee524ea01fb13ce9f196c..c01fb3cb2456537f486114433aca50ccb4ca1eb4 100644 --- a/crates/snowbound/src/library.rs +++ b/crates/snowbound/src/library.rs @@ -288,6 +288,17 @@ impl Library { Ok(section) } + /// Where `file`, a section file of this notebook, sits on this computer: under the + /// mount it was opened from when the embedded client holds it share-relative. + pub fn local(&self, file: &Path) -> Option { + if file.is_absolute() { + return Some(file.to_owned()); + } + let server = self.server.as_ref()?; + let file = file.strip_prefix(&server.mount.root).ok()?; + Some(Path::new(&self.location).join(file)) + } + /// How the notebook's files are reached. pub fn transport(&self) -> String { match (&self.server, &self.notice) { @@ -584,6 +595,43 @@ mod tests { let _ = std::fs::remove_dir_all(&cache); } + #[test] + fn share_relative_sections_show_under_their_mount() { + let library = |root: &str| Library { + location: "/Volumes/agent/lab".into(), + name: "lab".into(), + notebook: Ok(None), + server: Some(Arc::new(Server { + mount: Mount { + server: "nas".into(), + share: "agent".into(), + user: None, + domain: String::new(), + root: root.into(), + }, + login: Login { + user: String::new(), + password: String::new(), + domain: String::new(), + }, + })), + notice: None, + cache: PathBuf::new(), + }; + let shown = |root, file| library(root).local(Path::new(file)); + let under = Some(PathBuf::from("/Volumes/agent/lab/Group/New Section 1.one")); + assert_eq!( + shown("notes/lab", "notes/lab/Group/New Section 1.one"), + under + ); + assert_eq!(shown("", "Group/New Section 1.one"), under); + assert_eq!(shown("notes/lab", "elsewhere/New Section 1.one"), None); + assert_eq!( + shown("notes/lab", "/tmp/Section.one"), + Some(PathBuf::from("/tmp/Section.one")) + ); + } + #[test] fn mounts_name_their_server_share_account_and_folder() { assert_eq!( diff --git a/crates/snowbound/src/linux.rs b/crates/snowbound/src/linux.rs index 8b8d1ecd4700c393c5812cbb75178941e240c9e1..6c5861024be22516003aeeb68c6cab8890768291 100644 --- a/crates/snowbound/src/linux.rs +++ b/crates/snowbound/src/linux.rs @@ -17,7 +17,7 @@ use ui::{Flags, Spec, Ui, px}; use winit::{ error::EventLoopError, event_loop::{EventLoop, EventLoopProxy}, - platform::wayland::WindowAttributesExtWayland, + platform::wayland::{EventLoopExtWayland, WindowAttributesExtWayland}, window::{Icon, ResizeDirection, Theme, Window, WindowAttributes}, }; @@ -48,25 +48,34 @@ pub fn event_loop(_headless: bool) -> Result, EventL if desktop() == Desktop::Kde { follow_breeze_radius(event_loop.create_proxy()); } + let libadwaita = desktop() == Desktop::Gnome && event_loop.is_wayland(); + sctk_adwaita::LIBADWAITA_EDGE.store(libadwaita, Ordering::Relaxed); Ok(event_loop) } -/// How far the window manager rounds the window's corners: KWin rounds the bottom corners -/// of Breeze's borderless windows unless they are maximized or full screen. +/// How far the window's corners round unless it is maximized or full screen: KWin rounds +/// the bottom corners of Breeze's borderless windows, and on GNOME's Wayland the window cuts +/// its own to libadwaita's radius, which winit's Adwaita frame edges. pub fn corner_radius(window: &Window) -> f32 { - let units = f32::from_bits(BREEZE_RADIUS.load(Ordering::Relaxed)); - if units == 0.0 - || !window.is_decorated() - || window.is_maximized() - || window.fullscreen().is_some() - { + if !window.is_decorated() || window.is_maximized() || window.fullscreen().is_some() { return 0.0; } + if cuts_corners() { + return sctk_adwaita::WINDOW_RADIUS; + } // Breeze snaps its radius to the device pixel grid. + let units = f32::from_bits(BREEZE_RADIUS.load(Ordering::Relaxed)); let scale = window.scale_factor() as f32; (units * scale).round() / scale } +/// Whether the window erases its own bottom corners to transparent pixels: under winit's +/// Adwaita frame on GNOME's Wayland, which draws libadwaita's window edge. Mutter's X11 +/// frames stay square at the bottom, as libadwaita's own `ssd-frame` style has them. +pub fn cuts_corners() -> bool { + sctk_adwaita::LIBADWAITA_EDGE.load(Ordering::Relaxed) +} + /// Reads Breeze's corner radius now and again whenever KWin is told to reload its /// configuration, as Breeze's and the border size's settings pages do, redrawing then. fn follow_breeze_radius(proxy: EventLoopProxy) { @@ -141,8 +150,12 @@ pub fn window_attributes() -> WindowAttributes { Window::default_attributes() .with_name(APP_ID, APP_ID) .with_window_icon(icon) + .with_transparent(cuts_corners()) } +/// The window manager or the frame draws the title bar. +pub fn install_title_bar(_: &Window) {} + /// Wayland's clipboard through the window's own connection, as not every compositor offers /// a clipboard to clients without a window; X11's otherwise. pub enum Clipboard { @@ -180,10 +193,11 @@ impl Clipboard { } } -/// Whether the system draws the title bar; false only where Wayland's client-side frame -/// could not be made, leaving the app's own. +/// Whether the system draws the title bar: the window manager, or on Wayland the compositor. +/// Where winit's Adwaita frame decorates the window instead, as on GNOME, the frame has no +/// header and the toolbar's row is the title bar, as it is where no frame could be made. pub fn system_titlebar(window: &Window) -> bool { - window.is_decorated() + window.is_decorated() && !sctk_adwaita::FRAMED.load(Ordering::Relaxed) } /// Window managers show no document of a window's. @@ -463,41 +477,63 @@ fn adwaita_titlebar(appearance: Theme) -> [[u8; 3]; 2] { }) } -/// Minimize, maximize and close at the title bar's trailing end. +/// The window's buttons at the title bar's trailing end, in the order GNOME's +/// `button-layout` lists them, which is close alone by default; elsewhere minimize, +/// maximize and close. pub fn window_controls(ui: &mut Ui, window: &Window) { - let theme = ui.theme.clone(); - let mut button = |part: &str, icon| { - ui.leaf( - part, - Spec { - flags: Flags::CLICKABLE, - size: [px(crate::TITLE + 12.0), px(crate::TITLE)], - icon: Some(icon), - color: Some(theme.text), - hover_fill: Some(theme.hover()), - center: true, - ..Spec::default() - }, - ) - .clicked - }; - if button("minimize", MINIMIZE) { - window.set_minimized(true); - } - if button( - "maximize", - if window.is_maximized() { - RESTORE - } else { - MAXIMIZE - }, - ) { - zoom(window); - } - if button("close", crate::art::CLOSE) - && let Some(proxy) = QUIT.get() - { - let _ = proxy.send_event(crate::UserEvent::Quit); + static CONTROLS: OnceLock> = OnceLock::new(); + let controls = CONTROLS.get_or_init(|| { + let layout = portal_setting("org.gnome.desktop.wm.preferences", "button-layout") + .and_then(|value| String::try_from(value).ok()); + match layout { + Some(layout) => layout + .split([':', ',']) + .filter_map(|name| { + ["minimize", "maximize", "close"] + .into_iter() + .find(|known| *known == name) + }) + .collect(), + None if desktop() == Desktop::Gnome => vec!["close"], + None => vec!["minimize", "maximize", "close"], + } + }); + let text = ui.theme.text; + let disc = |alpha| [text[0], text[1], text[2], alpha]; + for control in controls { + let icon = match *control { + "minimize" => MINIMIZE, + "maximize" if window.is_maximized() => RESTORE, + "maximize" => MAXIMIZE, + _ => crate::art::CLOSE, + }; + // Round, on a faint disc, as libadwaita's are. + let clicked = ui + .leaf( + control, + Spec { + flags: Flags::CLICKABLE, + size: [px(ui::shell::TOOL); 2], + icon: Some(icon), + color: Some(text), + fill: Some(disc(0.1)), + hover_fill: Some(disc(0.18)), + radius: ui::shell::TOOL / 2.0, + center: true, + ..Spec::default() + }, + ) + .clicked; + match *control { + _ if !clicked => {} + "minimize" => window.set_minimized(true), + "maximize" => zoom(window), + _ => { + if let Some(proxy) = QUIT.get() { + let _ = proxy.send_event(crate::UserEvent::Quit); + } + } + } } } @@ -539,6 +575,21 @@ pub fn zoom(window: &Window) { /// Winit delivers typed text with its key events on Linux. pub fn install_text_input(_: &Window) {} +pub fn move_cursor() -> winit::window::CursorIcon { + winit::window::CursorIcon::Move +} + +/// Window drags are the app's own. +pub fn set_window_drags(_: bool) {} + +/// Scrollbars overlay the content. +pub fn scrollers() -> Option { + None +} + +/// Resizing takes the window's edges. +pub fn resize_grip(_: &mut Ui, _: &Window, _: [f32; 2]) {} + /// Options opens from the sidebar's footer and Ctrl+Comma, as Linux apps have no shared menu. pub fn install_menu() {} @@ -639,6 +690,45 @@ fn portal_setting(namespace: &str, key: &str) -> Option String { + use std::os::unix::ffi::OsStrExt; + let mut uri = "file://".to_owned(); + for &byte in path.as_os_str().as_bytes() { + match byte { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' | b'/' => { + uri.push(byte as char) + } + _ => uri.push_str(&format!("%{byte:02X}")), + } + } + uri +} + pub fn cache_dir() -> Option { xdg_dir("XDG_CACHE_HOME", ".cache") } @@ -727,12 +817,6 @@ fn xdg_base(variable: &str, fallback: &str) -> Option { .or_else(|| Some(PathBuf::from(std::env::var_os("HOME")?).join(fallback))) } -/// The application's icon from the icon theme, the smallest at least `pixels` square. -pub fn app_icon(pixels: u32) -> Option { - let (side, rgba) = icon_pixels(pixels)?; - draw::RasterImage::new([side; 2], rgba).ok() -} - /// Looks beside the executable first, as the release archive installs it, then in the /// XDG data directories. fn icon_pixels(pixels: u32) -> Option<(u32, Vec)> { @@ -1225,6 +1309,15 @@ mod tests { assert_eq!(dark.motion, ui::PopupMotion::Cut); } + #[test] + fn file_uris_escape_all_but_unreserved_bytes() { + let path = std::path::Path::new("/home/me/Notes & Plans/Café.one"); + assert_eq!( + file_uri(path), + "file:///home/me/Notes%20%26%20Plans/Caf%C3%A9.one" + ); + } + #[test] fn kconfig_colours_read_as_channels_or_hex() { assert_eq!(kde_color("222,224,226"), Some([222, 224, 226])); diff --git a/crates/snowbound/src/macos.rs b/crates/snowbound/src/macos.rs index 21943c1d8afc1c134dafce241069afd41ce70632..49256adcf8793f0adba8a36afa4120fb8fa1e819 100644 --- a/crates/snowbound/src/macos.rs +++ b/crates/snowbound/src/macos.rs @@ -29,6 +29,8 @@ use winit::{ window::{Window, WindowAttributes}, }; +pub use crate::aqua::{move_cursor, resize_grip, scrollers, set_window_drags}; + static QUIT: OnceLock> = OnceLock::new(); static INPUT_CLASS: OnceLock<&'static AnyClass> = OnceLock::new(); static BACKDROP_CLASS: OnceLock<&'static AnyClass> = OnceLock::new(); @@ -86,8 +88,20 @@ pub const LEADING: f32 = 78.0; /// How far AppKit rounds a window's corners. pub const CORNER_RADIUS: f32 = 10.0; +/// How far the window's corners round: 10.6 rounds a textured window's bottom corners +/// by about 3.5 pixels. pub fn corner_radius(_: &Window) -> f32 { - CORNER_RADIUS + if crate::aqua::before_lion() { + 3.5 + } else { + CORNER_RADIUS + } +} + +/// AppKit clips the window's corners itself, except where the OpenGL surface covers +/// 10.6's textured window, whose rounded bottom corners the app leaves transparent. +pub fn cuts_corners() -> bool { + crate::aqua::before_lion() } /// A transparent title bar over the content, where the window draws its own. @@ -97,6 +111,57 @@ pub fn window_attributes() -> WindowAttributes { .with_fullsize_content_view(true) } +/// Makes the title bar a unified compact toolbar's, as tall as the toolbar's row, which the +/// app draws: an empty `NSToolbar` and a hidden title. AppKit places the traffic lights in +/// it. The title is still set, for the Window menu, Mission Control and VoiceOver. +pub fn install_title_bar(window: &Window) { + MainThreadMarker::new().expect("Windows belong to the main thread"); + let window = ns_window(window); + let responds = + |selector: Sel| -> bool { unsafe { msg_send![&window, respondsToSelector: selector] } }; + // Before 10.10 the title bar keeps its own line above the row. + if !responds(sel!(setTitleVisibility:)) { + return; + } + unsafe { + let class = AnyClass::get("NSToolbar").expect("AppKit is linked"); + let toolbar: Allocated = msg_send_id![class, alloc]; + let toolbar: Retained = + msg_send_id![toolbar, initWithIdentifier: &*NSString::from_str("Snowbound")]; + let _: () = msg_send![&toolbar, setShowsBaselineSeparator: false]; + let _: () = msg_send![&window, setToolbar: &*toolbar]; + // NSWindowTitleHidden. + let _: () = msg_send![&window, setTitleVisibility: 1isize]; + if responds(sel!(setToolbarStyle:)) { + // NSWindowToolbarStyleUnifiedCompact and NSTitlebarSeparatorStyleNone. + let _: () = msg_send![&window, setToolbarStyle: 4isize]; + let _: () = msg_send![&window, setTitlebarSeparatorStyle: 1isize]; + } + } +} + +/// The centres of the close, minimize and zoom buttons where AppKit placed them, in points +/// from the content's top left corner. +pub fn traffic_lights(window: &Window) -> [[f32; 2]; 3] { + let window = ns_window(window); + unsafe { + let content: Retained = msg_send_id![&window, contentView]; + let content: NSRect = msg_send![&content, frame]; + // NSWindowCloseButton, NSWindowMiniaturizeButton and NSWindowZoomButton, in the + // window's coordinates, which rise from its bottom left corner. + [0usize, 1, 2].map(|kind| { + let button: Retained = msg_send_id![&window, standardWindowButton: kind]; + let bounds: NSRect = msg_send![&button, bounds]; + let frame: NSRect = + msg_send![&button, convertRect: bounds, toView: std::ptr::null::()]; + [ + (frame.origin.x + frame.size.width / 2.0) as f32, + (content.size.height - frame.origin.y - frame.size.height / 2.0) as f32, + ] + }) + } +} + pub struct Clipboard(arboard::Clipboard); impl Clipboard { @@ -189,9 +254,9 @@ unsafe extern "C" fn hit_nothing(_: &AnyObject, _: Sel, _: NSPoint) -> *mut AnyO /// through it to the view it lies in. pub fn install_backdrop(window: &Window) -> bool { MainThreadMarker::new().expect("Views belong to the main thread"); - // Before 10.10 AppKit has no materials. + // Before 10.10 AppKit has no materials; 10.6's textured window stands in. let Some(effect) = AnyClass::get("NSVisualEffectView") else { - return false; + return crate::aqua::textured(window, crate::TITLE); }; let RawWindowHandle::AppKit(handle) = window.window_handle().expect("Live AppKit window").as_raw() @@ -230,6 +295,18 @@ pub fn resize_direction(_: &Window, _: [f32; 2]) -> Option Option { Some(std::path::PathBuf::from(std::env::var_os("HOME")?).join("Library/Caches/snowbound")) } @@ -453,6 +530,12 @@ pub fn install_text_input(window: &Window) { sel!(insertText:replacementRange:), insert_text as unsafe extern "C" fn(_, _, _, _), ); + if crate::aqua::before_lion() { + class.add_method( + sel!(mouseDownCanMoveWindow), + crate::aqua::window_drags as extern "C" fn(_, _) -> _, + ); + } class.register() }); assert_eq!(class.superclass(), Some(view.class())); @@ -676,11 +759,6 @@ pub fn commit_presentation(window: &Window) { } } -/// None: AppKit's title shows the document's icon in its place. -pub fn app_icon(_: u32) -> Option { - None -} - /// The insertion point's colour and selected text's fill with and without keyboard focus, /// linear RGBA, in `window`'s appearance. pub fn text_colors(window: &Window) -> [[f32; 4]; 3] { @@ -770,17 +848,29 @@ declare_class!( objc2::runtime::Bool::new(status.enabled) } - // NSAlert consumes Escape before it reaches cancelOperation:. + // NSAlert consumes Escape before it reaches cancelOperation:, and 10.6's date + // picker consumes Return before it reaches the default button. #[method(sendEvent:)] fn send_event(&self, event: &NSEvent) { unsafe { - if event.r#type() == NSEventType::KeyDown - && self.modalWindow().is_some() - && event.charactersIgnoringModifiers().is_some_and(|text| text.to_string() == "\u{1b}") - { - self.stopModal(); - } else { - let _: () = msg_send![super(self), sendEvent: event]; + let key = (event.r#type() == NSEventType::KeyDown) + .then(|| event.charactersIgnoringModifiers()) + .flatten() + .map(|text| text.to_string()); + let modal = self.modalWindow(); + match (key.as_deref(), &modal) { + (Some("\u{1b}"), Some(_)) => self.stopModal(), + (Some("\r" | "\u{3}"), Some(modal)) if { + let responder: *mut AnyObject = msg_send![modal, firstResponder]; + !responder.is_null() + && msg_send![responder, isKindOfClass: NSDatePicker::class()] + } => { + let cell: *mut AnyObject = msg_send![modal, defaultButtonCell]; + let _: () = msg_send![cell, performClick: std::ptr::null::()]; + } + _ => { + let _: () = msg_send![super(self), sendEvent: event]; + } } } } diff --git a/crates/snowbound/src/main.rs b/crates/snowbound/src/main.rs index 86b103d0d1cb6d9985a185e3bf9d1c7c58c329a3..2e59deec87ebae9d394676ce4d13591df3f83f01 100644 --- a/crates/snowbound/src/main.rs +++ b/crates/snowbound/src/main.rs @@ -1,4 +1,7 @@ +#[cfg(target_os = "macos")] +mod aqua; mod art; +mod background; mod commands; #[cfg(test)] mod conflict_render; @@ -12,6 +15,8 @@ mod menubar; mod menus; mod navigation; mod options; +mod palette; +mod pane; #[cfg_attr(target_os = "linux", path = "linux.rs")] #[cfg_attr(target_os = "macos", path = "macos.rs")] mod platform; @@ -36,7 +41,6 @@ use canvas::{ use canvas::{ gpu::{colorref, tag_sources}, outline::TagIcon, - template::PAGE_COLORS, }; use draw::Renderer; use library::Library; @@ -61,8 +65,12 @@ use winit::{ window::{CursorIcon, Window, WindowId}, }; -/// Height of the title bar, which holds the window's controls. -const TITLE: f32 = 28.0; +/// Height of the toolbar's row, which is the title bar where the platform lets it: a +/// unified compact toolbar's on macOS. +const TITLE: f32 = 38.0; +/// Space between toolbar groups, and the toolbar's margin at an edge without window controls. +const GAP: f32 = 6.0; +const EDGE: f32 = 8.0; const TAB_ROW: f32 = 28.0; /// Width of the section colour around the page. const FRAME: f32 = 6.0; @@ -185,11 +193,13 @@ fn cursor_icon(cursor: Cursor) -> CursorIcon { Cursor::Default => CursorIcon::Default, Cursor::Text => CursorIcon::Text, Cursor::Pointer => CursorIcon::Pointer, - Cursor::Move => CursorIcon::Move, + Cursor::Move => platform::move_cursor(), Cursor::EwResize => CursorIcon::EwResize, Cursor::NsResize => CursorIcon::NsResize, Cursor::NwseResize => CursorIcon::NwseResize, Cursor::NeswResize => CursorIcon::NeswResize, + Cursor::RowResize => CursorIcon::RowResize, + Cursor::ColResize => CursorIcon::ColResize, } } @@ -489,6 +499,7 @@ struct State { last_pages: HashMap, /// Pages visited, for Back and Forward. trail: navigation::Trail, + swipe: navigation::Swipe, /// Asks for a frame when a worker thread finishes something the page shows. redraw: std::task::Waker, surface: surface::Surface, @@ -547,8 +558,6 @@ struct State { recent_fonts: Vec, /// What the toolbar's buttons apply from their menus' last picks. toolbar: settings::Toolbar, - /// The application's icon at the display's scale, for the title bar. - app_icon: Option, commands: Vec, /// The page changed during this frame, to be saved and announced after it. changed: bool, @@ -567,6 +576,9 @@ struct State { strip_held: bool, /// Where a replay asked the next frame to be written. snapshot: Option, + /// `SNOWBOUND_FRAMES`: a directory every frame drawn is also written to, named by + /// milliseconds since the window opened. + frames: Option<(PathBuf, Instant)>, initial: Vec<(onestore::ExGuid, TextDocument)>, initial_layouts: Vec<(onestore::ExGuid, onestore::document::Layout)>, initial_date: Option, @@ -590,6 +602,10 @@ fn frame() -> Id { Id::ROOT.child("frame") } +fn sections() -> Id { + Id::ROOT.child("sections") +} + impl State { async fn new( event_loop: &ActiveEventLoop, @@ -624,6 +640,7 @@ impl State { .with_min_inner_size(LogicalSize::new(600.0, 400.0)), )?, ); + platform::install_title_bar(&window); platform::install_text_input(&window); // Beneath the surface's layer, which is added over it. let backdrop = visible && platform::install_backdrop(&window); @@ -743,6 +760,7 @@ impl State { let titlebar = platform::titlebar(appearance).unwrap_or([ui.theme.strip; 2]); // A window shown but never focused hears no focus event; a hidden one draws as focused. ui.window_focused = !visible || window.has_focus(); + ui.scrollers = platform::scrollers(); ui.set_focus(Some(page())); for family in FONTS { for (face, _) in engine.substitute(family).map_or(&[][..], |s| &s.faces) { @@ -806,7 +824,6 @@ impl State { fonts, recent_fonts: stored.recent_fonts, toolbar: stored.toolbar, - app_icon: platform::app_icon((16.0 * dpr).round() as u32), commands: Vec::new(), changed: false, moved: false, @@ -816,6 +833,7 @@ impl State { strip_press: None, strip_held: false, snapshot: None, + frames: std::env::var_os("SNOWBOUND_FRAMES").map(|dir| (dir.into(), Instant::now())), initial, initial_date, initial_layouts, @@ -829,6 +847,7 @@ impl State { places: HashMap::new(), last_pages: HashMap::new(), trail: navigation::Trail::default(), + swipe: navigation::Swipe::default(), access_adapter, accessibility: accessibility::Accessibility::default(), }; @@ -903,10 +922,32 @@ impl State { let (section, open_tab, open_page) = self.build()?; self.options_dialog(); self.link_dialog()?; + self.palette(); self.sync_popup()?; self.text_menu()?; + platform::resize_grip(&mut self.ui, &self.window, size); self.ui.end(); self.edges(section, open_tab, open_page); + if platform::cuts_corners() { + let [width, height] = size; + let radius = platform::corner_radius(&self.window); + // Where the toolbar's row is the title bar, its corners are the window's too. + let top = if platform::system_titlebar(&self.window) { + 0.0 + } else { + radius + }; + self.ui.round_corners( + &[ + ([0.0, 0.0], top), + ([width, 0.0], top), + ([width, height], radius), + ([0.0, height], radius), + ], + [0.0; 4], + |_| [0.0; 4], + ); + } if let Some(rect) = self.ui.rect(page()) { let size = [ ((rect[2] - rect[0]) * scale).round() as u32, @@ -978,15 +1019,13 @@ impl State { // The page, and the open page's tab joined to it, take the page's colour. let mut theme = self.ui.theme.clone(); theme.paper = self.paper().color; - if !platform::system_titlebar(&self.window) { - self.title_bar(&theme); - } platform::update_menu(|| self.statuses()); - if self.session.is_none() && !self.temporary && self.sectionless.is_none() { + let welcome = self.session.is_none() && !self.temporary && self.sectionless.is_none(); + self.toolbar(&theme, !welcome)?; + if welcome { self.welcome(&theme); return Ok((section, Id::ROOT, None)); } - self.toolbar(&theme)?; self.ui.open( "body", Spec { @@ -1017,7 +1056,19 @@ impl State { ..Spec::default() }, ); - self.sidebar_button(&theme, sidebar); + if !self.temporary { + // Past the row's padding, the frame's corner and the first tab's shadow, the tabs' + // outlines start where the button's square ends; the room shrinks on the sidebar's + // easing, so the tabs ease with it. + let room = TAB_ROW - FRAME - self.rounding() - ui::SHADOW[0]; + self.ui.leaf( + "rail", + Spec { + size: [px(room * (1.0 - sidebar / sidebar::WIDTH)), px(1.0)], + ..Spec::default() + }, + ); + } self.ui.leaf( "corner", Spec { @@ -1025,7 +1076,7 @@ impl State { ..Spec::default() }, ); - let row = self.ui.id("sections"); + let row = sections(); let (clicked, open_tab) = match &self.session { Some(session) => { // A tab being renamed takes the name typed, which its field covers. @@ -1054,13 +1105,14 @@ impl State { open: open_tab, } = ui::shell::section_tabs( &mut self.ui, - "sections", + row, &tabs, session.tab, lit, dragged, §ion, TAB_ROW, + theme.strip, ); self.drag_tabs(held, slot, settled, row); let Some(session) = &self.session else { @@ -1100,19 +1152,27 @@ impl State { let shown = if self.temporary { &tabs[..] } else { &[] }; let open_tab = ui::shell::section_tabs( &mut self.ui, - "sections", + row, shown, 0, None, None, §ion, TAB_ROW, + theme.strip, ) .open; (None, open_tab) } }; self.commands.extend(clicked); + self.ui.leaf( + "space", + Spec { + size: [fill(), px(1.0)], + ..Spec::default() + }, + ); if self.session.is_some() { self.page_tools(&theme); } @@ -1153,6 +1213,7 @@ impl State { self.ui.close(); self.ui.close(); self.ui.close(); + self.sidebar_button(&theme, height); self.ui.close(); self.context_menu(); return Ok((section, open_tab, None)); @@ -1195,6 +1256,8 @@ impl State { let open_page = self.page_list(&theme, §ion, row); self.ui.close(); self.ui.close(); + self.task_pane(&theme); + self.sidebar_button(&theme, height); self.ui.close(); self.context_menu(); // No tab stands on the frame in Full Page View. @@ -1291,10 +1354,16 @@ impl State { |_| strip, ); } + // The border breaks where the open tab shows, which the row may scroll it out of. + let [left, right] = self + .ui + .rect(sections()) + .map_or([end; 2], |row| [row[0], row[2]]); let [foot, toe] = self .ui .rect(open_tab) - .map_or([end; 2], |tab| ui::shell::tab_base(tab, TAB_ROW)); + .map_or([end; 2], |tab| ui::shell::tab_base(tab, TAB_ROW)) + .map(|x| x.clamp(left, right)); // The border runs down both sides to the window's bottom, round the bottom corner // beside the sidebar. self.ui.border( @@ -1318,62 +1387,75 @@ impl State { ); } - /// The window's title bar beside the traffic lights: the application's icon, the - /// notebook's name and the sync status. - fn title_bar(&mut self, theme: &Theme) { - self.ui.open_as( - strip(), - Spec { - flags: Flags::CLICKABLE, - size: [fill(), px(TITLE)], - fill: Some(theme.strip), - ..Spec::default() + /// The toolbar: one row of groups that fold as the window narrows. Where the platform + /// lets it, the row is the window's title bar, and on the welcome page, without `tools`, + /// that is all it is. + fn toolbar(&mut self, theme: &Theme, tools: bool) -> Result<(), Box> { + let title = !platform::system_titlebar(&self.window); + if !tools && !title { + return Ok(()); + } + // Over the title's gradient, the row's empty space drags the window as the title does. + let strip_row = title || self.surface.translucent(); + let spec = Spec { + flags: if strip_row { + Flags::CLICKABLE + } else { + Flags::default() }, - ); + size: [fill(), px(TITLE)], + fill: Some(theme.strip), + pad: [0.0, (TITLE - ui::shell::TOOL) / 2.0], + gap: GAP, + ..Spec::default() + }; + if strip_row { + self.ui.open_as(strip(), spec); + } else { + self.ui.open("toolbar", spec); + } + let lead = if title { platform::LEADING } else { EDGE }; self.ui.leaf( - "lights", + "lead", Spec { - size: [px(platform::LEADING), px(1.0)], + size: [px(lead - GAP), px(1.0)], ..Spec::default() }, ); - // AppKit centres the window's title over the strip. - if !cfg!(target_os = "macos") { + if tools { + self.tools(theme); + } else { self.ui.leaf( - "notebook", + "space", Spec { - size: [fit(), px(TITLE)], - image: self.app_icon.as_ref(), - text: Some(match &self.session { - Some(session) => &session.library.name, - None if self.temporary => "Temporary page", - None => "Snowbound", - }), + size: [fill(), px(1.0)], ..Spec::default() }, ); } + if title { + platform::window_controls(&mut self.ui, &self.window); + } self.ui.leaf( - "space", + "trail", Spec { - size: [fill(), px(1.0)], + size: [px(EDGE - GAP), px(1.0)], ..Spec::default() }, ); - if let Some(session) = &self.session { - sync::control(&mut self.ui, session, theme, TITLE); - } - platform::window_controls(&mut self.ui, &self.window); self.ui.close(); + Ok(()) } - /// Two rows of navigation, clipboard, formatting, tag, insert and view buttons, grouped - /// as OneNote's ribbon groups them. - fn toolbar(&mut self, theme: &Theme) -> Result<(), Box> { + /// The toolbar's groups, as OneNote's ribbon groups its buttons. The groups used most in + /// taking notes fold last, and those with chords or a place in the menu bar first. + fn tools(&mut self, theme: &Theme) { + use Entry::{Open, Rule, Run, Soon}; use canvas::editor::{ Alignment, BULLET_LIBRARY, ListStyle, NUMBER_LIBRARY, NoteTag, Toggle, }; - use commands::{Choice, Id as Cmd, shortcut}; + use commands::{Choice, Id as Cmd}; + use ui::shell::TOOL; // A focused picture has no text to show a format for. let state = self.format_state(); let statuses: Vec<_> = commands::COMMANDS @@ -1391,52 +1473,39 @@ impl State { .find(|(listed, _)| *listed == id) .map_or_else(commands::Status::default, |(_, status)| *status) }; + let status_of = &status_of; let fonts = &self.fonts; let recent_fonts = &self.recent_fonts; let pens = &self.toolbar; let engine = &self.view.engine; - // Under the system's title bar the sync status joins the toolbar. - let status = platform::system_titlebar(&self.window) - .then_some(self.session.as_ref()) - .flatten(); + let session = self.session.as_ref(); + let label = format!("{:.0}%", self.view.zoom() * 100.0); let ui = &mut self.ui; let text = theme.text; - let on = |toggle| state.toggles.contains(&toggle); - let mut choice = None; - ui.open( - "toolbar", - Spec { - size: [fill(), children()], - fill: Some(theme.strip), - pad: [8.0, 3.0], - gap: 6.0, - ..Spec::default() + let tags = NoteTag::ALL.map(Cmd::Tag); + let mut choice = group( + ui, + "navigate", + 3, + |ui| { + let mut choice = None; + for id in [Cmd::Back, Cmd::Forward, Cmd::Undo, Cmd::Redo] { + choice = tool(ui, id, status_of(id)).or(choice); + } + choice + }, + |ui| { + let entries = [Run(Cmd::Redo), Rule, Run(Cmd::Back), Run(Cmd::Forward)]; + dropdown(ui, "menu", Head::Split(Cmd::Undo), &entries, status_of) }, ); - group(ui, "navigate", |ui| { - for (index, buttons) in [ - [ - ("back", art::BACK, Cmd::Back), - ("forward", art::FORWARD, Cmd::Forward), - ], - [ - ("undo", art::UNDO, Cmd::Undo), - ("redo", art::REDO, Cmd::Redo), - ], - ] - .into_iter() - .enumerate() - { - row(ui, index, |ui| { - for (part, icon, id) in buttons { - choice = tool(ui, part, icon, text, id, status_of(id)).or(choice.take()); - } - }); - } - }); - divider(ui, "navigate", theme); - group(ui, "clipboard", |ui| { - row(ui, 0, |ui| { + choice = group( + ui, + "clipboard", + 2, + |ui| { + divider(ui, theme); + let mut choice = None; let paste = ui.id("paste"); if !status_of(Cmd::Paste).enabled { ui::shell::unavailable(ui, "paste", art::PASTE, text, true); @@ -1474,138 +1543,251 @@ impl State { if ui::popup::menu(ui, toolbar_popup("paste"), anchor, &items, None).is_some() { choice = Some(Choice::Command(Cmd::Paste)); } - choice = tool(ui, "cut", art::CUT, text, Cmd::Cut, status_of(Cmd::Cut)) - .or(choice.take()); - }); - row(ui, 1, |ui| { - for (part, icon, id) in [ - ("copy", art::COPY, Cmd::Copy), - ("painter", art::FORMAT_PAINTER, Cmd::FormatPainter), - ] { - choice = tool(ui, part, icon, text, id, status_of(id)).or(choice.take()); + for id in [Cmd::Cut, Cmd::Copy, Cmd::FormatPainter] { + choice = tool(ui, id, status_of(id)).or(choice); } - }); - }); - divider(ui, "clipboard", theme); + choice + }, + |ui| { + let entries = [ + Run(Cmd::Cut), + Run(Cmd::Copy), + Run(Cmd::FormatPainter), + Rule, + Open("paste", Cmd::Paste), + ]; + dropdown(ui, "menu", Head::Split(Cmd::Paste), &entries, status_of) + }, + ) + .or(choice); let font = state.font.clone().unwrap_or_default(); let size = state .font_size .map_or(String::new(), |size| format!("{size}")); - group(ui, "text", |ui| { - row(ui, 0, |ui| { - let combo = ui.id("font"); - if ui::shell::combo(ui, "font", &font, 120.0).pressed { - ui.open_popup(toolbar_popup("font")); - } - // Each family is named with the substitute it shows in, and picked by its - // own name; a family of None heads a group. Typing searches the full list. - let label = |name: &str| { - engine.substitute(name).map_or_else( - || name.to_owned(), - |substitute| format!("{} ({name})", substitute.name), - ) - }; - let mut choices: Vec<(String, Option<&str>, bool)> = Vec::new(); - if ui::popup::query(ui, toolbar_popup("font")).is_none_or(str::is_empty) { - choices.extend(FONTS.map(|name| (label(name), Some(name), false))); - if !recent_fonts.is_empty() { - choices.push(("Recent".to_owned(), None, true)); - choices.extend( - recent_fonts + ui.open( + "font", + Spec { + gap: 1.0, + ..Spec::default() + }, + ); + divider(ui, theme); + let combo = ui.id("font"); + // It gives up room last of all, once every group has folded. + let width = ui::Extent { + size: ui::Size::Pixels(120.0), + strictness: 64.0 / 120.0, + }; + if ui::shell::combo(ui, "font", &font, width).pressed { + ui.open_popup(toolbar_popup("font")); + } + // Each family is named with the substitute it shows in, and picked by its own name; a + // family of None heads a group. Typing searches the full list. + let named = |name: &str| { + engine.substitute(name).map_or_else( + || name.to_owned(), + |substitute| format!("{} ({name})", substitute.name), + ) + }; + let mut choices: Vec<(String, Option<&str>, bool)> = Vec::new(); + if ui::popup::query(ui, toolbar_popup("font")).is_none_or(str::is_empty) { + choices.extend(FONTS.map(|name| (named(name), Some(name), false))); + if !recent_fonts.is_empty() { + choices.push(("Recent".to_owned(), None, true)); + choices.extend( + recent_fonts + .iter() + .map(|name| (named(name), Some(name.as_str()), false)), + ); + } + } + let listed = choices.len(); + choices.extend( + fonts + .iter() + .enumerate() + .map(|(index, name)| (named(name), Some(name.as_str()), index == 0 && listed > 0)), + ); + let items: Vec<_> = choices + .iter() + .map(|(label, name, separated)| ui::popup::Item { + text: label, + font: *name, + current: *name == Some(font.as_str()), + heading: name.is_none(), + separated: *separated, + ..Default::default() + }) + .collect(); + let anchor = ui::Anchor::Over(ui.rect(combo).unwrap_or_default()); + if let Some(index) = + ui::popup::menu(ui, toolbar_popup("font"), anchor, &items, Some("Font")) + && let Some(name) = choices[index].1 + { + choice = Some(Choice::Font(name.to_owned())); + } + let combo = ui.id("size"); + if ui::shell::combo(ui, "size", &size, 44.0).pressed { + ui.open_popup(toolbar_popup("size")); + } + // A size typed in the field joins the list, in half points as stored. + let mut sizes = SIZES.to_vec(); + if let Some(typed) = ui::popup::query(ui, toolbar_popup("size")) + .and_then(|query| query.trim().parse::().ok()) + .map(|typed| (typed * 2.0).round() / 2.0) + .filter(|typed| onestore::FONT_SIZES.contains(typed) && !sizes.contains(typed)) + { + let at = sizes.partition_point(|size| *size < typed); + sizes.insert(at, typed); + } + let labels: Vec<_> = sizes.iter().map(|size| format!("{size}")).collect(); + let items: Vec<_> = labels + .iter() + .map(|label| ui::popup::Item { + text: label, + checked: *label == size, + ..Default::default() + }) + .collect(); + let anchor = ui::Anchor::Over(ui.rect(combo).unwrap_or_default()); + if let Some(index) = ui::popup::menu(ui, toolbar_popup("size"), anchor, &items, Some(&size)) + { + choice = Some(Choice::Size(sizes[index])); + } + for toggle in [Toggle::Bold, Toggle::Italic, Toggle::Underline] { + let id = Cmd::Toggle(toggle); + choice = tool(ui, id, status_of(id)).or(choice); + } + ui.close(); + choice = group( + ui, + "character", + 6, + |ui| { + divider(ui, theme); + let mut choice = None; + // Each button applies its menu's last pick: the highlighter shows it in its + // artwork, the font colour in a bar. + for (part, id, swatches, columns, none, bar) in [ + ( + "highlight", + Cmd::Highlight, + &HIGHLIGHTS.map(|(color, _)| color)[..], + 5, + "No Color", + None, + ), + ( + "color", + Cmd::FontColor, + &FONT_COLORS[..], + 10, + "Automatic", + Some(pens.font_color.map_or(text, colorref)), + ), + ] { + let split = ui.id(part); + let icon = artwork(id).unwrap_or_default(); + if ui::shell::split_button(ui, part, icon, bar, false, toolbar_popup(part)) + .clicked + { + choice = Some(Choice::Command(id)); + } + tip(ui, id); + let colors: Vec<_> = swatches.iter().map(|color| colorref(*color)).collect(); + let anchor = ui::Anchor::Below(ui.rect(split).unwrap_or_default()); + if let Some(chosen) = + ui::popup::colors(ui, toolbar_popup(part), anchor, none, &colors, columns) + { + let color = chosen.and_then(|chosen| { + swatches .iter() - .map(|name| (label(name), Some(name.as_str()), false)), - ); + .zip(&colors) + .find(|(_, color)| **color == chosen) + .map(|(stored, _)| *stored) + }); + choice = Some(if id == Cmd::Highlight { + Choice::Highlight(color) + } else { + Choice::Color(color) + }); } } - let listed = choices.len(); - choices.extend(fonts.iter().enumerate().map(|(index, name)| { - (label(name), Some(name.as_str()), index == 0 && listed > 0) - })); - let items: Vec<_> = choices - .iter() - .map(|(label, name, separated)| ui::popup::Item { - text: label, - font: *name, - current: *name == Some(font.as_str()), - heading: name.is_none(), - separated: *separated, - ..Default::default() - }) - .collect(); - let anchor = ui::Anchor::Over(ui.rect(combo).unwrap_or_default()); - if let Some(index) = - ui::popup::menu(ui, toolbar_popup("font"), anchor, &items, Some("Font")) - && let Some(name) = choices[index].1 - { - choice = Some(Choice::Font(name.to_owned())); - } - let combo = ui.id("size"); - if ui::shell::combo(ui, "size", &size, 44.0).pressed { - ui.open_popup(toolbar_popup("size")); - } - // A size typed in the field joins the list, in half points as stored. - let mut sizes = SIZES.to_vec(); - if let Some(typed) = ui::popup::query(ui, toolbar_popup("size")) - .and_then(|query| query.trim().parse::().ok()) - .map(|typed| (typed * 2.0).round() / 2.0) - .filter(|typed| onestore::FONT_SIZES.contains(typed) && !sizes.contains(typed)) - { - let at = sizes.partition_point(|size| *size < typed); - sizes.insert(at, typed); - } - let labels: Vec<_> = sizes.iter().map(|size| format!("{size}")).collect(); - let items: Vec<_> = labels - .iter() - .map(|label| ui::popup::Item { - text: label, - checked: *label == size, - ..Default::default() - }) - .collect(); - let anchor = ui::Anchor::Over(ui.rect(combo).unwrap_or_default()); - if let Some(index) = - ui::popup::menu(ui, toolbar_popup("size"), anchor, &items, Some(&size)) - { - choice = Some(Choice::Size(sizes[index])); - } + dropdown( + ui, + "script", + Head::Menu(art::STRIKETHROUGH), + &[ + Run(Cmd::Toggle(Toggle::Strikethrough)), + Run(Cmd::Toggle(Toggle::Subscript)), + Run(Cmd::Toggle(Toggle::Superscript)), + Rule, + Run(Cmd::ClearFormatting), + ], + status_of, + ) + .or(choice) + }, + |ui| { + let entries = [ + Open("highlight", Cmd::Highlight), + Run(Cmd::FontColor), + Open("color", Cmd::FontColor), + Rule, + Run(Cmd::Toggle(Toggle::Strikethrough)), + Run(Cmd::Toggle(Toggle::Subscript)), + Run(Cmd::Toggle(Toggle::Superscript)), + Rule, + Run(Cmd::ClearFormatting), + ]; + dropdown(ui, "menu", Head::Split(Cmd::Highlight), &entries, status_of) + }, + ) + .or(choice); + let alignments = [Alignment::Left, Alignment::Center, Alignment::Right].map(Cmd::Align); + let paragraph = [ + Rule, + Run(alignments[0]), + Run(alignments[1]), + Run(alignments[2]), + Rule, + Run(Cmd::Outdent), + Run(Cmd::Indent), + ]; + choice = group( + ui, + "paragraph", + 5, + |ui| { + divider(ui, theme); + let mut choice = None; // The buttons apply OneNote's default bullet and number, as its own do. - let [bullets, numbering] = [ui.id("bullets"), ui.id("numbering")]; - if ui::shell::split_button( - ui, - "bullets", - art::BULLETS, - None, - state.bullets, - toolbar_popup("bullets"), - ) - .clicked - { - choice = Some(Choice::Command(Cmd::Bullets)); + for id in [Cmd::Bullets, Cmd::Numbering] { + let part = if id == Cmd::Bullets { + "bullets" + } else { + "numbering" + }; + let icon = artwork(id).unwrap_or_default(); + let on = status_of(id).checked; + if ui::shell::split_button(ui, part, icon, None, on, toolbar_popup(part)) + .clicked + { + choice = Some(Choice::Command(id)); + } + tip(ui, id); } - tip(ui, Cmd::Bullets); - if ui::shell::split_button( - ui, - "numbering", - art::NUMBERING, - None, - state.numbering, - toolbar_popup("numbering"), - ) - .clicked - { - choice = Some(Choice::Command(Cmd::Numbering)); - } - tip(ui, Cmd::Numbering); + let [bullets, numbering] = + ["bullets", "numbering"].map(|part| ui.rect(ui.id(part)).unwrap_or_default()); let current = |bullet| match state.list { Some(ListStyle::Bullet(place)) if bullet => Some(place), Some(ListStyle::Number(place)) if !bullet => Some(place), _ => None, }; - let anchor = ui::Anchor::Below(ui.rect(bullets).unwrap_or_default()); if let Some(place) = list_gallery( ui, toolbar_popup("bullets"), - anchor, + ui::Anchor::Below(bullets), ["Recently Used Bullets", "Bullet Library"], (&pens.bullets, BULLET_LIBRARY.len()), current(true), @@ -1627,11 +1809,10 @@ impl State { ) { choice = Some(Choice::List(place.map(ListStyle::Bullet))); } - let anchor = ui::Anchor::Below(ui.rect(numbering).unwrap_or_default()); if let Some(place) = list_gallery( ui, toolbar_popup("numbering"), - anchor, + ui::Anchor::Below(numbering), ["Recently Used Number Formats", "Numbering Library"], (&pens.numbering, NUMBER_LIBRARY.len()), current(false), @@ -1673,234 +1854,68 @@ impl State { ) { choice = Some(Choice::List(place.map(ListStyle::Number))); } - let clear = Cmd::ClearFormatting; - choice = tool( - ui, - "clear", - art::CLEAR_FORMATTING, - text, - clear, - status_of(clear), - ) - .or(choice.take()); - }); - row(ui, 1, |ui| { - for (part, icon, toggle) in [ - ("bold", art::BOLD, Toggle::Bold), - ("italic", art::ITALIC, Toggle::Italic), - ("underline", art::UNDERLINE, Toggle::Underline), - ("strikethrough", art::STRIKETHROUGH, Toggle::Strikethrough), - ] { - let id = Cmd::Toggle(toggle); - choice = tool(ui, part, icon, text, id, status_of(id)).or(choice.take()); - } - let script = ui.id("script"); - let superscript = on(Toggle::Superscript); - let command = Cmd::Toggle(if superscript { - Toggle::Superscript - } else { - Toggle::Subscript - }); - if ui::shell::split_button( - ui, - "script", - if superscript { - art::SUPERSCRIPT - } else { - art::SUBSCRIPT - }, - None, - on(Toggle::Subscript) || superscript, - toolbar_popup("script"), - ) - .clicked - { - choice = Some(Choice::Command(command)); - } - tip(ui, command); - let scripts = [ - (Toggle::Subscript, art::SUBSCRIPT), - (Toggle::Superscript, art::SUPERSCRIPT), - ]; - let keys = scripts.map(|(toggle, _)| shortcut(Cmd::Toggle(toggle))); - let items: Vec<_> = scripts - .iter() - .zip(&keys) - .map(|((toggle, icon), key)| ui::popup::Item { - text: commands::command(Cmd::Toggle(*toggle)).title, - icon: Some(icon), - shortcut: key, - checked: on(*toggle), - ..Default::default() - }) - .collect(); - let anchor = ui::Anchor::Below(ui.rect(script).unwrap_or_default()); - if let Some(index) = - ui::popup::menu(ui, toolbar_popup("script"), anchor, &items, None) - { - choice = Some(Choice::Command(Cmd::Toggle(scripts[index].0))); - } - // Each button applies its menu's last pick: the highlighter shows it in its - // artwork, the font colour in a bar. - for (part, icon, swatches, columns, none, bar) in [ - ( - "highlight", - art::HIGHLIGHTER, - &HIGHLIGHTS.map(|(color, _)| color)[..], - 5, - "No Color", - None, - ), - ( - "color", - art::FONT_COLOR, - &FONT_COLORS[..], - 10, - "Automatic", - Some(pens.font_color.map_or(text, colorref)), - ), - ] { - let split = ui.id(part); - let button = - ui::shell::split_button(ui, part, icon, bar, false, toolbar_popup(part)); - let highlight = part == "highlight"; - let command = if highlight { - Cmd::Highlight - } else { - Cmd::FontColor - }; - if button.clicked { - choice = Some(Choice::Command(command)); - } - tip(ui, command); - let colors: Vec<_> = swatches.iter().map(|color| colorref(*color)).collect(); - let anchor = ui::Anchor::Below(ui.rect(split).unwrap_or_default()); - if let Some(chosen) = - ui::popup::colors(ui, toolbar_popup(part), anchor, none, &colors, columns) - { - let color = chosen.and_then(|chosen| { - swatches - .iter() - .zip(&colors) - .find(|(_, color)| **color == chosen) - .map(|(stored, _)| *stored) - }); - choice = Some(if highlight { - Choice::Highlight(color) - } else { - Choice::Color(color) - }); - } - } - for (part, icon, id) in [ - ("outdent", art::OUTDENT, Cmd::Outdent), - ("indent", art::INDENT, Cmd::Indent), - ] { - choice = tool(ui, part, icon, text, id, status_of(id)).or(choice.take()); - } - let alignments = [ - (Alignment::Left, art::ALIGN_LEFT), - (Alignment::Center, art::ALIGN_CENTER), - (Alignment::Right, art::ALIGN_RIGHT), + let aligned = alignments + .into_iter() + .find(|id| status_of(*id).checked) + .unwrap_or(alignments[0]); + let icon = artwork(aligned).unwrap_or_default(); + dropdown(ui, "align", Head::Menu(icon), ¶graph[1..], status_of).or(choice) + }, + |ui| { + let mut entries = vec![ + Open("bullets", Cmd::Bullets), + Run(Cmd::Numbering), + Open("numbering", Cmd::Numbering), ]; - let keys = alignments.map(|(alignment, _)| shortcut(Cmd::Align(alignment))); - let current = alignments - .iter() - .find(|(alignment, _)| Some(*alignment) == state.alignment) - .unwrap_or(&alignments[0]); - let anchor = ui::shell::menu_button(ui, "align", current.1, toolbar_popup("align")); - tip(ui, Cmd::Align(current.0)); - // The current alignment starts highlighted, keeping its icon under the button's. - let items: Vec<_> = alignments - .iter() - .zip(&keys) - .map(|((alignment, icon), key)| ui::popup::Item { - text: commands::command(Cmd::Align(*alignment)).title, - icon: Some(icon), - shortcut: key, - current: Some(*alignment) == state.alignment, - ..Default::default() - }) - .collect(); - if let Some(index) = - ui::popup::menu(ui, toolbar_popup("align"), anchor, &items, None) - { - choice = Some(Choice::Command(Cmd::Align(alignments[index].0))); + entries.extend(paragraph); + dropdown(ui, "menu", Head::Split(Cmd::Bullets), &entries, status_of) + }, + ) + .or(choice); + // The gallery's menu: every tag, then managing them. + let mut all_tags: Vec<_> = tags.into_iter().map(Run).collect(); + all_tags.extend([ + Rule, + // Customize Tags awaits #8. + Soon("Customize Tags…", None), + Run(Cmd::RemoveTags), + Rule, + Run(Cmd::FindTags), + ]); + choice = group( + ui, + "tags", + 7, + |ui| { + divider(ui, theme); + let mut choice = None; + for id in &tags[..3] { + choice = tool(ui, *id, status_of(*id)).or(choice); } - }); - }); - divider(ui, "text", theme); - let tags = NoteTag::ALL.map(|tag| (tag_icon(tag), tag)); - group(ui, "tags", |ui| { - // OneNote's first nine, Ctrl+1 to Ctrl+9, then the gallery's arrow. - for (index, shown) in tags[..9].chunks(5).enumerate() { - row(ui, index, |ui| { - for (column, (icon, tag)) in shown.iter().enumerate() { - let id = Cmd::Tag(*tag); - choice = - tool(ui, column, icon, [1.0; 4], id, status_of(id)).or(choice.take()); - } - if index == 1 { - let more = ui.id("more"); - let open = ui.popup_open(toolbar_popup("tags")); - if ui::shell::tool_button(ui, "more", ui::shell::CHEVRON, text, open) - .pressed - { - ui.open_popup(toolbar_popup("tags")); - } - let shortcuts: Vec<_> = tags - .iter() - .map(|(_, tag)| shortcut(Cmd::Tag(*tag))) - .collect(); - let remove = shortcut(Cmd::RemoveTags); - let mut items: Vec<_> = tags - .iter() - .zip(&shortcuts) - .map(|((icon, tag), shortcut)| ui::popup::Item { - text: commands::command(Cmd::Tag(*tag)).title, - icon: Some(*icon), - colored: true, - shortcut, - ..Default::default() - }) - .collect(); - // Customize Tags awaits #8 and Find Tags its summary pane, #28. - items.extend([ - ui::popup::Item { - text: "Customize Tags…", - separated: true, - disabled: true, - ..Default::default() - }, - ui::popup::Item { - text: commands::command(Cmd::RemoveTags).title, - shortcut: &remove, - ..Default::default() - }, - ui::popup::Item { - text: "Find Tags", - icon: Some(art::FIND_TAGS), - separated: true, - disabled: true, - ..Default::default() - }, - ]); - let anchor = ui::Anchor::Below(ui.rect(more).unwrap_or_default()); - if let Some(chosen) = - ui::popup::menu(ui, toolbar_popup("tags"), anchor, &items, None) - { - choice = Some(Choice::Command( - tags.get(chosen) - .map_or(Cmd::RemoveTags, |(_, tag)| Cmd::Tag(*tag)), - )); - } - } - }); - } - }); - divider(ui, "tags", theme); - group(ui, "insert", |ui| { - row(ui, 0, |ui| { + dropdown(ui, "tags", Head::More, &all_tags, status_of).or(choice) + }, + |ui| dropdown(ui, "menu", Head::Split(tags[0]), &all_tags, status_of), + ) + .or(choice); + let inserted = [Cmd::Picture, Cmd::Link, Cmd::Date, Cmd::Equation]; + let more = [ + Cmd::ScreenClipping, + Cmd::Attachment, + Cmd::InsertSpace, + Cmd::Time, + Cmd::DateTime, + Cmd::Symbol, + Cmd::RecordAudio, + Cmd::RecordVideo, + ] + .map(Run); + choice = group( + ui, + "insert", + 1, + |ui| { + divider(ui, theme); + let mut choice = None; if status_of(Cmd::Table).enabled { let anchor = ui::shell::menu_button(ui, "table", art::TABLE, toolbar_popup("table")); @@ -1913,30 +1928,20 @@ impl State { } else { ui::shell::unavailable(ui, "table", art::TABLE, text, true); } - for (part, icon, id) in [ - ("picture", art::PICTURE, Cmd::Picture), - ("clipping", art::SCREEN_CLIPPING, Cmd::ScreenClipping), - ("file", art::ATTACHMENT, Cmd::Attachment), - ("link", art::LINK, Cmd::Link), - ("space", art::INSERT_SPACE, Cmd::InsertSpace), - ] { - choice = tool(ui, part, icon, text, id, status_of(id)).or(choice.take()); + for id in inserted { + choice = tool(ui, id, status_of(id)).or(choice); } - }); - row(ui, 1, |ui| { - for (part, icon, id) in [ - ("date", art::CALENDAR, Cmd::Date), - ("time", art::CLOCK, Cmd::Time), - ("date and time", art::DATE_TIME, Cmd::DateTime), - ("equation", art::EQUATION, Cmd::Equation), - ("symbol", art::SYMBOL, Cmd::Symbol), - ("audio", art::RECORD_AUDIO, Cmd::RecordAudio), - ("video", art::RECORD_VIDEO, Cmd::RecordVideo), - ] { - choice = tool(ui, part, icon, text, id, status_of(id)).or(choice.take()); - } - }); - }); + dropdown(ui, "more", Head::More, &more, status_of).or(choice) + }, + |ui| { + let mut entries = vec![Open("table", Cmd::Table)]; + entries.extend(inserted.map(Run)); + entries.push(Rule); + entries.extend(more); + dropdown(ui, "menu", Head::Menu(art::PLUS), &entries, status_of) + }, + ) + .or(choice); ui.leaf( "space", Spec { @@ -1944,13 +1949,35 @@ impl State { ..Spec::default() }, ); - if let Some(session) = status { - sync::control(ui, session, theme, ui::shell::TOOL); + if let Some(session) = session { + ui.open( + "sync", + Spec { + fold: Some(4), + ..Spec::default() + }, + ); + for compact in [false, true] { + sync::control(ui, session, theme, compact); + } + ui.close(); } - let zoom = self.view.zoom(); - let label = format!("{:.0}%", zoom * 100.0); - group(ui, "view", |ui| { - row(ui, 0, |ui| { + // Spelling awaits a platform spell checker. + let views = [ + Run(Cmd::Sidebar), + Run(Cmd::PageList), + Run(Cmd::DarkPages), + Run(Cmd::FullPageView), + Rule, + Soon("Spelling", Some(art::SPELLING)), + ]; + choice = group( + ui, + "view", + 0, + |ui| { + divider(ui, theme); + let mut choice = None; if status_of(Cmd::PageColor).enabled { let anchor = ui::shell::menu_button( ui, @@ -1959,83 +1986,28 @@ impl State { toolbar_popup("page color"), ); tip(ui, Cmd::PageColor); - let swatches: Vec<_> = PAGE_COLORS - .iter() - .map(|(_, color)| colorref(*color)) - .collect(); - if let Some(chosen) = ui::popup::colors( + choice = background::menu( ui, toolbar_popup("page color"), anchor, - "No Color", - &swatches, - 4, - ) { - let index = chosen.and_then(|chosen| { - swatches.iter().position(|swatch| *swatch == chosen) - }); - choice = Some(Choice::PageColor(index)); - } + &mut self.thumbnails, + canvas::gpu::Paper { + color: theme.paper, + ink: theme.paper_ink, + }, + self.view.editor.page_color(), + self.view.editor.rule_lines(), + ) + .or(choice.take()); } else { ui::shell::unavailable(ui, "page color", art::PAGE_COLOR, text, true); } - // Nothing reads or writes rule lines yet. - ui::shell::unavailable(ui, "rule lines", art::RULE_LINES, text, true); - let full = Cmd::FullPageView; - choice = tool( - ui, - "full page", - art::FULL_PAGE_VIEW, - text, - full, - status_of(full), - ) - .or(choice.take()); - let more = ui.id("more"); - let open = ui.popup_open(toolbar_popup("view")); - if ui::shell::tool_button(ui, "more", ui::shell::CHEVRON, text, open).pressed { - ui.open_popup(toolbar_popup("view")); - } - let views = [Cmd::Sidebar, Cmd::PageList, Cmd::DarkPages]; - let mut items: Vec<_> = views - .iter() - .map(|id| ui::popup::Item { - text: commands::command(*id).title, - checked: status_of(*id).checked, - disabled: !status_of(*id).enabled, - ..Default::default() - }) - .collect(); - // Spelling awaits a platform spell checker. - items.push(ui::popup::Item { - text: "Spelling", - icon: Some(art::SPELLING), - separated: true, - disabled: true, - ..Default::default() - }); - let anchor = ui::Anchor::Below(ui.rect(more).unwrap_or_default()); - if let Some(chosen) = - ui::popup::menu(ui, toolbar_popup("view"), anchor, &items, None) - { - choice = Some(Choice::Command(views[chosen])); - } - }); - row(ui, 1, |ui| { - choice = tool( - ui, - "out", - art::ZOOM_OUT, - text, - Cmd::ZoomOut, - status_of(Cmd::ZoomOut), - ) - .or(choice.take()); + choice = tool(ui, Cmd::ZoomOut, status_of(Cmd::ZoomOut)).or(choice); let level = ui.leaf( "level", Spec { flags: Flags::CLICKABLE, - size: [px(44.0), px(ui::shell::TOOL)], + size: [px(44.0), px(TOOL)], text: Some(&label), hover_fill: Some(theme.hover()), radius: 4.0, @@ -2047,51 +2019,76 @@ impl State { choice = Some(Choice::Command(Cmd::ActualSize)); } tip(ui, Cmd::ActualSize); - choice = tool( - ui, - "in", - art::ZOOM_IN, - text, - Cmd::ZoomIn, - status_of(Cmd::ZoomIn), - ) - .or(choice.take()); - }); - }); - ui.close(); + choice = tool(ui, Cmd::ZoomIn, status_of(Cmd::ZoomIn)).or(choice); + dropdown(ui, "view", Head::More, &views, status_of).or(choice) + }, + |ui| { + let mut entries = vec![ + Run(Cmd::ZoomIn), + Run(Cmd::ZoomOut), + Run(Cmd::ActualSize), + Rule, + Open("page color", Cmd::PageColor), + Rule, + ]; + entries.extend(views); + dropdown(ui, "menu", Head::Menu(art::ZOOM_IN), &entries, status_of) + }, + ) + .or(choice); if let Some(choice) = choice { self.choose(choice); } - Ok(()) } - /// The search box and the page list's buttons, above the list. + /// The search box and the page list's buttons, above the list. Where the tab row lacks + /// room the box folds to a button, which opens the search over the row; while finding + /// on the page it stays. fn page_tools(&mut self, theme: &Theme) { + let finding = self.search.finding; self.ui.open( "tools", Spec { - size: [px(PAGE_LIST), px(TAB_ROW)], - pad: [0.0, (TAB_ROW - ui::shell::TOOL) / 2.0], - gap: 3.0, + fold: (!finding).then_some(0), ..Spec::default() }, ); - if let Err(error) = self.search_box(theme) { - eprintln!("{error}"); + for folded in [false, true].into_iter().take(if finding { 1 } else { 2 }) { + self.ui.open( + folded, + Spec { + size: [if folded { children() } else { px(PAGE_LIST) }, px(TAB_ROW)], + pad: [0.0, (TAB_ROW - ui::shell::TOOL) / 2.0], + gap: 3.0, + ..Spec::default() + }, + ); + if folded { + let open = self.ui.popup_open(search::results()); + if ui::shell::tool_button(&mut self.ui, "search", art::SEARCH, theme.text, open) + .pressed + { + self.start_search(); + } + tip(&mut self.ui, commands::Id::Search); + } else if let Err(error) = self.search_box(theme) { + eprintln!("{error}"); + } + if ui::shell::tool_button(&mut self.ui, "new", art::PLUS, theme.text, false).clicked { + self.commands.push(Command::NewPage { under: None }); + } + tip(&mut self.ui, commands::Id::NewPage); + let toggle = if self.pages_open { + art::SIDEBAR_COLLAPSE + } else { + art::SIDEBAR_EXPAND + }; + if ui::shell::tool_button(&mut self.ui, "toggle", toggle, theme.text, false).clicked { + self.pages_open = !self.pages_open; + } + tip(&mut self.ui, commands::Id::PageList); + self.ui.close(); } - if ui::shell::tool_button(&mut self.ui, "new", art::PLUS, theme.text, false).clicked { - self.commands.push(Command::NewPage { under: None }); - } - tip(&mut self.ui, commands::Id::NewPage); - let toggle = if self.pages_open { - art::SIDEBAR_COLLAPSE - } else { - art::SIDEBAR_EXPAND - }; - if ui::shell::tool_button(&mut self.ui, "toggle", toggle, theme.text, false).clicked { - self.pages_open = !self.pages_open; - } - tip(&mut self.ui, commands::Id::PageList); self.ui.close(); } @@ -2941,6 +2938,17 @@ impl State { if let Some(path) = self.snapshot.take() { self.snapshot(&path)?; } + if let Some((dir, opened)) = &self.frames { + let path = dir.join(format!("{:09.2}.png", opened.elapsed().as_secs_f64() * 1e3)); + let pixels = self.capture()?; + let size = self.surface.size; + // Encoding off the frame keeps the frames at the pace they are drawn. + std::thread::spawn(move || { + if let Err(error) = write_png(&path, size, &pixels) { + eprintln!("{error}"); + } + }); + } if self.occluded { return Ok(()); } @@ -3006,6 +3014,7 @@ impl State { ], clip: Some(rect.map(|value| value * scale)), backdrop: Some(paper.color), + round: None, motion: None, primitives: &page_primitives, }, @@ -3022,25 +3031,48 @@ impl State { /// Writes the frame to a PNG, so a covered window can be reviewed. fn snapshot(&mut self, path: &Path) -> Result<(), Box> { - let size = self.surface.size; + let pixels = self.capture()?; + write_png(path, self.surface.size, &pixels) + } + + /// The frame's sRGB RGBA rows, drawn offscreen. + fn capture(&mut self) -> Result, Box> { let offscreen = self.surface.offscreen(&self.renderer)?; self.paint(&offscreen.target)?; - let pixels = self.surface.read(&self.renderer, offscreen)?; - let partial = path.with_extension("partial"); - let mut encoder = png::Encoder::new(std::fs::File::create(&partial)?, size[0], size[1]); - encoder.set_color(png::ColorType::Rgba); - encoder.set_depth(png::BitDepth::Eight); - encoder.write_header()?.write_image_data(&pixels)?; - std::fs::rename(partial, path)?; - Ok(()) + self.surface.read(&self.renderer, offscreen) } /// Queues input for the next frame. A press on the strip moves the window: at once on /// macOS, while AppKit still holds the press, and elsewhere once the pointer moves, as /// the window manager's move would swallow a second click; a double press zooms it. + fn over_page(&self) -> bool { + let [x0, y0, x1, y1] = self.ui.rect(page()).unwrap_or_default(); + let [x, y] = self.pointer; + (x0..x1).contains(&x) && (y0..y1).contains(&y) + } + + /// Zooms the page by `factor` about the pointer, when it is over the page. + fn pinch(&mut self, factor: f32) -> Result<(), Box> { + if self.over_page() { + let response = self.view.pinch(factor)?; + self.respond(response); + } + Ok(()) + } + + fn swipe_scroll(&mut self, phase: winit::event::TouchPhase, delta: [f32; 2]) { + let scroll = self.view.scroll(); + let armed = scroll.max[0] <= scroll.min[0] && self.over_page(); + if let Some(forward) = self.swipe.scroll(phase, delta, armed, Instant::now()) { + self.travel(forward); + self.window.request_redraw(); + } + } + fn input(&mut self, event: ui::Event) { if let ui::Event::PointerMoved(point) = event { self.pointer = point; + platform::set_window_drags(self.ui.box_at(point) == Some(strip())); if std::mem::take(&mut self.strip_held) { let _ = self.window.drag_window(); } @@ -3658,7 +3690,14 @@ fn list_gallery( size: [f32; 2], preview: impl Fn(&mut Ui, usize), ) -> Option> { - let groups = [(headings[0], recent.len()), (headings[1], count + 1)]; + let groups = [(headings[0], recent.len()), (headings[1], count + 1)].map(|(heading, cells)| { + ui::popup::Group { + heading, + cells, + columns: 5, + size, + } + }); let place = |index: usize| match index.checked_sub(recent.len()) { None => Some(recent[index]), Some(at) => at.checked_sub(1), @@ -3668,9 +3707,7 @@ fn list_gallery( id, anchor, &groups[usize::from(recent.is_empty())..], - 5, - size, - current.map(|place| recent.len() + 1 + place), + current.map(|place| recent.len() + 1 + place).as_slice(), |ui, index| match place(index) { Some(place) => preview(ui, place), None => { @@ -3689,44 +3726,161 @@ fn list_gallery( Some(place(chosen)) } -/// A toolbar group's rows, stacked. -fn group(ui: &mut Ui, part: &str, rows: impl FnOnce(&mut Ui)) { +/// A toolbar group that folds by `priority`: `full` builds its full form, `folded` the form +/// it folds to. Returns what either chose. +fn group( + ui: &mut Ui, + part: &str, + priority: u32, + full: impl FnOnce(&mut Ui) -> Option, + folded: impl FnOnce(&mut Ui) -> Option, +) -> Option { ui.open( part, Spec { - axis: Axis::Y, - gap: 2.0, + fold: Some(priority), ..Spec::default() }, ); - rows(ui); - ui.close(); -} - -fn row(ui: &mut Ui, part: usize, buttons: impl FnOnce(&mut Ui)) { ui.open( - part, + "full", Spec { gap: 1.0, ..Spec::default() }, ); - buttons(ui); + let shown = full(ui); ui.close(); + ui.open("folded", Spec::default()); + let choice = folded(ui).or(shown); + ui.close(); + ui.close(); + choice +} + +/// A row of a toolbar menu. +#[derive(Clone, Copy)] +enum Entry { + Run(commands::Id), + /// Opens the toolbar's popup `name`, which applies the command, under the button the + /// popup's own control would open it from, or where that is folded away, this menu's. + Open(&'static str, commands::Id), + /// A command not built yet, shown disabled. + Soon(&'static str, Option<&'static [&'static str]>), + /// Rules off the entries after it. + Rule, +} + +/// What opens a toolbar menu: the arrow of a button running a command, a button showing +/// artwork, or a bare arrow. +enum Head { + Split(commands::Id), + Menu(&'static [&'static str]), + More, +} + +/// A toolbar menu `part` of `entries` opened by `head`, whose command enabled and checked as +/// `status_of` says. Returns the command chosen, or the head's when clicked. +fn dropdown( + ui: &mut Ui, + part: &str, + head: Head, + entries: &[Entry], + status_of: &impl Fn(commands::Id) -> commands::Status, +) -> Option { + use commands::Choice; + let button = ui.id(part); + let menu = button.child("menu"); + let mut choice = None; + let anchor = match head { + Head::Split(id) if status_of(id).enabled => { + let icon = artwork(id).unwrap_or_default(); + let on = status_of(id).checked; + if ui::shell::split_button(ui, part, icon, None, on, menu).clicked { + choice = Some(Choice::Command(id)); + } + tip(ui, id); + ui::Anchor::Below(ui.rect(button).unwrap_or_default()) + } + // Where its command does not apply, the button only opens the menu. + Head::Split(id) => ui::shell::menu_button(ui, part, artwork(id).unwrap_or_default(), menu), + Head::Menu(icon) => ui::shell::menu_button(ui, part, icon, menu), + Head::More => { + let open = ui.popup_open(menu); + let text = ui.theme.text; + if ui::shell::tool_button(ui, part, ui::shell::CHEVRON, text, open).pressed { + ui.open_popup(menu); + } + ui::Anchor::Below(ui.rect(button).unwrap_or_default()) + } + }; + let keys: Vec<_> = entries + .iter() + .map(|entry| match entry { + Entry::Run(id) => commands::shortcut(*id), + _ => String::new(), + }) + .collect(); + let (mut items, mut actions) = (Vec::new(), Vec::new()); + let mut separated = false; + for (entry, key) in entries.iter().zip(&keys) { + let item = match *entry { + Entry::Rule => { + separated = true; + continue; + } + Entry::Run(id) => ui::popup::Item { + text: commands::command(id).title, + icon: artwork(id), + colored: matches!(id, commands::Id::Tag(_)), + shortcut: key, + checked: status_of(id).checked, + disabled: !status_of(id).enabled, + ..Default::default() + }, + Entry::Open(_, id) => ui::popup::Item { + text: commands::command(id).title, + icon: artwork(id), + shortcut: "›", + disabled: !status_of(id).enabled, + ..Default::default() + }, + Entry::Soon(text, icon) => ui::popup::Item { + text, + icon, + disabled: true, + ..Default::default() + }, + }; + items.push(ui::popup::Item { + separated: std::mem::take(&mut separated), + ..item + }); + actions.push(*entry); + } + match ui::popup::menu(ui, menu, anchor, &items, None).map(|index| actions[index]) { + Some(Entry::Run(id)) => Some(Choice::Command(id)), + Some(Entry::Open(name, _)) => { + ui.open_popup(toolbar_popup(name)); + None + } + _ => choice, + } } -/// A tooltip naming command `id` and its chord on the box built last. It names the command, -/// not a dialog it opens, so it leaves off the menu's ellipsis. /// A tool button for command `id`, lit while `status` checks it and faded while it does not /// apply; returns the command when clicked. -fn tool( - ui: &mut Ui, - part: impl std::hash::Hash, - icon: &'static [&'static str], - tint: [f32; 4], - id: commands::Id, - status: commands::Status, -) -> Option { +fn tool(ui: &mut Ui, id: commands::Id, status: commands::Status) -> Option { + let part = commands::COMMANDS + .iter() + .position(|command| command.id == id); + let icon = artwork(id).unwrap_or_default(); + // Artwork in its own colours takes white. + let tint = if matches!(id, commands::Id::Tag(_)) { + [1.0; 4] + } else { + ui.theme.text + }; if !status.enabled { ui::shell::unavailable(ui, part, icon, tint, false); return None; @@ -3736,6 +3890,57 @@ fn tool( clicked.then_some(commands::Choice::Command(id)) } +/// The artwork the toolbar and its menus show for command `id`. +fn artwork(id: commands::Id) -> Option<&'static [&'static str]> { + use canvas::editor::{Alignment, Toggle}; + use commands::Id as Cmd; + Some(match id { + Cmd::Back => art::BACK, + Cmd::Forward => art::FORWARD, + Cmd::Undo => art::UNDO, + Cmd::Redo => art::REDO, + Cmd::Cut => art::CUT, + Cmd::Copy => art::COPY, + Cmd::Paste => art::PASTE, + Cmd::FormatPainter => art::FORMAT_PAINTER, + Cmd::Toggle(Toggle::Bold) => art::BOLD, + Cmd::Toggle(Toggle::Italic) => art::ITALIC, + Cmd::Toggle(Toggle::Underline) => art::UNDERLINE, + Cmd::Toggle(Toggle::Strikethrough) => art::STRIKETHROUGH, + Cmd::Toggle(Toggle::Subscript) => art::SUBSCRIPT, + Cmd::Toggle(Toggle::Superscript) => art::SUPERSCRIPT, + Cmd::ClearFormatting => art::CLEAR_FORMATTING, + Cmd::Highlight => art::HIGHLIGHTER, + Cmd::FontColor => art::FONT_COLOR, + Cmd::Bullets => art::BULLETS, + Cmd::Numbering => art::NUMBERING, + Cmd::Align(Alignment::Left) => art::ALIGN_LEFT, + Cmd::Align(Alignment::Center) => art::ALIGN_CENTER, + Cmd::Align(Alignment::Right) => art::ALIGN_RIGHT, + Cmd::Outdent => art::OUTDENT, + Cmd::Indent => art::INDENT, + Cmd::Tag(tag) => tag_icon(tag), + Cmd::FindTags => art::FIND_TAGS, + Cmd::Table => art::TABLE, + Cmd::Picture => art::PICTURE, + Cmd::ScreenClipping => art::SCREEN_CLIPPING, + Cmd::Attachment => art::ATTACHMENT, + Cmd::Link => art::LINK, + Cmd::InsertSpace => art::INSERT_SPACE, + Cmd::Date => art::CALENDAR, + Cmd::Time => art::CLOCK, + Cmd::DateTime => art::DATE_TIME, + Cmd::Equation => art::EQUATION, + Cmd::Symbol => art::SYMBOL, + Cmd::RecordAudio => art::RECORD_AUDIO, + Cmd::RecordVideo => art::RECORD_VIDEO, + Cmd::PageColor => art::PAGE_COLOR, + Cmd::ZoomIn => art::ZOOM_IN, + Cmd::ZoomOut => art::ZOOM_OUT, + _ => return None, + }) +} + /// The artwork the toolbar shows for `tag`. fn tag_icon(tag: canvas::editor::NoteTag) -> &'static [&'static str] { use canvas::editor::NoteTag; @@ -3751,18 +3956,21 @@ fn toolbar_popup(name: &str) -> Id { Id::ROOT.child(("popup", name)) } +/// A tooltip naming command `id` and its chord on the box built last. It names the command, +/// not a dialog it opens, so it leaves off the menu's ellipsis. fn tip(ui: &mut Ui, id: commands::Id) { let title = commands::command(id).title.trim_end_matches('…'); ui::popup::tooltip(ui, title, &commands::shortcut(id), None); } -/// The line between two toolbar groups. -fn divider(ui: &mut Ui, part: &str, theme: &Theme) { +/// The line leading a toolbar group, as far from its buttons as the groups are apart. +fn divider(ui: &mut Ui, theme: &Theme) { ui.leaf( - ("divider", part), + "divider", Spec { - size: [px(1.0), px(2.0 * ui::shell::TOOL + 2.0)], + size: [px(GAP), px(ui::shell::TOOL)], fill: Some(theme.chip), + inset: [0.0, 0.0, GAP - 1.0, 0.0], ..Spec::default() }, ); @@ -4054,9 +4262,8 @@ impl ApplicationHandler for App { state.set_appearance(state.color_scheme.theme().unwrap_or(appearance)); state.window.request_redraw(); } - WindowEvent::ScaleFactorChanged { scale_factor, .. } => { + WindowEvent::ScaleFactorChanged { .. } => { state.renderer.clear_glyph_cache(); - state.app_icon = platform::app_icon((16.0 * scale_factor).round() as u32); state.window.request_redraw(); } WindowEvent::Focused(focused) => { @@ -4090,7 +4297,10 @@ impl ApplicationHandler for App { pressed: pressed == ElementState::Pressed, at: Instant::now(), }), - WindowEvent::MouseWheel { delta, .. } => { + WindowEvent::MouseWheel { delta, phase, .. } => { + if let MouseScrollDelta::PixelDelta(p) = delta { + state.swipe_scroll(phase, [p.x as f32 / scale, p.y as f32 / scale]); + } state.input(ui::Event::Wheel(match delta { MouseScrollDelta::LineDelta(x, y) => [x * 32.0, y * 32.0], MouseScrollDelta::PixelDelta(p) => [p.x as f32 / scale, p.y as f32 / scale], @@ -4105,6 +4315,13 @@ impl ApplicationHandler for App { }) } WindowEvent::Ime(ime) => state.input(ui::Event::Ime(ime)), + WindowEvent::PinchGesture { delta, .. } => state.pinch(1.0 + delta as f32)?, + // Smart zoom: to 200% about the fingers, or back to 100%. + WindowEvent::DoubleTapGesture { .. } => { + let zoom = state.view.zoom(); + let target = if (zoom - 1.0).abs() < 0.01 { 2.0 } else { 1.0 }; + state.pinch(target / zoom)? + } _ => {} } Ok(()) @@ -4137,6 +4354,16 @@ impl ApplicationHandler for App { } } +fn write_png(path: &Path, size: [u32; 2], pixels: &[u8]) -> Result<(), Box> { + let partial = path.with_extension("partial"); + let mut encoder = png::Encoder::new(std::fs::File::create(&partial)?, size[0], size[1]); + encoder.set_color(png::ColorType::Rgba); + encoder.set_depth(png::BitDepth::Eight); + encoder.write_header()?.write_image_data(pixels)?; + std::fs::rename(partial, path)?; + Ok(()) +} + /// Feeds a development script to the window from another thread, one command per line /// in logical pixels: `move X Y`, `press [right]`, `release [right]`, `wheel DX DY`, `key NAME`, `type /// TEXT`, `modifiers [shift] [command]`, `wait MILLISECONDS`, `snapshot PNG_PATH`, @@ -4214,9 +4441,15 @@ fn replay(script: String, proxy: EventLoopProxy) -> Result<(), Box { + // Ticks keep a 60 Hz display's pace, dropping those a slow frame missed. let start = Instant::now(); + let period = std::time::Duration::from_micros(16_667); + let mut tick = start; while start.elapsed() < duration { - std::thread::sleep(std::time::Duration::from_millis(16)); + while tick <= Instant::now() { + tick += period; + } + std::thread::sleep(tick - Instant::now()); let _ = proxy.send_event(UserEvent::Replay(Replay::Tick)); } } diff --git a/crates/snowbound/src/manage.rs b/crates/snowbound/src/manage.rs index 02176bd808972887171c8a834e0e9fcb99c73e18..d3c2385afee9d422163d91c014fff8bb38cf1765 100644 --- a/crates/snowbound/src/manage.rs +++ b/crates/snowbound/src/manage.rs @@ -2,6 +2,7 @@ //! structure through `notebook::session::Notebook`, pages as ops on the open section. use crate::{Command, Library, Loaded, State, notify, platform, read_session}; +use canvas::template::Template; use notebook::session::Notebook; use onestore::{ ExGuid, PageCreation, PageEdit, @@ -133,13 +134,28 @@ impl State { self.edit_page(ops) } - /// View, Page Color: gives the open page `color`, a COLORREF, or none; its art stays. - pub(crate) fn color_page(&mut self, color: Option) -> Result<(), Box> { - if self.view.editor.page_color() == color { - return Ok(()); - } + /// Gives the open page `template`'s art, or none, in place of the art it had; its colour + /// and rule lines stay. + pub(crate) fn art_page( + &mut self, + template: Option<&'static Template>, + ) -> Result<(), Box> { self.persist()?; - self.edit_page(vec![PageOp::Color(color)]) + let session = self.session.as_ref().ok_or("No section is open")?; + let ops = art_ops(&session.section.page(session.space)?, template)?; + self.edit_page(ops) + } + + /// View, Page Color and Rule Lines: gives the open page `color`, a COLORREF, and + /// `rule_lines` as one undo step; its art stays. + pub(crate) fn paper_page( + &mut self, + color: Option, + rule_lines: Option, + ) -> Result<(), Box> { + let response = self.view.set_paper(color, rule_lines)?; + self.respond(response); + Ok(()) } /// Applies `ops` to the open page as one edit and shows the result. @@ -429,60 +445,26 @@ impl State { } } -/// The ops giving `page` `choice`'s background: its template art (OneNote's pictures' -/// places, our recreations' bytes) or a page colour, in place of the background it had. +/// The ops giving `page` `choice`'s background: its template art or a page colour, in +/// place of the background it had. pub fn template_ops( page: &Page, choice: crate::templates::Choice, ) -> Result, Box> { use crate::templates::Choice; - let mut ops: Vec = page - .objects - .iter() - .filter(|object| matches!(object, PageObject::Image(image) if image.background)) - .map(|object| PageOp::Delete { - object: object.id(), - }) - .collect(); + let mut ops = match choice { + Choice::Template(name) => art_ops( + page, + Some(canvas::template::find(name).ok_or("That template is not available")?), + )?, + Choice::Color(_) => art_ops(page, None)?, + Choice::More | Choice::Dismiss | Choice::Colors => return Ok(Vec::new()), + }; match choice { Choice::Template(name) => { - let template = canvas::template::find(name).ok_or("That template is not available")?; if page.color.is_some() { ops.push(PageOp::Color(None)); } - // Art lies under everything else on the page, first of its children (the title - // is no child). - let under = page - .objects - .iter() - .find(|object| match object { - PageObject::Title(_) => false, - PageObject::Image(image) => !image.background, - _ => true, - }) - .map(PageObject::id); - for art in template.art { - let bytes = canvas::gpu::page::template_picture(art.art) - .ok_or("That template's art is missing")?; - ops.push(PageOp::Add { - object: PageObject::Image(Image { - id: onestore::page::text::new_id()?, - layout: onestore::document::Layout { - x: Some(art.position[0]), - y: Some(art.position[1]), - max_width: art.size.map(|size| size[0]), - max_height: art.size.map(|size| size[1]), - width_set_by_user: art.size.map(|_| true), - ..Default::default() - }, - size: art.size, - bytes: Some(bytes.into()), - alt: None, - background: true, - }), - before: under, - }); - } // The one template with content worth keeping. if name == "Informal Meeting Notes" { ops.extend(onestore::op::lower_page( @@ -494,7 +476,54 @@ pub fn template_ops( Choice::Color(index) => { ops.push(PageOp::Color(Some(canvas::template::PAGE_COLORS[index].1))) } - Choice::More | Choice::Dismiss | Choice::Colors => return Ok(Vec::new()), + Choice::More | Choice::Dismiss | Choice::Colors => {} + } + Ok(ops) +} + +/// The ops giving `page` `template`'s art (OneNote's pictures' places, our recreations' +/// bytes), or none, in place of the background pictures it had. +pub fn art_ops(page: &Page, template: Option<&Template>) -> Result, Box> { + let mut ops: Vec = page + .objects + .iter() + .filter(|object| matches!(object, PageObject::Image(image) if image.background)) + .map(|object| PageOp::Delete { + object: object.id(), + }) + .collect(); + // Art lies under everything else on the page, first of its children (the title is no + // child). + let under = page + .objects + .iter() + .find(|object| match object { + PageObject::Title(_) => false, + PageObject::Image(image) => !image.background, + _ => true, + }) + .map(PageObject::id); + for art in template.map_or(&[][..], |template| template.art) { + let bytes = + canvas::gpu::page::template_picture(art.art).ok_or("That template's art is missing")?; + ops.push(PageOp::Add { + object: PageObject::Image(Image { + id: onestore::page::text::new_id()?, + layout: onestore::document::Layout { + x: Some(art.position[0]), + y: Some(art.position[1]), + max_width: art.size.map(|size| size[0]), + max_height: art.size.map(|size| size[1]), + width_set_by_user: art.size.map(|_| true), + ..Default::default() + }, + size: art.size, + bytes: Some(bytes.into()), + alt: None, + background: true, + }), + before: under, + }); } Ok(ops) } @@ -842,4 +871,66 @@ mod tests { "New Section Group 2" ); } + + /// Backgrounds the Page Color menu gives OneNote's ruled pages + /// (`corpus/rule-lines/native`): art on a page with rule lines, keeping them, art taken + /// away, and a colour outside OneNote's menu. `SNOWBOUND_BACKGROUND_EXPORT` names a new + /// directory that receives the notebook for a cold reopen in OneNote 2010. + #[test] + fn backgrounds_go_on_and_off_pages_that_have_content() { + let temporary = + std::env::temp_dir().join(format!("snowbound-background-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&temporary); + copy( + Path::new(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../corpus/rule-lines/native/notebook" + )), + &temporary, + ); + let file = temporary.join("Rules.one"); + let titled = |title: &str| { + pages(&file) + .into_iter() + .find(|(_, name, _)| name == title) + .unwrap() + .0 + }; + let on_page = |space, ops: Vec| { + edit( + &file, + ops.into_iter().map(|op| Op::Page { space, op }).collect(), + ) + }; + let ivy = canvas::template::find("Ivy"); + let (standard, wide, grid) = (titled("Standard"), titled("Wide"), titled("SmallGrid")); + on_page(standard, art_ops(&page(&file, standard), ivy).unwrap()); + on_page(wide, art_ops(&page(&file, wide), ivy).unwrap()); + on_page(wide, art_ops(&page(&file, wide), None).unwrap()); + // A warm grey no menu offers. + on_page(grid, vec![PageOp::Color(Some(0x00c8d8e8))]); + + let standard = page(&file, standard); + assert!(standard.rule_lines.is_some()); + assert_eq!( + standard + .objects + .iter() + .filter(|object| matches!(object, PageObject::Image(image) if image.background)) + .count(), + ivy.unwrap().art.len() + ); + let wide = page(&file, wide); + assert!( + !wide + .objects + .iter() + .any(|object| matches!(object, PageObject::Image(_))) + ); + assert_eq!(page(&file, grid).color, Some(0x00c8d8e8)); + if let Some(directory) = std::env::var_os("SNOWBOUND_BACKGROUND_EXPORT") { + copy(&temporary, Path::new(&directory)); + } + std::fs::remove_dir_all(&temporary).unwrap(); + } } diff --git a/crates/snowbound/src/menubar.rs b/crates/snowbound/src/menubar.rs index bc91d02ebf868128c48c5eac58cdf18214b0eb75..3ed76986a00f27b1b691df3aa38b3367b2b4a347 100644 --- a/crates/snowbound/src/menubar.rs +++ b/crates/snowbound/src/menubar.rs @@ -79,6 +79,7 @@ const MENUS: &[Item] = &[ S, C(Id::Find), C(Id::Search), + C(Id::SearchResults), S, Item::System("Start Dictation…", "startDictation:", None), Item::System( @@ -92,6 +93,8 @@ const MENUS: &[Item] = &[ Item::Menu( "View", &[ + C(Id::CommandPalette), + S, C(Id::Back), C(Id::Forward), S, @@ -192,6 +195,7 @@ const MENUS: &[Item] = &[ ), S, C(Id::RemoveTags), + C(Id::FindTags), ], ), Item::Menu( diff --git a/crates/snowbound/src/navigation.rs b/crates/snowbound/src/navigation.rs index b69c1aee9500126bffc164c71e3d7c02a20ba658..a8d2b13e5452454d38d15d55d53ad421f10d24a4 100644 --- a/crates/snowbound/src/navigation.rs +++ b/crates/snowbound/src/navigation.rs @@ -2,6 +2,8 @@ //! 2010's Quick Access Toolbar offers them. use super::*; +use std::time::Duration; +use winit::event::TouchPhase; /// A page shown: its notebook's location, its section's catalog path and the page. #[derive(Clone, Debug, PartialEq)] @@ -107,6 +109,50 @@ impl State { } } +/// A two-finger horizontal trackpad swipe, which travels Back or Forward as macOS's +/// "swipe between pages" does. +#[derive(Default)] +pub struct Swipe { + travelled: Option<[f32; 2]>, + ended: Option, +} + +impl Swipe { + /// Feeds one trackpad scroll in logical pixels; `armed` when the page cannot scroll + /// sideways. Returns whether to go forward once a swipe completes. + pub fn scroll( + &mut self, + phase: TouchPhase, + delta: [f32; 2], + armed: bool, + at: Instant, + ) -> Option { + match phase { + // Momentum arrives as a second gesture right after the fingers lift. + TouchPhase::Started => { + let momentum = self + .ended + .is_some_and(|ended| at - ended < Duration::from_millis(150)); + self.travelled = (armed && !momentum).then_some([0.0; 2]); + None + } + TouchPhase::Moved => { + if let Some(travelled) = &mut self.travelled { + travelled[0] += delta[0]; + travelled[1] += delta[1]; + } + None + } + TouchPhase::Ended | TouchPhase::Cancelled => { + self.ended = Some(at); + let [x, y] = self.travelled.take()?; + (phase == TouchPhase::Ended && x.abs() > 120.0 && x.abs() > 2.0 * y.abs()) + .then_some(x < 0.0) + } + } + } +} + #[cfg(test)] mod tests { use super::*; @@ -122,6 +168,28 @@ mod tests { } } + #[test] + fn swipe_travels_on_a_long_sideways_stroke_only() { + let start = Instant::now(); + let at = |ms| start + Duration::from_millis(ms); + let stroke = |swipe: &mut Swipe, dx: f32, dy: f32, armed: bool, t: u64| { + swipe.scroll(TouchPhase::Started, [0.0; 2], armed, at(t)); + for _ in 0..10 { + swipe.scroll(TouchPhase::Moved, [dx / 10.0, dy / 10.0], armed, at(t)); + } + swipe.scroll(TouchPhase::Ended, [0.0; 2], armed, at(t)) + }; + let mut swipe = Swipe::default(); + // Fingers right go Back, left go Forward. + assert_eq!(stroke(&mut swipe, 200.0, 10.0, true, 0), Some(false)); + assert_eq!(stroke(&mut swipe, -200.0, 10.0, true, 1000), Some(true)); + // The momentum right after a stroke is not another. + assert_eq!(stroke(&mut swipe, -200.0, 0.0, true, 1050), None); + assert_eq!(stroke(&mut swipe, 60.0, 0.0, true, 2000), None); + assert_eq!(stroke(&mut swipe, 200.0, 150.0, true, 3000), None); + assert_eq!(stroke(&mut swipe, 200.0, 0.0, false, 4000), None); + } + #[test] fn back_and_forward_retrace_visits_across_sections() { let mut trail = Trail::default(); diff --git a/crates/snowbound/src/palette.rs b/crates/snowbound/src/palette.rs new file mode 100644 index 0000000000000000000000000000000000000000..ea81a5d6ba02c878bed1b4b0cfd68f98edcaaeed --- /dev/null +++ b/crates/snowbound/src/palette.rs @@ -0,0 +1,147 @@ +//! The command palette: the command table's commands, then the open notebooks' sections and +//! every page the search index holds to go to, narrowed as typed. + +use crate::{ + Command, Library, State, + commands::{self, COMMANDS, Choice}, + library, +}; +use onestore::ExGuid; +use std::sync::Arc; +use ui::{Id, popup::Item}; + +pub(crate) fn id() -> Id { + Id::ROOT.child("palette") +} + +enum Target { + Command(commands::Id), + Section(Arc, String), + Page(String, ExGuid), +} + +/// A row: its text, the dim text after it, and what it runs; a heading runs nothing. +struct Row { + text: String, + after: String, + disabled: bool, + target: Option, +} + +impl State { + /// Builds the palette while it is open, and runs what is chosen from it. + pub(crate) fn palette(&mut self) { + if !self.ui.popup_open(id()) { + return; + } + let format = self.format_state(); + let mut rows: Vec = COMMANDS + .iter() + .map(|command| Row { + text: command.title.to_owned(), + after: commands::shortcut(command.id), + disabled: !self.status(&Choice::Command(command.id), &format).enabled, + target: Some(Target::Command(command.id)), + }) + .collect(); + let heading = |text: &str| Row { + text: text.to_owned(), + after: String::new(), + disabled: false, + target: None, + }; + rows.push(heading("Sections")); + for library in &self.notebooks { + // A section opened on its own has no catalog, and lists itself at "". + let mut folders: Vec<_> = library.catalog().into_iter().collect(); + let mut at = 0; + while let Some(&folder) = folders.get(at) { + folders.extend( + folder + .groups + .iter() + .filter(|group| !library::recycle_bin(&group.path)), + ); + at += 1; + } + let paths = folders.iter().map(|folder| folder.path.as_str()); + let paths: Vec<&str> = if folders.is_empty() { + vec![""] + } else { + paths.collect() + }; + for path in paths { + rows.extend(library.tabs(path).into_iter().map(|tab| Row { + text: tab.name, + after: library.name.clone(), + disabled: false, + target: Some(Target::Section(Arc::clone(library), tab.path)), + })); + } + } + rows.push(heading("Pages")); + let index = self + .search + .index + .lock() + .unwrap_or_else(|poison| poison.into_inner()); + rows.extend(index.entries().iter().map(|entry| { + let (location, path) = entry.section.split_once('\n').unwrap_or_default(); + let mut section = library::section_name(path, &None); + if section.is_empty() { + section = (self.notebooks.iter()) + .find(|library| library.location == location) + .map_or_else(String::new, |library| library.name.clone()); + } + Row { + text: if entry.title.is_empty() { + "Untitled page".to_owned() + } else { + entry.title.clone() + }, + after: section, + disabled: false, + target: Some(Target::Page(entry.section.clone(), entry.space)), + } + })); + drop(index); + let items: Vec = rows + .iter() + .map(|row| Item { + text: &row.text, + shortcut: &row.after, + disabled: row.disabled, + heading: row.target.is_none(), + separated: row.target.is_none(), + ..Item::default() + }) + .collect(); + let chosen = ui::popup::palette( + &mut self.ui, + id(), + &items, + "Search commands, sections and pages", + ); + match chosen.and_then(|index| rows.swap_remove(index).target) { + Some(Target::Command(command)) => self.choose(Choice::Command(command)), + Some(Target::Section(library, path)) => { + self.commands.push(Command::OpenSection(library, path)); + } + Some(Target::Page(section, space)) => { + let open = (self.session.as_ref()) + .map(|session| session.library.key(&session.tabs[session.tab].path)); + let (location, path) = section.split_once('\n').unwrap_or_default(); + if open.as_ref() == Some(§ion) { + self.commands.push(Command::OpenPage(space)); + } else if let Some(library) = + (self.notebooks.iter()).find(|library| library.location == location) + { + self.commands + .push(Command::OpenSection(Arc::clone(library), path.to_owned())); + self.last_pages.insert(section, space); + } + } + None => {} + } + } +} diff --git a/crates/snowbound/src/pane.rs b/crates/snowbound/src/pane.rs new file mode 100644 index 0000000000000000000000000000000000000000..fdf7547dc0a03aa4ce011e0472746f544bb768ce --- /dev/null +++ b/crates/snowbound/src/pane.rs @@ -0,0 +1,906 @@ +//! OneNote 2010's task panes beside the page: Page Search Results (Alt+O), listing the +//! search's results sorted and grouped, and the Tags Summary, listing tagged paragraphs. + +use crate::search::{Scope, marked}; +use crate::{State, Theme, art, page}; +use canvas::gpu::tag_sources; +use canvas::outline::TagIcon; +use canvas::search::{Found, Tagged}; +use std::{error::Error, sync::atomic::Ordering}; +use ui::{Anchor, Axis, Flags, Id, Spec, Ui, fill, fit, px}; +use winit::keyboard::NamedKey; + +/// The pane's width, and its lists' rows and group headings. +const WIDTH: f32 = 260.0; +const RESULT: f32 = 40.0; +const TAGGED: f32 = 24.0; +const HEADING: f32 = 22.0; +/// A control's width inside the pane. +const INSIDE: f32 = WIDTH - 2.0 * PAD; +const PAD: f32 = 8.0; + +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum Pane { + Search { + sort: Sort, + descending: bool, + }, + Tags { + group: Group, + unchecked: bool, + scope: TagScope, + }, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Sort { + Section, + Title, + Date, +} + +impl Sort { + const ALL: [Sort; 3] = [Sort::Section, Sort::Title, Sort::Date]; + + fn name(self) -> &'static str { + match self { + Sort::Section => "Sort by Section", + Sort::Title => "Sort by Title", + Sort::Date => "Sort by Date Modified", + } + } +} + +/// What the Tags Summary groups its paragraphs by. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Group { + Tag, + Section, + Title, + Date, + Text, +} + +impl Group { + const ALL: [Group; 5] = [ + Group::Tag, + Group::Section, + Group::Title, + Group::Date, + Group::Text, + ]; + + fn name(self) -> &'static str { + match self { + Group::Tag => "Tag name", + Group::Section => "Section", + Group::Title => "Title", + Group::Date => "Date", + Group::Text => "Note text", + } + } +} + +/// Where the Tags Summary looks: part of the notebooks, or tags applied in a span of days. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum TagScope { + PageGroup, + Section, + SectionGroup, + Notebook, + All, + Today, + Yesterday, + ThisWeek, + LastWeek, + Older, +} + +impl TagScope { + const ALL: [TagScope; 10] = [ + TagScope::PageGroup, + TagScope::Section, + TagScope::SectionGroup, + TagScope::Notebook, + TagScope::All, + TagScope::Today, + TagScope::Yesterday, + TagScope::ThisWeek, + TagScope::LastWeek, + TagScope::Older, + ]; + + /// Its name, which also heads the paragraphs tagged in its span when grouped by date. + fn name(self) -> &'static str { + match self { + TagScope::PageGroup => "This page group", + TagScope::Section => "This section", + TagScope::SectionGroup => "This section group", + TagScope::Notebook => "This notebook", + TagScope::All => "All notebooks", + TagScope::Today => "Today's notes", + TagScope::Yesterday => "Yesterday's notes", + TagScope::ThisWeek => "This week's notes", + TagScope::LastWeek => "Last week's notes", + TagScope::Older => "Older notes", + } + } + + /// Whether a tag applied on local day `day` falls in the span, `today` being today. + fn spans(self, day: i64, today: i64) -> bool { + // OneNote's weeks start on Sunday; day 0 was a Thursday. + let week = today - (today + 4).rem_euclid(7); + match self { + TagScope::Today => day >= today, + TagScope::Yesterday => day == today - 1, + TagScope::ThisWeek => day >= week, + TagScope::LastWeek => (week - 7..week).contains(&day), + TagScope::Older => day < week - 7, + _ => true, + } + } + + /// The span heading a tag applied on `day` when grouped by date. + fn of_day(day: i64, today: i64) -> TagScope { + [ + TagScope::Today, + TagScope::Yesterday, + TagScope::ThisWeek, + TagScope::LastWeek, + ] + .into_iter() + .find(|span| span.spans(day, today)) + .unwrap_or(TagScope::Older) + } +} + +/// The local day, counted from 1970, of Time32 `time`. +fn local_day(time: u64) -> i64 { + let unix = time as i64 + 315_532_800; + // SAFETY: `localtime_r` writes only the `tm` it is given. + let offset = unsafe { + let mut tm: libc::tm = std::mem::zeroed(); + libc::localtime_r(&unix, &mut tm); + tm.tm_gmtoff + }; + (unix + offset as i64).div_euclid(86_400) +} + +/// Day `day` counted from 1970 as a year, month and day. +fn civil(day: i64) -> (i64, u32, u32) { + let z = day + 719_468; + let era = z.div_euclid(146_097); + let doe = z.rem_euclid(146_097); + let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let day = (doy - (153 * mp + 2) / 5 + 1) as u32; + let month = if mp < 10 { mp + 3 } else { mp - 9 } as u32; + (yoe + era * 400 + i64::from(month <= 2), month, day) +} + +const MONTHS: [&str; 12] = [ + "January", + "February", + "March", + "April", + "May", + "June", + "July", + "August", + "September", + "October", + "November", + "December", +]; + +/// Rows under group headings, each heading above the row starting its group. +struct Grouped { + count: usize, + /// The rows starting groups, in order. + starts: Vec, +} + +impl ui::Rows for Grouped { + fn count(&self) -> usize { + self.count + } + + fn key(&self, index: usize) -> u64 { + index as u64 + } + + fn find(&self, key: u64) -> Option { + ((key as usize) < self.count).then_some(key as usize) + } + + fn space_before(&self, index: usize) -> f32 { + self.starts.partition_point(|start| *start <= index) as f32 * HEADING + } +} + +/// `items` in `order`, and each group's first row with its heading. +fn grouped(order: &[usize], heading: impl Fn(usize) -> String) -> (Grouped, Vec) { + let mut starts = Vec::new(); + let mut headings: Vec = Vec::new(); + for (row, &item) in order.iter().enumerate() { + let name = heading(item); + if headings.last() != Some(&name) { + starts.push(row); + headings.push(name); + } + } + ( + Grouped { + count: order.len(), + starts, + }, + headings, + ) +} + +/// A section's name from its `Library::key`. +fn section_name(key: &str) -> String { + let path = key.split_once('\n').map_or(key, |(_, path)| path); + crate::library::section_name(path, &None) +} + +fn shown_title(title: &str) -> &str { + if title.trim().is_empty() { + "Untitled page" + } else { + title + } +} + +/// The search's results in `sort`'s order, and their groups: sections, first letters of +/// titles, or months. +fn arrange(found: &[Found], sort: Sort, descending: bool) -> (Vec, Grouped, Vec) { + let mut order: Vec = (0..found.len()).collect(); + match sort { + Sort::Section => order.sort_by_cached_key(|&at| { + let found = &found[at]; + ( + section_name(&found.section).to_lowercase(), + found.section.clone(), + found.order, + ) + }), + Sort::Title => { + order.sort_by_cached_key(|&at| shown_title(&found[at].title).to_lowercase()); + } + Sort::Date => order.sort_by_key(|&at| found[at].modified), + } + if descending { + order.reverse(); + } + let (rows, headings) = grouped(&order, |at| { + let found = &found[at]; + match sort { + Sort::Section => section_name(&found.section), + Sort::Title => shown_title(&found.title) + .chars() + .next() + .map_or_else(String::new, |first| first.to_uppercase().collect()), + Sort::Date => { + let (year, month, _) = civil(local_day(found.modified)); + format!("{}, {year}", MONTHS[month as usize - 1]) + } + } + }); + (order, rows, headings) +} + +/// Tagged paragraphs in `group`'s order and their groups, each group's paragraphs in page +/// order. +fn arrange_tags(tagged: &[Tagged], group: Group) -> (Vec, Grouped, Vec) { + let today = local_day(crate::search::now()); + let heading = |at: usize| -> String { + let tagged = &tagged[at]; + match group { + Group::Tag => tagged.name.clone(), + Group::Section => section_name(&tagged.section), + Group::Title => shown_title(&tagged.title).to_owned(), + Group::Date => TagScope::of_day(local_day(tagged.created), today) + .name() + .to_owned(), + Group::Text => Group::Text.name().to_owned(), + } + }; + let mut order: Vec = (0..tagged.len()).collect(); + match group { + Group::Date => order.sort_by_key(|&at| { + TagScope::ALL + .iter() + .position(|span| *span == TagScope::of_day(local_day(tagged[at].created), today)) + }), + Group::Text => order.sort_by_cached_key(|&at| tagged[at].text.to_lowercase()), + _ => order.sort_by_cached_key(|&at| heading(at).to_lowercase()), + } + let (rows, headings) = grouped(&order, heading); + (order, rows, headings) +} + +/// The field of the Search Results pane. +pub(crate) fn field() -> Id { + Id::ROOT.child("pane-field") +} + +fn popup(name: &str) -> Id { + Id::ROOT.child(("pane popup", name)) +} + +/// A combo box `width` wide showing `current` of `names`, returning the one chosen. +fn choice(ui: &mut Ui, name: &str, current: usize, names: &[&str], width: f32) -> Option { + let combo = ui.id(name); + if ui::shell::combo(ui, name, names[current], width).pressed { + ui.open_popup(popup(name)); + } + let items: Vec<_> = names + .iter() + .enumerate() + .map(|(index, text)| ui::popup::Item { + text, + checked: index == current, + ..Default::default() + }) + .collect(); + let anchor = Anchor::Below(ui.rect(combo).unwrap_or_default()); + ui::popup::menu(ui, popup(name), anchor, &items, None) +} + +fn label(ui: &mut Ui, theme: &Theme, part: &str, text: &str) { + ui.leaf( + part, + Spec { + size: [fill(), px(20.0)], + text: Some(text), + color: Some(theme.text_dim), + ..Spec::default() + }, + ); +} + +/// A group's heading, floating above the row starting it. +fn heading(ui: &mut Ui, theme: &Theme, text: &str) { + ui.leaf( + "heading", + Spec { + flags: Flags::FLOAT, + size: [fill(), px(HEADING)], + position: [0.0, -HEADING], + text: Some(text), + bold: true, + color: Some(theme.text), + pad: [4.0, 0.0], + ..Spec::default() + }, + ); +} + +impl State { + /// Opens the Search Results pane on the search, as Alt+O does. + pub(crate) fn open_search_pane(&mut self) { + if !matches!(self.search.pane, Some(Pane::Search { .. })) { + self.search.pane = Some(Pane::Search { + sort: Sort::Date, + descending: true, + }); + } + self.search.open = false; + self.search.finding = false; + self.search.claim = true; + self.ui.close_popup(crate::search::results()); + self.sync_index(true); + } + + /// Opens or closes a pane, as its command does. + pub(crate) fn toggle_pane(&mut self, tags: bool) { + match self.search.pane { + Some(Pane::Tags { .. }) if tags => self.close_pane(), + Some(Pane::Search { .. }) if !tags => self.close_pane(), + _ if tags => { + self.close_pane(); + self.search.pane = Some(Pane::Tags { + group: Group::Tag, + unchecked: false, + scope: TagScope::Notebook, + }); + self.sync_index(true); + } + _ => self.open_search_pane(), + } + } + + /// Closes the pane; the Search Results pane takes its search, and the marks, with it. + fn close_pane(&mut self) { + if matches!(self.search.pane.take(), Some(Pane::Search { .. })) { + self.end_search(); + } + } + + /// The open pane at the window's right, easing open and closed. + pub(crate) fn task_pane(&mut self, theme: &Theme) { + let id = self.ui.id("task pane"); + let width = self.ui.animate( + id, + if self.search.pane.is_some() { + WIDTH + } else { + 0.0 + }, + ); + self.ui.open( + "task pane", + Spec { + flags: Flags::CLIP, + size: [px(width), fill()], + fill: Some(theme.strip), + ..Spec::default() + }, + ); + // The content keeps its width as the pane eases, so its labels never reflow. + self.ui.open( + "content", + Spec { + axis: Axis::Y, + size: [px(WIDTH), fill()], + pad: [PAD, 4.0], + gap: 4.0, + ..Spec::default() + }, + ); + if let Some(pane) = self.search.pane { + self.ui.open( + "header", + Spec { + size: [fill(), px(ui::shell::TOOL)], + ..Spec::default() + }, + ); + self.ui.leaf( + "title", + Spec { + size: [fill(), px(ui::shell::TOOL)], + text: Some(match pane { + Pane::Search { .. } => "Search Results", + Pane::Tags { .. } => "Tags Summary", + }), + bold: true, + ..Spec::default() + }, + ); + if ui::shell::tool_button(&mut self.ui, "close", art::CLOSE, theme.text_dim, false) + .clicked + { + self.close_pane(); + } + self.ui.close(); + match self.search.pane { + Some(Pane::Search { sort, descending }) => { + if let Err(error) = self.search_pane(theme, sort, descending) { + eprintln!("{error}"); + } + } + Some(Pane::Tags { + group, + unchecked, + scope, + }) => self.tags_pane(theme, group, unchecked, scope), + None => {} + } + } + self.ui.close(); + self.ui.close(); + } + + fn search_pane( + &mut self, + theme: &Theme, + mut sort: Sort, + mut descending: bool, + ) -> Result<(), Box> { + self.refresh_results()?; + let keys = ui::popup::navigation( + &mut self.ui, + &[field()], + &[ + NamedKey::ArrowUp, + NamedKey::ArrowDown, + NamedKey::PageUp, + NamedKey::PageDown, + NamedKey::Enter, + NamedKey::Escape, + ], + ); + if keys.contains(&NamedKey::Escape) { + self.ui.set_focus(Some(page())); + } + let border = if self.ui.focused() == Some(field()) { + theme.accent + } else { + theme.chip + }; + ui::text_field( + &mut self.ui, + field(), + &mut self.search.query, + "Search", + Spec { + size: [fill(), px(ui::shell::TOOL)], + fill: Some(theme.base), + border: Some(border), + radius: 4.0, + pad: [6.0, 0.0], + ..Spec::default() + }, + ); + if std::mem::take(&mut self.search.claim) { + self.ui.set_focus(Some(field())); + } + let scopes = Scope::ALL.map(|scope| format!("Search {}", scope.name())); + let names = scopes.each_ref().map(String::as_str); + let current = Scope::ALL + .iter() + .position(|scope| *scope == self.search.scope) + .unwrap_or_default(); + if let Some(index) = choice(&mut self.ui, "scope", current, &names, INSIDE) { + self.search.scope = Scope::ALL[index]; + } + self.ui.open( + "sort", + Spec { + size: [fill(), px(ui::shell::TOOL)], + gap: 2.0, + ..Spec::default() + }, + ); + let names = Sort::ALL.map(Sort::name); + let current = Sort::ALL + .iter() + .position(|listed| *listed == sort) + .unwrap_or_default(); + let width = INSIDE - ui::shell::TOOL - 2.0; + if let Some(index) = choice(&mut self.ui, "by", current, &names, width) { + sort = Sort::ALL[index]; + // Dates start newest first, names from the start of the alphabet. + descending = sort == Sort::Date; + } + let arrow = if descending { + ui::shell::CHEVRON + } else { + art::CHEVRON_UP + }; + if ui::shell::tool_button(&mut self.ui, "direction", arrow, theme.text, false).clicked { + descending = !descending; + } + let tip = if descending { + "Sort ascending" + } else { + "Sort descending" + }; + ui::popup::tooltip(&mut self.ui, tip, "", None); + self.ui.close(); + self.search.pane = Some(Pane::Search { sort, descending }); + let found = &self.search.found; + if found.is_empty() { + let status = if self.search.query.trim().is_empty() { + "" + } else if self.search.busy.load(Ordering::Relaxed) { + "Searching…" + } else { + "No matches" + }; + label(&mut self.ui, theme, "status", status); + return Ok(()); + } + let (order, rows, headings) = arrange(found, sort, descending); + let dates: Vec = order + .iter() + .map(|&at| { + let (year, month, day) = civil(local_day(found[at].modified)); + format!("{month}/{day}/{year}") + }) + .collect(); + let before = self.search.selected; + let clicked = ui::list( + &mut self.ui, + Id::ROOT.child("pane results"), + Spec { + size: [fill(), fill()], + fill: Some(theme.base), + border: Some(theme.chip), + radius: 4.0, + ..Spec::default() + }, + ui::List { + rows: &rows, + row: RESULT, + keys: &keys, + hover_selects: false, + }, + &mut self.search.selected, + |ui, row| { + if let Ok(group) = rows.starts.binary_search(&row.index) { + heading(ui, theme, &headings[group]); + } + result_row( + ui, + theme, + &found[order[row.index]], + &dates[row.index], + row.selected, + ); + }, + ); + let moved = before != self.search.selected && !keys.contains(&NamedKey::Enter); + let chosen = clicked.or_else(|| { + self.search + .selected + .map(|key| key as usize) + .filter(|_| moved || keys.contains(&NamedKey::Enter)) + }); + if let Some(row) = chosen { + self.search.selected = Some(row as u64); + let found = &self.search.found[order[row]]; + self.reveal(found.section.clone(), found.space, None)?; + if clicked.is_some() || keys.contains(&NamedKey::Enter) { + self.ui.set_focus(Some(page())); + } + } + Ok(()) + } + + fn tags_pane( + &mut self, + theme: &Theme, + mut group: Group, + mut unchecked: bool, + mut scope: TagScope, + ) { + let status = if self.search.busy.load(Ordering::Relaxed) { + "Searching…" + } else { + "Search completed" + }; + label(&mut self.ui, theme, "status", status); + label(&mut self.ui, theme, "group by", "Group tags by:"); + let names = Group::ALL.map(Group::name); + let current = Group::ALL + .iter() + .position(|listed| *listed == group) + .unwrap_or_default(); + if let Some(index) = choice(&mut self.ui, "group", current, &names, INSIDE) { + group = Group::ALL[index]; + } + if ui::check_box( + &mut self.ui, + "unchecked", + "Show only unchecked items", + unchecked, + ) + .clicked + { + unchecked = !unchecked; + } + let tagged = self.tagged(scope, unchecked); + let (order, rows, headings) = arrange_tags(&tagged, group); + let mut selected = None; + let clicked = ui::list( + &mut self.ui, + Id::ROOT.child("pane tags"), + Spec { + size: [fill(), fill()], + fill: Some(theme.base), + border: Some(theme.chip), + radius: 4.0, + ..Spec::default() + }, + ui::List { + rows: &rows, + row: TAGGED, + keys: &[], + hover_selects: false, + }, + &mut selected, + |ui, row| { + if let Ok(at) = rows.starts.binary_search(&row.index) { + heading(ui, theme, &headings[at]); + } + tagged_row(ui, theme, &tagged[order[row.index]]); + }, + ); + label(&mut self.ui, theme, "search", "Search:"); + let names = TagScope::ALL.map(TagScope::name); + let current = TagScope::ALL + .iter() + .position(|listed| *listed == scope) + .unwrap_or_default(); + if let Some(index) = choice(&mut self.ui, "tag scope", current, &names, INSIDE) { + scope = TagScope::ALL[index]; + } + self.search.pane = Some(Pane::Tags { + group, + unchecked, + scope, + }); + if let Some(row) = clicked { + let tagged = &tagged[order[row]]; + if let Err(error) = + self.reveal(tagged.section.clone(), tagged.space, Some(tagged.paragraph)) + { + eprintln!("{error}"); + } + self.ui.set_focus(Some(page())); + } + } + + /// The tagged paragraphs `scope` takes in, only those not checked off when `unchecked`. + fn tagged(&self, scope: TagScope, unchecked: bool) -> Vec { + let session = self.session.as_ref(); + let open = session.map(|session| session.library.key(&session.tabs[session.tab].path)); + // The open page with its subpages, or the page it is a subpage of with its others. + let group: Vec<_> = session + .and_then(|session| { + let pages = &session.pages; + let at = pages + .iter() + .position(|(space, ..)| *space == session.space)?; + let base = pages.iter().map(|(.., level)| *level).min()?; + let start = pages[..=at] + .iter() + .rposition(|(.., level)| *level == base)?; + let end = pages[start + 1..] + .iter() + .position(|(.., level)| *level == base) + .map_or(pages.len(), |after| start + 1 + after); + Some(pages[start..end].iter().map(|(space, ..)| *space).collect()) + }) + .unwrap_or_default(); + let place = match scope { + TagScope::Section => Scope::Section, + TagScope::SectionGroup => Scope::Group, + TagScope::Notebook => Scope::Notebook, + _ => Scope::All, + }; + let today = local_day(crate::search::now()); + let index = self + .search + .index + .lock() + .unwrap_or_else(|error| error.into_inner()); + index + .tagged(|entry| match scope { + TagScope::PageGroup => { + Some(&entry.section) == open.as_ref() && group.contains(&entry.space) + } + _ => self.in_scope(place, &entry.section), + }) + .into_iter() + .filter(|tagged| scope.spans(local_day(tagged.created), today)) + .filter(|tagged| !(unchecked && tagged.checked)) + .collect() + } +} + +/// A result: its page's icon, title and date, and the snippet around its first match. +fn result_row(ui: &mut Ui, theme: &Theme, found: &Found, date: &str, selected: bool) { + ui.open( + "result", + Spec { + axis: Axis::Y, + size: [fill(), fill()], + fill: selected.then(|| theme.hover()), + hover_fill: Some(theme.hover()), + radius: 4.0, + pad: [4.0, 2.0], + ..Spec::default() + }, + ); + ui.open( + "top", + Spec { + size: [fill(), px(18.0)], + gap: 4.0, + ..Spec::default() + }, + ); + ui.leaf( + "icon", + Spec { + size: [px(16.0), px(18.0)], + icon: Some(art::PAGE), + color: Some(theme.text_dim), + ..Spec::default() + }, + ); + marked( + ui, + "title", + shown_title(&found.title), + &found.title_hits, + Spec { + size: [fill(), px(18.0)], + color: Some(theme.text), + ..Spec::default() + }, + ); + ui.leaf( + "date", + Spec { + size: [fit(), px(18.0)], + text: Some(date), + color: Some(theme.text_dim), + ..Spec::default() + }, + ); + ui.close(); + marked( + ui, + "snippet", + &found.snippet, + &found.snippet_hits, + Spec { + size: [fill(), px(18.0)], + color: Some(theme.text_dim), + pad: [20.0, 0.0], + ..Spec::default() + }, + ); + ui.close(); +} + +/// A tagged paragraph: its tag's symbol and its text. +fn tagged_row(ui: &mut Ui, theme: &Theme, tagged: &Tagged) { + ui.open( + "tagged", + Spec { + size: [fill(), fill()], + hover_fill: Some(theme.hover()), + radius: 4.0, + pad: [4.0, 0.0], + gap: 6.0, + ..Spec::default() + }, + ); + ui.leaf( + "icon", + Spec { + size: [px(16.0), px(TAGGED)], + icon: TagIcon::of(tagged.shape, tagged.checked).map(tag_sources), + color: Some([1.0; 4]), + ..Spec::default() + }, + ); + ui.leaf( + "text", + Spec { + size: [fill(), px(TAGGED)], + text: Some(&tagged.text), + overflow: ui::Overflow::Ellipsis, + ..Spec::default() + }, + ); + ui.close(); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn days_read_as_the_calendar_and_weeks_start_on_sunday() { + assert_eq!(civil(0), (1970, 1, 1)); + assert_eq!(civil(20_725), (2026, 9, 29)); + assert_eq!(civil(-1), (1969, 12, 31)); + // Tuesday, September 29, 2026: its week began on Sunday the 27th. + let today = 20_725; + assert_eq!(TagScope::of_day(today, today), TagScope::Today); + assert_eq!(TagScope::of_day(today - 1, today), TagScope::Yesterday); + assert_eq!(TagScope::of_day(today - 2, today), TagScope::ThisWeek); + assert_eq!(TagScope::of_day(today - 3, today), TagScope::LastWeek); + assert_eq!(TagScope::of_day(today - 9, today), TagScope::LastWeek); + assert_eq!(TagScope::of_day(today - 10, today), TagScope::Older); + assert!(TagScope::ThisWeek.spans(today, today)); + } +} diff --git a/crates/snowbound/src/screenshot.rs b/crates/snowbound/src/screenshot.rs index 63891890d1946382b462b585ec5f9b076930d25c..ae3fbdad62fccf29672eab69decabdc7dda73fc4 100644 --- a/crates/snowbound/src/screenshot.rs +++ b/crates/snowbound/src/screenshot.rs @@ -9,13 +9,12 @@ use winit::window::Theme as Appearance; /// drawn at `SCALE` pixels per point or fewer where the GPU caps textures. const SIZE: [f32; 2] = [1440.0, 900.0]; const SCALE: f32 = 2.0; -/// A standard window's close, minimize and zoom buttons as AppKit places them beside a -/// transparent title bar: centres in points, fill and rim. +/// The close, minimize and zoom buttons' fill and rim. #[cfg(target_os = "macos")] -const LIGHTS: [(f32, [u8; 3], [u8; 3]); 3] = [ - (14.0, [0xff, 0x5f, 0x57], [0xe2, 0x46, 0x3f]), - (34.0, [0xfe, 0xbc, 0x2e], [0xe1, 0xa1, 0x16]), - (54.0, [0x28, 0xc8, 0x40], [0x12, 0xac, 0x28]), +const LIGHTS: [([u8; 3], [u8; 3]); 3] = [ + ([0xff, 0x5f, 0x57], [0xe2, 0x46, 0x3f]), + ([0xfe, 0xbc, 0x2e], [0xe1, 0xa1, 0x16]), + ([0x28, 0xc8, 0x40], [0x12, 0xac, 0x28]), ]; impl State { @@ -32,7 +31,6 @@ impl State { let scale = SCALE.min(most / size[0].max(size[1])); self.surface.size = size.map(|side| (side * scale).max(1.0) as u32); self.renderer.clear_glyph_cache(); - self.app_icon = platform::app_icon((16.0 * scale) as u32); let response = self.view.scale_factor_changed(scale)?; self.respond(response); self.ui.set_focus(None); @@ -66,16 +64,26 @@ impl State { let path = PathBuf::from(format!("{}-{name}.png", prefix.display())); self.snapshot(&path)?; #[cfg(target_os = "macos")] - window_frame(&path, appearance, scale)?; + window_frame( + &path, + appearance, + scale, + platform::traffic_lights(&self.window), + )?; } Ok(()) } } -/// Paints the traffic lights into the PNG at `path`, rounds its corners to transparency -/// and edges them with the window's hairline. +/// Paints the traffic lights centred at `lights` into the PNG at `path`, rounds its corners +/// to transparency and edges them with the window's hairline. #[cfg(target_os = "macos")] -fn window_frame(path: &Path, appearance: Appearance, scale: f32) -> Result<(), Box> { +fn window_frame( + path: &Path, + appearance: Appearance, + scale: f32, + lights: [[f32; 2]; 3], +) -> Result<(), Box> { let mut reader = png::Decoder::new(std::io::BufReader::new(std::fs::File::open(path)?)).read_info()?; 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 for (index, pixel) in pixels.chunks_exact_mut(4).enumerate() { let x = (index as u32 % info.width) as f32 + 0.5; let y = (index as u32 / info.width) as f32 + 0.5; - for (centre, fill, rim) in LIGHTS { - let distance = (x - centre * scale).hypot(y - TITLE / 2.0 * scale); + for ([cx, cy], (fill, rim)) in lights.into_iter().zip(LIGHTS) { + let distance = (x - cx * scale).hypot(y - cy * scale); blend( pixel, [rim[0], rim[1], rim[2], 255], diff --git a/crates/snowbound/src/search.rs b/crates/snowbound/src/search.rs index 2743619831fe59b7f599fb620836ba275e407446..a56925ddc29ffcee2888428607065681548677b0 100644 --- a/crates/snowbound/src/search.rs +++ b/crates/snowbound/src/search.rs @@ -2,8 +2,9 @@ //! results dropping down from it, and Find on This Page. The index is kept on a thread of //! its own, from the section files and the open section's replica. +use crate::pane::Pane; use crate::{Command, Library, State, Theme, art, commands, lap, page}; -use canvas::search::{Entry, Found, Index, Query, page_matches}; +use canvas::search::{Entry, Found, Index, PageMatch, Query, page_matches, paragraph_match}; use notebook::Replica; use onestore::ExGuid; use std::{ @@ -31,9 +32,9 @@ pub enum Scope { } impl Scope { - const ALL: [Scope; 4] = [Scope::Section, Scope::Group, Scope::Notebook, Scope::All]; + pub(crate) const ALL: [Scope; 4] = [Scope::Section, Scope::Group, Scope::Notebook, Scope::All]; - fn name(self) -> &'static str { + pub(crate) fn name(self) -> &'static str { match self { Scope::Section => "This Section", Scope::Group => "This Section Group", @@ -52,7 +53,7 @@ const PLACE: f32 = 150.0; /// How long typing pauses before the index reads the pages it changed. const SETTLE: Duration = Duration::from_millis(400); -fn results() -> Id { +pub(crate) fn results() -> Id { Id::ROOT.child("results") } @@ -71,7 +72,12 @@ fn find_field() -> Id { /// Whether `focus` is one of the box's fields, which take typed text. pub fn takes_text(focus: Option) -> bool { - [Some(field()), Some(find_field())].contains(&focus) + [ + Some(field()), + Some(find_field()), + Some(crate::pane::field()), + ] + .contains(&focus) } /// Work for the index thread. @@ -97,26 +103,27 @@ pub struct Search { /// Scope searches start in, as "Set This Scope as Default" leaves it. pub default: Scope, /// Find on This Page is open, at the match selected. - finding: bool, + pub(crate) finding: bool, current: Option, - /// The page a result opened, whose first match is selected once it shows. - reveal: Option, - index: Arc>, + /// The page a result opened, whose first match, or paragraph, is selected once it shows. + reveal: Option<(ExGuid, Option)>, + pub(crate) index: Arc>, /// Counts the index's changes, so results are made again when it changes. version: Arc, - busy: Arc, + pub(crate) busy: Arc, jobs: mpsc::Sender, - found: Vec, + pub(crate) found: Vec, found_for: Option<(Query, Scope, u64, String)>, - selected: Option, + pub(crate) selected: Option, /// The notebooks and open section the index was last brought to, by identity. synced: Vec, /// The results drop down, until chosen from or dismissed. - open: bool, + pub(crate) open: bool, /// Where the scope menu opens. anchor: Option, - /// The find field takes the keys once built. - claim: bool, + /// The find field, or the Search Results pane's, takes the keys once built. + pub(crate) claim: bool, + pub(crate) pane: Option, } impl Search { @@ -148,6 +155,7 @@ impl Search { open: false, anchor: None, claim: false, + pane: None, } } @@ -188,7 +196,7 @@ fn stamp(file: &Path) -> Stamp { } /// Now in Time32, seconds since 1980, as page modification times are kept. -fn now() -> u64 { +pub(crate) fn now() -> u64 { SystemTime::now() .duration_since(SystemTime::UNIX_EPOCH) .map_or(0, |since| since.as_secs().saturating_sub(315_532_800)) @@ -395,7 +403,13 @@ fn reread( } /// A text box's label with `hits`, byte ranges of `text`, marked in OneNote's yellow. -fn marked(ui: &mut Ui, part: &str, text: &str, hits: &[std::ops::Range], spec: Spec) { +pub(crate) fn marked( + ui: &mut Ui, + part: &str, + text: &str, + hits: &[std::ops::Range], + spec: Spec, +) { let height = match spec.size[1].size { ui::Size::Pixels(height) => height, _ => 0.0, @@ -481,7 +495,7 @@ impl State { } /// Whether `key` names a section the search's scope takes in. - fn in_scope(&self, scope: Scope, key: &str) -> bool { + pub(crate) fn in_scope(&self, scope: Scope, key: &str) -> bool { let Some(session) = &self.session else { return scope == Scope::All; }; @@ -499,7 +513,7 @@ impl State { } /// The results for the query in its scope, made again when either or the index changed. - fn refresh_results(&mut self) -> Result<(), Box> { + pub(crate) fn refresh_results(&mut self) -> Result<(), Box> { let query = Query::new(&self.search.query); let version = self.search.version.load(Ordering::Relaxed); let open = self @@ -538,33 +552,37 @@ impl State { } /// Marks the query's matches on the page; with `select`, selects the match Find on - /// This Page is at, or the first when a result opened the page. + /// This Page is at, or the first, or the paragraph, when a result opened the page. pub(crate) fn refind(&mut self, select: bool) -> Result<(), Box> { self.view.found = page_matches(&self.view.editor, &self.search.shown()); if !select { return Ok(()); } let space = self.session.as_ref().map(|session| session.space); - let at = if self.search.finding { + let target = if self.search.finding { self.search.current = match self.search.current { Some(at) if at < self.view.found.len() => Some(at), _ => (!self.view.found.is_empty()).then_some(0), }; - self.search.current - } else if self.search.reveal.is_some() && self.search.reveal == space { + self.search + .current + .and_then(|at| self.view.found.get(at).copied()) + } else if let Some((_, paragraph)) = + self.search.reveal.filter(|(page, _)| Some(*page) == space) + { self.search.reveal = None; - (!self.view.found.is_empty()).then_some(0) + match paragraph { + Some(paragraph) => paragraph_match(&self.view.editor, paragraph), + None => self.view.found.first().copied(), + } } else { None }; - match at { - Some(at) => self.select_match(at), - None => Ok(()), - } + self.select(target) } - fn select_match(&mut self, at: usize) -> Result<(), Box> { - let Some(&(outline, selection)) = self.view.found.get(at) else { + fn select(&mut self, target: Option) -> Result<(), Box> { + let Some((outline, selection)) = target else { return Ok(()); }; self.view.editor.focus_outline(outline)?; @@ -598,26 +616,38 @@ impl State { } /// Ends the search or find: the box empties, the marks go and the page takes the keys, - /// keeping any match selected. - fn end_search(&mut self) { + /// keeping any match selected. The Search Results pane keeps the search while open. + pub(crate) fn end_search(&mut self) { self.search.open = false; - self.search.query.clear(); self.search.finding = false; self.search.current = None; - self.search.scope = self.search.default; - self.view.found.clear(); self.ui.close_popup(results()); self.ui.set_focus(Some(page())); + if !matches!(self.search.pane, Some(Pane::Search { .. })) { + self.search.query.clear(); + self.search.scope = self.search.default; + self.view.found.clear(); + } } /// Opens a result's page, whose first match is selected once it shows. fn open_result(&mut self, index: usize) -> Result<(), Box> { - let Some(found) = self.search.found.get(index) else { - return Ok(()); - }; - let (location, path) = found.section.split_once('\n').unwrap_or_default(); - let (space, section) = (found.space, found.section.clone()); - self.search.reveal = Some(space); + match self.search.found.get(index) { + Some(found) => self.reveal(found.section.clone(), found.space, None), + None => Ok(()), + } + } + + /// Opens page `space` of `section`, whose first match, or `paragraph`, is selected once + /// it shows. + pub(crate) fn reveal( + &mut self, + section: String, + space: ExGuid, + paragraph: Option, + ) -> Result<(), Box> { + let (location, path) = section.split_once('\n').unwrap_or_default(); + self.search.reveal = Some((space, paragraph)); let open = self.session.as_ref().map(|session| { ( session.library.key(&session.tabs[session.tab].path), @@ -633,9 +663,9 @@ impl State { .iter() .find(|library| library.location == location) .ok_or("The notebook is no longer open")?; - self.last_pages.insert(section, space); self.commands .push(Command::OpenSection(Arc::clone(library), path.to_owned())); + self.last_pages.insert(section, space); } } Ok(()) @@ -984,8 +1014,29 @@ impl State { .or_else(|| entered.then_some(0)) }); } + let link = format!( + "Open Search Results Pane ({})", + commands::shortcut(commands::Id::SearchResults) + ); + let pane = self + .ui + .leaf( + "pane", + Spec { + flags: Flags::CLICKABLE, + size: [fit(), px(22.0)], + icon: Some(art::SEARCH), + text: Some(&link), + color: Some(theme.accent), + pad: [6.0, 0.0], + ..Spec::default() + }, + ) + .clicked; self.ui.close(); - if let Some(index) = chosen { + if pane { + self.open_search_pane(); + } else if let Some(index) = chosen { self.open_result(index)?; self.search.open = false; self.ui.close_popup(results()); @@ -1084,7 +1135,7 @@ impl State { (at + count - 1) % count }; self.search.current = Some(next); - self.select_match(next)?; + self.select(self.view.found.get(next).copied())?; } Ok(()) } diff --git a/crates/snowbound/src/sidebar.rs b/crates/snowbound/src/sidebar.rs index f3829c792f407e3aae56bf84034e225000b1712a..939124e4bf13141c52146d4c54ade3c7f4da925f 100644 --- a/crates/snowbound/src/sidebar.rs +++ b/crates/snowbound/src/sidebar.rs @@ -139,7 +139,7 @@ fn sidebar(ui: &mut Ui, tree: &mut Tree, notebooks: &[Arc], header: f32 ..Spec::default() }, ); - // The button's square, which floats over this place from the section tabs. + // The notebook button's square, which floats over this place. ui.leaf( "toggle", Spec { @@ -710,26 +710,17 @@ impl crate::State { /// The sidebar's button in its square at the window's edge, and the room the section /// tabs, standing `width` from that edge, leave it; the button stays put as the sidebar /// eases open and shut. - pub(crate) fn sidebar_button(&mut self, theme: &Theme, width: f32) { + /// The notebook button, floating at the body's corner over the section tabs' row, `height` + /// tall as it eases, or over the sidebar's header while that is open. + pub(crate) fn sidebar_button(&mut self, theme: &Theme, height: f32) { if self.temporary { return; } - // Past the tab row's padding and the frame's corner, the tabs start where the square - // ends; the room shrinks on the sidebar's own easing, so the tabs ease with it. - let room = RAIL - crate::FRAME - self.rounding(); - self.ui.leaf( - "rail", - Spec { - size: [px(room * (1.0 - width / WIDTH)), px(1.0)], - ..Spec::default() - }, - ); self.ui.open( "toggle", Spec { - flags: Flags::FLOAT, - size: [px(RAIL), px(RAIL)], - position: [-width, 0.0], + flags: Flags::FLOAT | Flags::CLIP, + size: [px(RAIL), px(height)], pad: [(RAIL - ui::shell::TOOL) / 2.0; 2], ..Spec::default() }, diff --git a/crates/snowbound/src/surface.rs b/crates/snowbound/src/surface.rs index 0826b0ad18aca018973124dc185dc8faee1208eb..84a82401f932597898e612cdf67970d9b7fe2d0c 100644 --- a/crates/snowbound/src/surface.rs +++ b/crates/snowbound/src/surface.rs @@ -58,6 +58,12 @@ impl Surface { config.view_formats.push(window_format); draw::Translucent::new(&device, config.format, window_format) }); + if platform::cuts_corners() { + let alpha = &surface.get_capabilities(&adapter).alpha_modes; + if alpha.contains(&wgpu::CompositeAlphaMode::PreMultiplied) { + config.alpha_mode = wgpu::CompositeAlphaMode::PreMultiplied; + } + } surface.configure(&device, &config); eprintln!("Canvas GPU: {:?}", adapter.get_info()); let renderer = Renderer::new(device, queue, config.format); @@ -205,17 +211,15 @@ impl Surface { self.config.format, wgpu::TextureFormat::Bgra8Unorm | wgpu::TextureFormat::Bgra8UnormSrgb ); - Ok(buffer - .get_mapped_range(..)? - .chunks_exact(row as usize) - .flat_map(|line| line[..size[0] as usize * 4].chunks_exact(4)) - .flat_map(|pixel| { - if bgra { - [pixel[2], pixel[1], pixel[0], pixel[3]] - } else { - [pixel[0], pixel[1], pixel[2], pixel[3]] - } - }) - .collect()) + let mut pixels = Vec::with_capacity(size[0] as usize * size[1] as usize * 4); + for line in buffer.get_mapped_range(..)?.chunks_exact(row as usize) { + pixels.extend_from_slice(&line[..size[0] as usize * 4]); + } + if bgra { + for pixel in pixels.chunks_exact_mut(4) { + pixel.swap(0, 2); + } + } + Ok(pixels) } } diff --git a/crates/snowbound/src/surface_gl.rs b/crates/snowbound/src/surface_gl.rs index 77952dd0ddb3c3d87597f23d630ffd094943c90b..3d0b620a7908ab4518361b3e405a3cb148fa7cb1 100644 --- a/crates/snowbound/src/surface_gl.rs +++ b/crates/snowbound/src/surface_gl.rs @@ -18,6 +18,9 @@ pub struct Surface { /// Device pixels frames and snapshots are drawn at; `configure` gives the window it. pub size: [u32; 2], context: Retained, + /// Frames leave the window's textured backdrop showing through their transparent + /// pixels. + translucent: bool, /// The frame target, kept between frames. target: Option, } @@ -29,10 +32,10 @@ pub struct Frame { pub type Offscreen = Frame; impl Surface { - /// No backdrop shows through an OpenGL view. + /// With `backdrop`, the surface is transparent where frames are. pub async fn new( window: Arc, - _backdrop: bool, + backdrop: bool, ) -> Result<(Self, Renderer), Box> { let RawWindowHandle::AppKit(handle) = window.window_handle()?.as_raw() else { unreachable!() @@ -59,6 +62,11 @@ impl Surface { // NSOpenGLCPSwapInterval: present on the display's refresh. let interval: i32 = 1; let _: () = msg_send![&context, setValues: &interval, forParameter: 222 as NSInteger]; + if backdrop { + // NSOpenGLCPSurfaceOpacity. + let opaque: i32 = 0; + let _: () = msg_send![&context, setValues: &opaque, forParameter: 236 as NSInteger]; + } context }; let renderer = Renderer::new()?; @@ -68,6 +76,7 @@ impl Surface { Self { size: [size.width, size.height], context, + translucent: backdrop, target: None, }, renderer, @@ -75,7 +84,7 @@ impl Surface { } pub fn translucent(&self) -> bool { - false + self.translucent } pub fn configure(&mut self, _: &Renderer) { @@ -94,8 +103,12 @@ impl Surface { Ok(Some(Frame { target })) } - pub fn present(&mut self, _: &Renderer, frame: Frame) { - frame.target.present(); + pub fn present(&mut self, renderer: &Renderer, frame: Frame) { + if self.translucent { + renderer.present_translucent(&frame.target); + } else { + frame.target.present(); + } let _: () = unsafe { msg_send![&self.context, flushBuffer] }; self.target = Some(frame.target); } diff --git a/crates/snowbound/src/sync.rs b/crates/snowbound/src/sync.rs index c9f2df01cd32ceef017a397095582c3b6af006db..eda9b6c19a213d6e19c3caa1211afe318fdcaa18 100644 --- a/crates/snowbound/src/sync.rs +++ b/crates/snowbound/src/sync.rs @@ -1,10 +1,12 @@ //! The sync status at the top right, and the popup it opens: OneNote's Shared Notebook //! Synchronization for the open section. -use crate::{Session, State, filetime, library, platform}; +use crate::{Session, State, art, filetime, library, platform}; use notebook::session::SyncStatus; use std::{error::Error, io::ErrorKind}; -use ui::{Anchor, Axis, Flags, Id, Overflow, Spec, Theme, Ui, children, fill, fit, px}; +use ui::{ + Anchor, Axis, Flags, Id, Overflow, Spec, Theme, Ui, children, fill, fit, px, shell::TOOL, +}; const WIDTH: f32 = 380.0; @@ -16,24 +18,27 @@ fn button() -> Id { Id::ROOT.child("sync-button") } -/// The status's label, and what the reader can do about an error. -fn describe(sync: &SyncStatus) -> (&'static str, Option<&'static str>) { +/// The status's label and icon, and what the reader can do about an error. +fn describe(sync: &SyncStatus) -> (&'static str, &'static [&'static str], Option<&'static str>) { use ErrorKind::*; if library::offline() { return ( "Working offline", + art::SYNC_OFFLINE, Some("Changes stay on this computer until you sync."), ); } match sync.error.as_ref().map(std::io::Error::kind) { Some(PermissionDenied | ReadOnlyFilesystem) => ( "Unable to sync", + art::SYNC_ERROR, Some( "You can’t change this notebook where it’s stored. Changes stay on this computer.", ), ), Some(WouldBlock | ResourceBusy) => ( "Section in use", + art::SYNC_BUSY, Some("Someone else is saving this section. Sync continues when they finish."), ), Some( @@ -42,11 +47,12 @@ fn describe(sync: &SyncStatus) -> (&'static str, Option<&'static str>) { | AddrNotAvailable, ) => ( "Not connected", + art::SYNC_OFFLINE, Some("Changes stay on this computer and sync when the notebook is back."), ), - Some(_) => ("Unable to sync", None), - None if sync.synced.is_none() || sync.queued > 0 => ("Syncing…", None), - None => ("Up to date", None), + Some(_) => ("Unable to sync", art::SYNC_ERROR, None), + None if sync.synced.is_none() || sync.queued > 0 => ("Syncing…", art::SYNC_BUSY, None), + None => ("Up to date", art::SYNC_DONE, None), } } @@ -65,25 +71,47 @@ fn when(time: u64) -> String { } } -/// The status beside the window's controls, which opens the popup. -pub(crate) fn control(ui: &mut Ui, session: &Session, theme: &Theme, height: f32) { +/// The status in the toolbar, which opens the popup: its label, or where `compact` its icon, +/// naming it in a tooltip. +pub(crate) fn control(ui: &mut Ui, session: &Session, theme: &Theme, compact: bool) { let strong = ui.popup_open(id()) || session.sync.error.is_some() && !library::offline(); + let (label, icon, _) = describe(&session.sync); + let spec = Spec { + flags: Flags::CLICKABLE, + color: Some(if strong { theme.text } else { theme.text_dim }), + hover_fill: Some(theme.hover()), + radius: 4.0, + center: true, + ..Spec::default() + }; + // The popup opens from the label's box, which stands where the icon shows. + let shown = if compact { + button().child("icon") + } else { + button() + }; ui.open_as( - button(), - Spec { - flags: Flags::CLICKABLE, - size: [fit(), px(height)], - text: Some(label(&session.sync)), - color: Some(if strong { theme.text } else { theme.text_dim }), - hover_fill: Some(theme.hover()), - radius: 4.0, - pad: [8.0, 0.0], - center: true, - ..Spec::default() + shown, + if compact { + Spec { + size: [px(TOOL), px(TOOL)], + icon: Some(icon), + ..spec + } + } else { + Spec { + size: [fit(), px(TOOL)], + text: Some(label), + pad: [8.0, 0.0], + ..spec + } }, ); ui.close(); - if ui.signal(button()).clicked { + if compact { + ui::popup::tooltip(ui, label, "", None); + } + if ui.signal(shown).clicked { if ui.popup_open(id()) { ui.close_popup(id()); } else { @@ -107,7 +135,7 @@ impl State { session.sync = session.section.sync_status()?; let theme = ui.theme.clone(); let offline = library::offline(); - let (progress, advice) = describe(&session.sync); + let (progress, _, advice) = describe(&session.sync); ui.open_as( id(), Spec { @@ -209,6 +237,26 @@ impl State { ..Spec::default() }, ); + let file = session.library.local(session.section.file()); + let show = match file { + Some(_) => ui::button(ui, "show-file", platform::SHOW_FILE).clicked, + None => { + ui.leaf( + "show-file", + Spec { + size: [fit(), px(theme.font_size * 2.0)], + text: Some(platform::SHOW_FILE), + color: Some(theme.text_dim), + fill: Some(theme.chip), + radius: 4.0, + pad: [theme.font_size * 0.75, 0.0], + center: true, + ..Spec::default() + }, + ); + false + } + }; let now = ui::button(ui, "sync-now", "Sync Now").clicked; ui.close(); ui.close(); @@ -219,6 +267,9 @@ impl State { if now { session.section.wake(); } + if let Some(file) = file.filter(|_| show) { + platform::show_file(&file); + } Ok(()) } } diff --git a/crates/snowbound/src/templates.rs b/crates/snowbound/src/templates.rs index 810ddea1eb98be9acd563a87962d0a3319e9149d..941ca8efe05f00b4b8e6ffc62942371b02d8fef9 100644 --- a/crates/snowbound/src/templates.rs +++ b/crates/snowbound/src/templates.rs @@ -12,7 +12,7 @@ use ui::{Axis, Flags, Spec, Theme, Ui, fill, px}; /// What a thumbnail shows: the top of a letter page, in points from the page origin. const REGION: [f32; 4] = [-72.0, -40.0, 540.0, 419.0]; -const TILE: [f32; 2] = [112.0, 84.0]; +pub(crate) const TILE: [f32; 2] = [112.0, 84.0]; const SWATCH: f32 = 24.0; const LABEL: f32 = 22.0; const GAP: f32 = 12.0; @@ -59,7 +59,7 @@ pub struct Thumbnails(HashMap<(&'static str, bool), Art>); impl Thumbnails { /// `template`'s art and where it lies in a `TILE`, drawn at `scale` for `paper`. - fn art( + pub(crate) fn art( &mut self, template: &'static Template, paper: Paper, diff --git a/crates/ui/examples/popups.rs b/crates/ui/examples/popups.rs index 2d2b1bcbd384d5286767c1de30fc1a352bfe2642..e780401a48b1299066afa952a1cf8d3770b45436 100644 --- a/crates/ui/examples/popups.rs +++ b/crates/ui/examples/popups.rs @@ -761,7 +761,7 @@ fn palette(scene: &mut Scene) { }); let id = Id::ROOT.child("palette"); scene.open(id, typed("ne"), |ui, _| { - ui::popup::palette(ui, id, &commands); + ui::popup::palette(ui, id, &commands, "Search commands"); }); } diff --git a/crates/ui/src/layout.rs b/crates/ui/src/layout.rs index 370ae8ca52d625db55e386c6e6b8350c2fe7e8f9..e496441c6a71ff2da813e535a102110ff6df39ff 100644 --- a/crates/ui/src/layout.rs +++ b/crates/ui/src/layout.rs @@ -2,8 +2,9 @@ use crate::{Anchor, Axis, Built, Flags, ICON, ICON_GAP, Id, Overflow, Size, Stat use std::collections::HashMap; /// Sizes each box on both axes, then places it: standalone sizes, sizes taken from -/// ancestors (pre-order), sizes summed from children (post-order), overflow shared out -/// by strictness, and positions along each parent's flow. Labels too wide for their +/// ancestors (pre-order), sizes summed from children (post-order), overflow taken back +/// (from space sized by ancestors, then by folding a row's groups, then from the least +/// strict boxes first), and positions along each parent's flow. Labels too wide for their /// solved width wrap or shorten before heights are solved. Boxes are in build order, so /// index order is pre-order. pub(crate) fn solve( @@ -13,6 +14,12 @@ pub(crate) fn solve( texts: &mut Texts, frame: u64, ) { + for index in 0..nodes.len() { + if nodes[index].fold.is_some() { + let folded = nodes[index].children[1]; + nodes[folded].hidden = true; + } + } for axis in 0..2 { if axis == 1 { fit_labels(nodes, texts, frame); @@ -48,7 +55,7 @@ pub(crate) fn solve( while ancestor != 0 && nodes[ancestor].size[axis].size == Size::Children { ancestor = nodes[ancestor].parent; } - let room = nodes[ancestor].computed[axis] - 2.0 * nodes[ancestor].pad[axis]; + let room = across(nodes, ancestor, index, axis) - 2.0 * nodes[ancestor].pad[axis]; nodes[index].computed[axis] = room.max(0.0) * fraction; } } @@ -66,22 +73,48 @@ pub(crate) fn solve( let room = (node.computed[axis] - 2.0 * node.pad[axis]).max(0.0); let children: Vec<_> = in_flow(nodes, index).collect(); if along(node, axis) { - let excess = flow(nodes, index, axis) - room; - let give: f32 = children + let space = + |child: &&usize| matches!(nodes[**child].size[axis].size, Size::Fraction(_)); + let (space, sized): (Vec<_>, Vec<_>) = children.iter().partition(space); + let mut excess = flow(nodes, index, axis) - room; + excess = give_back(nodes, &space, axis, excess); + while excess > 0.0 && axis == 0 { + let Some(group) = children + .iter() + .copied() + .filter(|child| { + nodes[*child].fold.is_some() && !nodes[nodes[*child].children[0]].hidden + }) + .min_by_key(|child| nodes[*child].fold) + else { + break; + }; + let [full, folded] = [nodes[group].children[0], nodes[group].children[1]]; + nodes[full].hidden = true; + nodes[folded].hidden = false; + let width = nodes[folded].computed[0] + 2.0 * nodes[group].pad[0]; + excess -= nodes[group].computed[0] - width; + nodes[group].computed[0] = width; + } + let mut tiers: Vec = sized .iter() - .map(|child| { - nodes[*child].computed[axis] * (1.0 - strictness(nodes, *child, axis)) - }) - .sum(); - if excess > 0.0 && give > 0.0 { - for child in children { - let share = - nodes[child].computed[axis] * (1.0 - strictness(nodes, child, axis)); - nodes[child].computed[axis] -= excess.min(give) * share / give; - } + .map(|child| strictness(nodes, *child, axis)) + .filter(|strictness| *strictness < 1.0) + .collect(); + tiers.sort_by(f32::total_cmp); + tiers.dedup(); + for tier in tiers { + let members: Vec<_> = sized + .iter() + .copied() + .filter(|child| strictness(nodes, *child, axis) == tier) + .collect(); + excess = give_back(nodes, &members, axis, excess); } } else { for child in children { + let room = + (across(nodes, index, child, axis) - 2.0 * nodes[index].pad[axis]).max(0.0); let over = nodes[child].computed[axis] - room; if over > 0.0 { nodes[child].computed[axis] -= @@ -93,6 +126,13 @@ pub(crate) fn solve( let window = nodes[0].computed[axis]; for index in 0..nodes.len() { let mut cursor = nodes[index].pad[axis]; + // Where a popup widening over its anchor lays its children out, from where it is. + let shift = match (nodes[index].anchor, nodes[index].size[axis].size) { + (Some(anchor @ Anchor::Over(_)), Size::Pixels(full)) if axis == 0 => { + anchor.place(axis, full, full, window) - nodes[index].relative[axis] + } + _ => 0.0, + }; for child in nodes[index].children.clone() { nodes[child].relative[axis] = if let Some(anchor) = nodes[child].anchor { let shown = nodes[child].computed[axis]; @@ -103,6 +143,8 @@ pub(crate) fn solve( anchor.place(axis, size, shown, window) } else if nodes[child].flags.contains(Flags::FLOAT) { nodes[child].position[axis] + } else if nodes[child].hidden { + nodes[index].pad[axis] } else if along(&nodes[index], axis) { let at = cursor; cursor += nodes[child].computed[axis] + nodes[index].gap; @@ -110,6 +152,9 @@ pub(crate) fn solve( } else { nodes[index].pad[axis] }; + if !nodes[child].flags.contains(Flags::STILL) { + nodes[child].relative[axis] += shift; + } } if axis == 1 { nodes[index].content = flow(nodes, index, axis) + 2.0 * nodes[index].pad[axis]; @@ -124,7 +169,13 @@ pub(crate) fn solve( snap(nodes[0].computed[1]), ]; for index in 1..nodes.len() { - let parent = &nodes[nodes[index].parent]; + let parent = nodes[index].parent; + if nodes[index].hidden || nodes[parent].hidden { + nodes[index].hidden = true; + nodes[index].rect = nodes[parent].rect; + continue; + } + let parent = &nodes[parent]; let scroll = if parent.flags.contains(Flags::SCROLL) && !nodes[index].flags.contains(Flags::FLOAT) { states.get(&parent.id).map_or(0.0, |state| state.scroll) @@ -143,17 +194,60 @@ pub(crate) fn solve( } } +/// The length `parent` lays `child` out across: a popup widening over its anchor lays its +/// contents out at its full width, but for boxes standing in for the anchor, which widen with it. +fn across(nodes: &[Built], parent: usize, child: usize, axis: usize) -> f32 { + let node = &nodes[parent]; + match (node.anchor, node.size[axis].size) { + (Some(Anchor::Over(_)), Size::Pixels(full)) + if axis == 0 && !nodes[child].flags.contains(Flags::STILL) => + { + full + } + _ => node.computed[axis], + } +} + +/// Takes up to `excess` back from `boxes`, each giving in proportion to what its strictness +/// lets it, and returns what is left. +fn give_back(nodes: &mut [Built], boxes: &[usize], axis: usize, excess: f32) -> f32 { + let give = |nodes: &[Built], child: usize| { + nodes[child].computed[axis] * (1.0 - strictness(nodes, child, axis)) + }; + let total: f32 = boxes.iter().map(|child| give(nodes, *child)).sum(); + if excess <= 0.0 || total <= 0.0 { + return excess; + } + let taken = excess.min(total); + for child in boxes { + nodes[*child].computed[axis] -= taken * give(nodes, *child) / total; + } + excess - taken +} + fn along(node: &Built, axis: usize) -> bool { (node.axis == Axis::Y) == (axis == 1) } +/// The share of its size a box keeps when its siblings overflow. A box sized by the children +/// along its flow keeps what they keep. fn strictness(nodes: &[Built], index: usize, axis: usize) -> f32 { - nodes[index].size[axis].strictness.clamp(0.0, 1.0) + let node = &nodes[index]; + let declared = node.size[axis].strictness.clamp(0.0, 1.0); + if node.size[axis].size != Size::Children || !along(node, axis) || node.computed[axis] <= 0.0 { + return declared; + } + let give: f32 = in_flow(nodes, index) + .map(|child| nodes[child].computed[axis] * (1.0 - strictness(nodes, child, axis))) + .sum(); + declared.min(1.0 - give / node.computed[axis]) } fn in_flow(nodes: &[Built], index: usize) -> impl Iterator + '_ { nodes[index].children.iter().copied().filter(|child| { - !nodes[*child].flags.contains(Flags::FLOAT) && nodes[*child].anchor.is_none() + !nodes[*child].flags.contains(Flags::FLOAT) + && nodes[*child].anchor.is_none() + && !nodes[*child].hidden }) } diff --git a/crates/ui/src/lib.rs b/crates/ui/src/lib.rs index c18e6ec16f52c902a02532a95b43ddbff115acf2..7f2467804556dc5338f3d506ba5a0f149fae6a05 100644 --- a/crates/ui/src/lib.rs +++ b/crates/ui/src/lib.rs @@ -13,7 +13,10 @@ mod widgets; pub use list::{List, Row, Rows, list}; pub use theme::{Menu, PopupMotion, Section, Shades, Shadow, Theme}; -pub use widgets::{button, check_box, edit_key, edit_modifiers, scrollbar, text_field}; +pub use widgets::{ + PaintedScroller, Scroller, ScrollerPart, Scrollers, button, check_box, edit_key, + edit_modifiers, scrollbar, text_field, +}; use draw::{ PathStyle, Primitive, RasterImage, Stroke, @@ -38,7 +41,7 @@ use winit::{ const HALF_LIFE: f32 = 0.03; /// How far a box's shadow spreads, in logical pixels, and how far below the box it /// falls; a popup's, floating higher, spreads and falls further. -const SHADOW: [f32; 2] = [3.0, 0.0]; +pub const SHADOW: [f32; 2] = [3.0, 0.0]; const POPUP_SHADOW: [f32; 2] = [12.0, 4.0]; /// Logical size of a box's icon, and its distance from the label. const ICON: f32 = 16.0; @@ -93,6 +96,13 @@ impl Default for Extent { } } +/// Logical pixels, as `px`. +impl From for Extent { + fn from(pixels: f32) -> Self { + px(pixels) + } +} + pub fn px(pixels: f32) -> Extent { Extent { size: Size::Pixels(pixels), @@ -202,6 +212,9 @@ pub enum Anchor { Point([f32; 2]), /// Centred in the window over the interface, which dims, as a dialog opens. Dialog, + /// Centred across the window near its top, swinging in as a dialog does, as a command + /// palette opens. + Top, } impl Anchor { @@ -215,6 +228,8 @@ impl Anchor { Anchor::Over(rect) => (rect, None), Anchor::Point([x, y]) => ([x, y, x, y], Some(1)), Anchor::Dialog => return ((room - size) / 2.0).max(0.0), + Anchor::Top if axis == 0 => return ((room - size) / 2.0).max(0.0), + Anchor::Top => return room / 8.0, }; let [low, high] = [rect[axis], rect[axis + 2]]; let (first, second) = if along == Some(axis) { @@ -231,31 +246,37 @@ impl Anchor { } } - /// How a popup laid out at `rect` beside the anchor shows `open` of the way open: it - /// grows out of the anchor as it fades in where it `grows`, and a dialog swings up into - /// place. + /// How a popup laid out at `rect` beside the anchor shows `open` of the way open: where it + /// `grows`, it swings out of the anchor's edge as it grows and fades in, and a dialog swings + /// up into place. fn motion(self, rect: [f32; 4], open: f32, grows: bool) -> Motion { let pivot = match self { Anchor::Below(anchor) => [anchor[0], anchor[3]], Anchor::Right(anchor) => [anchor[2], anchor[1]], Anchor::Over(anchor) => [anchor[0], anchor[1]], Anchor::Point(point) => point, - Anchor::Dialog => [(rect[0] + rect[2]) / 2.0, rect[1]], + Anchor::Dialog | Anchor::Top => [(rect[0] + rect[2]) / 2.0, rect[1]], }; let (from, tilt) = match self { _ if !grows => (1.0, 0.0), - Anchor::Dialog => (0.95, 0.4), + Anchor::Dialog | Anchor::Top => (0.95, 0.2), // Over a box, the popup widens out of it in layout instead of growing. Anchor::Over(_) => (1.0, 0.2), - Anchor::Point(_) => (0.94, 0.2), - _ => (0.94, 0.0), + _ => (0.94, 0.2), + }; + let pivot = [ + pivot[0].clamp(rect[0], rect[2]), + pivot[1].clamp(rect[1], rect[3]), + ]; + // A popup flipped above its anchor swings out of its bottom edge, its top leaning away. + let tilt = if pivot[1] > (rect[1] + rect[3]) / 2.0 { + -tilt + } else { + tilt }; Motion { zoom: from + (1.0 - from) * open, - pivot: [ - pivot[0].clamp(rect[0], rect[2]), - pivot[1].clamp(rect[1], rect[3]), - ], + pivot, tilt: tilt * (1.0 - open), opacity: open, } @@ -263,7 +284,7 @@ impl Anchor { /// Seconds the popup takes to open and to close. fn durations(self) -> [f32; 2] { - if self == Anchor::Dialog { + if matches!(self, Anchor::Dialog | Anchor::Top) { DIALOG } else { POPUP @@ -332,6 +353,16 @@ pub struct Spec<'a> { /// Floats the box over all others beside a rectangle in the window, as a popup /// `Ui::open_popup` opened; boxes beneath take no input while one is open. pub anchor: Option, + /// Makes the box a group of a row in two forms, its first two children, both built every + /// frame: its full form, and the one it folds to where the row lacks room. The row folds + /// its groups by ascending priority until it fits, once the space sized by ancestors has + /// yielded and before any other box gives up room. The form not shown takes no room, + /// paint or input, and its boxes take the group's rectangle, so what opens from them + /// opens from the form shown. + pub fold: Option, + /// How far the children fade out into the box's fill towards its leading and trailing + /// edges, as a row of them cut there does. + pub fade: [f32; 2], } /// Input the host forwards; positions and wheel distances are logical pixels. @@ -387,6 +418,8 @@ pub enum Layer<'a> { /// opening or closing. pub struct Primitives<'a> { pub clip: Option<[f32; 4]>, + /// A rounded rectangle and its corner radius the primitives paint only inside. + round: Option<([f32; 4], f32)>, motion: Option, pub primitives: Vec>, } @@ -411,14 +444,18 @@ impl Primitives<'_> { pivot[axis] + (point[axis] - pivot[axis]) * zoom })) }; + let moved_rect = |[left, top, right, bottom]: [f32; 4]| { + let [[left, top], [right, bottom]] = [moved([left, top]), moved([right, bottom])]; + [left, top, right, bottom] + }; draw::Layer { scale: scale * zoom, origin: moved([0.0; 2]), - clip: self.clip.map(|[left, top, right, bottom]| { - let [[left, top], [right, bottom]] = [moved([left, top]), moved([right, bottom])]; - [left, top, right, bottom] - }), + clip: self.clip.map(moved_rect), backdrop: None, + round: self + .round + .map(|(rect, radius)| (moved_rect(rect), radius * scale * zoom)), motion: self.motion.map(|_| draw::Motion { opacity, tilt, @@ -462,6 +499,11 @@ struct Built { motion: Option, /// Rectangles relative to the box, painted over its fill. marks: Vec<([f32; 4], [f32; 4], f32)>, + fold: Option, + fade: [f32; 2], + /// Folded away with the form it belongs to: it takes no room, paint or input, and takes + /// its group's rectangle. + hidden: bool, computed: [f32; 2], relative: [f32; 2], rect: [f32; 4], @@ -486,6 +528,8 @@ struct State { select_all: bool, /// Where a dragged scrollbar thumb was taken, from its start. grab: f32, + /// A system scroller's part held down, and when a held arrow or track next repeats. + held: Option<(ScrollerPart, Instant)>, /// An animated value and its target, for `Ui::animate`. tween: Option<[f32; 2]>, } @@ -531,6 +575,8 @@ struct Hit { #[derive(Clone)] enum Display { Clip(Option<[f32; 4]>), + /// A rounded rectangle and its corner radius, clipping what follows. + Round(Option<([f32; 4], f32)>), Motion(Option), Rect { rect: [f32; 4], @@ -582,6 +628,9 @@ pub struct Ui { /// Whether the window has keyboard focus; without it a field hides its caret and dims /// its selection. pub window_focused: bool, + /// The platform's scrollers, where it draws fixed ones beside the content; scrollbars + /// overlay the content otherwise. + pub scrollers: Option, frame: u64, now: Instant, scale: f32, @@ -608,6 +657,8 @@ pub struct Ui { modifiers: ModifiersState, clicks: Clicks, display: Vec, + /// The painting of what holds still in the popup being painted, which goes over it. + still: Vec, /// Where the popups' painting starts in `display`, for edges drawn beneath them. popups_painted: usize, /// Each popup painted, its anchor and rectangle, and its painting's place after @@ -634,6 +685,7 @@ impl Ui { theme, icon_palette: draw::Palette::default(), window_focused: true, + scrollers: None, frame: 0, now: Instant::now(), scale: 1.0, @@ -656,6 +708,7 @@ impl Ui { modifiers: ModifiersState::empty(), clicks: Clicks::new(double_click), display: Vec::new(), + still: Vec::new(), popups_painted: 0, painted: Vec::new(), closing: Vec::new(), @@ -1188,6 +1241,15 @@ impl Ui { } let from = self.display.len() - self.popups_painted; self.paint(index, None, None); + if !self.still.is_empty() { + let still = std::mem::take(&mut self.still); + self.display.extend(still); + self.display.extend([ + Display::Motion(None), + Display::Clip(None), + Display::Round(None), + ]); + } painted.push(( id, anchor, @@ -1279,9 +1341,10 @@ impl Ui { }) } - /// How popups beside `anchor` open and close; dialogs always swing as Snowbound's own. + /// How popups beside `anchor` open and close; dialogs and the palette always swing as + /// Snowbound's own. fn popup_motion(&self, anchor: Anchor) -> PopupMotion { - if anchor == Anchor::Dialog { + if matches!(anchor, Anchor::Dialog | Anchor::Top) { PopupMotion::Grow } else { self.theme.menu().motion @@ -1311,6 +1374,18 @@ impl Ui { } fn paint(&mut self, index: usize, clip: Option<[f32; 4]>, motion: Option) { + // Painted over the popup it holds still in, which appears as one picture beneath. + if self.nodes[index].flags.contains(Flags::STILL) && motion.is_some() { + let from = self.display.len(); + self.paint_box(index, clip, motion); + let still = self.display.split_off(from); + self.still.extend(still); + } else { + self.paint_box(index, clip, motion); + } + } + + fn paint_box(&mut self, index: usize, clip: Option<[f32; 4]>, motion: Option) { let node = &self.nodes[index]; // The motion the box and its children paint with, where it changes it. let own = if node.flags.contains(Flags::STILL) { @@ -1485,7 +1560,9 @@ impl Ui { }); } } - let inner_clip = if node.flags.contains(Flags::CLIP) { + // A popup's contents show only inside its rounded outline. + let round = node.anchor.map(|_| (rect, node.radius)); + let inner_clip = if node.flags.contains(Flags::CLIP) || round.is_some() { Some(visible.unwrap_or([rect[0], rect[1], rect[0], rect[1]])) } else { clip @@ -1493,11 +1570,18 @@ impl Ui { if inner_clip != clip { self.display.push(Display::Clip(inner_clip)); } + if round.is_some() { + self.display.push(Display::Round(round)); + } for child in node.children.clone() { - if self.nodes[child].anchor.is_none() { + if self.nodes[child].anchor.is_none() && !self.nodes[child].hidden { self.paint(child, inner_clip, own.unwrap_or(motion)); } } + if round.is_some() { + self.display.push(Display::Round(None)); + } + self.fade(index); if inner_clip != clip { self.display.push(Display::Clip(clip)); } @@ -1506,21 +1590,69 @@ impl Ui { } } + /// Fades box `index`'s children out towards its ends by its `fade`, in steps of a point, + /// into its fill however opaque that is: each step clears what lies beneath towards + /// transparency and lays the fill over it. + fn fade(&mut self, index: usize) { + let node = &self.nodes[index]; + let [left, top, right, bottom] = node.rect; + let fill = node.fill.unwrap_or_default(); + let mut steps = Vec::new(); + for (side, width) in node.fade.into_iter().enumerate() { + let count = width.round() as usize; + for step in 0..count { + // Through the step's middle, eased so the fade leaves the children softly. + let alpha = 1.0 - (step as f32 + 0.5) / count as f32; + let alpha = alpha * alpha * (3.0 - 2.0 * alpha); + let x = if side == 0 { + left + step as f32 + } else { + right - 1.0 - step as f32 + }; + steps.push(([x, top, x + 1.0, bottom], alpha)); + } + } + let data = format!("M0 0H1V{}H0Z", bottom - top); + for (rect, alpha) in steps { + // Laid over what the erasing leaves, the fill's share makes up the rest of the + // children's lost opacity: all of it where the fill is opaque, none where clear. + let laid = alpha * fill[3]; + if laid < 1.0 { + self.display.push(Display::Path { + data: data.clone(), + origin: [rect[0], rect[1]], + style: PathStyle::Erase, + colors: [[0.0, 0.0, 0.0, 1.0 - (1.0 - alpha) / (1.0 - laid)]; 2], + }); + } + self.display.push(Display::Rect { + rect, + fill: [fill[0], fill[1], fill[2], laid], + shade: None, + border: None, + radius: 0.0, + }); + } + } + /// This frame's painting in order, in logical pixels. pub fn layers(&self) -> Vec> { let mut layers = Vec::new(); let mut clip = None; + let mut round = None; let mut motion = None; let mut primitives = Vec::new(); fn flush<'a>( layers: &mut Vec>, clip: Option<[f32; 4]>, + round: Option<([f32; 4], f32)>, motion: Option, primitives: &mut Vec>, ) { if !primitives.is_empty() { layers.push(Layer::Primitives(Primitives { clip, + round, motion, primitives: std::mem::take(primitives), })); @@ -1529,11 +1661,15 @@ impl Ui { for item in &self.display { match item { Display::Clip(next) => { - flush(&mut layers, clip, motion, &mut primitives); + flush(&mut layers, clip, round, motion, &mut primitives); clip = *next; } + Display::Round(next) => { + flush(&mut layers, clip, round, motion, &mut primitives); + round = *next; + } Display::Motion(next) => { - flush(&mut layers, clip, motion, &mut primitives); + flush(&mut layers, clip, round, motion, &mut primitives); motion = *next; } Display::Rect { @@ -1617,7 +1753,7 @@ impl Ui { ink: *color, }), Display::Custom { id, rect } => { - flush(&mut layers, clip, motion, &mut primitives); + flush(&mut layers, clip, round, motion, &mut primitives); layers.push(Layer::Custom { id: *id, rect: *rect, @@ -1625,7 +1761,7 @@ impl Ui { } } } - flush(&mut layers, clip, motion, &mut primitives); + flush(&mut layers, clip, round, motion, &mut primitives); layers } @@ -1648,6 +1784,10 @@ impl Ui { pub(crate) fn grab(&mut self, id: Id) -> &mut f32 { &mut self.states.entry(id).or_default().grab } + + pub(crate) fn held(&mut self, id: Id) -> &mut Option<(ScrollerPart, Instant)> { + &mut self.states.entry(id).or_default().held + } } impl Built { @@ -1699,6 +1839,9 @@ impl Built { open: 1.0, motion: None, marks: Vec::new(), + fold: spec.fold, + fade: spec.fade, + hidden: false, computed: [0.0; 2], relative: [0.0; 2], rect: [0.0; 4], diff --git a/crates/ui/src/list.rs b/crates/ui/src/list.rs index 27d56c60772e7b584425c9e041c7668857046c34..b812511a4086a3d3611dfe41068c98a228fc9875 100644 --- a/crates/ui/src/list.rs +++ b/crates/ui/src/list.rs @@ -189,18 +189,18 @@ pub fn list( let scroll = state.scroll; ui.animating |= scroll != state.target; + // Rows fill all but a gutter for the scrollbar, from their first frame: the padding + // narrows what they fill, and they float from the corner. + let gutter = if most > 0.0 { GUTTER / 2.0 } else { 0.0 }; ui.open_as( id, Spec { flags: spec.flags | Flags::SCROLL | Flags::CLIP, + pad: [spec.pad[0] + gutter, spec.pad[1]], ..spec }, ); - // Rows leave the scrollbar a gutter. - let width = match rect { - Some(rect) if most > 0.0 => px(rect[2] - rect[0] - GUTTER), - _ => fill(), - }; + let width = fill(); let in_view = row_at(scroll)..(row_at(scroll + view) + 1).min(len); let mut shown = Vec::with_capacity(in_view.len()); for index in in_view { diff --git a/crates/ui/src/popup.rs b/crates/ui/src/popup.rs index 9e1baddcdfbc25b4ae97207dafb8405680e4e2b7..ff35cea6d8bd864bdd3e13aa1c6f8ac5be0a4bce 100644 --- a/crates/ui/src/popup.rs +++ b/crates/ui/src/popup.rs @@ -209,24 +209,15 @@ pub fn tooltip(ui: &mut Ui, title: &str, keys: &str, description: Option<&str>) } /// Builds popup `id` as a command palette across the top of the window while it is open, -/// listing `commands` that match what is typed, most relevant first. Returns the index of -/// the command chosen. -pub fn palette(ui: &mut Ui, id: Id, commands: &[Item]) -> Option { +/// listing `items` that match what is typed, most relevant first, under a filter field +/// showing `placeholder` while empty. Returns the index of the item chosen. +pub fn palette(ui: &mut Ui, id: Id, items: &[Item], placeholder: &str) -> Option { if !ui.popup_open(id) { return None; } let window = ui.rect(Id::ROOT).unwrap_or_default(); let width = PALETTE.min(window[2] - 8.0 * PAD).max(NARROWEST); - let [left, top] = [(window[2] - width) / 2.0, window[3] / 8.0]; - choose( - ui, - id, - Anchor::Below([left, top, left, top]), - commands, - Some("Search commands"), - width, - ROW, - ) + choose(ui, id, Anchor::Top, items, Some(placeholder), width, ROW) } fn choose( @@ -694,26 +685,42 @@ pub fn table_picker(ui: &mut Ui, id: Id, anchor: Anchor, size: [usize; 2]) -> Op None } -/// Builds popup `id` as a gallery beside `anchor` while it is open: `groups` of cells, each -/// a heading and a count, in rows of `columns` cells `size` large, with cell `current` -/// outlined. `cell` builds a cell's contents from its index, counted through the groups. -/// Returns the index of the cell chosen. -#[allow(clippy::too_many_arguments)] +/// A gallery's run of `cells` under `heading`, in rows of `columns` cells `size` large. +#[derive(Clone, Copy, Debug)] +pub struct Group<'a> { + pub heading: &'a str, + pub cells: usize, + pub columns: usize, + pub size: [f32; 2], +} + +/// Builds popup `id` as a gallery beside `anchor` while it is open: `groups` of cells, with +/// the cells in `current` outlined. `cell` builds a cell's contents from its index, counted +/// through the groups. Returns the index of the cell chosen. pub fn gallery( ui: &mut Ui, id: Id, anchor: Anchor, - groups: &[(&str, usize)], - columns: usize, - size: [f32; 2], - current: Option, + groups: &[Group], + current: &[usize], mut cell: impl FnMut(&mut Ui, usize), ) -> Option { if !ui.popup_open(id) { return None; } let cell_id = |index: usize| id.child(("cell", index)); - let count: usize = groups.iter().map(|(_, count)| count).sum(); + let count: usize = groups.iter().map(|group| group.cells).sum(); + // The columns of the group holding cell `at`. + let columns = |at: usize| { + let mut start = 0; + groups + .iter() + .find(|group| { + start += group.cells; + at < start + }) + .map_or(1, |group| group.columns) + }; let keys = navigation( ui, &[id], @@ -737,7 +744,7 @@ pub fn gallery( } } for key in keys { - let at = highlight.or(current).unwrap_or(0); + let at = highlight.or(current.first().copied()).unwrap_or(0); highlight = Some(match key { NamedKey::Enter => { chosen = chosen.or(highlight); @@ -745,8 +752,8 @@ pub fn gallery( } NamedKey::ArrowLeft => at.saturating_sub(1), NamedKey::ArrowRight => (at + 1).min(count - 1), - NamedKey::ArrowUp => at.saturating_sub(columns), - _ => (at + columns).min(count - 1), + NamedKey::ArrowUp => at.saturating_sub(columns(at)), + _ => (at + columns(at)).min(count - 1), }); } if let Some(index) = chosen { @@ -754,19 +761,18 @@ pub fn gallery( return Some(index); } let theme = ui.theme.clone(); - surface( - ui, - id, - anchor, - columns as f32 * size[0] + 2.0 * ui.theme.menu().pad, - ); + let width = groups + .iter() + .map(|group| group.columns as f32 * group.size[0]) + .fold(0.0, f32::max); + surface(ui, id, anchor, width + 2.0 * ui.theme.menu().pad); let mut index = 0; - for (group, (heading, cells)) in groups.iter().enumerate() { + for (number, group) in groups.iter().enumerate() { ui.leaf( - ("heading", group), + ("heading", number), Spec { size: [fill(), px(MENU_ROW)], - text: Some(heading), + text: Some(group.heading), font_size: Some(theme.font_size - 2.0), bold: true, color: Some(theme.text_dim), @@ -774,23 +780,23 @@ pub fn gallery( ..Spec::default() }, ); - for row in 0..cells.div_ceil(columns) { + for row in 0..group.cells.div_ceil(group.columns) { ui.open( - ("row", group, row), + ("row", number, row), Spec { - size: [fill(), px(size[1])], + size: [fill(), px(group.size[1])], ..Spec::default() }, ); - for _ in 0..columns.min(cells - row * columns) { + for _ in 0..group.columns.min(group.cells - row * group.columns) { ui.open_as( cell_id(index), Spec { flags: Flags::CLICKABLE, axis: Axis::Y, - size: [px(size[0]), px(size[1])], + size: [px(group.size[0]), px(group.size[1])], fill: (highlight == Some(index)).then(|| theme.hover()), - border: (current == Some(index)).then_some(theme.accent), + border: current.contains(&index).then_some(theme.accent), radius: 4.0, pad: [PAD, PAD], ..Spec::default() diff --git a/crates/ui/src/shell.rs b/crates/ui/src/shell.rs index 9ad1b98bda38377fc5f5238fe6f0865f67f97b0d..c4e00061a3c6dda3ed6d8dd390c589382ec23b41 100644 --- a/crates/ui/src/shell.rs +++ b/crates/ui/src/shell.rs @@ -4,7 +4,8 @@ use draw::{PathStyle, edit::SelectionUnit}; use crate::{ - Anchor, Corner, Display, Flags, Id, Section, Shape, Signal, Spec, Ui, children, fill, mix, px, + Anchor, Corner, Display, Extent, Flags, Id, Section, Shape, Signal, Spec, Ui, children, fill, + mix, px, }; use std::hash::Hash; @@ -18,34 +19,34 @@ pub const TOOL: f32 = 22.0; const ARROW: f32 = 10.0; /// Where a section tab's label starts inside it. pub const TAB_PAD: f32 = 10.0; +/// How far section tabs fade out at an end of their row they are cut at, and how near it a +/// dragged tab scrolls the row on. +const EDGE: f32 = 20.0; /// Section tabs in a row `height` tall, as OneNote draws them: each leans over the next /// at 45°, and the open one lies on top in `section`'s colours, rising to meet the frame /// below as its outline and shadow fade in. `tabs` are names and section colours; `lit` /// shows as hovered, as a tab something is dragged onto. A `dragged` tab follows the /// pointer, lifted over the others, which slide aside to open the gap it would land in; -/// each eases to its place once let go. +/// each eases to its place once let go, and near the row's ends the row scrolls on. +/// +/// The row is as wide as the tabs, and gives up all of that where its own row lacks room. +/// Then the tabs scroll sideways under the wheel, either way it turns, fading out at the +/// ends they are cut at into the row's `fill`, and the open tab scrolls into view as it +/// opens. #[allow(clippy::too_many_arguments)] pub fn section_tabs( ui: &mut Ui, - part: impl Hash, + row: Id, tabs: &[(&str, [f32; 4])], active: usize, lit: Option, dragged: Option, section: &Section, height: f32, + fill: [f32; 4], ) -> Tabs { let theme = ui.theme.clone(); - ui.open( - part, - Spec { - flags: Flags::CLIP, - size: [fill(), px(height)], - ..Spec::default() - }, - ); - let [low, tallest] = [height - 6.0, height - 2.0]; // Room for the first tab's outline and shadow, which the row would clip. let mut left = crate::SHADOW[0]; let placed: Vec<_> = tabs @@ -56,14 +57,65 @@ pub fn section_tabs( (left - width, width) }) .collect(); + // The last tab's slant and shadow reach past its box. + let wide = left + lean(height) + crate::SHADOW[0]; + let view = ui.rect(row).map_or(wide, |rect| rect[2] - rect[0]); + let most = (wide - view).max(0.0); + let [scroll, shown] = [row.child("scroll"), row.child("shown")]; + let held_value = |ui: &Ui, id: Id| ui.states.get(&id).and_then(|state| state.tween); + let mut target = held_value(ui, scroll).map_or(0.0, |[_, target]| target); + for event in ui.signal(row).events { + if let crate::Event::Wheel([x, y]) = event { + target -= x + y; + } + } + if held_value(ui, shown).is_none_or(|[_, was]| was != active as f32) + && let Some((x, width)) = placed.get(active) + { + target = target.min(*x).max(x + width + lean(height) - view); + } + ui.hold(shown, active as f32); + // A tab dragged near either end scrolls the row on, as long as it is held there. + let start = dragged.and_then(|dragged| { + let start = dragged.start?; + let width = placed[dragged.index].1; + let push = (start - EDGE).min(0.0) + (start + width - view + EDGE).max(0.0); + if push != 0.0 && (target > 0.0 || push > 0.0) && (target < most || push < 0.0) { + target += push * ui.dt * 8.0; + ui.animating = true; + } + Some(start) + }); + let target = ui.hold(scroll, target.clamp(0.0, most)); + let offset = ui.animate(row.child("offset"), target); + let fade = |cut: bool| if cut { EDGE } else { 0.0 }; + ui.open_as( + row, + Spec { + flags: Flags::CLIP | Flags::SCROLL, + size: [ + Extent { + size: crate::Size::Pixels(wide), + strictness: 0.0, + }, + px(height), + ], + fill: Some(fill), + fade: [fade(offset > 0.5), fade(offset < most - 0.5)], + ..Spec::default() + }, + ); + let [low, tallest] = [height - 6.0, height - 2.0]; let (mut clicked, mut context, mut held, mut renamed) = (None, None, None, None); let spans: Vec<[f32; 2]> = placed.iter().map(|(x, width)| [*x, *width]).collect(); + // The dragged tab's place along the tabs, however far they are scrolled. + let start = start.map(|start| start + offset); let slot = dragged.and_then(|dragged| { let width = placed[dragged.index].1; Some(crate::drop_slot( &spans, dragged.index, - dragged.start? + width / 2.0, + start? + width / 2.0, )) }); let lifted = dragged.map(|dragged| dragged.index); @@ -79,14 +131,9 @@ pub fn section_tabs( let (x, width) = placed[index]; // Keyed by name, so a tab eases from where it stood when the tabs are reordered. let key = ui.id(("x", name)); - let x = match (dragged, slot) { - ( - Some(Dragged { - start: Some(start), .. - }), - _, - ) if lifted == Some(index) => ui.hold(key, start), - (Some(dragged), Some(slot)) => ui.animate( + let x = match (dragged, slot, start) { + (_, _, Some(start)) if lifted == Some(index) => ui.hold(key, start), + (Some(dragged), Some(slot), _) => ui.animate( key, x + crate::slide(index, dragged.index, slot, placed[dragged.index].1), ), @@ -110,7 +157,7 @@ pub fn section_tabs( Spec { flags: Flags::CLICKABLE | Flags::FLOAT, size: [px(width), px(tall)], - position: [x, height - tall], + position: [x - offset, height - tall], text: Some(name), color: Some(mix(mix(theme.ink, fill, 0.2), theme.ink, open)), fill: Some(if lit == Some(index) { @@ -409,13 +456,13 @@ pub fn menu_button( } /// A drop-down box `width` wide showing `text`. -pub fn combo(ui: &mut Ui, part: impl Hash, text: &str, width: f32) -> Signal { +pub fn combo(ui: &mut Ui, part: impl Hash, text: &str, width: impl Into) -> Signal { let theme = ui.theme.clone(); let id = ui.open( part, Spec { flags: Flags::CLICKABLE, - size: [px(width), px(TOOL)], + size: [width.into(), px(TOOL)], fill: Some(theme.base), hover_border: Some(theme.accent), border: Some(theme.chip), diff --git a/crates/ui/src/tests.rs b/crates/ui/src/tests.rs index d22a277bb472bc580e86370558cbd7017b355a90..5e84bec039746ff1695f9bc9890dfd2c015988d0 100644 --- a/crates/ui/src/tests.rs +++ b/crates/ui/src/tests.rs @@ -127,6 +127,146 @@ fn children_sum_with_gaps_and_padding() { assert_eq!(ui.rect(second.unwrap()), Some([4.0, 18.0, 34.0, 38.0])); } +/// A row `width` wide of a space, a box that yields all but a quarter of its 80 pixels, and +/// groups 100 wide or 20 folded at each priority; returns the rectangles of the loose box and +/// of each group's full and folded button. +fn folding_row(ui: &mut Ui, width: f32, priorities: &[u32]) -> Vec> { + let mut ids = Vec::new(); + frame(ui, |ui| { + ui.open( + "row", + Spec { + size: [px(width), px(20.0)], + ..Spec::default() + }, + ); + ui.leaf( + "space", + Spec { + size: [fill(), px(1.0)], + ..Spec::default() + }, + ); + let loose = Extent { + size: Size::Pixels(80.0), + strictness: 0.25, + }; + ids.push(ui.open( + "loose", + Spec { + size: [loose, px(20.0)], + ..Spec::default() + }, + )); + ui.close(); + for priority in priorities { + let group = ui.open( + priority, + Spec { + fold: Some(*priority), + ..Spec::default() + }, + ); + for (form, width) in [("full", 100.0), ("folded", 20.0)] { + ui.open(form, Spec::default()); + ui.leaf( + "button", + Spec { + flags: Flags::CLICKABLE, + size: [px(width), px(20.0)], + ..Spec::default() + }, + ); + ui.close(); + } + ui.close(); + for form in ["full", "folded"] { + ids.push(group.child(form).child("button")); + } + } + ui.close(); + }); + ids.into_iter().map(|id| ui.rect(id)).collect() +} + +#[test] +fn a_row_folds_its_lowest_priorities_first_until_it_fits() { + let mut ui = Ui::new(Theme::dark(), DOUBLE_CLICK); + // 80 + 3 × 100 fits 380 with nothing folded or squeezed. + let rects = folding_row(&mut ui, 380.0, &[2, 0, 1]); + assert_eq!(rects[0], Some([0.0, 0.0, 80.0, 20.0])); + assert_eq!(rects[1], Some([80.0, 0.0, 180.0, 20.0])); + // At 300, folding priority 0 alone leaves 80 + 100 + 20 + 100; the full form's button + // stands where the folded one shows. + let rects = folding_row(&mut ui, 300.0, &[2, 0, 1]); + assert_eq!(rects[1], Some([80.0, 0.0, 180.0, 20.0])); + assert_eq!(rects[3..5], [Some([180.0, 0.0, 200.0, 20.0]); 2]); + assert_eq!(rects[5], Some([200.0, 0.0, 300.0, 20.0])); +} + +#[test] +fn a_row_squeezes_its_boxes_only_once_every_group_is_folded() { + let mut ui = Ui::new(Theme::dark(), DOUBLE_CLICK); + let rects = folding_row(&mut ui, 130.0, &[0, 1]); + // 80 + 20 + 20 fits 130 with 10 to spare, taken by the space. + assert_eq!(rects[0], Some([0.0, 0.0, 80.0, 20.0])); + let rects = folding_row(&mut ui, 100.0, &[0, 1]); + assert_eq!(rects[0], Some([0.0, 0.0, 60.0, 20.0])); + assert_eq!(rects[4], Some([80.0, 0.0, 100.0, 20.0])); +} + +#[test] +fn a_box_sized_by_its_children_yields_what_they_yield() { + let mut ui = Ui::new(Theme::dark(), DOUBLE_CLICK); + let mut combo = None; + frame(&mut ui, |ui| { + ui.open( + "row", + Spec { + size: [px(100.0), px(20.0)], + ..Spec::default() + }, + ); + ui.open("group", Spec::default()); + let loose = Extent { + size: Size::Pixels(80.0), + strictness: 0.5, + }; + combo = Some(ui.open( + "combo", + Spec { + size: [loose, px(20.0)], + ..Spec::default() + }, + )); + ui.close(); + ui.leaf( + "button", + Spec { + size: [px(40.0), px(20.0)], + ..Spec::default() + }, + ); + ui.close(); + ui.close(); + }); + assert_eq!(ui.rect(combo.unwrap()), Some([0.0, 0.0, 60.0, 20.0])); +} + +#[test] +fn a_folded_away_form_takes_no_input() { + let mut ui = Ui::new(Theme::dark(), DOUBLE_CLICK); + folding_row(&mut ui, 110.0, &[0]); + let folded = Id::ROOT + .child("row") + .child(0_u32) + .child("folded") + .child("button"); + assert_eq!(ui.box_at([90.0, 10.0]), Some(folded)); + // Where the full form's button would lie past the folded one. + assert_eq!(ui.box_at([150.0, 10.0]), None); +} + fn button_frame(ui: &mut Ui) -> Signal { let mut signal = Signal::default(); frame(ui, |ui| { @@ -709,6 +849,86 @@ fn scrollbar_thumbs_track_the_offset_and_drags_reach_both_ends() { assert_eq!(offset, 0.0); } +#[test] +fn system_scrollers_step_page_and_drag_by_the_parts_the_platform_paints() { + let mut ui = Ui::new(Theme::dark(), DOUBLE_CLICK); + // Arrows together at the far end, as 10.6 places them. + ui.scrollers = Some(Scrollers { + thickness: 15.0, + paint: Box::new(|request: &Scroller| { + let slot = request.length - 30.0; + let knob = slot * request.proportion; + let start = request.value * (slot - knob); + PaintedScroller { + image: RasterImage::new( + [15, request.length as u32], + vec![0; 60 * request.length as usize], + ) + .unwrap(), + knob: [start, start + knob], + slot: [0.0, slot], + decrement: [slot, slot + 15.0], + increment: [slot + 15.0, request.length], + } + }), + }); + let mut offset = 0.0; + let build = |ui: &mut Ui, offset: &mut f32| { + frame(ui, |ui| { + ui.open( + "view", + Spec { + flags: Flags::CUSTOM, + size: [px(200.0), px(215.0)], + ..Spec::default() + }, + ); + if let Some(chosen) = + scrollbar(ui, "bar", Axis::Y, *offset, [0.0, 1000.0], 400.0, [0.5; 4]) + { + *offset = chosen; + } + ui.close(); + }); + }; + build(&mut ui, &mut offset); + build(&mut ui, &mut offset); + let bar = Id::ROOT.child("view").child("bar"); + assert_eq!( + ui.rect(bar).unwrap(), + [185.0, 0.0, 200.0, 200.0], + "the corner stays clear" + ); + let at = Instant::now(); + let click = |ui: &mut Ui, y: f32, pressed: bool| { + ui.event(Event::PointerMoved([190.0, y])); + ui.event(Event::Button { + button: MouseButton::Left, + pressed, + at, + }); + }; + click(&mut ui, 190.0, true); + build(&mut ui, &mut offset); + assert_eq!( + offset, 20.0, + "the increment arrow steps a twentieth of the view" + ); + click(&mut ui, 190.0, false); + build(&mut ui, &mut offset); + // Below the knob, 170 × 400 / 1400 long, the track pages. + click(&mut ui, 160.0, true); + build(&mut ui, &mut offset); + assert_eq!(offset, 400.0); + click(&mut ui, 160.0, false); + build(&mut ui, &mut offset); + click(&mut ui, 60.0, true); + build(&mut ui, &mut offset); + ui.event(Event::PointerMoved([190.0, 500.0])); + build(&mut ui, &mut offset); + assert_eq!(offset, 1000.0, "the knob drags to the end"); +} + fn items() -> Vec> { let item = |text| popup::Item { text, @@ -1071,13 +1291,15 @@ fn nothing_matching_leaves_nothing_to_choose() { } #[test] -fn the_palette_centres_across_the_window_and_chooses_the_best_match() { +fn the_palette_centres_near_the_windows_top_and_chooses_the_best_match() { let mut ui = Ui::new(Theme::dark(), DOUBLE_CLICK); let palette = Id::ROOT.child("palette"); let commands = items(); let build = |ui: &mut Ui| { let mut chosen = None; - frame(ui, |ui| chosen = popup::palette(ui, palette, &commands)); + frame(ui, |ui| { + chosen = popup::palette(ui, palette, &commands, "Search commands") + }); chosen }; build(&mut ui); @@ -1087,6 +1309,7 @@ fn the_palette_centres_across_the_window_and_chooses_the_best_match() { } let rect = ui.rect(palette).unwrap(); assert_eq!(rect[0] + rect[2], 400.0, "centred"); + assert_eq!(rect[1], 300.0 / 8.0, "an eighth down"); ui.event(typed("a")); ui.event(key(NamedKey::Enter)); assert_eq!( @@ -1748,7 +1971,7 @@ fn popups_ease_open_and_closed_then_stop_asking_for_frames() { build(ui); }); let first = motion(&ui).unwrap(); - assert!(first.opacity < 0.1 && first.zoom < 0.96 && first.tilt > 0.3); + assert!(first.opacity < 0.1 && first.zoom < 0.96 && first.tilt > 0.15); assert_eq!( first.pivot, [200.0, 125.0], @@ -1895,7 +2118,7 @@ fn every_control_in_an_open_dialog_takes_clicks_where_it_rests() { } #[test] -fn a_combo_s_field_holds_its_place_as_its_list_opens_and_its_rows_take_clicks() { +fn a_combo_s_field_widens_over_rows_laid_out_where_they_end_and_its_rows_take_clicks() { let mut ui = Ui::new(Theme::light(), DOUBLE_CLICK); let fonts = fonts(); 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() let [box_rect, first] = [ui.rect(combo).unwrap(), ui.rect(field).unwrap()]; assert_eq!(first[..2], box_rect[..2], "the field starts on the box"); assert!(first[2] - first[0] < box_rect[2] - box_rect[0] + 20.0); - let moving: Vec<_> = ui - .layers() + let shown = ui.rect(menu_id()).unwrap(); + let row = menu_id().child("rows").child(3_u64); + let opening = ui.rect(row).unwrap(); + assert!( + opening[2] > shown[2], + "the rows lie out past the popup as it opens" + ); + let layers = ui.layers(); + let moving: Vec<_> = layers .iter() .filter_map(|layer| match layer { - Layer::Primitives(layer) => Some(layer.motion.is_some()), + Layer::Primitives(layer) => Some(layer), Layer::Custom { .. } => None, }) + .skip_while(|layer| layer.motion.is_none()) .collect(); - let panel = moving.iter().position(|moving| *moving).unwrap(); + let split = moving + .iter() + .position(|layer| layer.motion.is_none()) + .unwrap(); assert!( - moving[panel..].contains(&false), - "the field paints without the popup's motion" + moving[split..].iter().all(|layer| layer.motion.is_none()), + "the field paints over the popup, which appears as one" ); + assert!( + moving[..split] + .iter() + .filter(|layer| layer.round.is_some()) + .all(|layer| layer.round == Some((shown, ui.theme.menu().radius))), + "the rows show only inside the popup's outline" + ); + drop(layers); for _ in 0..20 { build(&mut ui); } assert!(!ui.wants_frame()); let [panel, last] = [ui.rect(menu_id()).unwrap(), ui.rect(field).unwrap()]; assert_eq!(last[2], panel[2] - 4.0, "the field widens across the popup"); + assert_eq!(ui.rect(row), Some(opening), "the rows never move"); click_box(&mut ui, menu_id().child("rows").child(3_u64)); assert_eq!(build(&mut ui), Some(3)); } @@ -1955,6 +2198,8 @@ fn a_popup_over_a_box_by_the_window_s_edge_widens_away_from_it() { let edges = |ui: &Ui| [ui.rect(field).unwrap(), ui.rect(menu_id()).unwrap()].map(|rect| rect[2]); let first = ui.rect(field).unwrap(); + let row = menu_id().child("rows").child(3_u64); + let opening = ui.rect(row).unwrap(); assert_eq!( edges(&ui), [390.0, 394.0], @@ -1969,6 +2214,7 @@ fn a_popup_over_a_box_by_the_window_s_edge_widens_away_from_it() { ui.rect(field).unwrap()[0] < first[0] - 30.0, "the field widens leftward" ); + assert_eq!(ui.rect(row), Some(opening), "the rows never move"); } fn built(ui: &Ui, id: Id) -> &Built { @@ -2113,29 +2359,25 @@ fn a_menu_button_opens_its_menu_with_the_icons_under_its_own() { fn galleries_choose_across_their_groups_by_keys_and_clicks() { let mut ui = Ui::new(Theme::dark(), DOUBLE_CLICK); let gallery = Id::ROOT.child("gallery"); - let groups = [("Recent", 2), ("Library", 5)]; + let groups = [("Recent", 2), ("Library", 5)].map(|(heading, cells)| popup::Group { + heading, + cells, + columns: 3, + size: [30.0, 30.0], + }); let build = |ui: &mut Ui| { let mut chosen = None; frame(ui, |ui| { - chosen = popup::gallery( - ui, - gallery, - BELOW, - &groups, - 3, - [30.0, 30.0], - Some(3), - |ui, index| { - ui.leaf( - "label", - Spec { - size: [fill(), fill()], - text: Some(["a", "b", "c", "d", "e", "f", "g"][index]), - ..Spec::default() - }, - ); - }, - ); + chosen = popup::gallery(ui, gallery, BELOW, &groups, &[3], |ui, index| { + ui.leaf( + "label", + Spec { + size: [fill(), fill()], + text: Some(["a", "b", "c", "d", "e", "f", "g"][index]), + ..Spec::default() + }, + ); + }); }); chosen }; @@ -2206,3 +2448,71 @@ fn desktop_menus_cut_or_fade_without_growing() { assert!((closing.opacity - (1.0 - 0.096 / 0.6_f32).powi(4)).abs() < 0.01); assert!(ui.wants_frame()); } + +/// A row 200 wide of twelve section tabs with `active` open; returns the row's id. +fn tab_row(ui: &mut Ui, active: usize) -> Id { + let names = [ + "One", "Two", "Three", "Four", "Five", "Six", "Seven", "Eight", "Nine", "Ten", "Eleven", + "Twelve", + ]; + let tabs: Vec<_> = names + .iter() + .map(|name| (*name, [0.5, 0.6, 0.9, 1.0])) + .collect(); + let section = ui.theme.section([0.5, 0.6, 0.9, 1.0]); + let row = Id::ROOT.child("tabs"); + frame(ui, |ui| { + ui.open( + "tab row", + Spec { + size: [px(200.0), px(28.0)], + ..Spec::default() + }, + ); + shell::section_tabs(ui, row, &tabs, active, None, None, §ion, 28.0, [0.0; 4]); + ui.close(); + }); + row +} + +/// Frames until the tabs' easing settles. +fn settle_tabs(ui: &mut Ui, active: usize) -> Id { + let mut row = tab_row(ui, active); + for _ in 0..60 { + row = tab_row(ui, active); + } + row +} + +#[test] +fn overflowing_tabs_scroll_the_open_one_into_view() { + let mut ui = Ui::new(Theme::dark(), DOUBLE_CLICK); + let row = settle_tabs(&mut ui, 0); + let view = ui.rect(row).unwrap(); + assert_eq!(view[2] - view[0], 200.0); + let row = settle_tabs(&mut ui, 11); + let last = ui.rect(shell::tab_id(row, 11)).unwrap(); + assert!( + last[0] >= view[0] && last[2] <= view[2], + "{last:?} in {view:?}" + ); +} + +#[test] +fn the_wheel_scrolls_overflowing_tabs_sideways_either_way_it_turns() { + let mut ui = Ui::new(Theme::dark(), DOUBLE_CLICK); + let row = settle_tabs(&mut ui, 0); + let first = ui.rect(shell::tab_id(row, 0)).unwrap()[0]; + ui.event(Event::PointerMoved([100.0, 14.0])); + ui.event(Event::Wheel([0.0, -40.0])); + settle_tabs(&mut ui, 0); + let scrolled = ui.rect(shell::tab_id(row, 0)).unwrap()[0]; + assert!( + (first - scrolled - 40.0).abs() < 0.5, + "{first} to {scrolled}" + ); + ui.event(Event::Wheel([30.0, 0.0])); + settle_tabs(&mut ui, 0); + let back = ui.rect(shell::tab_id(row, 0)).unwrap()[0]; + assert!((back - scrolled - 30.0).abs() < 0.5, "{scrolled} to {back}"); +} diff --git a/crates/ui/src/widgets.rs b/crates/ui/src/widgets.rs index 77449a4de9155c0649b8153cd89cc4f548948bb6..79783c5e17c5258cd00807341704c7f21be21246 100644 --- a/crates/ui/src/widgets.rs +++ b/crates/ui/src/widgets.rs @@ -1,10 +1,11 @@ use crate::{Axis, Event, Flags, Id, Signal, Spec, Ui, fit, px}; +use draw::RasterImage; use draw::edit::{self, Command, Movement}; use parley::{ Affinity, editing::{Cursor, Selection}, }; -use std::hash::Hash; +use std::{hash::Hash, time::Duration}; use winit::{ event::Ime, keyboard::{Key, ModifiersState, NamedKey}, @@ -75,9 +76,56 @@ pub fn check_box(ui: &mut Ui, part: impl Hash, label: &str, checked: bool) -> Si signal } -/// An overlay scrollbar along the far edge of the current box, for content whose scroll -/// offset ranges over `range` while `view` of it shows, all in one unit. Returns the offset -/// a drag of its thumb chose. +/// Scrollers the platform paints, fixed along the content's edges, as Mac OS X 10.6's are. +pub struct Scrollers { + /// Across a scroller, in logical pixels. + pub thickness: f32, + pub paint: Box PaintedScroller>, +} + +/// A scroller as it is to be painted, in logical pixels. +pub struct Scroller { + pub axis: Axis, + pub length: f32, + pub scale: f32, + /// Where the offset lies in its range, from 0 to 1. + pub value: f32, + /// How much of the content shows, from 0 to 1. + pub proportion: f32, + /// The part held down. + pub held: Option, + /// Whether the window is key, which colours the knob. + pub active: bool, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ScrollerPart { + Decrement, + Increment, + Knob, + /// The track either side of the knob, which pages. + Slot, +} + +/// A painted scroller and where its parts lie along it, from its start in logical pixels. +#[derive(Clone)] +pub struct PaintedScroller { + /// `length` by `thickness`, at the scroller's scale. + pub image: RasterImage, + pub knob: [f32; 2], + pub slot: [f32; 2], + pub decrement: [f32; 2], + pub increment: [f32; 2], +} + +/// How long a held arrow or track waits before repeating, and then between steps. +const REPEAT_DELAY: Duration = Duration::from_millis(300); +const REPEAT: Duration = Duration::from_millis(50); + +/// A scrollbar along the far edge of the current box, for content whose scroll offset +/// ranges over `range` while `view` of it shows, all in one unit: the platform's scroller +/// where `Ui::scrollers` has one, otherwise a thumb in `color` over the content. Returns +/// the offset the scrollbar chose. pub fn scrollbar( ui: &mut Ui, part: impl Hash, @@ -87,6 +135,9 @@ pub fn scrollbar( view: f32, color: [f32; 4], ) -> Option { + if ui.scrollers.is_some() { + return system_scrollbar(ui, part, axis, offset, range, view); + } let along = usize::from(axis == Axis::Y); let rect = ui.rect(ui.current())?; let length = rect[along + 2] - rect[along]; @@ -133,6 +184,115 @@ pub fn scrollbar( chosen } +fn system_scrollbar( + ui: &mut Ui, + part: impl Hash, + axis: Axis, + offset: f32, + range: [f32; 2], + view: f32, +) -> Option { + let along = usize::from(axis == Axis::Y); + let rect = ui.rect(ui.current())?; + let span = range[1] - range[0]; + let thickness = ui.scrollers.as_ref()?.thickness; + // The far end leaves the corner beside the other axis's scroller. + let length = rect[along + 2] - rect[along] - thickness; + if span <= 0.0 || length <= 0.0 { + return None; + } + let id = ui.id(&part); + let signal = ui.signal(id); + let pointer = ui.pointer().map(|pointer| pointer[along] - rect[along]); + let now = ui.now; + if !signal.pressed && !signal.dragging { + *ui.held(id) = None; + } + let request = Scroller { + axis, + length, + scale: ui.scale, + value: ((offset - range[0]) / span).clamp(0.0, 1.0), + proportion: (view / (view + span)).clamp(0.0, 1.0), + held: ui.held(id).map(|(part, _)| part), + active: ui.window_focused, + }; + let painted = (ui.scrollers.as_mut()?.paint)(&request); + let within = |[start, end]: [f32; 2], at: f32| start <= at && at < end; + let line = (view / 20.0).max(1.0); + let mut chosen = None; + if let Some(at) = pointer + && (signal.pressed || signal.dragging) + { + if signal.pressed { + let hit = [ + (ScrollerPart::Decrement, painted.decrement), + (ScrollerPart::Increment, painted.increment), + (ScrollerPart::Knob, painted.knob), + (ScrollerPart::Slot, painted.slot), + ] + .into_iter() + .find(|(_, extent)| within(*extent, at)); + *ui.held(id) = hit.map(|(part, _)| (part, now)); + *ui.grab(id) = at - painted.knob[0]; + } + match *ui.held(id) { + Some((ScrollerPart::Knob, _)) => { + let travel = + (painted.slot[1] - painted.slot[0]) - (painted.knob[1] - painted.knob[0]); + let fraction = ((at - *ui.grab(id) - painted.slot[0]) / travel.max(f32::EPSILON)) + .clamp(0.0, 1.0); + chosen = Some(range[0] + fraction * span); + } + Some((part, due)) if now >= due => { + let step = match part { + ScrollerPart::Decrement => -line, + ScrollerPart::Increment => line, + // The track pages towards the pointer and stops once the knob is under it. + _ if within(painted.knob, at) => 0.0, + _ if at < painted.knob[0] => line - view, + _ => view - line, + }; + let delay = if signal.pressed { REPEAT_DELAY } else { REPEAT }; + *ui.held(id) = Some((part, now + delay)); + chosen = Some((offset + step).clamp(range[0], range[1])); + } + _ => {} + } + // A held part repeats on later frames. + ui.animating = true; + } + let mut position = [0.0; 2]; + position[1 - along] = rect[3 - along] - rect[1 - along] - thickness; + let mut extent = [px(thickness); 2]; + extent[along] = px(length); + ui.leaf( + &part, + Spec { + flags: Flags::CLICKABLE | Flags::FLOAT, + size: extent, + position, + image: Some(&painted.image), + cursor: Some(CursorIcon::Default), + ..Spec::default() + }, + ); + if axis == Axis::Y { + // The corner between the two scrollers shows the content's white, as AppKit's does. + ui.leaf( + ("corner", &part), + Spec { + flags: Flags::FLOAT, + size: [px(thickness); 2], + position: [position[0], length], + fill: Some([1.0; 4]), + ..Spec::default() + }, + ); + } + chosen +} + /// A single-line field editing `text`, showing `placeholder` while empty. Presses, drags /// and keys select and edit as on the page: a double press selects a word, a triple press /// everything, and dragging extends by that unit. diff --git a/platform/snow-leopard/console/input.c b/platform/snow-leopard/console/input.c index d48e3faa059ea6ea2ed1da71ccfe699d2d1efdea..a32657db0c86ade131e8054a9f68900af6f1850c 100644 --- a/platform/snow-leopard/console/input.c +++ b/platform/snow-leopard/console/input.c @@ -1,5 +1,7 @@ // Posts input to the logged-in session as the HID system would, for driving the app -// over ssh: `input click X Y`, `input type TEXT`, `input key KEYCODE [command]`. +// over ssh: `input click X Y`, `input double X Y`, `input drag X Y X2 Y2`, +// `input scroll X Y LINES`, `input type TEXT`, +// `input key KEYCODE [command] [shift] [option] [control]`. // Points are the display's, from its top left. #include #include @@ -12,8 +14,15 @@ static void post(CGEventRef event) { usleep(20000); } -static void mouse(CGEventType type, CGPoint at) { - post(CGEventCreateMouseEvent(NULL, type, at, kCGMouseButtonLeft)); +static void mouse(CGEventType type, CGPoint at, int clicks) { + CGEventRef event = CGEventCreateMouseEvent(NULL, type, at, kCGMouseButtonLeft); + CGEventSetIntegerValueField(event, kCGMouseEventClickState, clicks); + post(event); +} + +static void click(CGPoint at, int clicks) { + mouse(kCGEventLeftMouseDown, at, clicks); + mouse(kCGEventLeftMouseUp, at, clicks); } static void key(CGKeyCode code, CGEventFlags flags, const UniChar *text, UniCharCount length) { @@ -26,11 +35,27 @@ static void key(CGKeyCode code, CGEventFlags flags, const UniChar *text, UniChar } int main(int argc, char **argv) { - if (argc == 4 && !strcmp(argv[1], "click")) { + if ((argc == 4 && !strcmp(argv[1], "click")) || (argc == 4 && !strcmp(argv[1], "double"))) { CGPoint at = CGPointMake(atof(argv[2]), atof(argv[3])); - mouse(kCGEventMouseMoved, at); - mouse(kCGEventLeftMouseDown, at); - mouse(kCGEventLeftMouseUp, at); + mouse(kCGEventMouseMoved, at, 0); + click(at, 1); + if (!strcmp(argv[1], "double")) click(at, 2); + } else if (argc == 6 && !strcmp(argv[1], "drag")) { + CGPoint from = CGPointMake(atof(argv[2]), atof(argv[3])); + CGPoint to = CGPointMake(atof(argv[4]), atof(argv[5])); + mouse(kCGEventMouseMoved, from, 0); + mouse(kCGEventLeftMouseDown, from, 1); + for (int step = 1; step <= 10; step++) { + CGPoint at = CGPointMake(from.x + (to.x - from.x) * step / 10, from.y + (to.y - from.y) * step / 10); + mouse(kCGEventLeftMouseDragged, at, 1); + } + mouse(kCGEventLeftMouseUp, to, 1); + } else if (argc == 5 && !strcmp(argv[1], "scroll")) { + CGPoint at = CGPointMake(atof(argv[2]), atof(argv[3])); + mouse(kCGEventMouseMoved, at, 0); + CGEventRef event = CGEventCreateScrollWheelEvent(NULL, kCGScrollEventUnitLine, 1, atoi(argv[4])); + CGEventSetLocation(event, at); + post(event); } else if (argc == 3 && !strcmp(argv[1], "type")) { CFStringRef text = CFStringCreateWithCString(NULL, argv[2], kCFStringEncodingUTF8); for (CFIndex i = 0; i < CFStringGetLength(text); i++) { @@ -38,10 +63,17 @@ int main(int argc, char **argv) { key(0, 0, &c, 1); } CFRelease(text); - } else if ((argc == 3 || argc == 4) && !strcmp(argv[1], "key")) { - key(atoi(argv[2]), argc == 4 && !strcmp(argv[3], "command") ? kCGEventFlagMaskCommand : 0, NULL, 0); + } else if (argc >= 3 && !strcmp(argv[1], "key")) { + CGEventFlags flags = 0; + for (int i = 3; i < argc; i++) { + if (!strcmp(argv[i], "command")) flags |= kCGEventFlagMaskCommand; + else if (!strcmp(argv[i], "shift")) flags |= kCGEventFlagMaskShift; + else if (!strcmp(argv[i], "option")) flags |= kCGEventFlagMaskAlternate; + else if (!strcmp(argv[i], "control")) flags |= kCGEventFlagMaskControl; + } + key(atoi(argv[2]), flags, NULL, 0); } else { - fprintf(stderr, "usage: input click X Y | input type TEXT | input key KEYCODE [command]\n"); + 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"); return 2; } return 0; diff --git a/platform/snow-leopard/remote.sh b/platform/snow-leopard/remote.sh index 96abff474fbdbbd1bdc0fa10f31412d1abee664b..a4f9e5a09e1c1cf9d042e8a935ac96a96b0271b4 100755 --- a/platform/snow-leopard/remote.sh +++ b/platform/snow-leopard/remote.sh @@ -9,7 +9,8 @@ # remote.sh stop NAME end what start started (a bundle by its executable) # remote.sh screen LOCAL.png wake the display and capture it (screencapture # writes nothing from ssh) -# remote.sh input ARGS... click X Y | type TEXT | key KEYCODE [command] +# remote.sh input ARGS... click|double X Y | drag X Y X2 Y2 | scroll X Y LINES +# | type TEXT | key KEYCODE [command|shift|option|control]... # remote.sh selectors BINARY selector-like names BINARY holds that nothing on # 10.6 implements # remote.sh mirror PATH... copy repo paths to the same absolute paths there, so @@ -119,7 +120,7 @@ sh) ssh "$host" "$@" ;; *) - sed -n '2,20s/^# \{0,1\}//p' "$0" >&2 + sed -n '2,21s/^# \{0,1\}//p' "$0" >&2 exit 2 ;; esac diff --git a/platform/snow-leopard/rt/appkit.c b/platform/snow-leopard/rt/appkit.c index 51983705345d64edb17839edd9680e37cf9c7987..b47e88a1c18c259d0442fbfa30dd3030097ca20b 100644 --- a/platform/snow-leopard/rt/appkit.c +++ b/platform/snow-leopard/rt/appkit.c @@ -112,7 +112,6 @@ __attribute__((constructor)) static void polyfill(void) { add("NSWindow", 0, "setTabbingIdentifier:", ignore, "v@:@"); add("NSWindow", 1, "setAllowsAutomaticWindowTabbing:", ignore, "v@:c"); add("NSWindow", 0, "setTitlebarAppearsTransparent:", ignore, "v@:c"); - add("NSWindow", 0, "setTitleVisibility:", ignore, "v@:q"); add("NSView", 0, "setWantsBestResolutionOpenGLSurface:", ignore, "v@:c"); add("NSView", 0, "setAccessibilityLabel:", set_accessibility_label, "v@:@"); add("NSDatePicker", 0, "setPresentsCalendarOverlay:", ignore, "v@:c"); diff --git a/readme.md b/readme.md index 70660c2f8d1bd547ff4108ae5248004e042b0e67..14d0f90a456944b695aeb633c233ff139fcad301 100644 --- a/readme.md +++ b/readme.md @@ -10,8 +10,7 @@ feels more like a standard text editor software as textboxes automatically create and resize as you would expect. In addition, Snowbound supports pen and drawing tools [(WIP)](https://shale.paperclover.net/snowbound/issues/21), recording audio and video [(WIP)](https://shale.paperclover.net/snowbound/issues/36), revision -history[(WIP)](https://shale.paperclover.net/snowbound/issues/37), multi-machine -live collaboration, and much more. +history, multi-machine live collaboration, and much more.