From 04cf1f351630be2d74efa40ded0af99c0f440f8f Mon Sep 17 00:00:00 2001 From: clover caruso Date: Sun, 27 Sep 2026 11:27:28 -0700 Subject: [PATCH] feat: notebook search, Options, faster tab switches, and a split test suite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Search All Notebooks (⌘E) with OneNote's scopes, results and snippets, and Find on This Page (⌘F), on one engine shared with iOS. An Options dialog with OneNote's pages: color scheme, dark pages, user name, cache location. Page layout for a switched-to page runs on the loader thread and settings are only written when the notebook changes, so no tab switch exceeds a 165 Hz frame. The default test run keeps every correctness test plus a deterministic smoke slice of each sweep; SNOWBOUND_SWEEP runs the sweeps in full with shifted seeds, and tools/soak.sh runs sweeps and fuzz targets indefinitely. Seeded identities and generous wall-clock bounds remove the known flaky tests. Snow Leopard port tooling lives under platform/snow-leopard. Assisted-by: claude-opus-5.5 Assisted-by: claude-opus-5 --- .gitignore | 1 + Cargo.toml | 5 + apps/ios/Snowbound/Lists.swift | 2 +- apps/ios/Snowbound/Notebooks.swift | 11 +- corpus/search/README.md | 27 + corpus/search/notebook/Garden.one | Bin 0 -> 181224 bytes corpus/search/notebook/Home.one | Bin 0 -> 17104 bytes corpus/search/notebook/Journal.one | Bin 0 -> 17376 bytes corpus/search/notebook/Kitchen.one | Bin 0 -> 22488 bytes corpus/search/notebook/Open Notebook.onetoc2 | Bin 0 -> 5856 bytes corpus/search/notebook/Reading.one | Bin 0 -> 16792 bytes corpus/search/notebook/Trips.one | Bin 0 -> 17336 bytes crates/canvas/src/editor.rs | 122 +- crates/canvas/src/interaction/mod.rs | 30 +- crates/canvas/src/interaction/tests.rs | 5 +- crates/canvas/src/layout.rs | 21 +- crates/canvas/src/search.rs | 47 +- crates/canvas/tests/ops_differential.rs | 20 +- crates/canvas/tests/search.rs | 216 ++ crates/canvas/tests/structural_roundtrip.rs | 23 +- crates/mobile/src/lib.rs | 25 +- crates/mobile/src/library.rs | 206 +- crates/mobile/src/tests.rs | 14 +- crates/notebook/README.md | 4 + crates/notebook/src/session.rs | 34 + crates/notebook/tests/assets.rs | 6 +- crates/notebook/tests/cache.rs | 15 +- crates/notebook/tests/session.rs | 6 +- crates/notebook/tests/sync.rs | 14 +- crates/notebook/tests/sync/tree.rs | 5 +- crates/notebook/tests/sync_formatting.rs | 7 +- crates/notebook/tests/sync_outline.rs | 2 + crates/notebook/tests/sync_page.rs | 4 +- crates/notebook/tests/sync_worker.rs | 63 +- crates/onestore/src/active.rs | 2 + crates/onestore/src/create.rs | 13 + crates/onestore/src/lib.rs | 3 + crates/onestore/src/op/tests.rs | 10 +- crates/onestore/src/section.rs | 7 +- crates/onestore/tests/commit.rs | 98 + crates/onestore/tests/formatting.rs | 4 +- crates/onestore/tests/insertion.rs | 4 +- crates/onestore/tests/outline.rs | 4 +- crates/onestore/tests/page_deletion.rs | 12 +- crates/onestore/tests/page_movement.rs | 10 +- crates/onestore/tests/support/sweep.rs | 20 + crates/onestore/tests/tree.rs | 7 +- crates/snowbound/Cargo.toml | 2 +- crates/snowbound/assets/icons/options.svg | 5 + crates/snowbound/assets/icons/page.svg | 3 + crates/snowbound/examples/search_probe.rs | 76 + crates/snowbound/src/art.rs | 2 + crates/snowbound/src/conflict_render.rs | 2 +- crates/snowbound/src/linux.rs | 10 + crates/snowbound/src/macos.rs | 37 + crates/snowbound/src/main.rs | 294 +-- crates/snowbound/src/menus.rs | 3 - crates/snowbound/src/options.rs | 303 +++ crates/snowbound/src/screenshot.rs | 2 +- crates/snowbound/src/search.rs | 1212 +++++++++++ crates/snowbound/src/settings.rs | 45 +- crates/snowbound/src/sidebar.rs | 9 +- crates/ui/src/lib.rs | 4 +- crates/ui/src/popup.rs | 4 +- crates/ui/src/widgets.rs | 45 + fuzz/Cargo.lock | 16 + platform/snow-leopard/Cargo.lock | 1907 +++++++++++++++++ platform/snow-leopard/Cargo.toml | 13 + platform/snow-leopard/cargo.sh | 21 + platform/snow-leopard/gl21/Cargo.toml | 6 + platform/snow-leopard/gl21/src/draw.glsl | 102 + platform/snow-leopard/gl21/src/lib.rs | 118 + platform/snow-leopard/gl21/src/painter.rs | 272 +++ platform/snow-leopard/hello/Cargo.toml | 6 + platform/snow-leopard/hello/src/main.rs | 42 + platform/snow-leopard/link.sh | 43 + platform/snow-leopard/probe/Cargo.toml | 24 + platform/snow-leopard/probe/src/main.rs | 12 + platform/snow-leopard/probe/src/render.rs | 104 + platform/snow-leopard/probe/src/scene.rs | 40 + platform/snow-leopard/probe/src/window.rs | 133 ++ platform/snow-leopard/reference/Cargo.toml | 12 + platform/snow-leopard/reference/src/main.rs | 111 + platform/snow-leopard/remote.sh | 60 + platform/snow-leopard/rt/coretext.c | 9 + platform/snow-leopard/rt/objc.c | 39 + platform/snow-leopard/rt/shims.c | 188 ++ .../snow-leopard/x86_64-apple-macosx10.6.json | 54 + tools/TESTING.md | 22 +- tools/canvas/README.md | 6 +- tools/check_public.py | 2 +- tools/soak.sh | 35 + tools/test_concurrent_rust.py | 6 +- 93 files changed, 6096 insertions(+), 494 deletions(-) create mode 100644 corpus/search/README.md create mode 100644 corpus/search/notebook/Garden.one create mode 100644 corpus/search/notebook/Home.one create mode 100644 corpus/search/notebook/Journal.one create mode 100644 corpus/search/notebook/Kitchen.one create mode 100644 corpus/search/notebook/Open Notebook.onetoc2 create mode 100644 corpus/search/notebook/Reading.one create mode 100644 corpus/search/notebook/Trips.one create mode 100644 crates/canvas/tests/search.rs create mode 100644 crates/onestore/tests/support/sweep.rs create mode 100644 crates/snowbound/assets/icons/options.svg create mode 100644 crates/snowbound/assets/icons/page.svg create mode 100644 crates/snowbound/examples/search_probe.rs create mode 100644 crates/snowbound/src/options.rs create mode 100644 crates/snowbound/src/search.rs create mode 100644 platform/snow-leopard/Cargo.lock create mode 100644 platform/snow-leopard/Cargo.toml create mode 100755 platform/snow-leopard/cargo.sh create mode 100644 platform/snow-leopard/gl21/Cargo.toml create mode 100644 platform/snow-leopard/gl21/src/draw.glsl create mode 100644 platform/snow-leopard/gl21/src/lib.rs create mode 100644 platform/snow-leopard/gl21/src/painter.rs create mode 100644 platform/snow-leopard/hello/Cargo.toml create mode 100644 platform/snow-leopard/hello/src/main.rs create mode 100755 platform/snow-leopard/link.sh create mode 100644 platform/snow-leopard/probe/Cargo.toml create mode 100644 platform/snow-leopard/probe/src/main.rs create mode 100644 platform/snow-leopard/probe/src/render.rs create mode 100644 platform/snow-leopard/probe/src/scene.rs create mode 100644 platform/snow-leopard/probe/src/window.rs create mode 100644 platform/snow-leopard/reference/Cargo.toml create mode 100644 platform/snow-leopard/reference/src/main.rs create mode 100755 platform/snow-leopard/remote.sh create mode 100644 platform/snow-leopard/rt/coretext.c create mode 100644 platform/snow-leopard/rt/objc.c create mode 100644 platform/snow-leopard/rt/shims.c create mode 100644 platform/snow-leopard/x86_64-apple-macosx10.6.json create mode 100755 tools/soak.sh diff --git a/.gitignore b/.gitignore index 19a307f96fbafcca1a913a7d9fb1c406245f7467..7d98cbe15fe953417407d91c82207fddea55d31e 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,7 @@ __pycache__/ /fuzz/corpus/ /fuzz/artifacts/ /fuzz/coverage/ +/soak/ /tools/w7/payload/vendor/ /resources/ /.env diff --git a/Cargo.toml b/Cargo.toml index 7c253db8cb534ca7b5d4dfbbadf0ae15d6c72d3b..f0ff815adc8d9261dc0b92ca78a68e1e1a346c50 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,3 +14,8 @@ wgpu = "30.0.1" # unoptimised dependencies alone drop frames. [profile.dev.package."*"] opt-level = 3 + +# Tests replay thousands of commits and seals through the store, which unoptimised takes most +# of the suite's time. +[profile.test.package.onestore] +opt-level = 1 diff --git a/apps/ios/Snowbound/Lists.swift b/apps/ios/Snowbound/Lists.swift index 8d3fc289f0f0e3a9340b79a67eca2c848b22f3b8..c7590ae1b346196bb0f3fc1a2add3bb5f3a9a518 100644 --- a/apps/ios/Snowbound/Lists.swift +++ b/apps/ios/Snowbound/Lists.swift @@ -32,7 +32,7 @@ final class NotebooksViewController: UITableViewController, UIDocumentPickerDele UIAction(title: "Connect to Server…", image: UIImage(systemName: "server.rack")) { [weak self] _ in self?.connect() }, - UIAction(title: "Change Your Name…", image: UIImage(systemName: "person.crop.circle")) { [weak self] _ in + UIAction(title: "Personalize…", image: UIImage(systemName: "person.crop.circle")) { [weak self] _ in guard let self else { return } Author.ask(from: self) {} }, diff --git a/apps/ios/Snowbound/Notebooks.swift b/apps/ios/Snowbound/Notebooks.swift index 241af272b1fb40a4dfa7fa27cebfe61619012013..6cec50e0b6977f18e3ee0b55a9d1135b8f445cb5 100644 --- a/apps/ios/Snowbound/Notebooks.swift +++ b/apps/ios/Snowbound/Notebooks.swift @@ -24,7 +24,7 @@ func titleDate(_ date: Date = Date()) -> (date: String, time: String) { return (day, formatter.string(from: date)) } -/// Who edits name, as OneNote names the Office user; asked once. +/// The user name edits are stored under, OneNote's Personalize setting; asked once. enum Author { private static let key = "author" @@ -33,18 +33,19 @@ enum Author { set { UserDefaults.standard.set(newValue, forKey: key) } } - /// Asks for the name OneNote shows beside changes, then runs `then`. + /// Asks for the user name OneNote shows beside changes, then runs `then`. static func ask(from controller: UIViewController, then: @escaping () -> Void) { let alert = UIAlertController( - title: "What’s Your Name?", - message: "OneNote shows it beside the pages and changes you make.", preferredStyle: .alert) + title: "Personalize", + message: "OneNote shows your user name beside the pages and changes you make.", + preferredStyle: .alert) let save = UIAlertAction(title: "Continue", style: .default) { [weak alert] _ in name = alert?.textFields?.first?.text?.trimmingCharacters(in: .whitespaces) then() } alert.addTextField { field in field.text = name - field.placeholder = "Name" + field.placeholder = "User name" field.textContentType = .name field.autocapitalizationType = .words save.isEnabled = !(field.text ?? "").isEmpty diff --git a/corpus/search/README.md b/corpus/search/README.md new file mode 100644 index 0000000000000000000000000000000000000000..544823d6baad6eda314a5a02a15ebf9a7e19e987 --- /dev/null +++ b/corpus/search/README.md @@ -0,0 +1,27 @@ +# Search + +OneNote 2010's search, observed on the Win7 VM (2026-09-27) with `notebook` open, a copy of +`docs/sample-notebook/Personal`. `crates/canvas/tests/search.rs` checks the shared engine +against it. + +- The box beside the section tabs reads "Search All Notebooks (Ctrl+E)". Its arrow opens + "Search In:" with "Find on This Page (Ctrl+F)", "This Section", "This Section Group", + "This Notebook", "All Notebooks" (checked) and "Set This Scope as Default"; a scope not set + as default returns to it for the next search. +- Typing `tom` with This Notebook lists, under "Finished: This Notebook (change)" and "Find + on page: Ctrl+F", "Title contains: tom (1)" (Tomatoes) and "Body contains: tom (4)" + (Weeknight dal, Spring planting plan, Harvest log 2025, Seed inventory), each with its + notebook and section, "(Personal/Garden)". Words match word starts: `tom` finds + "Tomatoes", `sungold` finds "‘Sungold’". +- `sungold tom` lists only "Body contains: sungold tom (4)": Tomatoes, Spring planting plan, + Harvest log 2025, Seed inventory. A page matches when every word is in its title or text; + "Title contains" needs every word in the title. +- Down arrow selects the next result and shows its page with every match highlighted in + yellow. Esc closes the list and empties the box; the page keeps its first match selected. +- `zzq` shows "No matches: All Notebooks". +- Ctrl+F on Spring planting plan turns the box into "Find on page"; `bed` shows "Match 1 of + 6" with previous and next arrows ("Previous Match (Shift+F3)"), the current match + selected and the others yellow. Enter goes to the next. `zzq` shows "No matches. Try + Ctrl+E". +- The Search Results pane (Alt+O) lists each page under its date with the paragraph around + its first match, "…" where the paragraph is cut. diff --git a/corpus/search/notebook/Garden.one b/corpus/search/notebook/Garden.one new file mode 100644 index 0000000000000000000000000000000000000000..b28aa934db60b077a3d42852761e7102023c3a67 GIT binary patch literal 181224 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

JJi!3Tg6~oV| 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 z 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 literal 0 HcmV?d00001 diff --git a/corpus/search/notebook/Home.one b/corpus/search/notebook/Home.one new file mode 100644 index 0000000000000000000000000000000000000000..1f815e842c5cad8b6e82222ea85fa87ac0712059 GIT binary patch literal 17104 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 literal 0 HcmV?d00001 diff --git a/corpus/search/notebook/Journal.one b/corpus/search/notebook/Journal.one new file mode 100644 index 0000000000000000000000000000000000000000..191ad7d1d9cf6e000f9087b1d288bb5070cca9dc GIT binary patch literal 17376 zcmeHO3v`r4vhIWc0RjvmumL78fd~;11BnoP;Dite5@Hq2 zFTbouCA`Gs{T1Wa@?FHM_(MVZTqNMAKkj*?>TX&K07`o`~LqpH!3B&aeOyn zDfgW$b#z*KY1pQZezEj?)wZKOf%gX8{3;6H8|e!5)b+YLZQS-(4lf>`{zB2WBiF`c z8y)s7m{Z%r#OC3V6JfqTh)1ESFEXnxeE7eswx5P4n>A zZ~GM|zJD%b<5%lG>)2)91>asbf8CV8|33+Y2w`xGH4+I<;mmc4XqQvOIZzXxeDGJ> zqO05$i`(AavFYrd#%~H<8I^f51U<}RG!pY-MAYIW5w+JTqT*3YcxLly)qpM8ypHz7 z7}1NglNxSpm|5 zHjV|q#qiyU^WJW^f3coV8&;x36$-=3FA1=8t`f!_$DjFd*;`vumL-mA{a*g19vWx1 zsQYWymluoT62;R;K-x)aL7^xz`7$}vG`>Io_#d-vxS>Qy-3~{AKpeu!=?a&cR2gcl z^NmCuO9a-!ZoUq;D9&qawy7~6w(K6%k2H)XQg^?G{%U=^3caK*Q+o!s z*!0V%@1u!zD-S~wvv+8KGt)bU*OqHJOvuu3o~Pcvleg{+TVF&OJ=NevVz( zO!iT@E);futfDk~-1?OYnFT~UK!SiUI3kgbrg%Xq1aRgpyn7l7nF9n>EW#UEt&p@u z9Ji%6GCCYJCAW-E<+XQwDiS4j5cDu)Q{IWEw8alGHXDM{@oe;!slAY<_yHa5H{ zad}6uF}XkS?eM*0FYP@4$o75f*X5?nr6KdS7S?@Dxv}(|4xRmcpwX+*P41C%3y^3|r zQvPu<`da5jMP%7;_-c|>d5HH`9;?2PhMm4RP~Qx|BZ4Xpb(0e3{%r-|>abp~1m!}1 zoqTa+xN_(39MQf~ADnge2AG@WrUY(E;D5OU{KOH5H@=C9X?v+mvy1~?J674Y-S#=| zXV>i;9r z_WlFi1lm=41gxi>Z3*C^PrK^`5FZ2Jf!i5CI$Z#?*Dm#p#gnwV0%(uB0oXqd!2Y)Y z*sr_#-9tU&@oWva6~KPX5pkT`)Uy|!?0-9eUW}g zCgI6(?gVfgo*!&SIh;Lk`{Q`rEH@?ae?|h{K30&EGE_UGsoyyHix|W%Xcl~|tnDK0 z49iaVpA&nQ=d{dRleYDT#lzZlaF%cn24k12m2=JgPdmkUOKxC>RA&`33kbe?;w@#g zQROWYZ5K6vw0|t*Z};Sgl~>2jP1`kf;l!_d_<*)Z1I#dEAT&$(n3)|BD5oi_M8=ay9@wc$B=RP|2)NxRkZ*WdNtz71v! zM^5bK2ZA!sdF}b5gy?NsJ6(Kl=!)uhiwfIfuPsd3$pfPuqqUC77$cL zpFC88GMH{4s9!#Ls6vGt0|dq4lZUEO$Yda>88Qz=lWu3Rq#p{L8$ea?`#C4mO&HRW&yZABfb5WEJjo)L{jm5kY!#-%GHCED`j_n(e8$Wu@-<(X*)ySmVv7GjS_yLCWvt|(J}>;c;_}l(wc@} z=3Sf~r4XY}2d)<|6XY%31Thi6XG%}bx33)6hm+v=$N(RMFwb1jq(?Xr&na?_1sG=r zYIv753G?QAf#gE-_c$=qB!`sDG)SmE7j@@|3s@G~ugq^&j{o3tE)0a19LWaSHl zWMNFQdi`5%LeIoytT_1H+bJ<=do)fp@jT3W#uQn~dtf+f=ZX1?DH6(u%G_O^Ix0#) z+wR2n*PppNj$flpX$AV*oizUX^Io3)9WHZH1|D{n*k6A=eqOBE|06u?ERMhadi?Tk zb^Yx$El_{nxmj0}CnZqV1oYoJ0;xN2A7{kj1tozbQS9iU ztwCSu+T+57&sQv;vhKIT|FcJWyTSVl7I2w|c-r@uMi z_&4K0GYIfKORVu=!}6tEZ_M`yH$EOzn@HO0e}+fLPu-g6{{6vUKlad{$4bXV`%{>L zuxgg}??n${%}i3gcraQa^U$DPJZLCnI*|3^!77EE1!TQ=&`pI!aRchbgN8zm0g`U4 z$J{e&JF1Y$KzQOo1`~Gipo8}WC~iQ5@nDu}%ppDTAouun#Dif_n&9I>$A&XEI383c zO6tYwIk$azwq*F=y%!=kh5o%Cy)@4`j@Np65eM7HHda4T>q~7<_JV-{1RD>6p3xY8 zf{QOG4{~tr@dfXVSm?hjEggMv#MFgD7k{v;??0M7d%$#6F1uN&Gn+1 z`rF?%YChaCTmxS(-2eF%*bnC%c7tLn{LRPlFIV}a{7y~#o{!>$nkasa8hXgN@X}pq zS%CAau8psMI$8O(q%c|f^s~{ACth##83a$h0#j`<*Cx(Of(US_RUF z=cwigy}@}1z{{@VSKWFj+a=fI!W$zW-u?dS%tf8%JP`AF-c%D=v6{R4t11N3B%2cJxQH?jYazK1?mLoj(e+5%_yntGCM& z`_3;;46KqJdfvKw(kwOR3ICvUbOE&0T|{rtaMl#`TPUYYUw!kHr@4_sJV zfBxv&z`9wrqG5lQHiSUl^oZKNs7);8{A0VB^XK%LJ!tRpkmQT;nVs3*Th7*zwVdA= z@7O+N^U`L?iO=s^cP2Yu%UL9X07^YkGX76j$UHRgS%koH)_g1DA^BeQx6;=x4Y)IP z^{UsV%-!?cLMz|a?-@1U*6$xR-?m@nb0PukHT{85=nKEz553cAS&WB&(?>4ng{_0SW@)x5^r#`awOxp6%y+2$#YVcpk zo%epQa-j9<^s$w*62r!ZIc`mT>FMX!_15wX?qV?Qu5qPe0kF#m%m zN`?-alDgyd{>Nv=L}id~yKl`{Z{D}3_m!`r0uMWD>+dYo_wD6sGqb;)6=|q{UXnRJ z&tkjX?61G`AA@7*i@5pO7D^SsZrAwhuYHk9C4BO4x9a}-bN>IT?***d_4D)_Ykf06 zgvDi2AaH&tI5~6gs8|BA*!BK@l^dTQQm&!2U$1=qL5=%&`_k6Dx_;Bi9}lf3&w=ch zwo}mq&=4gWK)dhry+DFO(oWH>^>Loc6p|-?z5LKwg`5Rsz5GzlU8qHI1M206$`o=8 zkUUB~0TlB?wF;RGM1%REguBrS#SLgMKUAn1bI6~YA8G^PUqgN<`tl9V4<%p@EX+6T z8T9e8xQop*-uP{wcWOVm+_fRS(C3=J{7Lk^CT#Y!0nhI$|K-O4xDD-`wC6GZQChx$H?JBDj;gYrX| zLpCT5fyZUukXmmhd7qh!9L{ukcdD~Byvck*`Dx4n@mABkS>?@VK4{HEpN8@`$^nt` z8t~IIjR(EzO5GasoAj8cn&whQ4atjMxIcMSD|wZm4cF9TeggQxI!;lJjm+Oz>60%O z`fT~%wmJCcX{G5Uo4f7EdOunEY$n$Cui1w_kD;s+^EY;u-k;B+^EV}G*K$^N7QtVC z?K2gtU16ZV-Og&Lf4Y}c?KHfh{j7!#jUt#RZ?gQI4q0-&m<| z?HtJ8I8w-W;QWo&pY~|Se*C|Ca_`~XAFc0s`krz7EYCT6?X`c_ zUVH6#?Q^)FjlJ#OMMpLyKeuIE;o)l@fBW-QBii^ieR#yCj6*}x!^f`IOzDP?Rs?i@ zB<=~X7~22dZJ)VHU3$8tf&V@Y_-UHXCDvGr4Az3D1#3aJV6AHaa?(1)hVQMs%)W8% zO*e&p_EMdGU(Tj+V?OeOgpu^d+JhllU~!ZdxI0)2j6g1FVVPQ3EXm^ab*G1DJz4jA zH#MKtvdPBbQ{^zUSU-^%zOH#JJKJY~HZ4+-)JflSY z|3-d!iB?i7`*avgD@{F=s}&jjGJ2-!ID2aPW4BuLh8kh*Ry-;M>d=BYT+Jp2wFWun z@wP)AlRxIdYP{r?2dDtawnbtF4 zSH_Deela7SY+L#4V;^>lV!sVbrM0pqWqr(NJ6GV^Qk#o9xE5xIXu)-$>p`Po=gFWk zpcK&l@DmeQ1A8`B%h9H4GqhQnO-t0KXfw5G$ZaM#iTK}W7uDZjkY-1wo$UqTriG)u z#=)9@JB%d|^>+Yu1_e8{>8*2sB_6FQs$o#sKShK@CdUuZ9d=@<6hux?fV&87B7YY*PM^5B)x z?ANdH3uWv+66b}=9_Z{BDkU=&j1Hhk4a#7P^a}yaaf6f#;>cS&OX`xD1_n*c-X74}ze$?(;zT19k(w04IS5J$%pN`DgTbS21^}<9yX7;bFkBNRPF5}%# zD*rNO)Y9Ob*E*bhEnV@967~NZ`SfGuvWq&htY+4oA6qphf3KCVVozDS6^|20RT+M) zW;AY*szK)QvLqAznALdc$0F%6k!p;Kr5{uK0Ih?m1x7s4$)v1&B+J+&_NCcbwciB& zvXVWId9+OsO=%Uln8!s!WZJ8{T9Vm#$agj#v%RnmD|-n*eoGLy2%0#|O=z6!w+)E1 z!+bRp)C=)C`^A~z%$@3=+_6$^ob~<&oJ;AF1}3-M__q6hXiwehu zo=tkFK>y0ko_;tO#PT5^ZdYC8H56CYVFS_chJomx!$Fk43PicC z^0}M5cE|N{(A6NyMSv)G4Ty3*K$N>yKKGQ@>u{x9FA(K=gIGQiMEO4Qxi7Ai?*}3< zh?3X-xU&2J`8-fQN6YIVT&aV>Al5$w#PZjJs3-0_mw|?YSnn`-C2HogU0!2wrJiCz zl&6kZFZU@vQ{O@FKR*kXOX-pZ{?9ewjIn%a4E8C`cP>0`?$jzL?alDJ;}WNp5hkm1tqaMb}?Gei|o@Jc;W3X1V6|^1n2-E-QksSG@UrtKgo#Kt>S3awra>jvxnvfCK~r>zzatoMAlX5*FPHc`OEM>dL3Oyq-<6Wdorjho;%|1_ z96~Yvu5)UJrr?ObSKTx#EiZm*@lQd=-r9L!^*R=grUH=IByC3&=2_X&zYPtm_@>v^ z(1OsA2TT0YJm44F9W`+r%5NpsjHmOg$st--o_T4@KEIjTy67`4u`i+m2Y%zp1k-ld zMO?SWFKv&XI(kQX?7W=Iw^e(yi>{DinfH43>ohYdA!dEfi<3GJy!DjBUZm0KD8tiO z$Ek}4y`Y%hTgxY6=CJ{;TR!>z%lg ziSrsUGO+jZg|?K%KRtBIUCRbfHP5TN`Ky!0k&1f00<|OJqcB%(qHKZ8(`II=6SLOL zJnqCiGG9YOyRZJz{v4f+ODe-`ZSY<3rXH!XqtiO2KVCC7s78=o0me?F`9 zNLr~H~i>yb@k5sLg zpW(H4R{E#JCNFyT?TrsC3+#fbJ!_K$LUdw?BRj6!_Ey}n%+1dy0}ZlV3L^+swo;02qO544a6G&oB< zO$Tzw0aDDv{%b?TW|YfDu1&iK2qRC+mUjif8b+ys$YD)$kTXTpN7+73E%1#>0owHg z)xUMXlMnr66qpQ5Qz&|rj^5qvXn8t%FawyDxX0$;%tBtKmSxc*szUOu-7J($f>sJp zuMPia$u?#PY?UGM_{NB1%|MIUSi5|0#AoU=ANScpW5nB21okHGo{9TA1qRQ@-NoyL z(Rg3{<2;rFu!An*uX?82hmqK;1~CYN*h@krWOaqW@dDl;Av}cp;vK{ zJR+H#B)SR*)6@D;{($EtOnm=g?lV8t zSLbueUTi4E4L3>_P`nLSp1oMp@wrQ)%29u6bXku`|lt0a?Nw?X_K$>U@sf}IBOd!b|&g4rN` zuaQhUm<{6h$g7cylnM#}IXpe1Y+W*=!EnUy1oBq#dxb1)2j6S_UMCB)Nk{z7wS5us z`*%>j@A13-#@D}b{4SlUu!|mhUb8P7(;;rjmg{pju4?+EH@oNxTVfgWzOU?M4PO=@ z=^$U@chskUkN&GW+fqM5o|-=)`riNqDdIWPwn?)ELuaRtY)9yN}|jznZk98Ba)teh_pG2@FF zK*a`L&cr^I2i^>vJ`*oz3am;LZ$uOYA_B=4&#KH6lw*zjMk@z6nol_bVe?9i+Y5Q4 zahEOHA?7X+r3~cG9%YDkR(|MdeH2o!cj?`P-v1{qW&GSWXHx!pDD#~qdw=$)j5obw z-redwi*OyO8bsT0xG@j$y|QEJ9tB=C&nx@IM7+9n{m^#P+dP`^=(bszeUg$NRy?Cb z{r^Tj@m`AvBbRv9YR7Pgx3brEw^80ES4SzTvGOeLm9R!gmZ#CndUL4+h zZr>kv?%35nc~i3w0(Rv;9!?8%URfC;?w+g76)W8)-Xz_Lm1m7S(Zx%+6S^Vja6Owb zzd!O_&zyEAANIS#q0*@cKRRuG{OR=r-%R<_;4L>+%?lFInDV0-f@Zcfg)3~4GXUgp;KZRV`XUD@8srs+ z+GSxo3ezM!zDLq!VK(_|acCWk#&;a5wfgZl#-S1_3cEN_b^L7Vi($hbD0*Oi-&V!d zv}aFt;SGmIM4mTHZU<%R_3 zar54c=hS+z^P?H0G!W;}R~-5i%6iA49Ge&)yWqf~a&r~^p0&1a`{)A?P{ z>q1#j%@^;@{Z=^C?fM?TmW&m{IrwdGD94b8TH}ELXXER0;?VlDTi0ESXfYM#xNp^$ z$wCgL@{xBB&U}s2UK_3j+Lbou^1Ph6GjY$-1(ukSV^jD|LO-F$(6h%@2L(>EFD~y= zqy5`Y{Naf=JrY;C(_@X)V>!xjt{ra42V5y3gus>N`caig#FhD>@n3~xY)gJ&ep;W_ z4|f}+c(RD&V&oGSmPoHfTxqofxWiQnSC+}uOk8H=Dcs2`T)9TB79H|dE6$tzM5$G_ zZ)N%3f3ql~13U>}}sDRuy)?XL#vl`Bx;5Se#n* z!%F`XgUxntwk(Ak3Ws2TNI@Wa5tr|=N+grU-vCF~Nv0)^wqFksq(V>w99@ z=sL-q2xbEuoyt#ok?4dRIJyNMtj=?bWMMn_?LZ!Jbd@aZf}?p-d=cll^b@}0X!^a5 zXy~{59!o|d!Y+RHALX~$zyFA*9y;0Y*y4vPz1f8~f0!%3r_|A7UvV^w>P@x$D9t}T zN(;yUW#aFOv+#W)ICS|&Rpa5*Yyz5zta^MT!Gai1#Jpa^_V~ROvmegVbNB-r*7LsR#!+}8Zu{(=6vQK#;B$^$OKUM9XrpuF)Njrz{h z`5THqMQL_5rz3y@eR!L%wQZrrH_?jlwdgEpCI^%YEk(pOnt-MpN#VtQGKc%!^4b5o zW>D*U5;x46)O_L2uUmW5hniojeUR#K=O54P_U{_8u+Kg1-#B(}2J3Rh2WI$!2;RkuH=dc`)1leLdwmI=h6YtxQSh1-4#(7^<4KR=AH=DP3F-$Q| z;#XhCc@AYScHU$#AjWxfdQQ;ot8b6pRM%+LV}ZpV`Z`W`>o#k6{dkDUeHjm?%P@qJb3nxf$ZSE53VS^m}1zHaZoy?670n7!&>5kZ#XROUOPyeBsf{o5sD|Wr;8P zfT_8)zn;zVm#b>Tz1I9q3dIIKQZy8g63r^R5A)U z{cp%H9ty|HQ@dK0~<*!$eX%(&MPBnE#84<0qu=&>r44qloiP9JKPrc9=KbPI-oo^PLj$1IX z$B;Wd3GBsutKY3)zVY|Gwo&3%TZ4<0HMm<#k5)vrr7x?L@{y?0YPQ_T+YanmXT1Am z`&Q#)ABp(H757aXzw5-`UW$z}e%opH!DAg(2M6va+4Bv93Fe{g|I6hPc9A4s6H1q)n#9&jJXHI;jH=E z1N&p&3@mLHx#Hv!`^uwx)cQM|Oey2*GU8bNY7?=vFyWvvWEp=vK?VA_{vhE+Pv*Dp z3}`RvyGtMD^+fsyhhnz2O?dpH@Zh6&ogJ!l%t4as^*l^G8Te_(`(+sqoIjB=uSap- zL_(E7$7Pa2lKsJGIyJ;kUz3l|qv(p8j6W+Ip_li=CU zp|lr0n@^*Ce-CbDl|p0qPh8Qs^*ZWy|IjaP*8St=FUsFCWY@E;-0NF{Ct=t7-hCwc z&B!TACiU*uev$ru#+hx$B?%25ud#^c*zSjVfu^X_DE% z=snwY6X>TA=7sTcBjB4x$f*ClJpL_a02+1oTYM-ilNx`Fd}drUC_d!er=N};_2ez~ zrKOY7I-AFD#wlw2R_mRnW94rN$*k;^Ju~Z9=}{kNbdoNBDAMt}ds0KeCQWXnyDk=iOh*R58ZeFha=bR zyZevx?hNX~ep&ry2=fg*Q6x8?3OrlwdhYOm!V@*psZ!p`n|qT_lTL#2R^G{*e3dkQ z%3G~Eukz9&sv&P>weI9qY*#iOf=HzxE30rPAN)ytm}fkt?2f1JoG<VeH8l`|D8811E65IsV8IY)BM%38A`a9<)I*syUO^Oe?q zW^Cei$7^d^TsRb(QYM5q8Xw*G`LW337n?RVjeh*Q4R6z4V}kRo=1t2&8lpSAev4|a zzS1VQSwd23_tG$d`Q}hYW_&-XCz<7i~YAwJGPKBc+s zEWA^ieeV3g-H{UFeSi|)gC}BabbslS8+?s2IOMjP6#**(_Xz@|1A>Y&Nwc8}GbY%v zdJm@S)G1ENY<_2C87NajXKR($lw`Ow4LAiEMIR1ky3nLx!#XE+$UK}ptctOuxIlI6_@ zGf{Wv^}ro2HmLjX`mjc;M?IL=mwM$jq8`rcuLs$Ein^ZHi z6(gXpeFcM;o^aF0R()6Qy6@S?OQ2ConoDlc(9YO@(pTs%Ui)X!@okg3H7mPzo=TYL zum4ciT{V|ZCEfMsZKCedBvKg2x$X`?V96ZGC+n`Qj4Kspv8BqaS->~P_sXxAiZc>w zuBSz2*!%ln48lsFGpn3Vuo61QitakWO!37C;5(dlE^}n+Y|P0q*ZtRnCXEPL33GCb zCN1oq@I9Lxw%m8a&Ey@%w=N8yD2f8^2*(oc`5Zb=>SKQ1Rr5BBxEuIKj;9Ik^l2gh zZxt0(^yb;S1OI5DcIA76->R$UQ*ULbw7M5|IZXrvpg(X=rQh>fx;GRM@*C#G9V3AI z+d*By4IUG*@4VV~qSN_Y1aMX2g50A03)~IK$qnbjMk3np7`)%x8&fa;uFdZgGVV%z zAWTbq4B>ny^Q2_Xhxm;~evuD~Sqd9D-v`(DN8;1ullK9&TEKhC!A)h`n-+c0aNt<6 z{^f4`*`=YhnttO2tdzZS7uM9ssFxt}N(GT)a7}(myD*1jruzY2@X~$8rhV!(ORReG zW?=d^0lN{p5?6c*zQ8&A!6CPH<&v+flDi7G7@M7He*w;z#{4r_=0%Aw><1;jz>5-J z2w#aW+zCrLM5V(!wZE-YSf}aW^!<;|UvtH)!Z&DZ%a{vGU literal 0 HcmV?d00001 diff --git a/corpus/search/notebook/Reading.one b/corpus/search/notebook/Reading.one new file mode 100644 index 0000000000000000000000000000000000000000..4efb023f19f11faedeeb2592c44756c94dac9079 GIT binary patch literal 16792 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 Result, EditError> { + let [anchor, focus] = selection.positions; + if anchor == focus { + return Ok(Vec::new()); + } + let (start, end, affinities) = if anchor < focus { + (anchor, focus, selection.affinities) + } else { + ( + focus, + anchor, + [selection.affinities[1], selection.affinities[0]], + ) + }; + let mut rectangles = Vec::new(); + for (visible, (index, paragraph)) in self + .layouts() + .enumerate() + .filter(|(_, (index, _))| *index >= start.paragraph && *index <= end.paragraph) + { + let source = self.document.paragraph(index).unwrap(); + let first = if index == start.paragraph { + paragraph.cursor(start.offset, affinities[0])? + } else { + paragraph.cursor(0, Affinity::Downstream)? + }; + let last = if index == end.paragraph { + paragraph.cursor(end.offset, affinities[1])? + } else { + paragraph.cursor( + source.utf16_offset(source.text().len())?, + Affinity::Upstream, + )? + }; + let mut local = paragraph + .text + .selection(ParagraphSelection::new(first, last)); + if index < end.paragraph { + local.push(paragraph.text.caret(last, 4.0)); + } + for mut rect in local { + rect.x0 += f64::from(paragraph.origin[0]); + rect.x1 += f64::from(paragraph.origin[0]); + rect.y0 += f64::from(paragraph.origin[1]); + rect.y1 += f64::from(paragraph.origin[1]); + if let Some(cell) = self.shaped.paragraph_cell(visible) { + let Some(clipped) = cell.clip(rect) else { + continue; + }; + rect = clipped; + } + rectangles.push(rect); + } + } + Ok(rectangles) + } + /// Where `position` falls in `shown_text`; a hidden paragraph falls at the start of the /// next shown one. pub fn utf16_offset(&self, position: TextPosition) -> Result { @@ -2445,12 +2503,13 @@ impl CanvasEditor { } pub fn selection_rects(&self) -> Result, EditError> { - self.range_rects(self.active_outline().selection) + self.active_outline() + .range_rects(self.active_outline().selection) } pub fn marked_rects(&self) -> Result, EditError> { match &self.composition { - Some(composition) => self.range_rects(Selection { + Some(composition) => self.active_outline().range_rects(Selection { positions: [composition.range.start, composition.range.end], affinities: [Affinity::Downstream, Affinity::Upstream], }), @@ -2458,65 +2517,6 @@ impl CanvasEditor { } } - /// Highlight rectangles of `selection` in the active outline, in its coordinates. - pub fn range_rects(&self, selection: Selection) -> Result, EditError> { - let [anchor, focus] = selection.positions; - if anchor == focus { - return Ok(Vec::new()); - } - let (start, end, affinities) = if anchor < focus { - (anchor, focus, selection.affinities) - } else { - ( - focus, - anchor, - [selection.affinities[1], selection.affinities[0]], - ) - }; - let mut rectangles = Vec::new(); - for (visible, (index, paragraph)) in self - .active_outline() - .layouts() - .enumerate() - .filter(|(_, (index, _))| *index >= start.paragraph && *index <= end.paragraph) - { - let source = self.active_outline().document.paragraph(index).unwrap(); - let first = if index == start.paragraph { - paragraph.cursor(start.offset, affinities[0])? - } else { - paragraph.cursor(0, Affinity::Downstream)? - }; - let last = if index == end.paragraph { - paragraph.cursor(end.offset, affinities[1])? - } else { - paragraph.cursor( - source.utf16_offset(source.text().len())?, - Affinity::Upstream, - )? - }; - let mut local = paragraph - .text - .selection(ParagraphSelection::new(first, last)); - if index < end.paragraph { - local.push(paragraph.text.caret(last, 4.0)); - } - for mut rect in local { - rect.x0 += f64::from(paragraph.origin[0]); - rect.x1 += f64::from(paragraph.origin[0]); - rect.y0 += f64::from(paragraph.origin[1]); - rect.y1 += f64::from(paragraph.origin[1]); - if let Some(cell) = self.active_outline().shaped.paragraph_cell(visible) { - let Some(clipped) = cell.clip(rect) else { - continue; - }; - rect = clipped; - } - rectangles.push(rect); - } - } - Ok(rectangles) - } - pub fn replace( &mut self, engine: &mut TextEngine, diff --git a/crates/canvas/src/interaction/mod.rs b/crates/canvas/src/interaction/mod.rs index 80bc6baa4304acd0560c79939dbd7b2b92ff9604..45d14e0f66b662d76c851f0bd2be490abe0a8e93 100644 --- a/crates/canvas/src/interaction/mod.rs +++ b/crates/canvas/src/interaction/mod.rs @@ -104,7 +104,7 @@ pub struct TextColors { /// How to paint an outline: the caret's opacity, the view scale, document points per /// logical pixel, and the text colours. #[derive(Clone, Copy)] -struct Paint { +struct Paint<'a> { caret: f32, scale: f32, pixel: f32, @@ -114,6 +114,8 @@ struct Paint { visible: [f32; 2], /// Whether the active outline shows its frame and grips. chrome: bool, + /// Search matches, marked as OneNote marks them. + found: &'a [crate::search::PageMatch], } enum Drag { @@ -202,6 +204,8 @@ pub struct PageView { /// A platform scroll view owns the viewport: changes neither clamp it nor reveal the /// caret, which the host does knowing its bars and keyboard. pub host_viewport: bool, + /// Matches of the search shown on the page, marked under their text. + pub found: Vec, /// The caret's opacity in its blink. caret: f32, /// When the caret last moved, which restarts its blink. @@ -260,6 +264,7 @@ impl PageView { focused: true, touch: false, host_viewport: false, + found: Vec::new(), caret: 1.0, blink_from: Instant::now(), }; @@ -273,6 +278,7 @@ impl PageView { self.scene = scene; self.drag = None; self.object_focus = None; + self.found.clear(); } /// Shows the stored page after a change made elsewhere in place of the one shown, @@ -730,6 +736,7 @@ impl PageView { visible: [0.0, self.viewport.size[1] as f32] .map(|y| (y - self.viewport.origin[1]) / self.viewport.scale), chrome: !self.touch || self.focused, + found: &self.found, }, ) } @@ -1920,6 +1927,7 @@ fn append_outline<'a>( pixel, colors, visible, + found, .. } = paint; let rows = [visible[0] - y, visible[1] - y]; @@ -1929,6 +1937,26 @@ fn append_outline<'a>( outline .shaped() .append_background_primitives(primitives, origin, rows, colors.paper); + // OneNote's search highlight: yellow, deepened to gold under light text on dark paper. + let [red, green, blue, _] = colors.paper.color; + let marker = if red + green + blue < 1.5 { + draw::srgb(0x7a, 0x62, 0x00) + } else { + draw::srgb(0xff, 0xe6, 0x00) + }; + for (_, selection) in found.iter().filter(|(id, _)| *id == outline.id) { + for rect in outline.range_rects(*selection)? { + primitives.push(Primitive::Rect { + rect: [ + rect.x0 as f32 + x, + rect.y0 as f32 + y, + rect.x1 as f32 + x, + rect.y1 as f32 + y, + ], + color: marker, + }); + } + } if let Some(editor) = editor { for rect in editor.selection_rects()? { primitives.push(Primitive::Rect { diff --git a/crates/canvas/src/interaction/tests.rs b/crates/canvas/src/interaction/tests.rs index 96fdcb873c1bfca3ccafe141b10e28db6f53415b..4e28a644281fff8039023a448db759afd42eb43b 100644 --- a/crates/canvas/src/interaction/tests.rs +++ b/crates/canvas/src/interaction/tests.rs @@ -13,7 +13,7 @@ const COLORS: TextColors = TextColors { }; /// Paint for a view at `scale` device pixels per point on a display of `display_scale`. -pub(super) fn paint(show_caret: bool, scale: f32, display_scale: f32) -> Paint { +pub(super) fn paint(show_caret: bool, scale: f32, display_scale: f32) -> Paint<'static> { Paint { caret: f32::from(u8::from(show_caret)), scale, @@ -21,6 +21,7 @@ pub(super) fn paint(show_caret: bool, scale: f32, display_scale: f32) -> Paint { colors: COLORS, visible: [f32::NEG_INFINITY, f32::INFINITY], chrome: true, + found: &[], } } @@ -833,6 +834,7 @@ fn table_glyphs_highlights_and_selection_share_cell_paint_bounds() { colors: COLORS, visible: [f32::NEG_INFINITY, f32::INFINITY], chrome: true, + found: &[], }, &mut primitives, ) @@ -905,6 +907,7 @@ fn editable_tables_paint_borders_before_selection_and_cell_text() { colors: COLORS, visible: [f32::NEG_INFINITY, f32::INFINITY], chrome: true, + found: &[], }, &mut primitives, ) diff --git a/crates/canvas/src/layout.rs b/crates/canvas/src/layout.rs index 025b4d581c67b54796e33fbcfe47ea3e3fa8eac6..3d98e60e3d002ec3366cee2ba7a524f1860a75c8 100644 --- a/crates/canvas/src/layout.rs +++ b/crates/canvas/src/layout.rs @@ -4,7 +4,7 @@ use parley::{ FontStyle, FontWeight, GenericFamily, Layout, LayoutContext, OverflowWrap, PositionedLayoutItem, StyleProperty, editing::{Cursor, Selection}, - fontique::{Blob, FontInfo}, + fontique::{Blob, FontInfo, SourceCache}, }; use skrifa::{FontRef, MetadataProvider, raw::TableProvider, string::StringId}; use std::{ @@ -26,6 +26,7 @@ pub struct TextEngine { } /// A font laid out in place of a family. +#[derive(Clone)] pub struct Substitute { pub name: &'static str, /// Its faces as registered for the family. @@ -132,7 +133,11 @@ impl Default for TextEngine { /// An engine that lays out each bundled family in its substitute where it is missing. fn default() -> Self { let mut engine = Self { - fonts: FontContext::default(), + // Clones share loaded font files, so glyphs one lays out draw from the same cache. + fonts: FontContext { + source_cache: SourceCache::new_shared(), + ..FontContext::default() + }, context: LayoutContext::default(), arial_substitutes: BTreeSet::new(), substitutes: BTreeMap::new(), @@ -150,6 +155,18 @@ impl Default for TextEngine { } } +/// Another engine with the same fonts and substitutes, to lay out on another thread. +impl Clone for TextEngine { + fn clone(&self) -> Self { + Self { + fonts: self.fonts.clone(), + context: LayoutContext::default(), + arial_substitutes: self.arial_substitutes.clone(), + substitutes: self.substitutes.clone(), + } + } +} + impl TextEngine { /// Register Arimo as Arial, Carlito as Calibri, Tinos as Times New Roman or Cousine as /// Courier New before creating layouts, in place of the bundled substitute. diff --git a/crates/canvas/src/search.rs b/crates/canvas/src/search.rs index 60ad2b9486d4f54e8a380483f5025537e7fef404..7a15ec1f7fef0e1b69b75c5ade7cf9f34bf91446 100644 --- a/crates/canvas/src/search.rs +++ b/crates/canvas/src/search.rs @@ -110,7 +110,7 @@ fn fold_char(character: char, out: &mut String) { if character.is_ascii() { out.push(match character { '\n' => '\n', - space if space.is_ascii_whitespace() => ' ', + space if space.is_whitespace() => ' ', other => other.to_ascii_lowercase(), }); return; @@ -215,6 +215,22 @@ pub struct Entry { folded_text: String, } +impl Entry { + /// Page `space` of `section` as `page` shows it. + pub fn new(section: &str, space: ExGuid, page: &Page, modified: u64) -> Self { + let text = page_text(page); + Self { + section: section.to_owned(), + space, + folded_title: fold(&page.title), + folded_text: fold(&text), + title: page.title.clone(), + modified, + text, + } + } +} + /// A page matching a query. #[derive(Clone, Debug, PartialEq)] pub struct Found { @@ -245,22 +261,12 @@ impl Index { self.entries.is_empty() } - /// Adds or replaces page `space` of `section` as `page` shows it. - pub fn set(&mut self, section: &str, space: ExGuid, page: &Page, modified: u64) { - let text = page_text(page); - let entry = Entry { - section: section.to_owned(), - space, - folded_title: fold(&page.title), - folded_text: fold(&text), - title: page.title.clone(), - modified, - text, - }; + /// Adds `entry`, replacing the page it names. + pub fn set(&mut self, entry: Entry) { match self .entries .iter_mut() - .find(|entry| entry.space == space && entry.section == section) + .find(|old| old.space == entry.space && old.section == entry.section) { Some(old) => *old = entry, None => self.entries.push(entry), @@ -323,7 +329,10 @@ impl Index { fn snippet(entry: &Entry, query: &Query) -> (String, Vec>) { let first = query.hits(&entry.folded_text).first().map(|hit| hit.start); let line = first.map_or(0, |at| { - entry.folded_text[..at].bytes().filter(|byte| *byte == b'\n').count() + entry.folded_text[..at] + .bytes() + .filter(|byte| *byte == b'\n') + .count() }); let text = entry.text.split('\n').nth(line).unwrap_or_default(); let hits = query.find(text); @@ -335,12 +344,16 @@ fn snippet(entry: &Entry, query: &Query) -> (String, Vec>) { let mut end = (start + SNIPPET).min(chars.len()); // Cut ends fall back to the nearest space, so no word shows in part. if start > 0 - && let Some(space) = chars[start..at].iter().position(|(_, char)| char.is_whitespace()) + && let Some(space) = chars[start..at] + .iter() + .position(|(_, char)| char.is_whitespace()) { start += space + 1; } if end < chars.len() - && let Some(space) = chars[at..end].iter().rposition(|(_, char)| char.is_whitespace()) + && let Some(space) = chars[at..end] + .iter() + .rposition(|(_, char)| char.is_whitespace()) { end = at + space; } diff --git a/crates/canvas/tests/ops_differential.rs b/crates/canvas/tests/ops_differential.rs index 836885a452deb566fa673902388bde69adc9dcd8..3655c2b4dc4a290bd1f9c6fc9a8e50e2f0c12307 100644 --- a/crates/canvas/tests/ops_differential.rs +++ b/crates/canvas/tests/ops_differential.rs @@ -2,7 +2,8 @@ //! oracle (`op::predict`) predicts from them, read back so once sealed, and hold the editor's //! page or what it lowered whole stores: every editor operation alone and in random sequences with undo and redo, on every page of the corpus sections //! below and of any named in `OPS_SWEEP_SECTIONS` (`:`-separated paths; the structural -//! probe section, which `structural_roundtrip` sweeps, takes minutes). +//! probe section, which `structural_roundtrip` sweeps, takes minutes). The smoke slice takes +//! the first pages of each section. use canvas::{ document::TextPosition, @@ -18,6 +19,9 @@ use onestore::{ }; use std::{collections::BTreeMap, path::Path}; +#[path = "../../onestore/tests/support/sweep.rs"] +mod sweep; + const SECTIONS: [&str; 15] = [ "corpus/outline-edit/before/notebook/synthetic.one", "corpus/paragraph-edit/before/notebook/synthetic.one", @@ -634,12 +638,12 @@ fn sweep( random: &mut Random, engine: &mut TextEngine, tally: &mut Tally, - sequences: usize, + pages: usize, ) { let image = std::fs::read(path).unwrap(); let arena = Arena::default(); let mut section = Section::open(&arena, image.clone()).unwrap(); - for (space, title, _) in section.pages().unwrap() { + for (space, title, _) in section.pages().unwrap().into_iter().take(pages) { let Ok(page) = section.page(space) else { continue; }; @@ -650,7 +654,7 @@ fn sweep( .iter() .map(|kind| vec![*kind]) .chain(KINDS.iter().map(|kind| vec![*kind, Kind::Undo, Kind::Redo])) - .chain((0..sequences).map(|_| { + .chain((0..4).map(|_| { (0..3 + random.below(8)) .map(|_| KINDS[random.below(KINDS.len())]) .collect() @@ -695,14 +699,14 @@ fn editor_ops_store_the_editor_s_page() { if let Some(extra) = std::env::var_os("OPS_SWEEP_SECTIONS") { paths.extend(std::env::split_paths(&extra)); } - let seed = std::env::var("OPS_SWEEP_SEED").map_or(1, |seed| seed.parse().unwrap()); - let sequences = std::env::var("OPS_SWEEP_SEQUENCES").map_or(4, |n| n.parse().unwrap()); - let mut random = Random(seed); + let full = sweep::full(); + let pages = if full.is_some() { usize::MAX } else { 2 }; + let mut random = Random(1 + full.unwrap_or(0)); let mut engine = TextEngine::default(); let mut tally = Tally::default(); for path in &paths { let start = std::time::Instant::now(); - sweep(path, &mut random, &mut engine, &mut tally, sequences); + sweep(path, &mut random, &mut engine, &mut tally, pages); if std::env::var_os("OPS_SWEEP_DEBUG").is_some() { eprintln!("TIME {:?} {}", start.elapsed(), path.display()); } diff --git a/crates/canvas/tests/search.rs b/crates/canvas/tests/search.rs new file mode 100644 index 0000000000000000000000000000000000000000..00387b54b3f73f645221f7d4318fbff6884ac4bf --- /dev/null +++ b/crates/canvas/tests/search.rs @@ -0,0 +1,216 @@ +//! The search engine against OneNote 2010's own results on the sample notebook +//! (`corpus/search`), and its folding, word starts, ranking and snippets. + +use canvas::{ + document::TextDocument, + editor::CanvasEditor, + layout::TextEngine, + search::{Entry, Index, Query, fold, page_matches}, +}; +use onestore::{ + ExGuid, RevisionIndex, Store, + document::{Document, Format}, + page::{Page, text::Paragraph}, +}; +use std::path::Path; + +/// Each page of the corpus notebook's section `name`. +fn section(name: &str) -> Vec<(ExGuid, Page)> { + let file = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../corpus/search/notebook") + .join(format!("{name}.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(); + document + .pages() + .unwrap() + .into_iter() + .map(|(space, id)| { + let page = Page::from_revision(document.active(space).unwrap(), id).unwrap(); + (space, page) + }) + .collect() +} + +fn notebook() -> Index { + let mut index = Index::default(); + for name in ["Garden", "Home", "Journal", "Kitchen", "Reading", "Trips"] { + for (space, page) in section(name) { + index.set(Entry::new(name, space, &page, 0)); + } + } + index +} + +fn titles(found: &[canvas::search::Found], in_title: bool) -> Vec<&str> { + let mut titles: Vec<&str> = found + .iter() + .filter(|found| found.in_title == in_title) + .map(|found| found.title.as_str()) + .collect(); + titles.sort(); + titles +} + +#[test] +fn a_notebook_search_finds_what_onenote_finds() { + let index = notebook(); + let found = index.search(&Query::new("tom"), |_| true); + assert_eq!(titles(&found, true), ["Tomatoes"]); + assert_eq!( + titles(&found, false), + [ + "Harvest log 2025", + "Seed inventory", + "Spring planting plan", + "Weeknight dal" + ] + ); + assert!(found[0].in_title, "title hits come first"); + let found = index.search(&Query::new("sungold tom"), |_| true); + assert_eq!(titles(&found, true), Vec::<&str>::new()); + assert_eq!( + titles(&found, false), + [ + "Harvest log 2025", + "Seed inventory", + "Spring planting plan", + "Tomatoes" + ] + ); + assert!(index.search(&Query::new("zzq"), |_| true).is_empty()); + let garden = index.search(&Query::new("tom"), |section| section == "Kitchen"); + assert_eq!(titles(&garden, false), ["Weeknight dal"]); +} + +#[test] +fn snippets_show_the_first_match_marked() { + let index = notebook(); + let found = index.search(&Query::new("lentils"), |_| true); + let dal = found + .iter() + .find(|found| found.title == "Weeknight dal") + .unwrap(); + assert!(dal.snippet.contains("lentils"), "{:?}", dal.snippet); + let [hit] = dal.snippet_hits.as_slice() else { + panic!("{:?}", dal.snippet_hits) + }; + assert_eq!(dal.snippet[hit.clone()].to_lowercase(), "lentils"); + assert!(!dal.in_title && dal.title_hits.is_empty()); +} + +#[test] +fn find_on_page_counts_what_onenote_counts() { + let (_, page) = section("Garden") + .into_iter() + .find(|(_, page)| page.title == "Spring planting plan") + .unwrap(); + let mut engine = TextEngine::default(); + let editor = CanvasEditor::from_page(page, &mut engine).unwrap(); + let matches = page_matches(&editor, &Query::new("bed")); + assert_eq!(matches.len(), 6, "OneNote shows Match 1 of 6"); + for (outline, selection) in &matches { + let outline = editor + .outlines() + .iter() + .find(|candidate| candidate.id == *outline) + .unwrap(); + let [start, end] = selection.positions; + let paragraph = outline + .document() + .paragraphs() + .nth(start.paragraph) + .unwrap(); + let text: String = paragraph + .text() + .encode_utf16() + .skip(start.offset as usize) + .take((end.offset - start.offset) as usize) + .map(|unit| char::from_u32(u32::from(unit)).unwrap()) + .collect(); + assert_eq!(text.to_lowercase(), "bed"); + } + assert!(page_matches(&editor, &Query::new("zzq")).is_empty()); +} + +#[test] +fn folding_ignores_case_and_diacritics() { + assert_eq!(fold("Crème BRÛLÉE"), "creme brulee"); + assert_eq!(fold("Straße Ærø don’t"), "strasse aero don't"); + assert_eq!(fold("a\u{a0}b\u{b}c\nd"), "a b c\nd"); +} + +/// A page titled `title` holding one paragraph of `text`. +fn page(engine: &mut TextEngine, title: &str, text: &str) -> Page { + let document = + TextDocument::new(vec![Paragraph::new(text.to_owned(), Format::default())]).unwrap(); + let mut page = CanvasEditor::new(engine, document, 480.0) + .unwrap() + .page() + .unwrap(); + page.title = title.to_owned(); + page +} + +#[test] +fn words_match_word_starts_and_pages_rank_by_title_then_recency() { + let mut engine = TextEngine::default(); + let mut index = Index::default(); + let space = |n: u32| ExGuid { + guid: [n as u8; 16], + n, + }; + index.set(Entry::new( + "a", + space(1), + &page(&mut engine, "Notes", "café au lait at the bottom"), + 30, + )); + index.set(Entry::new( + "a", + space(2), + &page(&mut engine, "Café list", "nothing else"), + 10, + )); + index.set(Entry::new( + "b", + space(3), + &page(&mut engine, "Old", "CAFE menu"), + 20, + )); + index.set(Entry::new( + "b", + space(4), + &page(&mut engine, "東京", "今日は東京に行く"), + 5, + )); + let order = |index: &Index, query: &str| -> Vec { + index + .search(&Query::new(query), |_| true) + .into_iter() + .map(|found| found.title) + .collect() + }; + assert_eq!(order(&index, "cafe"), ["Café list", "Notes", "Old"]); + assert_eq!( + order(&index, "tom"), + Vec::::new(), + "only word starts match" + ); + assert_eq!(order(&index, "\"au lait\""), ["Notes"]); + assert_eq!(order(&index, "\"lait au\""), Vec::::new()); + assert_eq!(order(&index, "京に"), ["東京"]); + // Replacing a page, then dropping a section. + index.set(Entry::new( + "a", + space(1), + &page(&mut engine, "Notes", "tea"), + 40, + )); + assert_eq!(order(&index, "cafe"), ["Café list", "Old"]); + index.retain(|entry| entry.section != "b"); + assert_eq!(order(&index, "cafe"), ["Café list"]); + assert_eq!(index.len(), 2); +} diff --git a/crates/canvas/tests/structural_roundtrip.rs b/crates/canvas/tests/structural_roundtrip.rs index cd541b34b5c7e5d1e3ee1aa0fa04b993a4d99987..daccc0ad5b3c76bb8d4d43277b2d548fe63264ed 100644 --- a/crates/canvas/tests/structural_roundtrip.rs +++ b/crates/canvas/tests/structural_roundtrip.rs @@ -1,7 +1,8 @@ //! Every structural edit the editor makes saves as the ops the editor recorded and rereads //! as the model it saved: random Enter, Backspace, Delete, Tab, //! Shift+Tab, list and paste edits, alone and several to a save, on every outline of the probe -//! section and of any sections named in `CANVAS_SWEEP_SECTIONS` (`:`-separated paths). +//! section and of any sections named in `CANVAS_SWEEP_SECTIONS` (`:`-separated paths). The +//! smoke slice takes the first pages of each section. use canvas::{ document::TextPosition, @@ -15,6 +16,9 @@ use onestore::{ }; use std::{collections::BTreeMap, path::Path}; +#[path = "../../onestore/tests/support/sweep.rs"] +mod sweep; + #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] enum Kind { EnterStart, @@ -289,9 +293,15 @@ struct Tally { failures: BTreeMap>, } -fn sweep(path: &Path, random: &mut Random, engine: &mut TextEngine, tally: &mut Tally) { +fn sweep( + path: &Path, + random: &mut Random, + engine: &mut TextEngine, + tally: &mut Tally, + count: usize, +) { let section = std::fs::read(path).unwrap(); - for (space, page) in pages(§ion) { + for (space, page) in pages(§ion).into_iter().take(count) { let Ok(editor) = CanvasEditor::from_page(page.clone(), engine) else { continue; }; @@ -378,12 +388,13 @@ fn structural_edits_reread_as_saved() { if let Some(extra) = std::env::var_os("CANVAS_SWEEP_SECTIONS") { paths.extend(std::env::split_paths(&extra)); } - let seed = std::env::var("CANVAS_SWEEP_SEED").map_or(1, |seed| seed.parse().unwrap()); - let mut random = Random(seed); + let full = sweep::full(); + let count = if full.is_some() { usize::MAX } else { 24 }; + let mut random = Random(1 + full.unwrap_or(0)); let mut engine = TextEngine::default(); let mut tally = Tally::default(); for path in &paths { - sweep(path, &mut random, &mut engine, &mut tally); + sweep(path, &mut random, &mut engine, &mut tally, count); } let failed: usize = tally.failures.values().map(Vec::len).sum(); let mut report = String::new(); diff --git a/crates/mobile/src/lib.rs b/crates/mobile/src/lib.rs index 5ffcb0bd7eec49f6ff08100088b9f0980d22822b..7b6397546d1ae390d4c3559e8ed5b1fb4e864f3b 100644 --- a/crates/mobile/src/lib.rs +++ b/crates/mobile/src/lib.rs @@ -413,26 +413,15 @@ impl Canvas { Ok(true) } - /// Selects the first place `query` occurs on the page, ignoring case. + /// Selects the first match of `query` on the page, as the search that found it matches. fn find(&mut self, query: &str) -> Result { - let lower: Vec = query.to_lowercase().encode_utf16().collect(); - if lower.is_empty() { - return Ok(false); - } - let found = self.page.editor.outlines().iter().find_map(|outline| { - let text: Vec = outline.shown_text().to_lowercase().encode_utf16().collect(); - let at = text - .windows(lower.len()) - .position(|window| window == lower)?; - // Lowercasing can change the length; the match is then only found in place. - (text.len() == outline.shown_text().encode_utf16().count()) - .then_some((outline.id, at as u32)) - }); - let Some((id, at)) = found else { + let query = canvas::search::Query::new(query); + let Some(&(id, selection)) = canvas::search::page_matches(&self.page.editor, &query).first() + else { return Ok(false); }; self.page.editor.focus_outline(id)?; - self.select([at, at + lower.len() as u32])?; + self.page.editor.select(selection)?; Ok(true) } @@ -447,7 +436,7 @@ impl Canvas { outline .document() .paragraphs() - .map(library::shown) + .map(canvas::search::shown) .collect::>() .join("\n") }) @@ -511,7 +500,7 @@ impl Canvas { /// The selection rectangles of a range, one or more per line. fn range_rects(&self, range: [u32; 2]) -> Result> { - let rects = self.page.editor.range_rects(Selection { + let rects = self.active().range_rects(Selection { positions: self.positions(range)?, affinities: [Affinity::Downstream, Affinity::Upstream], })?; diff --git a/crates/mobile/src/library.rs b/crates/mobile/src/library.rs index 30f780a626019555c659c33dc360be3ecedc68c1..8967f027835dbcddf1d9c9a6696068e51ebf32ac 100644 --- a/crates/mobile/src/library.rs +++ b/crates/mobile/src/library.rs @@ -3,6 +3,7 @@ //! cache, which stores each edit at once and publishes it in the background. use crate::{Result, owned, report, string}; +use canvas::search::{Entry, Index, Query}; use notebook::{ Remote, Replica, discover::{Folder, SectionState}, @@ -10,11 +11,9 @@ use notebook::{ smb::{Client, Credentials}, }; use onestore::{ - CommitError, CommitState, ExGuid, PageCreation, PageEdit, RevisionIndex, Stamp, Store, - Transaction, - document::Document, + CommitError, CommitState, ExGuid, PageCreation, PageEdit, Stamp, Transaction, op::{Edit, Op, SectionOp}, - page::{Page, PageObject, PageParagraph, ParagraphContent}, + page::Page, }; use std::{ collections::{BTreeMap, HashMap}, @@ -172,11 +171,10 @@ fn files(folder: &Folder, files: &mut BTreeMap) { } } -/// Page texts of a section as last read, and when its file was last checked. -struct Indexed { - checked: Instant, +/// When a section's file was last checked for changes, and how it was then. +struct Checked { + at: Instant, stamp: Vec, - pages: Vec<(ExGuid, String, String)>, } pub struct Library { @@ -184,7 +182,7 @@ pub struct Library { notebook: Mutex>, place: Place, cache: PathBuf, - index: Mutex>, + index: Mutex<(HashMap, Index)>, } #[derive(serde::Serialize, serde::Deserialize)] @@ -665,65 +663,6 @@ const CHANGED: u32 = 2; /// The section refused an edit; the page shown returns to what is stored. const REJECTED: u32 = 4; -/// The text a paragraph shows, without hidden field codes. -pub(crate) fn shown(paragraph: &onestore::page::text::Paragraph) -> String { - let mut start = 0; - paragraph - .spans() - .iter() - .filter_map(|span| { - let run = ¶graph.text()[start..span.end]; - start = span.end; - (span.format.hidden != Some(true)).then_some(run) - }) - .collect() -} - -/// A page's text, paragraph by paragraph, the title first. -pub(crate) fn page_text(page: &Page) -> String { - fn walk(paragraphs: &[PageParagraph], out: &mut Vec) { - for paragraph in paragraphs { - match ¶graph.content { - ParagraphContent::Text(text) => out.push(shown(&text.text)), - ParagraphContent::Table(table) => { - for cell in table.rows.iter().flat_map(|row| &row.cells) { - walk(&cell.paragraphs, out); - } - } - _ => {} - } - } - } - let mut out = Vec::new(); - for object in &page.objects { - match object { - PageObject::Title(title) => { - for outline in &title.outlines { - walk(&outline.paragraphs, &mut out); - } - } - PageObject::Outline(outline) => walk(&outline.paragraphs, &mut out), - _ => {} - } - } - out.join("\n") -} - -/// Each page of a section file with its title and text. -fn section_texts(bytes: &[u8]) -> Result> { - let store = Store::parse(bytes)?; - let index = RevisionIndex::parse(&store)?; - let document = Document::parse(&index)?; - let mut pages = Vec::new(); - for (space, id) in document.pages()? { - let revision = document.active(space)?; - let (title, _) = Page::heading(revision, id); - let page = Page::from_revision(revision, id)?; - pages.push((space, title, page_text(&page))); - } - Ok(pages) -} - #[derive(serde::Serialize)] pub(crate) struct Found { /// The section's catalog path. @@ -734,81 +673,63 @@ pub(crate) struct Found { pub(crate) snippet: String, } -/// Up to 60 characters either side of the first place `lower` occurs in `text`, on its line. -fn snippet(text: &str, lower: &str) -> Option { - let line = text - .lines() - .find(|line| line.to_lowercase().contains(lower))?; - let chars: Vec = line.chars().collect(); - let folded: Vec = line.to_lowercase().chars().collect(); - let needle: Vec = lower.chars().collect(); - // Lowercasing can change a line's length; then the match is shown from the line start. - let at = (folded.len() == chars.len()) - .then(|| { - folded - .windows(needle.len()) - .position(|window| window == needle) - }) - .flatten() - .unwrap_or(0); - let start = at.saturating_sub(60); - let end = (at + needle.len() + 60).min(chars.len()); - let mut text: String = chars[start..end].iter().collect(); - if start > 0 { - text.insert(0, '…'); - } - if end < chars.len() { - text.push('…'); - } - Some(text) -} - impl Library { - /// Pages of the notebook whose title or text holds `query`, ignoring case: the open - /// section as its edits leave it, others as stored, reread when their file changed. + /// Pages of the notebook holding every word of `query` in their title or text, as the + /// desktop searches: the open section as its edits leave it, others as stored, read + /// again when their file changed. pub(crate) fn search(&self, open: Option<(&str, &Section)>, query: &str) -> Result> { - let lower = query.to_lowercase(); - let mut found = Vec::new(); - for tab in self.tabs()?.into_iter().filter(|tab| tab.readable) { - let pages = match open.filter(|(path, _)| *path == tab.path) { + let tabs: Vec = self + .tabs()? + .into_iter() + .filter(|tab| tab.readable) + .map(|tab| tab.path) + .collect(); + let mut indexed = self.index.lock().unwrap_or_else(|error| error.into_inner()); + let (checked, index) = &mut *indexed; + for path in &tabs { + match open.filter(|(open, _)| open == path) { Some((_, section)) => { let section = §ion.shared.section; - section - .pages()? - .into_iter() - .map(|(space, title, _)| { - Ok((space, title, page_text(§ion.page(space)?))) - }) - .collect::>>()? + let mut entries = Vec::new(); + for (space, ..) in section.pages()? { + let modified = index.get(path, space).map_or(0, |entry| entry.modified); + entries.push(Entry::new(path, space, §ion.page(space)?, modified)); + } + index.retain(|entry| entry.section != *path); + entries.into_iter().for_each(|entry| index.set(entry)); + checked.remove(path); } - None => match report(self.indexed(&tab.path)) { - Some(pages) => pages, - None => continue, - }, - }; - for (space, title, text) in pages { - let snippet = snippet(&text, &lower); - if title.to_lowercase().contains(&lower) || snippet.is_some() { - found.push(Found { - section: tab.path.clone(), - page: space.to_string(), - title: title.replace(|char: char| char.is_control(), " "), - snippet: snippet.unwrap_or_default(), - }); + None => { + report(self.reindex(path, checked, index)); } } } - Ok(found) + index.retain(|entry| tabs.contains(&entry.section)); + Ok(index + .search(&Query::new(query), |_| true) + .into_iter() + .map(|found| Found { + section: found.section, + page: found.space.to_string(), + title: found.title.replace(|char: char| char.is_control(), " "), + snippet: found.snippet, + }) + .collect()) } - /// The pages of the section at catalog `path` with their texts, read again only when - /// the file changed, and checked at most every 30 seconds. - fn indexed(&self, path: &str) -> Result> { - let mut index = self.index.lock().unwrap_or_else(|error| error.into_inner()); - if let Some(indexed) = index.get(path) - && indexed.checked.elapsed() < Duration::from_secs(30) + /// Reads the section at catalog `path` into `index` again when its file changed, and + /// checks at most every 30 seconds. + fn reindex( + &self, + path: &str, + checked: &mut HashMap, + index: &mut Index, + ) -> Result<()> { + if checked + .get(path) + .is_some_and(|known| known.at.elapsed() < Duration::from_secs(30)) { - return Ok(indexed.pages.clone()); + return Ok(()); } let (stamp, read): (Vec, Reader) = match &self.place { Place::Share { root, .. } => { @@ -829,19 +750,22 @@ impl Library { Place::Folder(root) => local(root.join(path))?, Place::File(file) => local(file.clone())?, }; - let pages = match index.remove(path) { - Some(indexed) if indexed.stamp == stamp => indexed.pages, - _ => section_texts(&read()?)?, - }; - index.insert( + if checked.get(path).is_none_or(|known| known.stamp != stamp) { + let pages = session::stored_pages(&read()?)?; + index.retain(|entry| entry.section != path); + for stored in pages { + let modified = stored.modified.map_or(0, u64::from); + index.set(Entry::new(path, stored.space, &stored.page, modified)); + } + } + checked.insert( path.to_owned(), - Indexed { - checked: Instant::now(), + Checked { + at: Instant::now(), stamp, - pages: pages.clone(), }, ); - Ok(pages) + Ok(()) } } diff --git a/crates/mobile/src/tests.rs b/crates/mobile/src/tests.rs index 1797fdf97aef780a10b2f5e02db9691f9bc255d4..659d7949c781968790d0d998a258600a970112eb 100644 --- a/crates/mobile/src/tests.rs +++ b/crates/mobile/src/tests.rs @@ -214,7 +214,7 @@ fn undo_restores_typing() { fn stored_text(file: &Path, space: ExGuid) -> String { let arena = onestore::Arena::default(); let section = onestore::Section::open(&arena, onestore::read_file(file).unwrap()).unwrap(); - library::page_text(§ion.page(space).unwrap()) + canvas::search::page_text(§ion.page(space).unwrap()) } #[test] @@ -245,7 +245,7 @@ fn typing_is_stored_in_the_cache_and_published_to_the_file() { let reopened = open(&library, &file.to_string_lossy()); let (page, read_only) = reopened.shared.page(space).unwrap(); assert!(!read_only); - assert!(library::page_text(&page).contains("Saved Collapsed parent")); + assert!(canvas::search::page_text(&page).contains("Saved Collapsed parent")); } #[test] @@ -347,7 +347,7 @@ fn find_selects_the_match_and_formatting_reports_and_toggles() { let mut canvas = canvas(§ion, "Paragraph controls"); assert!(canvas.find("collapsed PARENT").unwrap()); let [start, end] = canvas.selection().unwrap(); - assert_eq!(canvas.text([start, end]).to_lowercase(), "collapsed parent"); + assert_eq!(canvas.text([start, end]).to_lowercase(), "collapsed"); assert_eq!(canvas.format_bits().unwrap() & 1, 0); assert!(canvas.format(0).unwrap()); assert_eq!(canvas.format_bits().unwrap() & 1, 1); @@ -494,7 +494,7 @@ fn a_notebook_on_a_share_saves_and_opens_offline() { .unwrap(); let arena = onestore::Arena::default(); let stored = onestore::Section::open(&arena, published).unwrap(); - assert!(library::page_text(&stored.page(canvas.space).unwrap()).contains("Shared ")); + assert!(canvas::search::page_text(&stored.page(canvas.space).unwrap()).contains("Shared ")); let shared = Arc::clone(§ion.shared); drop((canvas, section, library)); Arc::into_inner(shared).unwrap().section.close().unwrap(); @@ -517,7 +517,7 @@ fn a_notebook_on_a_share_saves_and_opens_offline() { .page(space(&reopened, "Paragraph controls")) .unwrap() .0; - assert!(library::page_text(&page).contains("Shared ")); + assert!(canvas::search::page_text(&page).contains("Shared ")); } /// Paths the test coordinator was asked for, with whether to write. @@ -584,7 +584,7 @@ fn tapping_the_date_asks_for_it_and_a_new_date_is_stored() { } ))); section.shared.apply(edit).unwrap(); - let stored = library::page_text(§ion.shared.page(canvas.space).unwrap().0); - assert!(stored.contains("Friday, January 2, 2026"), "{stored}"); + let stored = section.shared.page(canvas.space).unwrap().0.date_text(); + assert_eq!(stored.unwrap()[0], "Friday, January 2, 2026"); assert_ne!(canvas.page.editor.date().unwrap().timestamp(), before); } diff --git a/crates/notebook/README.md b/crates/notebook/README.md index e1e12b3928758918be6c3b00aa740fee0d30b9e5..76681b41152cdfc807b5cbe3a8587ac9e9e3190c 100644 --- a/crates/notebook/README.md +++ b/crates/notebook/README.md @@ -267,6 +267,10 @@ section, so a link follows its page when the section is renamed or moved and when the page itself was moved to another section. Other URLs and unknown targets are `None`. +`Notebook::read_section(path)` reads a section file as stored, without opening a +replica, and `session::stored_pages(image)` builds its pages with each page's +`LastModifiedTime`: the app's search indexes the sections it has not opened this way. + With the optional `protected` feature, `Notebook::unlock(path, password)` reads a `Locked` section as `Unlocked { pages, .. }`, and `Notebook::apply_unlocked(path, password, &mut unlocked, author, edit)` diff --git a/crates/notebook/src/session.rs b/crates/notebook/src/session.rs index b457a99ad59fec6e23bf8395ee78d6a4a06ca982..cb6e11014221b7f621f9c1997f113305b993c132 100644 --- a/crates/notebook/src/session.rs +++ b/crates/notebook/src/session.rs @@ -746,6 +746,12 @@ impl Notebook { Ok(None) } + /// The section at catalog `path` as its file stores it, without the edits a replica + /// may hold (`stored_pages` reads it). + pub fn read_section(&self, path: &str) -> Result> { + self.storage.read(&self.section_path(path)?.path) + } + /// The pages of a password-protected section: nothing is cached, and the decoded /// buffers go when the pages have been built. #[cfg(feature = "protected")] @@ -828,6 +834,34 @@ impl Notebook { } } +/// A page as a section file stores it. +pub struct StoredPage { + pub space: ExGuid, + pub page: Page, + /// The page's `LastModifiedTime`, Time32 seconds since 1980. + pub modified: Option, +} + +/// The pages of the section file `image` holds, in section order; pages the model cannot +/// build are left out. +pub fn stored_pages(image: &[u8]) -> Result> { + let store = Store::parse(image)?; + let index = RevisionIndex::parse(&store)?; + let document = Document::parse(&index)?; + Ok(document + .pages()? + .into_iter() + .filter_map(|(space, id)| { + let revision = document.active(space).ok()?; + Some(StoredPage { + space, + page: Page::from_revision(revision, id).ok()?, + modified: revision.nodes.get(&id).and_then(|node| node.modified), + }) + }) + .collect()) +} + /// The colours OneNote 2010 gives a folder's first sixteen new sections, COLORREF, in the /// order it gives them (`corpus/notebook-management/native/section-colors`: sixteen sections /// made with New Section in a new notebook). diff --git a/crates/notebook/tests/assets.rs b/crates/notebook/tests/assets.rs index 17a308be3320d6f122ccb1c81dcda68b29b4254f..2c2571d3e7fedbed427957ecd1ed5a4e066c159c 100644 --- a/crates/notebook/tests/assets.rs +++ b/crates/notebook/tests/assets.rs @@ -207,7 +207,9 @@ fn download_network_wait_does_not_block_local_edits() { } fn read(&mut self, _: &str, _: usize) -> io::Result> { self.entered.send(()).unwrap(); - self.released.recv_timeout(Duration::from_secs(5)).unwrap(); + self.released + .recv_timeout(Duration::from_secs(120)) + .unwrap(); Ok(self.bytes.clone()) } } @@ -227,7 +229,7 @@ fn download_network_wait_does_not_block_local_edits() { .fetch_asset(&mut source, "synthetic.one", &name, 1024) .unwrap() }); - waiting.recv_timeout(Duration::from_secs(5)).unwrap(); + waiting.recv_timeout(Duration::from_secs(120)).unwrap(); let text = first_text(&snapshot(&cache)); let id = model_ops::save(&cache, text, |page| { model_ops::replace_text(page, text, 0..0, "during download ") diff --git a/crates/notebook/tests/cache.rs b/crates/notebook/tests/cache.rs index d06e3fc9eaad9cff72a0d930c38326f8f12e04c6..c4235dcb0c691298944ae6581fe27905d4af4d99 100644 --- a/crates/notebook/tests/cache.rs +++ b/crates/notebook/tests/cache.rs @@ -19,6 +19,8 @@ use std::{ mod model_ops; #[path = "support/server.rs"] mod server; +#[path = "../../onestore/tests/support/sweep.rs"] +mod sweep; use server::snapshot; fn target(source: &[u8]) -> (ExGuid, ExGuid, String) { @@ -377,7 +379,8 @@ fn seeded_unicode_edits_and_restarts_match_an_independent_text_model() { let source = onestore::create_section("model.one", &text, "Fixture").unwrap(); let mut replica = Replica::create(&path, &source).unwrap(); let (_, object, _) = target(&source); - let mut random = 911_u64; + let steps = sweep::seeds(0..512, 64); + let mut random = 911 + steps.start; let mut next = || { random ^= random << 13; random ^= random >> 7; @@ -385,7 +388,7 @@ fn seeded_unicode_edits_and_restarts_match_an_independent_text_model() { random }; let mut acknowledged = Vec::new(); - for step in 0..512 { + for step in steps.clone() { let boundaries: Vec<_> = text .char_indices() .map(|(at, _)| at) @@ -412,11 +415,7 @@ fn seeded_unicode_edits_and_restarts_match_an_independent_text_model() { None => assert_eq!(text, expected), } text = expected; - assert_eq!( - target(&snapshot(&replica)).2, - text, - "seed 911, step {step}" - ); + assert_eq!(target(&snapshot(&replica)).2, text, "step {step}"); if step % 37 == 0 { let pending = replica.pending().unwrap(); drop(replica); @@ -426,7 +425,7 @@ fn seeded_unicode_edits_and_restarts_match_an_independent_text_model() { } } assert!( - acknowledged.len() > 256, + acknowledged.len() as u64 * 2 > steps.end - steps.start, "Most random edits change the text" ); assert_eq!( diff --git a/crates/notebook/tests/session.rs b/crates/notebook/tests/session.rs index a58a6215634697375500e5554820c527fe3b2c61..640cc42a21e7dd103aa3250de1c4365b59716a72 100644 --- a/crates/notebook/tests/session.rs +++ b/crates/notebook/tests/session.rs @@ -630,13 +630,13 @@ fn dropping_during_connection_keeps_cache_owned_until_the_worker_finishes() { 1024 * 1024, move || { entered.send(()).unwrap(); - stalled.recv_timeout(Duration::from_secs(10)).unwrap(); + stalled.recv_timeout(Duration::from_secs(120)).unwrap(); Err(std::io::ErrorKind::TimedOut.into()) }, || {}, ) .unwrap(); - connecting.recv_timeout(Duration::from_secs(5)).unwrap(); + connecting.recv_timeout(Duration::from_secs(120)).unwrap(); let space = section.pages().unwrap()[0].0; let before = section.page(space).unwrap(); let after = edited(&before, "Saved during connection "); @@ -646,7 +646,7 @@ fn dropping_during_connection_keeps_cache_owned_until_the_worker_finishes() { drop(section); dropped.send(()).unwrap(); }); - finished.recv_timeout(Duration::from_secs(2)).unwrap(); + finished.recv_timeout(Duration::from_secs(60)).unwrap(); assert!(matches!( notebook::Replica::open(&cache), Err(notebook::Error::Database(error)) diff --git a/crates/notebook/tests/sync.rs b/crates/notebook/tests/sync.rs index bfc7d5dfa34d8b4f6a5b2d3ce866f079f6f922bf..2f1bf4fcd2d8b43b91badad1b77d7a7c10140e2f 100644 --- a/crates/notebook/tests/sync.rs +++ b/crates/notebook/tests/sync.rs @@ -9,6 +9,8 @@ mod server; use server::*; #[path = "support/model_ops.rs"] mod model_ops; +#[path = "../../onestore/tests/support/sweep.rs"] +mod sweep; /// Saves a replacement of `range` in the page holding `text`. fn save(cache: &Replica, text: ExGuid, range: Range, replacement: &str) -> Option { @@ -444,7 +446,7 @@ fn twelve_local_editors_progress_during_remote_reads_publication_and_confirmatio if self.phase == phase { self.entered.send(()).unwrap(); self.resume - .recv_timeout(std::time::Duration::from_secs(5)) + .recv_timeout(std::time::Duration::from_secs(120)) .unwrap(); } } @@ -502,7 +504,7 @@ fn twelve_local_editors_progress_during_remote_reads_publication_and_confirmatio paused.server }); entered_rx - .recv_timeout(std::time::Duration::from_secs(5)) + .recv_timeout(std::time::Duration::from_secs(120)) .unwrap(); assert!( matches!(cache.sync_once(&mut Server::new(&source)),Err(Error::Io(error)) if error.kind()==io::ErrorKind::WouldBlock) @@ -537,8 +539,9 @@ fn twelve_local_editors_progress_during_remote_reads_publication_and_confirmatio .map(|handle| handle.join().unwrap()) .collect(); assert_eq!(ids.len(), 12, "every local edit is its own"); + // Well inside the paused remote's wait, which edits queued behind it would outlast. assert!( - started.elapsed() < std::time::Duration::from_secs(2), + started.elapsed() < std::time::Duration::from_secs(60), "Local edits waited for remote {phase}" ); resume_tx.send(()).unwrap(); @@ -671,8 +674,9 @@ fn a_failed_rebase_leaves_the_queue_as_it_was() { fn seeded_conflicts_keep_the_remote_text_and_later_edits_publish_once() { let dir = tempfile::tempdir().unwrap(); let original: Vec<_> = "abcdefghij🦀klmnop".chars().collect(); - let mut seed = 911_u64; - for case in 0..64 { + let cases = sweep::seeds(0..64, 16); + let mut seed = 911 + cases.start; + for case in cases { seed ^= seed << 13; seed ^= seed >> 7; seed ^= seed << 17; diff --git a/crates/notebook/tests/sync/tree.rs b/crates/notebook/tests/sync/tree.rs index ba1f76c27c65ac8704f72946e5dc63327df0fb6b..168b29be8c8b3b19f7113b736bb9b0cb07ea68b3 100644 --- a/crates/notebook/tests/sync/tree.rs +++ b/crates/notebook/tests/sync/tree.rs @@ -12,8 +12,9 @@ fn save_page(cache: &Replica, space: ExGuid, edit: impl FnOnce(&mut Page)) -> Op #[test] fn twelve_offline_clients_reconcile_tree_text_and_interrupted_publication() { - let mut random = 1940_u64; - for _ in 0..24 { + let runs = sweep::seeds(0..24, 1); + let mut random = 1940 + runs.start; + for _ in runs { let input: Vec<_> = (0..256) .map(|_| { random ^= random << 13; diff --git a/crates/notebook/tests/sync_formatting.rs b/crates/notebook/tests/sync_formatting.rs index 2810b47732944500353c35c1ac95d4d2275082e7..4550f8275dc49c9d28fbdf1e4e8536646a39c65e 100644 --- a/crates/notebook/tests/sync_formatting.rs +++ b/crates/notebook/tests/sync_formatting.rs @@ -16,6 +16,8 @@ mod server; use server::{Fault, Server, conflicted, pages, snapshot}; #[path = "support/model_ops.rs"] mod model_ops; +#[path = "../../onestore/tests/support/sweep.rs"] +mod sweep; use model_ops::{ AUTHOR, insert_after, insert_outline, page_of, paragraph_with, replace_text, restyle, save, }; @@ -263,7 +265,8 @@ fn seeded_formatting_reconciles_exactly_when_the_remote_left_the_paragraph_alone .map(|id| u32::try_from(text_of(&page, *id).encode_utf16().count()).unwrap()) .collect(); let (mut published, mut conflicts) = (0, 0); - for seed in 1_u64..=64 { + let seeds = sweep::seeds(1..65, 16); + for seed in seeds.clone() { let mut state = seed; let mut next = || { state = state @@ -341,7 +344,7 @@ fn seeded_formatting_reconciles_exactly_when_the_remote_left_the_paragraph_alone assert_eq!(snapshot(&cache), server.durable, "seed {seed}"); } assert!( - published >= 16, + published * 4 >= seeds.end - seeds.start, "{published} published, {conflicts} conflicts" ); } diff --git a/crates/notebook/tests/sync_outline.rs b/crates/notebook/tests/sync_outline.rs index 79b2085fb9e9750bb53c2cb233be5c9187d2e528..829b756ca62f14089a6fdc9532694559c43113f8 100644 --- a/crates/notebook/tests/sync_outline.rs +++ b/crates/notebook/tests/sync_outline.rs @@ -23,6 +23,8 @@ use model_ops::{ }; #[path = "support/model_schedule.rs"] mod model_schedule; +#[path = "../../onestore/tests/support/sweep.rs"] +mod sweep; #[path = "sync/tree.rs"] mod tree; diff --git a/crates/notebook/tests/sync_page.rs b/crates/notebook/tests/sync_page.rs index 1fa47119d629b644e5e0c5ef2c259a0ad5e18e6f..16a1c74c24c0713aa7c9490b5d43997b8c8d3a49 100644 --- a/crates/notebook/tests/sync_page.rs +++ b/crates/notebook/tests/sync_page.rs @@ -22,10 +22,12 @@ mod model_ops; #[path = "support/page_schedule.rs"] mod page_schedule; use page_schedule::body_outline; +#[path = "../../onestore/tests/support/sweep.rs"] +mod sweep; #[test] fn twelve_replica_page_schedules_retain_acknowledged_pages_through_interruptions() { - for seed in 0..24_u64 { + for seed in sweep::seeds(0..24, 1) { let mut random = seed + 1956; let mut input = Vec::new(); for step in 0..48 { diff --git a/crates/notebook/tests/sync_worker.rs b/crates/notebook/tests/sync_worker.rs index 14a63b9e1b065f3b5d31046a883c3cc2e1452b5a..151c8c1c9cb3eaf7e241756a0f0c23bcaf722b01 100644 --- a/crates/notebook/tests/sync_worker.rs +++ b/crates/notebook/tests/sync_worker.rs @@ -19,6 +19,10 @@ use std::{ time::{Duration, Instant}, }; +/// How long a wait may take on a loaded machine before it fails: far short of the hourly +/// poll the waits rule out. +const PATIENCE: Duration = Duration::from_secs(120); + #[derive(Clone)] struct Shared(Arc>); @@ -130,7 +134,7 @@ fn reconnects_after_connect_read_and_uncertain_publish_without_replaying() { .unwrap(); let mut errors = 0; let published = loop { - match observed_rx.recv_timeout(Duration::from_secs(5)).unwrap() { + match observed_rx.recv_timeout(PATIENCE).unwrap() { Err(_) => errors += 1, Ok(Some((actual, status @ EditStatus::Published { .. }))) => { assert_eq!(actual, id); @@ -183,15 +187,12 @@ fn local_saves_wake_an_idle_worker_and_publish_every_writer_marker() { .unwrap(); if first { first = false; - resume_rx.recv_timeout(Duration::from_secs(5)).unwrap(); + resume_rx.recv_timeout(PATIENCE).unwrap(); } }, ) .unwrap(); - assert_eq!( - observed_rx.recv_timeout(Duration::from_secs(5)).unwrap(), - None - ); + assert_eq!(observed_rx.recv_timeout(PATIENCE).unwrap(), None); let started = Instant::now(); let queued = std::thread::scope(|scope| { (0..12) @@ -219,12 +220,12 @@ fn local_saves_wake_an_idle_worker_and_publish_every_writer_marker() { }); resume_tx.send(()).unwrap(); while !cache.pending().unwrap().is_empty() { - if let Some((_, status)) = observed_rx.recv_timeout(Duration::from_secs(5)).unwrap() { + if let Some((_, status)) = observed_rx.recv_timeout(PATIENCE).unwrap() { assert!(matches!(status, EditStatus::Published { .. }), "{status:?}"); } } assert!( - started.elapsed() < Duration::from_secs(5), + started.elapsed() < PATIENCE, "Edits waited for the hourly poll" ); worker.stop().unwrap(); @@ -262,7 +263,7 @@ fn dropping_during_publication_is_nonblocking_and_retains_ownership_until_recove } fn publish(&mut self, transaction: &Transaction) -> Result<(), CommitError> { self.entered.send(()).unwrap(); - self.resume.recv_timeout(Duration::from_secs(5)).unwrap(); + self.resume.recv_timeout(PATIENCE).unwrap(); self.shared.publish(transaction) } fn confirm(&mut self, base: &Stamp) -> Result<(), CommitError> { @@ -293,10 +294,10 @@ fn dropping_during_publication_is_nonblocking_and_retains_ownership_until_recove |_| {}, ) .unwrap(); - entered_rx.recv_timeout(Duration::from_secs(5)).unwrap(); + entered_rx.recv_timeout(PATIENCE).unwrap(); let stopped = Instant::now(); drop(worker); - assert!(stopped.elapsed() < Duration::from_millis(500)); + assert!(stopped.elapsed() < PATIENCE); let shared = Shared(Arc::clone(&server)); let start = cache.start_sync( Duration::from_secs(3600), @@ -315,7 +316,7 @@ fn dropping_during_publication_is_nonblocking_and_retains_ownership_until_recove if error.sqlite_error_code() == Some(rusqlite::ErrorCode::DatabaseBusy) => {} Err(error) => panic!("{error}"), } - assert!(stopped.elapsed() < Duration::from_secs(5)); + assert!(stopped.elapsed() < PATIENCE); std::thread::sleep(Duration::from_millis(1)); }); assert!(matches!( @@ -335,7 +336,7 @@ fn dropping_during_publication_is_nonblocking_and_retains_ownership_until_recove ) .unwrap(); assert!( - matches!(rx.recv_timeout(Duration::from_secs(5)).unwrap(), Some((actual, EditStatus::Published { .. })) if actual == id) + matches!(rx.recv_timeout(PATIENCE).unwrap(), Some((actual, EditStatus::Published { .. })) if actual == id) ); worker.stop().unwrap(); let server = server.lock().unwrap(); @@ -369,7 +370,7 @@ fn cache_failures_stop_retries_and_return_the_error_without_remote_publication() }, ) .unwrap(); - assert!(rx.recv_timeout(Duration::from_secs(5)).unwrap()); + assert!(rx.recv_timeout(PATIENCE).unwrap()); assert!(matches!(worker.stop(), Err(Error::Database(_)))); assert!(rx.try_iter().next().is_none()); assert_eq!(server.lock().unwrap().publications, 0); @@ -405,13 +406,9 @@ fn polling_reports_an_uncertain_attempt_once_per_remote_change_without_replay() }, ) .unwrap(); - assert!(rx.recv_timeout(Duration::from_secs(5)).unwrap().is_err()); + assert!(rx.recv_timeout(PATIENCE).unwrap().is_err()); for round in 0..3 { - let (actual, status) = rx - .recv_timeout(Duration::from_secs(5)) - .unwrap() - .unwrap() - .unwrap(); + let (actual, status) = rx.recv_timeout(PATIENCE).unwrap().unwrap().unwrap(); assert_eq!(actual, id); assert!( matches!(status, EditStatus::AwaitingConfirmation { .. }), @@ -450,9 +447,9 @@ fn reachability_notification_retries_without_waiting_for_the_poll() { }, ) .unwrap(); - assert!(rx.recv_timeout(Duration::from_secs(5)).unwrap()); + assert!(rx.recv_timeout(PATIENCE).unwrap()); worker.wake(); - assert!(rx.recv_timeout(Duration::from_secs(5)).unwrap()); + assert!(rx.recv_timeout(PATIENCE).unwrap()); worker.stop().unwrap(); assert!(rx.try_iter().next().is_none()); } @@ -472,7 +469,7 @@ fn cancellation_during_connect_does_not_read_or_report_a_false_refresh() { Duration::from_secs(3600), move || { entered_tx.send(()).unwrap(); - resume_rx.recv_timeout(Duration::from_secs(5)).unwrap(); + resume_rx.recv_timeout(PATIENCE).unwrap(); Ok(shared.clone()) }, move |_| { @@ -480,18 +477,18 @@ fn cancellation_during_connect_does_not_read_or_report_a_false_refresh() { }, ) .unwrap(); - entered_rx.recv_timeout(Duration::from_secs(5)).unwrap(); + entered_rx.recv_timeout(PATIENCE).unwrap(); drop(worker); let weak = Arc::downgrade(&cache); drop(cache); resume_tx.send(()).unwrap(); let started = Instant::now(); while weak.upgrade().is_some() { - assert!(started.elapsed() < Duration::from_secs(5)); + assert!(started.elapsed() < PATIENCE); std::thread::sleep(Duration::from_millis(1)); } assert_eq!( - rx.recv_timeout(Duration::from_secs(5)), + rx.recv_timeout(PATIENCE), Err(mpsc::RecvTimeoutError::Disconnected) ); } @@ -519,7 +516,7 @@ fn invalid_intervals_and_callback_panics_leave_worker_ownership_recoverable() { }, ) .unwrap(); - rx.recv_timeout(Duration::from_secs(5)).unwrap(); + rx.recv_timeout(PATIENCE).unwrap(); assert!(matches!(worker.stop(), Err(Error::Io(_)))); let (tx, rx) = mpsc::channel(); let worker = cache @@ -531,7 +528,7 @@ fn invalid_intervals_and_callback_panics_leave_worker_ownership_recoverable() { }, ) .unwrap(); - assert_eq!(rx.recv_timeout(Duration::from_secs(5)).unwrap(), None); + assert_eq!(rx.recv_timeout(PATIENCE).unwrap(), None); worker.stop().unwrap(); assert_eq!(snapshot(&cache), source); } @@ -559,7 +556,7 @@ fn a_conflict_publishes_its_conflict_page_in_one_worker_step() { ) .unwrap(); assert!( - matches!(rx.recv_timeout(Duration::from_secs(5)).unwrap(), Some((actual, EditStatus::Published { .. })) if actual > id) + matches!(rx.recv_timeout(PATIENCE).unwrap(), Some((actual, EditStatus::Published { .. })) if actual > id) ); worker.stop().unwrap(); assert!(cache.pending().unwrap().is_empty()); @@ -640,10 +637,10 @@ fn ordinary_read_and_unpublished_write_contention_reuse_the_connection() { ) .unwrap(); for _ in 0..4 { - assert!(rx.recv_timeout(Duration::from_secs(5)).unwrap().is_err()); + assert!(rx.recv_timeout(PATIENCE).unwrap().is_err()); } assert!( - matches!(rx.recv_timeout(Duration::from_secs(5)).unwrap().unwrap(), Some((actual, EditStatus::Published { .. })) if actual == id) + matches!(rx.recv_timeout(PATIENCE).unwrap().unwrap(), Some((actual, EditStatus::Published { .. })) if actual == id) ); worker.stop().unwrap(); assert_eq!(text(&snapshot(&cache)).2, "L abc"); @@ -706,10 +703,10 @@ fn publication_backoff_drains_local_wakes_without_waiting_for_the_idle_poll() { }, ) .unwrap(); - let (second, status) = rx.recv_timeout(Duration::from_secs(5)).unwrap(); + let (second, status) = rx.recv_timeout(PATIENCE).unwrap(); assert_eq!(status, None); for expected in [first, second] { - let (id, status) = rx.recv_timeout(Duration::from_secs(5)).unwrap(); + let (id, status) = rx.recv_timeout(PATIENCE).unwrap(); assert_eq!(id, expected); assert!(matches!(status, Some(EditStatus::Published { .. }))); } diff --git a/crates/onestore/src/active.rs b/crates/onestore/src/active.rs index 7abdae2e79e56a3a4beb7e111dd2320b3cc8cc87..3d4f3ca32192e37431d4515be3c60c9326c67ff3 100644 --- a/crates/onestore/src/active.rs +++ b/crates/onestore/src/active.rs @@ -499,6 +499,8 @@ pub(crate) mod tests { #[test] fn writes_leave_the_page_that_appending_and_reading_their_revisions_does() { + // Seeded identities: the writes pick nodes in identity order. + crate::write::GUIDS.set(Some(1 << 61)); // Past 512 revisions, so the chain checkpoints once. check_writes( include_bytes!( diff --git a/crates/onestore/src/create.rs b/crates/onestore/src/create.rs index c39c23aa9ee1c14540c8df06d473eeb5c268e8e9..6122092b22c26a42f24409571cbf19d859a53f2e 100644 --- a/crates/onestore/src/create.rs +++ b/crates/onestore/src/create.rs @@ -661,3 +661,16 @@ fn create(file_name: &str, file_type: FileType, spaces: Vec) -> Result RevisionIndex::parse(&store)?.validate_current()?; Ok(output) } + +#[cfg(test)] +mod tests { + #[test] + fn initials_take_each_word_first_letter() { + // OneNote 2010 stores "snow" with "S" in the native corpus. + assert_eq!(super::initials("snow"), "S"); + assert_eq!(super::initials("Clover"), "C"); + assert_eq!(super::initials(" Snowbound Test "), "ST"); + assert_eq!(super::initials("émile zola"), "ÉZ"); + assert_eq!(super::initials(""), ""); + } +} diff --git a/crates/onestore/src/lib.rs b/crates/onestore/src/lib.rs index 065d879e8c8c54755bb14f599a51308e60dec2a1..f366c891e9cf9c2f0fb92dd8dd84e1642aadf764 100644 --- a/crates/onestore/src/lib.rs +++ b/crates/onestore/src/lib.rs @@ -24,6 +24,9 @@ mod revisions; mod section; mod snapshot; mod store; +#[cfg(test)] +#[path = "../tests/support/sweep.rs"] +mod sweep; mod toc; mod tree; mod write; diff --git a/crates/onestore/src/op/tests.rs b/crates/onestore/src/op/tests.rs index 6fafcec7d42b38ad91f38f1af19b9365e581a98c..6718cacfde026d68a838470e01746c557e253b0f 100644 --- a/crates/onestore/src/op/tests.rs +++ b/crates/onestore/src/op/tests.rs @@ -1225,11 +1225,10 @@ pub(super) fn first_difference(expected: &Page, actual: &Page) -> String { fn lowered_edits_store_the_edited_page() { let mut tally: BTreeMap<(Family, String), usize> = BTreeMap::new(); let mut mismatches = Vec::new(); - let seeds = std::env::var("OP_SEEDS").map_or(3, |s| s.parse().unwrap()); for (name, source) in SOURCES { for (p, space) in pages(source).into_iter().enumerate().take(4) { for family in FAMILIES { - for seed in 0..seeds { + for seed in crate::sweep::seeds(0..3, 1) { let mut rng = Rng(seed * 7919 + p as u64 * 131 + family as u64); let mut after = read(source, space); GUIDS.set(Some(1 << 60 | seed << 32 | (family as u64) << 20)); @@ -1621,12 +1620,12 @@ fn random_object_op(page: &Page, rng: &mut Rng) -> Option { /// the same pages. #[test] fn random_ops_read_back_as_the_model_predicts() { - let steps = std::env::var("OP_STEPS").map_or(60, |s| s.parse().unwrap()); + let steps = 60; let (mut applied, mut refused, mut unseen) = (0, 0, 0); let mut mismatches: Vec = Vec::new(); for (name, source) in SOURCES { let spaces = pages(source); - for seed in 0..2u64 { + for seed in crate::sweep::seeds(0..2, 2) { let arena = Arena::default(); let mut section = Section::open(&arena, source.to_vec()).unwrap(); let mut rng = Rng(seed * 104_729 + name.len() as u64); @@ -1952,11 +1951,10 @@ fn a_range_lowers_as_its_page_does() { Family::ParagraphPicture, Family::Attachment, Family::Equation, ]; let (mut same, mut total, mut smaller) = (0, 0, 0); - let seeds = std::env::var("OP_SEEDS").map_or(3, |s| s.parse().unwrap()); for (name, source) in SOURCES { for (p, space) in pages(source).into_iter().enumerate().take(4) { for family in families { - for seed in 0..seeds { + for seed in crate::sweep::seeds(0..3, 1) { let mut rng = Rng(seed * 7919 + p as u64 * 131 + family as u64); let before = read(source, space); let mut after = before.clone(); diff --git a/crates/onestore/src/section.rs b/crates/onestore/src/section.rs index 7588ac5631b6cd8d064121e160ff21d50d3af7e7..a27c7de6672d317066e35d791fd6678f3e3f34d8 100644 --- a/crates/onestore/src/section.rs +++ b/crates/onestore/src/section.rs @@ -928,7 +928,10 @@ mod tests { differential(source, seed as u64, 60, 1, 2); differential(source, seed as u64 + 100, 60, 5, 2); } - let created = crate::create_section("model.one", "First", "Author").unwrap(); + // Seeded identities: the writes pick nodes in identity order. + let created = seeded(7, || { + crate::create_section("model.one", "First", "Author").unwrap() + }); differential(&created, 7, 60, 3, 1); } @@ -942,7 +945,7 @@ mod tests { include_bytes!("../../../corpus/outline-edit/tree/before/notebook/synthetic.one"), include_bytes!("../../../corpus/paragraph-edit/before/notebook/synthetic.one"), ]; - for seed in 4..=8 { + for seed in crate::sweep::seeds(4..9, 5) { for source in corpus { differential(source, seed, 30, 12, 3); } diff --git a/crates/onestore/tests/commit.rs b/crates/onestore/tests/commit.rs index 0b7ea4099d653c3bc903383fa0202943b600d64e..c4cf9bc71a37cdf2ac64dfb66d5c73caf5993d01 100644 --- a/crates/onestore/tests/commit.rs +++ b/crates/onestore/tests/commit.rs @@ -209,3 +209,101 @@ fn bounded_file_reads_reject_partial_images_and_release_the_owner() { assert_eq!(fs::read(&path).unwrap(), bytes); fs::remove_file(path).unwrap(); } + +/// Zeroes the chunk reference of the file node at `offset`, as OneNote's garbage +/// collection leaves a node whose target it freed. +fn free_reference(bytes: &mut [u8], offset: usize) { + let header = u32::from_le_bytes(bytes[offset..offset + 4].try_into().unwrap()); + let stp = [8, 4, 2, 4][(header >> 23 & 3) as usize]; + let cb = [4, 8, 1, 2][(header >> 25 & 3) as usize]; + bytes[offset + 4..offset + 4 + stp + cb].fill(0); +} + +#[test] +fn garbage_collected_references_read_as_absent_and_accept_appends() { + let source = fs::read("../../corpus/page-lifecycle/removal/features/native/after/notebook/Lifecycle.one") + .unwrap(); + let store = Store::parse(&source).unwrap(); + let index = RevisionIndex::parse(&store).unwrap(); + let document = Document::parse(&index).unwrap(); + let pages = document.pages().unwrap(); + let live = |space: &ExGuid| *space == index.root || pages.iter().any(|(page, _)| page == space); + let payloads = |spaces: &mut dyn Iterator| { + let mut guids = std::collections::BTreeSet::new(); + for space in spaces { + for rid in index.spaces[space].revisions.keys() { + for object in index.resolve(*space, *rid).unwrap().objects.values() { + if let Some(onestore::FileDataReference::Internal(guid)) = + object.file_reference().unwrap() + { + guids.insert(guid); + } + } + } + } + guids + }; + let deleted: Vec<_> = index.spaces.keys().filter(|space| !live(space)).copied().collect(); + let referenced = payloads(&mut index.spaces.keys().filter(|space| live(space))); + let orphans: Vec<[u8; 16]> = store + .lists + .values() + .flat_map(|list| &list.nodes) + .filter(|node| node.id == 0x94) + .map(|node| node.payload[..16].try_into().unwrap()) + .filter(|guid| !referenced.contains(guid)) + .collect(); + assert!(!deleted.is_empty() && !orphans.is_empty()); + let mut collected = source.clone(); + for node in store.lists.values().flat_map(|list| &list.nodes) { + let tail = |width: usize| &node.payload[node.payload.len() - width..]; + let freed = match node.id { + 8 => deleted.iter().any(|space| { + tail(20) == [space.guid.as_slice(), &space.n.to_le_bytes()].concat() + }), + 0x94 => orphans.iter().any(|guid| node.payload[..16] == *guid), + _ => false, + }; + if freed { + free_reference(&mut collected, node.offset); + } + } + let store = Store::parse(&collected).unwrap(); + let index = RevisionIndex::parse(&store).unwrap(); + index.validate_current().unwrap(); + assert!(deleted.iter().all(|space| !index.spaces.contains_key(space))); + assert_eq!(Document::parse(&index).unwrap().pages().unwrap(), pages); + for guid in &orphans { + assert_eq!( + store.file_data(*guid).unwrap_err().message, + "File-data object is not declared" + ); + } + + let arena = Arena::default(); + let (space, object, transaction) = document + .spaces + .iter() + .flat_map(|(space, view)| { + let nodes = view.active().map(|revision| &revision.nodes); + nodes.into_iter().flatten().filter_map(|(id, node)| match &node.kind { + Kind::RichText { text, .. } => Some((*space, *id, text.encode_utf16().count())), + _ => None, + }) + }) + .find_map(|(space, object, length)| { + let mut section = Section::open(&arena, collected.clone()).unwrap(); + let op = PageOp::Text { + text: object, + range: 0..length as u32, + with: "After collection".into(), + }; + let edit = Edit { at: 133_700_000_000_000_000, ops: vec![Op::Page { space, op }] }; + section.apply("Author", &edit).ok()?; + Some((space, object, section.seal().unwrap().unwrap())) + }) + .unwrap(); + let mut written = collected.clone(); + transaction.apply(&mut written).unwrap(); + assert_eq!(text_of(&written, space, object), "After collection"); +} diff --git a/crates/onestore/tests/formatting.rs b/crates/onestore/tests/formatting.rs index 168ea29a9c670856cd3ef4558738e4a3756724f5..9631bb8119fb36b0a9d86ceb842603f106b4f24c 100644 --- a/crates/onestore/tests/formatting.rs +++ b/crates/onestore/tests/formatting.rs @@ -4,6 +4,8 @@ mod ops; mod current; #[path = "support/disk.rs"] mod disk; +#[path = "support/sweep.rs"] +mod sweep; use onestore::{ ExGuid, RevisionIndex, Store, TextAttribute as A, document::{Document, Kind}, @@ -49,7 +51,7 @@ fn overlapping_unicode_format_edits_match_an_independent_character_model() { let text = "abcdefgh 東京 🦀 café\rSecond\tline"; let original = onestore::create_section("format.one", text, "Author").unwrap(); let (sid, id) = target(&original); - for seed in 1..=16_u64 { + for seed in sweep::seeds(1..17, 16) { let mut rng = seed; let mut source = original.clone(); let mut expected = characters(&source, sid, id); diff --git a/crates/onestore/tests/insertion.rs b/crates/onestore/tests/insertion.rs index f24b03b3d26019258ee549e4ebb61c1a75660465..b06fb730ed426c58a5b52cfed1b1c9a5221004a1 100644 --- a/crates/onestore/tests/insertion.rs +++ b/crates/onestore/tests/insertion.rs @@ -6,6 +6,8 @@ mod current; mod disk; #[path = "support/ops.rs"] mod ops; +#[path = "support/sweep.rs"] +mod sweep; use onestore::{ ExGuid, RevisionIndex, Store, TextAttribute as A, @@ -105,7 +107,7 @@ fn atomic_formatted_insertions_match_a_character_model_and_preserve_history() { .collect(); let old_store = Store::parse(&source).unwrap(); let old_index = RevisionIndex::parse(&old_store).unwrap(); - for seed in 1..=48_u64 { + for seed in sweep::seeds(1..49, 48) { let mut random = seed; let mut expected: Vec<_> = text.chars().map(|c| (c, default.clone())).collect(); let mut formats = Vec::new(); diff --git a/crates/onestore/tests/outline.rs b/crates/onestore/tests/outline.rs index ba143b2a9943619e5edc4f3858607d96726db6a5..232e76eea2a21ec45da8ef058f09474b9ceef7ca 100644 --- a/crates/onestore/tests/outline.rs +++ b/crates/onestore/tests/outline.rs @@ -11,6 +11,8 @@ mod current; mod disk; #[path = "support/ops.rs"] mod ops; +#[path = "support/sweep.rs"] +mod sweep; const SOURCE: &[u8] = include_bytes!("../../../corpus/outline-edit/before/notebook/synthetic.one"); @@ -464,7 +466,7 @@ fn repeated_geometry_changes_and_expansion_match_an_independent_model() { let text = view.nodes[¶graph].content[0]; let (add, _, _) = ops::new_outline(144.0, 36.0, "Second"); let original = ops::page_op(&original, sid, add).unwrap().image; - for seed in 1..=16_u64 { + for seed in sweep::seeds(1..17, 16) { let mut rng = seed; let mut source = original.clone(); let mut layout = serde_json::to_value(&view.nodes[&outline].layout).unwrap(); diff --git a/crates/onestore/tests/page_deletion.rs b/crates/onestore/tests/page_deletion.rs index 109c789c7d860fb3c0c35f6939aa710f858089a2..002580cc331cde6982c37db49122ec2a92a565c3 100644 --- a/crates/onestore/tests/page_deletion.rs +++ b/crates/onestore/tests/page_deletion.rs @@ -276,7 +276,13 @@ fn tombstones_and_first_page_promotion_survive_each_storage_interruption() { old.keys().collect::>(), new.keys().collect::>() ); - for write_limit in [1, 17, 4096] { + // Byte-at-a-time writes tear the commit at every byte, a full sweep's matrix. + let limits: &[usize] = if sweep::full().is_some() { + &[1, 17, 4096] + } else { + &[17, 4096] + }; + for &write_limit in limits { let mut complete = disk::Disk { visible: source.to_vec(), durable: source.to_vec(), @@ -360,6 +366,8 @@ fn stale_removal_cannot_delete_a_newer_page_edit() { #[path = "support/page_schedule.rs"] mod page_schedule; +#[path = "support/sweep.rs"] +mod sweep; #[test] fn twelve_client_page_schedules_include_removal_empty_sections_and_stale_edits() { @@ -367,7 +375,7 @@ fn twelve_client_page_schedules_include_removal_empty_sections_and_stale_edits() 0, 192, 0, 128, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 2, 18, 0, 0, 0, 0, 0, 0, 3, 192, 0, 128, 0, 0, 0, 0, 4, 0, 0, 0, 0, 0, 0, 0, ]); - for seed in 1_u64..=32 { + for seed in sweep::seeds(1..33, 2) { let mut random = seed * 2003; let mut input = [0; 192]; for byte in &mut input { diff --git a/crates/onestore/tests/page_movement.rs b/crates/onestore/tests/page_movement.rs index f463a29bd29fbf1f5f40c06a4c1079172304e67d..6b4042e1a78b0497d2e9d5cd0825d5a1de849e32 100644 --- a/crates/onestore/tests/page_movement.rs +++ b/crates/onestore/tests/page_movement.rs @@ -11,6 +11,8 @@ use onestore::{ mod current; #[path = "support/disk.rs"] mod disk; +#[path = "support/sweep.rs"] +mod sweep; const SOURCE: &[u8] = include_bytes!("../../../corpus/page-lifecycle/04-nested/notebook/Lifecycle.one"); @@ -426,7 +428,13 @@ fn grouped_page_levels_and_membership_survive_each_storage_interruption() { let old = current::current(source); let new = current::current(prepared.as_bytes()); assert_eq!(new.len(), old.len()); - for write_limit in [1, 17, 4096] { + // Byte-at-a-time writes tear the commit at every byte, a full sweep's matrix. + let limits: &[usize] = if sweep::full().is_some() { + &[1, 17, 4096] + } else { + &[17, 4096] + }; + for &write_limit in limits { let mut complete = disk::Disk { visible: source.to_vec(), durable: source.to_vec(), diff --git a/crates/onestore/tests/support/sweep.rs b/crates/onestore/tests/support/sweep.rs new file mode 100644 index 0000000000000000000000000000000000000000..84787e041edf483d2339ad91b2b7378c69d87c98 --- /dev/null +++ b/crates/onestore/tests/support/sweep.rs @@ -0,0 +1,20 @@ +//! How far seeded sweeps reach. By default each runs its smoke slice; `SNOWBOUND_SWEEP=n` +//! runs every sweep in full with its seeds shifted by `n` (`0` for the seeds as written), and +//! a failure replays under the same value. +#![allow(dead_code)] + +use std::ops::Range; + +/// The shift `SNOWBOUND_SWEEP` asks full sweeps to run with. +pub fn full() -> Option { + let shift = std::env::var("SNOWBOUND_SWEEP").ok()?; + Some(shift.parse().expect("SNOWBOUND_SWEEP is a seed shift")) +} + +/// The seeds of `all` a sweep runs: its first `smoke`, or all of them shifted. +pub fn seeds(all: Range, smoke: u64) -> Range { + match full() { + None => all.start..all.end.min(all.start + smoke), + Some(shift) => all.start + shift..all.end + shift, + } +} diff --git a/crates/onestore/tests/tree.rs b/crates/onestore/tests/tree.rs index 1041f367712c9b3cb17fddd6000abe3a4bb54905..cfa6c40d95f3fbb4f95b12004a78368b5d54abf3 100644 --- a/crates/onestore/tests/tree.rs +++ b/crates/onestore/tests/tree.rs @@ -11,6 +11,8 @@ mod current; mod disk; #[path = "support/ops.rs"] mod ops; +#[path = "support/sweep.rs"] +mod sweep; #[path = "support/tree_model.rs"] mod tree_model; @@ -27,8 +29,9 @@ const FIXTURES: [(&[u8], &[u8]); 2] = [ #[test] fn twelve_client_tree_schedules_preserve_content_and_indentation() { - let mut random = 1932_u64; - for _ in 0..80 { + let runs = sweep::seeds(0..80, 4); + let mut random = 1932 + runs.start; + for _ in runs { let input: Vec<_> = (0..241) .map(|_| { random ^= random << 13; diff --git a/crates/snowbound/Cargo.toml b/crates/snowbound/Cargo.toml index 6187dcbcb0ce6ec2290ef922661e52a8bc86e2c5..3ab0961a1339d0c585661b2815548209e716106a 100644 --- a/crates/snowbound/Cargo.toml +++ b/crates/snowbound/Cargo.toml @@ -24,7 +24,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", "NSControl", "NSView", "NSResponder", "NSWindow", "NSApplication", "NSEvent", "NSColor", "NSColorSpace", "NSDatePicker", "NSDatePickerCell", "NSOpenPanel", "NSPanel", "NSSavePanel"] } +objc2-app-kit = { version = "0.2.2", default-features = false, features = ["NSAlert", "NSButton", "NSControl", "NSView", "NSResponder", "NSWindow", "NSApplication", "NSEvent", "NSMenu", "NSMenuItem", "NSColor", "NSColorSpace", "NSDatePicker", "NSDatePickerCell", "NSOpenPanel", "NSPanel", "NSSavePanel"] } 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/options.svg b/crates/snowbound/assets/icons/options.svg new file mode 100644 index 0000000000000000000000000000000000000000..17ba065db0c8afd48aac374efd9edba57aad5657 --- /dev/null +++ b/crates/snowbound/assets/icons/options.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/crates/snowbound/assets/icons/page.svg b/crates/snowbound/assets/icons/page.svg new file mode 100644 index 0000000000000000000000000000000000000000..eab7c5dbcb7cc041be3e791024cf1fcc7197c9cb --- /dev/null +++ b/crates/snowbound/assets/icons/page.svg @@ -0,0 +1,3 @@ + + + diff --git a/crates/snowbound/examples/search_probe.rs b/crates/snowbound/examples/search_probe.rs new file mode 100644 index 0000000000000000000000000000000000000000..6a01451e48acd551a452ef485118d240989ec432 --- /dev/null +++ b/crates/snowbound/examples/search_probe.rs @@ -0,0 +1,76 @@ +//! Times the search index as the app builds and queries it: `search_probe NOTEBOOK_FOLDER +//! [QUERY]...` reads every section file, indexes its pages, runs each query over the whole +//! notebook, and finds each query on the largest page. + +use canvas::{ + editor::CanvasEditor, + layout::TextEngine, + search::{Entry, Index, Query, page_matches}, +}; +use std::time::Instant; + +fn main() -> Result<(), Box> { + let mut args = std::env::args().skip(1); + let root = args.next().ok_or("Name a notebook folder")?; + let queries: Vec = args.collect(); + let mut files: Vec<_> = std::fs::read_dir(&root)? + .flatten() + .map(|entry| entry.path()) + .filter(|path| path.extension().is_some_and(|extension| extension == "one")) + .collect(); + files.sort(); + let mut index = Index::default(); + let (mut read, mut built, mut text) = (0.0, 0.0, 0); + let mut largest = None; + for file in &files { + let start = Instant::now(); + let pages = notebook::session::stored_pages(&onestore::read_file(file)?)?; + read += start.elapsed().as_secs_f64(); + let start = Instant::now(); + let key = file.display().to_string(); + for stored in pages { + let entry = Entry::new(&key, stored.space, &stored.page, 0); + let length = canvas::search::page_text(&stored.page).len(); + text += length; + if largest.as_ref().is_none_or(|(size, _)| length > *size) { + largest = Some((length, stored.page)); + } + index.set(entry); + } + built += start.elapsed().as_secs_f64(); + } + println!( + "{} sections, {} pages, {} KB of text: read {:.0} ms, indexed {:.1} ms", + files.len(), + index.len(), + text / 1024, + read * 1e3, + built * 1e3 + ); + let (_, page) = largest.ok_or("No pages")?; + let start = Instant::now(); + let entry = Entry::new("again", onestore::ExGuid::default(), &page, 0); + println!( + "largest page, {:?}: indexed again in {:.2} ms", + page.title, + start.elapsed().as_secs_f64() * 1e3 + ); + drop(entry); + let mut engine = TextEngine::default(); + let editor = CanvasEditor::from_page(page, &mut engine)?; + for query in &queries { + let parsed = Query::new(query); + let start = Instant::now(); + let found = index.search(&parsed, |_| true); + let searched = start.elapsed().as_secs_f64() * 1e3; + let start = Instant::now(); + let matches = page_matches(&editor, &parsed); + let found_on_page = start.elapsed().as_secs_f64() * 1e3; + println!( + "{query:?}: {} pages in {searched:.2} ms; {} matches on the largest page in {found_on_page:.2} ms", + found.len(), + matches.len() + ); + } + Ok(()) +} diff --git a/crates/snowbound/src/art.rs b/crates/snowbound/src/art.rs index 7a467f129394d5dbad86fb2ea9150eed1b6a13e8..278e733d7b1d4e30811c8fe85f406fb7ae721d93 100644 --- a/crates/snowbound/src/art.rs +++ b/crates/snowbound/src/art.rs @@ -26,7 +26,9 @@ pub const ITALIC: &[&str] = art!("icons/italic"); pub const LINK: &[&str] = art!("icons/link"); pub const NOTEBOOK: &[&str] = art!("icons/notebook"); pub const NUMBERING: &[&str] = art!("icons/numbering"); +pub const OPTIONS: &[&str] = art!("icons/options"); pub const OUTDENT: &[&str] = art!("icons/outdent"); +pub const PAGE: &[&str] = art!("icons/page"); pub const PICTURE: &[&str] = art!("icons/picture"); pub const PLUS: &[&str] = art!("icons/plus"); pub const REDO: &[&str] = art!("icons/redo"); diff --git a/crates/snowbound/src/conflict_render.rs b/crates/snowbound/src/conflict_render.rs index 8459cceb798002d0b11cad30d2a964a4686ae0d1..33bb5f243abc3fa99093526dae4fe1a9ddb65430 100644 --- a/crates/snowbound/src/conflict_render.rs +++ b/crates/snowbound/src/conflict_render.rs @@ -58,7 +58,7 @@ fn layout(ui: &mut Ui, session: &Session, menu: bool) -> Vec { ..Spec::default() }, ); - let rows = page_rows(ui, &theme, §ion, session, ""); + let rows = page_rows(ui, &theme, §ion, session, &HashSet::new()); commands.extend(rows.clicked.map(Command::OpenPage)); ui.close(); ui.close(); diff --git a/crates/snowbound/src/linux.rs b/crates/snowbound/src/linux.rs index f6408c04f2cecec9526c045a9dbf7ae641ff1910..0c6423436fc3bf0728d951a4861cac50597b8d5b 100644 --- a/crates/snowbound/src/linux.rs +++ b/crates/snowbound/src/linux.rs @@ -163,6 +163,9 @@ pub fn zoom(window: &Window) { /// Winit delivers typed text with its key events on Linux. pub fn install_text_input(_: &Window) {} +/// Options opens from the sidebar's footer and Ctrl+Comma, as Linux apps have no shared menu. +pub fn install_menu() {} + pub fn clear_marked_text(_: &Window) {} pub fn configure_presentation(_: &wgpu::Surface<'_>) {} @@ -540,6 +543,13 @@ fn dialog( .then(|| String::from_utf8_lossy(&output.stdout).trim().to_owned())) } +/// Opens `folder` in the file manager. +pub fn reveal(folder: &std::path::Path) { + if let Err(error) = Command::new("xdg-open").arg(folder).spawn() { + eprintln!("Cannot open {}: {error}", folder.display()); + } +} + /// Asks whether to go ahead with `action`; false when no tool can ask. pub fn confirm(message: &str, detail: &str, cancel: &str, action: &str) -> bool { let status = Command::new("zenity") diff --git a/crates/snowbound/src/macos.rs b/crates/snowbound/src/macos.rs index be31fe80e4ff6ee1eb0ef37d12e702ec7debd8e7..7b48ce64ad94239aafa821c1d4314077e243da98 100644 --- a/crates/snowbound/src/macos.rs +++ b/crates/snowbound/src/macos.rs @@ -10,6 +10,7 @@ use objc2::{ use objc2_app_kit::{ NSAlert, NSAlertFirstButtonReturn, NSAlertSecondButtonReturn, NSApplication, NSColor, NSColorSpace, NSDatePicker, NSDatePickerElementFlags, NSDatePickerStyle, NSEvent, NSEventType, + NSMenuItem, }; use objc2_foundation::{ MainThreadMarker, NSAttributedString, NSCalendar, NSCalendarUnit, NSDate, NSDateFormatter, @@ -634,6 +635,11 @@ declare_class!( if let Some(proxy) = QUIT.get() { let _ = proxy.send_event(crate::UserEvent::Quit); } } + #[method(showOptions:)] + fn show_options(&self, _sender: Option<&AnyObject>) { + if let Some(proxy) = QUIT.get() { let _ = proxy.send_event(crate::UserEvent::Options); } + } + // NSAlert consumes Escape before it reaches cancelOperation:. #[method(sendEvent:)] fn send_event(&self, event: &NSEvent) { @@ -670,6 +676,37 @@ pub fn event_loop(headless: bool) -> Result, EventLo Ok(event_loop) } +/// Adds Settings… to the application menu Winit made, below About as macOS places it. +pub fn install_menu() { + let mtm = MainThreadMarker::new().expect("Menus belong to the main thread"); + let app = NSApplication::sharedApplication(mtm); + // Winit's menu is plain AppKit objects on the main thread. + unsafe { + let Some(menu) = app + .mainMenu() + .and_then(|bar| bar.itemAtIndex(0)) + .and_then(|item| item.submenu()) + else { + return; + }; + let settings = NSMenuItem::initWithTitle_action_keyEquivalent( + mtm.alloc(), + &NSString::from_str("Settings…"), + Some(sel!(showOptions:)), + &NSString::from_str(","), + ); + menu.insertItem_atIndex(&settings, 2); + menu.insertItem_atIndex(&NSMenuItem::separatorItem(mtm), 3); + } +} + +/// Opens `folder` in Finder. +pub fn reveal(folder: &std::path::Path) { + if let Err(error) = std::process::Command::new("open").arg(folder).spawn() { + eprintln!("Cannot open {}: {error}", folder.display()); + } +} + /// Asks whether to go ahead with `action`, offering `cancel` first. pub fn confirm(message: &str, detail: &str, cancel: &str, action: &str) -> bool { let mtm = MainThreadMarker::new().expect("Window events run on the main thread"); diff --git a/crates/snowbound/src/main.rs b/crates/snowbound/src/main.rs index 6943aa57df2462682b98ecc789108d78ac4efeb3..6c76877efbbf48280c4c7ea9d2a8e46b28b9e24e 100644 --- a/crates/snowbound/src/main.rs +++ b/crates/snowbound/src/main.rs @@ -5,10 +5,12 @@ mod library; mod manage; mod meeting; mod menus; +mod options; #[cfg_attr(target_os = "linux", path = "linux.rs")] #[cfg_attr(target_os = "macos", path = "macos.rs")] mod platform; mod screenshot; +mod search; mod settings; mod sidebar; mod templates; @@ -35,7 +37,7 @@ use std::{ collections::{HashMap, HashSet}, error::Error, path::{Path, PathBuf}, - sync::{Arc, mpsc}, + sync::{Arc, Mutex, mpsc}, time::Instant, }; use ui::{Axis, Flags, Id, Spec, Theme, Ui, children, fill, fit, px}; @@ -102,6 +104,9 @@ enum UserEvent { Replay(Replay), /// A page background finished rasterizing on its worker thread. Redraw, + /// The app menu's Settings item was chosen. + #[cfg_attr(not(target_os = "macos"), allow(dead_code))] + Options, } /// Asks the event loop for a frame from any thread. @@ -316,8 +321,13 @@ enum Shown { Page(ExGuid), } -/// A loader thread's request number and what it read. -type Read = (u64, Result); +/// A loader thread's request number and what it read, its page laid out. +type Read = (u64, Result); + +enum Laid { + Page(Box), + Library(Arc, String), +} /// The newest page read, laid out and waiting for the pictures it shows first, so it /// never appears without them. @@ -367,12 +377,13 @@ enum Command { struct State { window: Arc, - /// Who this machine's edits name, as OneNote names the Office user: the account's - /// full name. + /// The user name edits are stored under, as OneNote names the Office user. author: String, proxy: EventLoopProxy, /// Results of reads done off the frame thread, by request number. loads: (mpsc::Sender, mpsc::Receiver), + /// Lays out pages the loader threads read, off the frame. + layouts: Arc>, /// The newest read requested; older ones are dropped when they finish. loading: u64, opening: Option, @@ -404,6 +415,10 @@ struct State { folded: HashSet, /// The open context menu: what it was opened on, and where. menu: Option<(menus::Target, [f32; 2])>, + /// The Options dialog's choices while it is open. + options: Option, + color_scheme: settings::ColorScheme, + light_pages: bool, /// A section or group being renamed in the sidebar. renaming: Option, /// What the pointer is dragging: a page's tab, a section tab or a sidebar row. @@ -415,8 +430,7 @@ struct State { /// What the template strip shows over a blank page. templates: templates::View, thumbnails: templates::Thumbnails, - /// Text filtering the page list. - filter: String, + search: search::Search, /// Whether the page list is shown beside the page. pages_open: bool, /// Installed font families, for the font box; listing them is slow. @@ -466,11 +480,6 @@ fn frame() -> Id { Id::ROOT.child("frame") } -/// The field filtering the page list. -fn filter() -> Id { - Id::ROOT.child("filter") -} - impl State { async fn new( event_loop: &ActiveEventLoop, @@ -505,6 +514,7 @@ impl State { )?, ); platform::install_text_input(&window); + platform::install_menu(); let access_adapter = accesskit_winit::Adapter::with_event_loop_proxy(event_loop, &window, proxy.clone()); window.set_visible(visible); @@ -532,6 +542,7 @@ impl State { .register_substitute(parley::fontique::Blob::new(Arc::new(std::fs::read(path)?)))?; eprintln!("Using {} for {target}", path.display()); } + let layouts = Arc::new(Mutex::new(engine.clone())); let temporary = matches!(input, Input::Notes { .. } | Input::Page(_)); let mut notebooks = Vec::new(); let mut session = None; @@ -617,8 +628,15 @@ impl State { window.set_ime_allowed(true); window.request_redraw(); eprintln!("Canvas GPU: {:?}; scale factor {dpr}", adapter.get_info()); + window.set_theme(stored.color_scheme.theme()); let mut ui = Ui::new( - theme(platform::appearance(&window)), + theme( + stored + .color_scheme + .theme() + .unwrap_or_else(|| platform::appearance(&window)), + stored.light_pages, + ), platform::double_click_interval(), ); if !interface_font.is_empty() { @@ -644,10 +662,12 @@ impl State { fonts.sort_unstable_by_key(|name| name.to_lowercase()); fonts.dedup(); let clipboard = platform::Clipboard::new(&window)?; + let redraw: std::task::Waker = Arc::new(Redraw(proxy.clone())).into(); + let search = search::Search::new(stored.search_scope, redraw.clone()); let state = Self { - author: platform::user_name(), + author: stored.user_name.unwrap_or_else(platform::user_name), window, - redraw: Arc::new(Redraw(proxy.clone())).into(), + redraw, proxy, instance, surface, @@ -672,11 +692,14 @@ impl State { dismissed: HashSet::new(), title_focus: None, menu: None, + options: None, + color_scheme: stored.color_scheme, + light_pages: stored.light_pages, renaming: None, drag: None, templates: templates::View::Strip, thumbnails: templates::Thumbnails::default(), - filter: String::new(), + search, pages_open: true, fonts, recent_fonts: stored.recent_fonts, @@ -697,6 +720,7 @@ impl State { ime_allowed: true, clipboard, loads: mpsc::channel(), + layouts, loading: 0, opening: None, places: HashMap::new(), @@ -732,7 +756,7 @@ impl State { let ime = if self.ui.focused() == Some(page()) { self.view.accepts_text() } else { - self.ui.focused() == Some(filter()) + search::takes_text(self.ui.focused()) }; if self.ime_allowed != ime { self.ime_allowed = ime; @@ -740,6 +764,7 @@ impl State { } self.draw()?; lap("drawn", start); + self.sync_index(false); let commands = std::mem::take(&mut self.commands); let follow = !commands.is_empty() || self.ui.wants_frame() || self.opening.is_some(); for command in commands { @@ -767,6 +792,7 @@ impl State { fn layout(&mut self, size: [f32; 2], scale: f32) -> Result<(), Box> { self.ui.begin(size, scale, Instant::now()); let (section, open_tab, open_page) = self.build()?; + self.options_dialog(); self.ui.end(); self.edges(section, open_tab, open_page); if let Some(rect) = self.ui.rect(page()) { @@ -1554,9 +1580,6 @@ impl State { /// The search box and the page list's buttons, above the list. fn page_tools(&mut self, theme: &Theme) { - let Some(session) = &self.session else { - return; - }; self.ui.open( "tools", Spec { @@ -1566,60 +1589,8 @@ impl State { ..Spec::default() }, ); - let focused = self.ui.focused() == Some(filter()); - self.ui.open( - "search", - Spec { - size: [fill(), px(ui::shell::TOOL)], - fill: Some(theme.base), - border: Some(if focused { theme.accent } else { theme.chip }), - radius: 4.0, - pad: [6.0, 0.0], - ..Spec::default() - }, - ); - self.ui.leaf( - "icon", - Spec { - size: [fit(), px(ui::shell::TOOL)], - icon: Some(art::SEARCH), - color: Some(theme.text_dim), - ..Spec::default() - }, - ); - let signal = ui::text_field( - &mut self.ui, - filter(), - &mut self.filter, - "Search pages", - Spec { - size: [fill(), px(ui::shell::TOOL)], - pad: [6.0, 0.0], - ..Spec::default() - }, - ); - if !self.filter.is_empty() - && ui::shell::tool_button(&mut self.ui, "clear", art::CLOSE, theme.text_dim, false) - .clicked - { - self.filter.clear(); - } - self.ui.close(); - for event in &signal.events { - if let ui::Event::Key { - key: Key::Named(key @ (NamedKey::Escape | NamedKey::Enter)), - .. - } = event - { - let first = matching(&session.pages, &self.filter).next(); - if *key == NamedKey::Enter - && let Some((space, ..)) = first.filter(|(space, ..)| *space != session.space) - { - self.commands.push(Command::OpenPage(*space)); - } - self.filter.clear(); - self.ui.set_focus(Some(page())); - } + 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 }); @@ -1652,7 +1623,10 @@ impl State { ..Spec::default() }, ); - let rows = page_rows(&mut self.ui, theme, section, session, &self.filter); + let found = self + .search + .found_in(&session.library.key(&session.tabs[session.tab].path)); + let rows = page_rows(&mut self.ui, theme, section, session, &found); self.commands.extend(rows.clicked.map(Command::OpenPage)); if let Some((space, point)) = rows.context { self.menu = Some((menus::Target::Page(space), point)); @@ -1718,11 +1692,19 @@ impl State { ui::Event::Wheel(delta) => self.view.wheel([delta[0] * scale, delta[1] * scale])?, ui::Event::Key { key, text } => { let modifiers = self.view.modifiers(); - let find = matches!(&key, Key::Character(character) - if character.eq_ignore_ascii_case("f")); - if modifiers.command && !modifiers.shift && find && self.session.is_some() { - self.ui.set_focus(Some(filter())); - continue; + // OneNote's Find on This Page is Ctrl+F, its search Ctrl+E. + if modifiers.command && !modifiers.shift && self.session.is_some() { + match &key { + Key::Character(character) if character.eq_ignore_ascii_case("f") => { + self.start_find(false)?; + continue; + } + Key::Character(character) if character.eq_ignore_ascii_case("e") => { + self.start_search(); + continue; + } + _ => {} + } } // OneNote's New Page is Ctrl+N. let new = matches!(&key, Key::Character(character) @@ -1819,10 +1801,20 @@ impl State { Command::NewNotebook => self.new_notebook()?, Command::CloseNotebook(library) => self.close_notebook(&library), Command::Structure(library, change) => self.restructure(library, change), - Command::NewPage { under } => self.new_page(under)?, - Command::DeletePages(pages) => self.delete_pages(pages)?, + // The index follows the section's page list. + Command::NewPage { under } => { + self.new_page(under)?; + self.edited(Vec::new()); + } + Command::DeletePages(pages) => { + self.delete_pages(pages)?; + self.edited(Vec::new()); + } Command::Pages(edits) => self.edit_pages(edits)?, - Command::MovePage { space, path } => self.move_page(space, path)?, + Command::MovePage { space, path } => { + self.move_page(space, path)?; + self.edited(Vec::new()); + } Command::Template(choice) => self.apply_template(choice)?, Command::Page(Request::EditDate(field)) => self.edit_date(field)?, Command::Page(Request::Copy(text)) => self.clipboard.set_text(text)?, @@ -1837,48 +1829,52 @@ impl State { Ok(()) } - /// Runs `read` on a thread of its own; `open_loaded` shows what it read unless a newer - /// read was asked for meanwhile. + /// Runs `read` on a thread of its own, which lays out the page it read; `open_loaded` + /// shows it unless a newer read was asked for meanwhile. fn load( &mut self, read: impl FnOnce() -> Result> + Send + 'static, ) { self.loading += 1; let (id, sender, redraw) = (self.loading, self.loads.0.clone(), self.redraw.clone()); + let layouts = Arc::clone(&self.layouts); std::thread::spawn(move || { - let _ = sender.send((id, read().map_err(|error| error.to_string()))); + let laid = read().and_then(|loaded| { + let (shown, page) = match loaded { + Loaded::Section(session, page) => (Shown::Section(session), page), + Loaded::Page(space, page) => (Shown::Page(space), page), + Loaded::Library(library, path) => return Ok(Laid::Library(library, path)), + }; + let mut engine = layouts.lock().map_err(|_| "Page layout failed")?; + let (scene, editor) = PageScene::from_page(page, &mut engine)?; + Ok(Laid::Page(Box::new(Opening { + loaded: shown, + scene: (scene, [0.0; 2]), + editor, + since: Instant::now(), + }))) + }); + let _ = sender.send((id, laid.map_err(|error| error.to_string()))); redraw.wake(); }); } - /// Lays out the newest page read, and shows it once the pictures it shows first are - /// drawn, or after `HOLD`. + /// Shows the newest page read once the pictures it shows first are drawn, or after + /// `HOLD`. fn open_loaded(&mut self) -> Result<(), Box> { let reads: Vec = self.loads.1.try_iter().collect(); for (id, loaded) in reads { if id != self.loading { continue; } - let (loaded, page) = match loaded { - Ok(Loaded::Section(session, page)) => (Shown::Section(session), page), - Ok(Loaded::Page(space, page)) => (Shown::Page(space), page), - Ok(Loaded::Library(library, path)) => { - self.adopt(library, &path)?; - continue; - } + match loaded { + Ok(Laid::Page(opening)) => self.opening = Some(*opening), + Ok(Laid::Library(library, path)) => self.adopt(library, &path)?, Err(error) => { eprintln!("{error}"); platform::alert("Couldn't open", &error); - continue; } - }; - let (scene, editor) = PageScene::from_page(page, &mut self.view.engine)?; - self.opening = Some(Opening { - loaded, - scene: (scene, [0.0; 2]), - editor, - since: Instant::now(), - }); + } } let Some(mut opening) = self.opening.take() else { return Ok(()); @@ -1905,6 +1901,11 @@ impl State { } match opening.loaded { Shown::Section(session) => { + // The settings keep the notebook shown, not the section. + let other_notebook = self + .session + .as_ref() + .is_none_or(|open| open.library.location != session.library.location); // A notebook read again after a change replaces the one it was. match self .notebooks @@ -1915,8 +1916,9 @@ impl State { None => self.notebooks.push(Arc::clone(&session.library)), } self.session = Some(*session); - self.filter.clear(); - self.save_settings(); + if other_notebook { + self.save_settings(); + } } Shown::Page(space) => { let session = self.session.as_mut().ok_or("No section is open")?; @@ -1932,6 +1934,7 @@ impl State { }); self.view.open(opening.editor, Some(opening.scene), place); self.opened()?; + self.refind(true)?; if self.title_focus.take().is_some_and(|space| { self.session .as_ref() @@ -1987,7 +1990,10 @@ impl State { /// Follows a page shown in place of another. fn opened(&mut self) -> Result<(), Box> { - self.ui.set_focus(Some(page())); + // A page a search result shows leaves the keys with the search. + if !search::takes_text(self.ui.focused()) { + self.ui.set_focus(Some(page())); + } self.title(); self.update_accessibility()?; self.window.request_redraw(); @@ -2000,6 +2006,7 @@ impl State { let start = Instant::now(); let saved = self.persist(); lap("save", start); + self.refind(false)?; self.title(); self.after_move()?; saved @@ -2167,6 +2174,7 @@ impl State { if self.view.editor.active_outline().title { session.pages = session.section.pages()?; } + self.edited(vec![space]); Ok(()) } @@ -2225,6 +2233,11 @@ impl State { Event::Changed(spaces) => { listed = true; changed |= spaces.contains(&session.space); + self.search.changed( + session.library.key(&session.tabs[session.tab].path), + session.section.replica(), + spaces, + ); continue; } Event::Rejected { spaces, error } => { @@ -2600,16 +2613,33 @@ impl State { } return; } - self.ui.event(event); + // Settings' shortcut, which AppKit's menu takes first on macOS. + if let ui::Event::Key { + key: Key::Character(character), + .. + } = &event + && character == "," + && ui::edit_modifiers(self.ui.modifiers()).command + { + self.open_options(); + } else { + self.ui.event(event); + } self.window.request_redraw(); } } -fn theme(appearance: winit::window::Theme) -> Theme { - match appearance { +/// The interface's colours in `appearance`, on white pages when `light_pages`. +fn theme(appearance: winit::window::Theme, light_pages: bool) -> Theme { + let mut theme = match appearance { winit::window::Theme::Dark => Theme::dark(), winit::window::Theme::Light => Theme::light(), + }; + if light_pages { + let light = Theme::light(); + [theme.paper, theme.paper_ink] = [light.paper, light.paper_ink]; } + theme } /// A section's colour as linear RGBA; sections without one take OneNote's default blue. @@ -2754,17 +2784,17 @@ struct Rows { held: Option, } -/// The page list's rows for the pages matching `filter`, a page's conflict pages beneath it -/// while shown. +/// The page list's rows, a page's conflict pages beneath it while shown and the pages a +/// search `found` marked. fn page_rows( ui: &mut Ui, theme: &Theme, section: &ui::Section, session: &Session, - filter: &str, + found: &HashSet, ) -> Rows { let mut rows = Rows::default(); - for (space, title, level) in matching(&session.pages, filter) { + for (space, title, level) in &session.pages { let versions = session.versions(*space); let tab = PageTab { label: if title.is_empty() { @@ -2775,6 +2805,7 @@ fn page_rows( dim: title.is_empty(), indent: level.saturating_sub(1), conflicted: !versions.is_empty(), + found: found.contains(space), }; let selected = *space == session.space; if selected { @@ -2809,6 +2840,7 @@ fn page_rows( dim: false, indent: level.saturating_sub(1), conflicted: false, + found: false, }; let selected = version.space == session.space; if selected { @@ -2830,6 +2862,8 @@ struct PageTab<'a> { indent: u32, /// The page has conflict pages, which OneNote marks at the tab's end. conflicted: bool, + /// A search found the page, which OneNote marks yellow. + found: bool, } /// A page's tab down the frame's right side: the open one is the page's colour and joins @@ -2857,9 +2891,14 @@ fn page_tab( }; (spec, theme.paper_ink) } else { + let fill = if tab.found { + ui::mix(section.tab, draw::srgb(0xff, 0xd8, 0x30), 0.6) + } else { + section.tab + }; let spec = Spec { - fill: Some(section.tab), - hover_fill: Some(ui::mix(section.tab, section.accent, 0.3)), + fill: Some(fill), + hover_fill: Some(ui::mix(fill, section.accent, 0.3)), radius: ROUNDING, inset: [PILL_MARGIN, 0.0, 6.0, ROW_GAP], // The label keeps its place as the tab opens and closes. @@ -2899,17 +2938,6 @@ fn page_tab( ui.signal(row) } -/// The pages whose titles contain `filter`, ignoring case. -fn matching<'a>( - pages: &'a [(ExGuid, String, u32)], - filter: &str, -) -> impl Iterator { - let filter = filter.to_lowercase(); - pages - .iter() - .filter(move |(_, title, _)| title.to_lowercase().contains(&filter)) -} - /// A toolbar group's rows, stacked. fn group(ui: &mut Ui, part: &str, rows: impl FnOnce(&mut Ui)) { ui.open( @@ -3083,6 +3111,13 @@ impl ApplicationHandler for App { } return; } + UserEvent::Options => { + if let Some(state) = &mut self.state { + state.open_options(); + state.window.request_redraw(); + } + return; + } UserEvent::Redraw => { if let Some(state) = &self.state { state.window.request_redraw(); @@ -3097,7 +3132,7 @@ impl ApplicationHandler for App { Replay::Tick => {} Replay::Appearance(appearance) => { state.window.set_theme(Some(appearance)); - state.ui.theme = theme(appearance); + state.ui.theme = theme(appearance, state.light_pages); } Replay::Quit => { self.close(event_loop); @@ -3206,7 +3241,8 @@ impl ApplicationHandler for App { return state.frame(); } WindowEvent::ThemeChanged(appearance) => { - state.ui.theme = theme(appearance); + let chosen = state.color_scheme.theme().unwrap_or(appearance); + state.ui.theme = theme(chosen, state.light_pages); state.window.request_redraw(); } WindowEvent::ScaleFactorChanged { scale_factor, .. } => { diff --git a/crates/snowbound/src/menus.rs b/crates/snowbound/src/menus.rs index 9ec7fee52f9107de5bf5b8e0ff80f749acbee5d8..48c3e7c07a9f9faeca75c604ea1db6f47e58ee3e 100644 --- a/crates/snowbound/src/menus.rs +++ b/crates/snowbound/src/menus.rs @@ -194,9 +194,6 @@ impl State { let Some(session) = &self.session else { return; }; - if held.is_some() && !self.filter.is_empty() { - return; - } let dropped = match (&self.drag, held) { (_, Some(space)) => Some(space), (Some(Drag::Page(space)), None) => Some(*space), diff --git a/crates/snowbound/src/options.rs b/crates/snowbound/src/options.rs new file mode 100644 index 0000000000000000000000000000000000000000..b3e1f8df21ed26b5c0fc26781274e3967091ee38 --- /dev/null +++ b/crates/snowbound/src/options.rs @@ -0,0 +1,303 @@ +//! OneNote's Options dialog: pages of settings listed on the left as OneNote 2010 lists +//! them, grouped under the same headings, each kept when OK is chosen. + +use crate::{State, platform, settings::ColorScheme}; +use ui::{Anchor, Axis, Flags, Id, Spec, Theme, Ui, children, fill, popup::Item, px}; +use winit::keyboard::NamedKey; + +const WIDTH: f32 = 640.0; +/// The pages' area, the same for every page so the dialog keeps its size. +const HEIGHT: f32 = 320.0; +const NAV: f32 = 150.0; +/// The column the fields' labels share. +const LABEL: f32 = 104.0; +const SCHEMES: [(ColorScheme, &str); 3] = [ + (ColorScheme::System, "System"), + (ColorScheme::Light, "Light"), + (ColorScheme::Dark, "Dark"), +]; + +#[derive(Clone, Copy, PartialEq)] +enum Page { + General, + Display, + SaveBackup, +} + +impl Page { + const ALL: [(Page, &str); 3] = [ + (Page::General, "General"), + (Page::Display, "Display"), + (Page::SaveBackup, "Save & Backup"), + ]; +} + +/// The page shown and what the fields hold until OK keeps them. +pub struct Options { + page: Page, + user_name: String, + color_scheme: ColorScheme, + light_pages: bool, +} + +fn id() -> Id { + Id::ROOT.child("options") +} + +fn user_name() -> Id { + id().child("user-name") +} + +fn schemes() -> Id { + id().child("color-schemes") +} + +impl State { + pub(crate) fn open_options(&mut self) { + self.options = Some(Options { + page: Page::General, + user_name: self.author.clone(), + color_scheme: self.color_scheme, + light_pages: self.light_pages, + }); + self.ui.open_popup(id()); + self.ui.set_focus(Some(user_name())); + } + + /// Builds the Options dialog while it is open. OK, or Enter in a field, keeps its choices + /// with a non-empty user name; Cancel, Escape or a press outside leave them. + pub(crate) fn options_dialog(&mut self) { + let Some(options) = &mut self.options else { + return; + }; + let ui = &mut self.ui; + if !ui.popup_open(id()) { + self.options = None; + return; + } + let theme = ui.theme.clone(); + let row = theme.font_size * 2.0; + let window = ui.rect(Id::ROOT).unwrap_or_default(); + let height = ui.rect(id()).map_or(HEIGHT, |rect| rect[3] - rect[1]); + let [left, top] = [ + ((window[2] - WIDTH) / 2.0).max(0.0), + ((window[3] - height) / 2.0).max(0.0), + ]; + let entered = ui::popup::navigation(ui, &[user_name()], &[NamedKey::Enter]) + .contains(&NamedKey::Enter); + ui.open_as( + id(), + Spec { + axis: Axis::Y, + size: [px(WIDTH), children()], + fill: Some(theme.popup), + border: Some(theme.chip), + shadow: Some(theme.shadow), + radius: 8.0, + pad: [6.0, 6.0], + anchor: Some(Anchor::Below([left, top, left, top])), + ..Spec::default() + }, + ); + ui.open( + "body", + Spec { + size: [fill(), px(HEIGHT)], + ..Spec::default() + }, + ); + ui.open( + "pages", + Spec { + axis: Axis::Y, + size: [px(NAV), fill()], + fill: Some(theme.panel), + radius: 5.0, + pad: [6.0, 6.0], + gap: 2.0, + ..Spec::default() + }, + ); + for (page, name) in Page::ALL { + let shown = page == options.page; + let spec = Spec { + flags: Flags::CLICKABLE, + size: [fill(), px(row)], + text: Some(name), + bold: shown, + fill: shown.then(|| theme.hover()), + hover_fill: Some(theme.hover()), + radius: 4.0, + pad: [8.0, 0.0], + ..Spec::default() + }; + if ui.leaf(name, spec).clicked { + options.page = page; + } + } + ui.close(); + ui.open( + "page", + Spec { + axis: Axis::Y, + size: [fill(), fill()], + pad: [16.0, 10.0], + gap: 6.0, + ..Spec::default() + }, + ); + match options.page { + Page::General => { + heading(ui, &theme, "User Interface Options"); + field(ui, "Color scheme:", |ui| { + let combo = ui.id("combo"); + let current = SCHEMES + .iter() + .find(|(scheme, _)| *scheme == options.color_scheme) + .map_or("", |(_, name)| name); + if ui::shell::combo(ui, "combo", current, 140.0).pressed { + ui.open_popup(schemes()); + } + let items = SCHEMES.map(|(scheme, name)| Item { + text: name, + checked: scheme == options.color_scheme, + ..Item::default() + }); + let anchor = Anchor::Below(ui.rect(combo).unwrap_or_default()); + if let Some(index) = ui::popup::menu(ui, schemes(), anchor, &items, None) { + options.color_scheme = SCHEMES[index].0; + } + }); + heading(ui, &theme, "Personalize"); + field(ui, "User name:", |ui| { + ui::text_field( + ui, + user_name(), + &mut options.user_name, + "", + Spec { + size: [px(260.0), px(row)], + fill: Some(theme.base), + border: Some(theme.accent), + radius: 4.0, + pad: [6.0, 0.0], + ..Spec::default() + }, + ); + }); + } + Page::Display => { + heading(ui, &theme, "Display"); + let dark = !options.light_pages; + if ui::check_box(ui, "dark-pages", "Pages appear dark in the Dark color scheme", dark) + .clicked + { + options.light_pages = dark; + } + } + Page::SaveBackup => { + heading(ui, &theme, "Cache file location"); + let cache = &self.cache; + field(ui, "Path:", |ui| { + ui.leaf( + "path", + Spec { + flags: Flags::CLIP, + size: [fill(), px(row)], + text: Some(&cache.to_string_lossy()), + color: Some(theme.text_dim), + ..Spec::default() + }, + ); + let label = if cfg!(target_os = "macos") { + "Show in Finder" + } else { + "Open Folder" + }; + if ui::button(ui, "reveal", label).clicked { + platform::reveal(cache); + } + }); + } + } + ui.close(); + ui.close(); + ui.open( + "buttons", + Spec { + size: [fill(), children()], + pad: [10.0, 8.0], + gap: 8.0, + ..Spec::default() + }, + ); + ui.leaf( + "space", + Spec { + size: [fill(), px(1.0)], + ..Spec::default() + }, + ); + let cancel = ui::button(ui, "cancel", "Cancel").clicked; + let ok = ui::button(ui, "ok", "OK").clicked || entered; + ui.close(); + ui.close(); + let name = options.user_name.trim(); + if ok && !name.is_empty() { + self.author = name.to_owned(); + self.color_scheme = options.color_scheme; + self.light_pages = options.light_pages; + self.window.set_theme(self.color_scheme.theme()); + let appearance = self + .color_scheme + .theme() + .unwrap_or_else(|| platform::appearance(&self.window)); + self.ui.theme = crate::theme(appearance, self.light_pages); + self.save_settings(); + } else if !cancel { + return; + } + self.ui.close_popup(id()); + self.options = None; + } +} + +/// A group's heading on a band, as OneNote heads the groups of its Options pages. +fn heading(ui: &mut Ui, theme: &Theme, text: &str) { + ui.leaf( + text, + Spec { + size: [fill(), px(theme.font_size * 1.8)], + text: Some(text), + bold: true, + fill: Some(theme.strip), + radius: 4.0, + pad: [8.0, 0.0], + ..Spec::default() + }, + ); +} + +/// A row of `label` in the labels' column and the controls `build` adds after it. +fn field(ui: &mut Ui, label: &str, build: impl FnOnce(&mut Ui)) { + let row = ui.theme.font_size * 2.0; + ui.open( + label, + Spec { + size: [fill(), px(row)], + pad: [8.0, 0.0], + gap: 8.0, + ..Spec::default() + }, + ); + ui.leaf( + "label", + Spec { + size: [px(LABEL), px(row)], + text: Some(label), + ..Spec::default() + }, + ); + build(ui); + ui.close(); +} diff --git a/crates/snowbound/src/screenshot.rs b/crates/snowbound/src/screenshot.rs index b6fddb09e7adf32491a6c8af147c9ded852722f3..03e1856c562e5d1405e3e47027feb726f4fba69c 100644 --- a/crates/snowbound/src/screenshot.rs +++ b/crates/snowbound/src/screenshot.rs @@ -37,7 +37,7 @@ impl State { } for (name, appearance) in [("light", Appearance::Light), ("dark", Appearance::Dark)] { self.window.set_theme(Some(appearance)); - self.ui.theme = theme(appearance); + self.ui.theme = theme(appearance, self.light_pages); // The page's box sizes the view whose pictures settle. self.layout(SIZE, SCALE)?; let paper = self.paper(); diff --git a/crates/snowbound/src/search.rs b/crates/snowbound/src/search.rs new file mode 100644 index 0000000000000000000000000000000000000000..2368eee8476b44d89a91d3ea1f4059b8ec744f6b --- /dev/null +++ b/crates/snowbound/src/search.rs @@ -0,0 +1,1212 @@ +//! OneNote 2010's search: the box beside the section tabs searching the open notebooks, its +//! 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::{Command, Library, State, Theme, art, lap, page}; +use canvas::search::{Entry, Found, Index, Query, page_matches}; +use notebook::Replica; +use onestore::ExGuid; +use std::{ + collections::{HashMap, HashSet}, + error::Error, + path::{Path, PathBuf}, + sync::{ + Arc, Mutex, Weak, + atomic::{AtomicBool, AtomicU64, Ordering}, + mpsc, + }, + time::{Duration, Instant, SystemTime}, +}; +use ui::{Anchor, Axis, Flags, Id, Spec, Ui, children, fill, fit, px}; +use winit::keyboard::NamedKey; + +/// Where a search looks, as OneNote's scope menu offers it. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub enum Scope { + Section, + Group, + Notebook, + #[default] + All, +} + +impl Scope { + const ALL: [Scope; 4] = [Scope::Section, Scope::Group, Scope::Notebook, Scope::All]; + + fn name(self) -> &'static str { + match self { + Scope::Section => "This Section", + Scope::Group => "This Section Group", + Scope::Notebook => "This Notebook", + Scope::All => "All Notebooks", + } + } +} + +const SEARCH_KEY: &str = if cfg!(target_os = "macos") { + "⌘E" +} else { + "Ctrl+E" +}; +const FIND_KEY: &str = if cfg!(target_os = "macos") { + "⌘F" +} else { + "Ctrl+F" +}; +/// The results list's rows, the headings above their groups, and its width. +const RESULT: f32 = 42.0; +const HEADING: f32 = 24.0; +const RESULTS: f32 = 460.0; +/// Room the section name takes beside a result's title. +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 { + Id::ROOT.child("results") +} + +fn scope_menu() -> Id { + Id::ROOT.child("scope") +} + +/// The field of the box, and of the results over it. +fn field() -> Id { + Id::ROOT.child("search-field") +} + +fn find_field() -> Id { + Id::ROOT.child("find-field") +} + +/// 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) +} + +/// Work for the index thread. +enum Job { + /// Brings the index to these notebooks: sections whose files changed are read again and + /// those no longer listed dropped. The open section, by key, is read through its replica. + Notebooks { + libraries: Vec>, + open: Option<(String, Weak)>, + }, + /// Reads pages of the open section again, with its page list. + Pages { + key: String, + replica: Weak, + spaces: Vec, + }, +} + +/// The search box's state and the index behind it. +pub struct Search { + pub query: String, + pub scope: Scope, + /// 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, + current: Option, + /// The page a result opened, whose first match is selected once it shows. + reveal: Option, + index: Arc>, + /// Counts the index's changes, so results are made again when it changes. + version: Arc, + busy: Arc, + jobs: mpsc::Sender, + found: Vec, + found_for: Option<(Query, Scope, u64, String)>, + 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, + /// Where the scope menu opens. + anchor: Option, + /// The find field takes the keys once built. + claim: bool, +} + +impl Search { + pub fn new(default: Scope, redraw: std::task::Waker) -> Self { + let (jobs, receiver) = mpsc::channel(); + let index = Arc::new(Mutex::new(Index::default())); + let version = Arc::new(AtomicU64::new(0)); + let busy = Arc::new(AtomicBool::new(false)); + let shared = (Arc::clone(&index), Arc::clone(&version), Arc::clone(&busy)); + std::thread::Builder::new() + .name("search-index".into()) + .spawn(move || run(receiver, shared, redraw)) + .expect("the index thread starts"); + Self { + query: String::new(), + scope: default, + default, + finding: false, + current: None, + reveal: None, + index, + version, + busy, + jobs, + found: Vec::new(), + found_for: None, + selected: None, + synced: Vec::new(), + open: false, + anchor: None, + claim: false, + } + } + + /// Asks the index to read `spaces` of the open section again, as edits or another + /// writer changed them, and its page list. + pub fn changed(&self, key: String, replica: &Arc, spaces: Vec) { + let _ = self.jobs.send(Job::Pages { + key, + replica: Arc::downgrade(replica), + spaces, + }); + } + + /// Pages of section `key` among the results, which the page list marks. + pub fn found_in(&self, key: &str) -> HashSet { + if self.finding || self.query.trim().is_empty() { + return HashSet::new(); + } + self.found + .iter() + .filter(|found| found.section == key) + .map(|found| found.space) + .collect() + } + + /// The query whose matches the page shows. + fn shown(&self) -> Query { + Query::new(&self.query) + } +} + +/// When a file last changed, as far as its length and modification time tell. +type Stamp = Option<(u64, SystemTime)>; + +fn stamp(file: &Path) -> Stamp { + let metadata = std::fs::metadata(file).ok()?; + Some((metadata.len(), metadata.modified().ok()?)) +} + +/// Now in Time32, seconds since 1980, as page modification times are kept. +fn now() -> u64 { + SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH) + .map_or(0, |since| since.as_secs().saturating_sub(315_532_800)) +} + +/// The index thread: answers jobs, the newest notebooks first, then page changes, and +/// wakes the window once the index changed. +fn run( + jobs: mpsc::Receiver, + (index, version, busy): (Arc>, Arc, Arc), + redraw: std::task::Waker, +) { + let mut stamps: HashMap = HashMap::new(); + while let Ok(first) = jobs.recv() { + busy.store(true, Ordering::Relaxed); + // Typing sends a job a keystroke; a pause gathers them into one read. + if matches!(first, Job::Pages { .. }) { + std::thread::sleep(SETTLE); + } + let mut notebooks = None; + let mut pages: HashMap, HashSet)> = HashMap::new(); + for job in std::iter::once(first).chain(jobs.try_iter()) { + match job { + Job::Notebooks { libraries, open } => notebooks = Some((libraries, open)), + Job::Pages { + key, + replica, + spaces, + } => pages + .entry(key) + .or_insert_with(|| (replica, HashSet::new())) + .1 + .extend(spaces), + } + } + let start = Instant::now(); + if let Some((libraries, open)) = notebooks { + sync(&index, &version, &mut stamps, &libraries, open.as_ref()); + lap("index notebooks", start); + } + for (key, (replica, spaces)) in pages { + if let Err(error) = reread(&index, &key, &replica, &spaces) { + eprintln!("Cannot index the open section: {error}"); + } + version.fetch_add(1, Ordering::Relaxed); + } + lap("index", start); + busy.store(false, Ordering::Relaxed); + redraw.wake_by_ref(); + } +} + +/// The readable sections of `library` outside its recycle bin, by catalog path. +fn sections(library: &Library) -> Vec { + match &library.notebook { + Ok(Some(notebook)) => { + let mut paths = Vec::new(); + let mut folders = vec![notebook.catalog()]; + while let Some(folder) = folders.pop() { + paths.extend( + folder + .sections + .iter() + .filter(|section| { + matches!( + section.state, + notebook::discover::SectionState::Readable { .. } + ) + }) + .map(|section| section.path.clone()), + ); + folders.extend( + folder + .groups + .iter() + .filter(|group| !crate::library::recycle_bin(group)), + ); + } + paths + } + Ok(None) => vec![library.location.clone()], + Err(_) => Vec::new(), + } +} + +fn sync( + index: &Mutex, + version: &AtomicU64, + stamps: &mut HashMap, + libraries: &[Arc], + open: Option<&(String, Weak)>, +) { + let mut listed = HashSet::new(); + for library in libraries { + for path in sections(library) { + let key = library.key(&path); + listed.insert(key.clone()); + let notebook = library.notebook.as_ref().ok().and_then(Option::as_ref); + let file = match notebook { + Some(_) => Path::new(&library.location).join(&path), + None => PathBuf::from(&library.location), + }; + let stamp = stamp(&file); + // A file with no stamp, as on a share not mounted, is read once. + if stamps + .get(&key) + .is_some_and(|known| known == &stamp || stamp.is_none()) + { + continue; + } + let replica = open + .filter(|(open, _)| *open == key) + .and_then(|(_, replica)| replica.upgrade()); + let read = || -> Result, Box> { + let bytes = match notebook { + Some(notebook) => notebook.read_section(&path)?, + None => onestore::read_file(&file)?, + }; + let stored = notebook::session::stored_pages(&bytes)?; + let modified = |space: ExGuid| { + stored + .iter() + .find(|page| page.space == space) + .and_then(|page| page.modified) + .map_or_else(now, u64::from) + }; + Ok(match &replica { + // The open section shows its edits before they reach the file. + Some(replica) => replica + .pages()? + .into_iter() + .map(|(space, ..)| { + Ok(Entry::new( + &key, + space, + &replica.page(space)?, + modified(space), + )) + }) + .collect::>()?, + None => stored + .iter() + .map(|page| Entry::new(&key, page.space, &page.page, modified(page.space))) + .collect(), + }) + }; + match read() { + Ok(entries) => { + let mut index = index.lock().unwrap_or_else(|error| error.into_inner()); + index.retain(|entry| entry.section != key); + for entry in entries { + index.set(entry); + } + drop(index); + stamps.insert(key, stamp); + version.fetch_add(1, Ordering::Relaxed); + } + Err(error) => eprintln!("Cannot index {path}: {error}"), + } + } + } + stamps.retain(|key, _| listed.contains(key)); + index + .lock() + .unwrap_or_else(|error| error.into_inner()) + .retain(|entry| listed.contains(&entry.section)); + version.fetch_add(1, Ordering::Relaxed); +} + +/// Reads `spaces` of the open section `key` again, and pages its list gained, dropping +/// those it lost. +fn reread( + index: &Mutex, + key: &str, + replica: &Weak, + spaces: &HashSet, +) -> Result<(), Box> { + let Some(replica) = replica.upgrade() else { + return Ok(()); + }; + let listed: Vec = replica + .pages()? + .into_iter() + .map(|(space, ..)| space) + .collect(); + let unread: Vec = { + let index = index.lock().unwrap_or_else(|error| error.into_inner()); + listed + .iter() + .copied() + .filter(|space| spaces.contains(space) || index.get(key, *space).is_none()) + .collect() + }; + let entries = unread + .into_iter() + .map(|space| Ok(Entry::new(key, space, &replica.page(space)?, now()))) + .collect::, notebook::Error>>()?; + let mut index = index.lock().unwrap_or_else(|error| error.into_inner()); + index.retain(|entry| entry.section != key || listed.contains(&entry.space)); + for entry in entries { + index.set(entry); + } + Ok(()) +} + +/// 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) { + let height = match spec.size[1].size { + ui::Size::Pixels(height) => height, + _ => 0.0, + }; + let pad = spec.pad[0]; + ui.open( + part, + Spec { + text: Some(text), + ..spec + }, + ); + for hit in hits { + let (Some(before), Some(word)) = (text.get(..hit.start), text.get(hit.clone())) else { + continue; + }; + let [x, _] = ui.measure(before); + let [width, tall] = ui.measure(word); + let top = (height - tall) / 2.0; + ui.mark( + [pad + x - 1.0, top, pad + x + width + 1.0, top + tall], + [1.0, 0.82, 0.0, 0.45], + 2.0, + ); + } + ui.close(); +} + +/// A section's place as a result names it: its notebook, groups and name. +fn place(notebooks: &[Arc], key: &str) -> String { + let (location, path) = key.split_once('\n').unwrap_or((key, "")); + let notebook = notebooks + .iter() + .find(|library| library.location == location) + .map_or("", |library| library.name.as_str()); + let path = match notebooks + .iter() + .find(|library| library.location == location) + { + Some(library) if matches!(library.notebook, Ok(None)) => { + crate::library::section_name(path, &None) + } + _ => path.strip_suffix(".one").unwrap_or(path).to_owned(), + }; + format!("({notebook}/{path})") +} + +impl State { + /// Brings the index to the open notebooks and section when they changed, and to + /// section files changed since, when `always`. + pub(crate) fn sync_index(&mut self, always: bool) { + let open = self.session.as_ref().map(|session| { + ( + session.library.key(&session.tabs[session.tab].path), + Arc::downgrade(session.section.replica()), + ) + }); + let identity: Vec = self + .notebooks + .iter() + .map(|library| Arc::as_ptr(library) as usize) + .chain(open.as_ref().map(|(_, replica)| replica.as_ptr() as usize)) + .collect(); + if !always && identity == self.search.synced { + return; + } + self.search.synced = identity; + let _ = self.search.jobs.send(Job::Notebooks { + libraries: self.notebooks.clone(), + open, + }); + } + + /// Tells the index an edit changed page `space` of the open section. + pub(crate) fn edited(&self, spaces: Vec) { + if let Some(session) = &self.session { + self.search.changed( + session.library.key(&session.tabs[session.tab].path), + session.section.replica(), + spaces, + ); + } + } + + /// Whether `key` names a section the search's scope takes in. + fn in_scope(&self, scope: Scope, key: &str) -> bool { + let Some(session) = &self.session else { + return scope == Scope::All; + }; + let path = &session.tabs[session.tab].path; + let notebook = format!("{}\n", session.library.location); + match scope { + Scope::Section => *key == session.library.key(path), + Scope::Group => match path.rsplit_once('/') { + Some((folder, _)) => key.starts_with(&format!("{notebook}{folder}/")), + None => key.starts_with(¬ebook), + }, + Scope::Notebook => key.starts_with(¬ebook), + Scope::All => true, + } + } + + /// The results for the query in its scope, made again when either or the index changed. + 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 + .session + .as_ref() + .map(|session| session.library.key(&session.tabs[session.tab].path)) + .unwrap_or_default(); + let wanted = (query.clone(), self.search.scope, version, open); + if self.search.found_for.as_ref() == Some(&wanted) { + return Ok(()); + } + let start = Instant::now(); + let scope = self.search.scope; + let found = { + let index = self + .search + .index + .lock() + .unwrap_or_else(|error| error.into_inner()); + index.search(&query, |key| self.in_scope(scope, key)) + }; + lap("search", start); + if self + .search + .found_for + .as_ref() + .is_none_or(|(before, ..)| *before != query) + { + // The page shown marks the matches as they are typed. + self.search.selected = None; + self.refind(false)?; + } + self.search.found = found; + self.search.found_for = Some(wanted); + Ok(()) + } + + /// 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. + 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 { + 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.reveal = None; + (!self.view.found.is_empty()).then_some(0) + } else { + None + }; + match at { + Some(at) => self.select_match(at), + None => Ok(()), + } + } + + fn select_match(&mut self, at: usize) -> Result<(), Box> { + let Some(&(outline, selection)) = self.view.found.get(at) else { + return Ok(()); + }; + self.view.editor.focus_outline(outline)?; + self.view.editor.select(selection)?; + let response = self.view.focus_text()?; + self.respond(response); + Ok(()) + } + + /// Opens the results under the box, as Ctrl+E does. + pub(crate) fn start_search(&mut self) { + self.search.finding = false; + if !self.ui.popup_open(results()) { + self.sync_index(true); + self.ui.open_popup(results()); + self.search.open = true; + } + } + + /// Turns the box into Find on This Page, as Ctrl+F does; from the page it starts empty. + pub(crate) fn start_find(&mut self, keep: bool) -> Result<(), Box> { + if !keep { + self.search.query.clear(); + } + self.search.open = false; + self.ui.close_popup(results()); + self.search.finding = true; + self.search.claim = true; + self.search.current = None; + self.refind(true) + } + + /// 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) { + 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())); + } + + /// 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); + let open = self.session.as_ref().map(|session| { + ( + session.library.key(&session.tabs[session.tab].path), + session.space, + ) + }); + match open { + Some((key, shown)) if key == section && shown == space => self.refind(true)?, + Some((key, _)) if key == section => self.commands.push(Command::OpenPage(space)), + _ => { + let library = self + .notebooks + .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())); + } + } + Ok(()) + } + + /// The search box in the tab row: OneNote's search while idle or searching, Find on + /// This Page while finding. + pub(crate) fn search_box(&mut self, theme: &Theme) -> Result<(), Box> { + let focused = [Some(field()), Some(find_field())].contains(&self.ui.focused()); + let searching = self.ui.popup_open(results()); + let spec = |focused: bool| Spec { + size: [fill(), px(ui::shell::TOOL)], + fill: Some(theme.base), + border: Some(if focused { theme.accent } else { theme.chip }), + radius: 4.0, + pad: [4.0, 0.0], + gap: 2.0, + ..Spec::default() + }; + let box_id = self.ui.open("search", spec(focused || searching)); + if self.search.finding { + self.find_bar(theme)?; + } else if searching { + // The results' own field lies over the box. + self.ui.leaf( + "covered", + Spec { + size: [fill(), px(ui::shell::TOOL)], + ..Spec::default() + }, + ); + } else { + let placeholder = format!("Search {} ({SEARCH_KEY})", self.search.scope.name()); + let signal = ui::text_field( + &mut self.ui, + field(), + &mut self.search.query, + &placeholder, + Spec { + size: [fill(), px(ui::shell::TOOL)], + pad: [4.0, 0.0], + ..Spec::default() + }, + ); + if signal.pressed && !searching { + self.start_search(); + } + self.scope_button(theme); + } + self.ui.close(); + // Results opened this frame build from the next, as the box's field was built. + if searching || !self.ui.popup_open(results()) { + let rect = self.ui.rect(box_id).unwrap_or_default(); + self.results_popup(theme, rect)?; + } + self.scope_popup(); + Ok(()) + } + + /// Whether `signal`, a field's, holds the command key with `letter`. + fn shortcut(&self, signal: &ui::Signal, letter: &str) -> bool { + self.view.modifiers().command + && signal.events.iter().any(|event| { + matches!(event, ui::Event::Key { key: winit::keyboard::Key::Character(key), .. } + if key.eq_ignore_ascii_case(letter)) + }) + } + + fn scope_button(&mut self, theme: &Theme) { + let button = self.ui.id("scope"); + if ui::shell::tool_button(&mut self.ui, "scope", art::SEARCH, theme.text_dim, false).pressed + { + let rect = self.ui.rect(button).unwrap_or_default(); + self.search.anchor = Some(Anchor::Below(rect)); + self.ui.open_popup(scope_menu()); + } + } + + /// OneNote's "Search In:" menu. + fn scope_popup(&mut self) { + let current = self.search.scope; + let mut items = vec![ + ui::popup::Item { + text: "Search In:", + heading: true, + ..Default::default() + }, + ui::popup::Item { + text: "Find on This Page", + shortcut: FIND_KEY, + ..Default::default() + }, + ]; + items.extend( + Scope::ALL + .iter() + .enumerate() + .map(|(index, scope)| ui::popup::Item { + text: scope.name(), + checked: *scope == current, + separated: index == 0, + ..Default::default() + }), + ); + items.push(ui::popup::Item { + text: "Set This Scope as Default", + separated: true, + disabled: current == self.search.default, + ..Default::default() + }); + let anchor = self.search.anchor.unwrap_or(Anchor::Point([0.0, 0.0])); + match ui::popup::menu(&mut self.ui, scope_menu(), anchor, &items, None) { + Some(1) => { + if let Err(error) = self.start_find(true) { + eprintln!("{error}"); + } + } + Some(index @ 2..=5) => { + self.search.scope = Scope::ALL[index - 2]; + self.start_search(); + } + Some(6) => { + self.search.default = current; + self.save_settings(); + } + _ => {} + } + } + + /// The dropdown of results, over the box it drops from. + fn results_popup(&mut self, theme: &Theme, rect: [f32; 4]) -> Result<(), Box> { + if !self.ui.popup_open(results()) { + // Closed by Esc or a press elsewhere: the search ends. + if std::mem::take(&mut self.search.open) { + self.end_search(); + } + return Ok(()); + } + self.refresh_results()?; + let keys = ui::popup::navigation( + &mut self.ui, + &[field(), results()], + &[ + NamedKey::ArrowUp, + NamedKey::ArrowDown, + NamedKey::PageUp, + NamedKey::PageDown, + NamedKey::Enter, + ], + ); + if self.ui.focused() == Some(results()) { + self.ui.set_focus(Some(field())); + } + let [left, top, right, bottom] = rect; + self.ui.open_as( + results(), + Spec { + axis: Axis::Y, + size: [px(RESULTS.max(right - left)), children()], + fill: Some(theme.popup), + border: Some(theme.chip), + shadow: Some(theme.shadow), + radius: 6.0, + pad: [4.0, 4.0], + gap: 4.0, + anchor: Some(Anchor::Over([ + left - 4.0, + top - 4.0, + right + 4.0, + bottom + 4.0, + ])), + ..Spec::default() + }, + ); + self.ui.open( + "box", + Spec { + size: [fill(), px(ui::shell::TOOL)], + fill: Some(theme.base), + border: Some(theme.accent), + radius: 4.0, + pad: [4.0, 0.0], + gap: 2.0, + ..Spec::default() + }, + ); + let placeholder = format!("Search {} ({SEARCH_KEY})", self.search.scope.name()); + let signal = ui::text_field( + &mut self.ui, + field(), + &mut self.search.query, + &placeholder, + Spec { + size: [fill(), px(ui::shell::TOOL)], + pad: [4.0, 0.0], + ..Spec::default() + }, + ); + self.scope_button(theme); + self.ui.close(); + if self.shortcut(&signal, "f") { + self.ui.close(); + return self.start_find(true); + } + let query = self.search.query.trim().to_owned(); + let found = &self.search.found; + let titled = found.iter().take_while(|found| found.in_title).count(); + // OneNote's status line: whether the search is done, and where it looked. + let status = if query.is_empty() { + "Search In:" + } else if self.search.busy.load(Ordering::Relaxed) { + "Searching:" + } else if found.is_empty() { + "No matches:" + } else { + "Finished:" + }; + let scope = if query.is_empty() || !found.is_empty() { + format!("{} (change)", self.search.scope.name()) + } else { + self.search.scope.name().to_owned() + }; + self.ui.open( + "status", + Spec { + size: [fill(), px(22.0)], + pad: [6.0, 0.0], + gap: 4.0, + ..Spec::default() + }, + ); + self.ui.leaf( + "state", + Spec { + size: [fit(), px(22.0)], + text: Some(status), + color: Some(theme.text_dim), + ..Spec::default() + }, + ); + let change = self.ui.id("scope"); + if self + .ui + .leaf( + "scope", + Spec { + flags: Flags::CLICKABLE, + size: [fit(), px(22.0)], + text: Some(&scope), + color: Some(theme.accent), + ..Spec::default() + }, + ) + .clicked + { + let rect = self.ui.rect(change).unwrap_or_default(); + self.search.anchor = Some(Anchor::Below(rect)); + self.ui.open_popup(scope_menu()); + } + self.ui.leaf( + "space", + Spec { + size: [fill(), px(1.0)], + ..Spec::default() + }, + ); + self.ui.leaf( + "find", + Spec { + size: [fit(), px(22.0)], + text: Some(&format!("Find on page: {FIND_KEY}")), + color: Some(theme.text_dim), + ..Spec::default() + }, + ); + self.ui.close(); + let mut chosen = None; + if !found.is_empty() { + let rows = Results { + titled, + count: found.len(), + }; + let content = found.len() as f32 * RESULT + ui::Rows::space_before(&rows, found.len()); + let window = self.ui.rect(Id::ROOT).map_or(600.0, |window| window[3]); + let view = content.min((window - bottom - 80.0).max(RESULT)); + let before = self.search.selected; + let places: Vec = found + .iter() + .map(|found| place(&self.notebooks, &found.section)) + .collect(); + let headings = [ + format!("Title contains: {query} ({titled})"), + format!("Body contains: {query} ({})", found.len() - titled), + ]; + let clicked = ui::list( + &mut self.ui, + results().child("rows"), + Spec { + size: [fill(), px(view)], + fill: Some(theme.popup), + ..Spec::default() + }, + ui::List { + rows: &rows, + row: RESULT, + keys: &keys, + hover_selects: true, + }, + &mut self.search.selected, + |ui, row| { + let found = &found[row.index]; + if row.index == 0 || row.index == titled { + let heading = if found.in_title { + &headings[0] + } else { + &headings[1] + }; + ui.leaf( + "heading", + Spec { + flags: Flags::FLOAT, + size: [fill(), px(HEADING)], + position: [0.0, -HEADING], + text: Some(heading), + bold: true, + color: Some(theme.text), + pad: [6.0, 0.0], + ..Spec::default() + }, + ); + } + result_row(ui, theme, found, &places[row.index], row.selected); + }, + ); + // Arrows show the page they select, as OneNote previews it. + if before != self.search.selected + && keys.iter().any(|key| *key != NamedKey::Enter) + && let Some(index) = self.search.selected.map(|key| key as usize) + { + self.open_result(index)?; + } + let entered = keys.contains(&NamedKey::Enter); + chosen = clicked.or_else(|| { + self.search + .selected + .map(|key| key as usize) + .filter(|_| entered) + .or_else(|| entered.then_some(0)) + }); + } + self.ui.close(); + if let Some(index) = chosen { + self.open_result(index)?; + self.search.open = false; + self.ui.close_popup(results()); + self.ui.set_focus(Some(page())); + } + Ok(()) + } + + /// Find on This Page in the box: the match count with previous and next, then the field. + fn find_bar(&mut self, theme: &Theme) -> Result<(), Box> { + let keys = ui::popup::navigation( + &mut self.ui, + &[find_field()], + &[NamedKey::Enter, NamedKey::F3, NamedKey::Escape], + ); + let before = self.search.query.clone(); + let count = self.view.found.len(); + let label = if self.search.query.trim().is_empty() { + "Find on page".to_owned() + } else if count == 0 { + format!("No matches. Try {SEARCH_KEY}") + } else { + format!( + "Match {} of {count}", + self.search.current.map_or(0, |at| at + 1) + ) + }; + self.ui.leaf( + "count", + Spec { + size: [fit(), px(ui::shell::TOOL)], + inset: [0.0, 3.0, 0.0, 3.0], + text: Some(&label), + fill: Some(draw::srgb(0xff, 0xe0, 0x5a)), + color: Some(draw::srgb(0x20, 0x20, 0x20)), + radius: 3.0, + pad: [5.0, 0.0], + ..Spec::default() + }, + ); + let mut step = None; + if count > 0 { + if ui::shell::tool_button(&mut self.ui, "previous", art::CHEVRON_UP, theme.text, false) + .clicked + { + step = Some(false); + } + if ui::shell::tool_button(&mut self.ui, "next", ui::shell::CHEVRON, theme.text, false) + .clicked + { + step = Some(true); + } + } + let signal = ui::text_field( + &mut self.ui, + find_field(), + &mut self.search.query, + "", + Spec { + size: [fill(), px(ui::shell::TOOL)], + pad: [4.0, 0.0], + ..Spec::default() + }, + ); + if std::mem::take(&mut self.search.claim) { + self.ui.set_focus(Some(find_field())); + } + if self.shortcut(&signal, "e") { + self.start_search(); + return Ok(()); + } + if ui::shell::tool_button(&mut self.ui, "close", art::CLOSE, theme.text_dim, false).clicked + { + self.end_search(); + return Ok(()); + } + let shift = self.view.modifiers().shift; + for key in keys { + match key { + NamedKey::Escape => { + self.end_search(); + return Ok(()); + } + _ => step = Some(!shift), + } + } + if self.search.query != before { + self.search.current = None; + self.refind(true)?; + } else if let Some(forward) = step + && count > 0 + { + let at = self.search.current.unwrap_or(0); + let next = if forward { + (at + 1) % count + } else { + (at + count - 1) % count + }; + self.search.current = Some(next); + self.select_match(next)?; + } + Ok(()) + } +} + +/// The results list's rows: those whose titles hold every word, then the others, each +/// group under its heading. +struct Results { + titled: usize, + count: usize, +} + +impl ui::Rows for Results { + 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 { + let headings = usize::from(self.count > 0) + + usize::from(0 < self.titled && self.titled < self.count && index >= self.titled); + headings as f32 * HEADING + } +} + +/// A result: its page's title and section, and the snippet around its first match. +fn result_row(ui: &mut Ui, theme: &Theme, found: &Found, place: &str, selected: bool) { + // On the highlight every line takes the text's colour, as menus do. + let dim = if selected { theme.text } else { theme.text_dim }; + ui.open( + "result", + Spec { + size: [fill(), fill()], + fill: selected.then(|| theme.hover()), + radius: 4.0, + pad: [6.0, 3.0], + gap: 6.0, + ..Spec::default() + }, + ); + ui.leaf( + "icon", + Spec { + size: [px(16.0), px(18.0)], + icon: Some(art::PAGE), + color: Some(dim), + ..Spec::default() + }, + ); + ui.open( + "lines", + Spec { + axis: Axis::Y, + size: [fill(), fill()], + ..Spec::default() + }, + ); + ui.open( + "top", + Spec { + size: [fill(), px(18.0)], + gap: 8.0, + ..Spec::default() + }, + ); + let title = if found.title.trim().is_empty() { + "Untitled page" + } else { + found.title.as_str() + }; + marked( + ui, + "title", + title, + &found.title_hits, + Spec { + size: [fill(), px(18.0)], + color: Some(theme.text), + ..Spec::default() + }, + ); + ui.leaf( + "place", + Spec { + size: [px(PLACE), px(18.0)], + text: Some(place), + color: Some(dim), + ..Spec::default() + }, + ); + ui.close(); + marked( + ui, + "snippet", + &found.snippet, + &found.snippet_hits, + Spec { + size: [fill(), px(18.0)], + color: Some(dim), + ..Spec::default() + }, + ); + ui.close(); + ui.close(); +} diff --git a/crates/snowbound/src/settings.rs b/crates/snowbound/src/settings.rs index 56bd14011e04a27fd61113a86a4e8a72a54be845..6896c43cb39b7689b98fe28331f66ebac07dcb49 100644 --- a/crates/snowbound/src/settings.rs +++ b/crates/snowbound/src/settings.rs @@ -1,5 +1,5 @@ -//! What the app keeps between launches: the open notebooks, the sidebar and recent fonts, -//! as JSON in the platform's settings directory. +//! What the app keeps between launches: the open notebooks, the sidebar, recent fonts and +//! the Options dialog's choices, as JSON in the platform's settings directory. use serde::{Deserialize, Serialize}; use std::path::{Path, PathBuf}; @@ -15,6 +15,35 @@ pub struct Settings { pub sidebar: bool, /// Fonts picked from the font box, latest first. pub recent_fonts: Vec, + /// The name edits are stored under, OneNote's User name; the account's full name until + /// first saved. + pub user_name: Option, + pub color_scheme: ColorScheme, + /// Keeps pages white in the Dark color scheme. + pub light_pages: bool, + /// Where searches look first, as "Set This Scope as Default" chose. + pub search_scope: crate::search::Scope, +} + +/// The interface's colours: the system's appearance, or one chosen in Options. +#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum ColorScheme { + #[default] + System, + Light, + Dark, +} + +impl ColorScheme { + /// The appearance the window is held to; none follows the system. + pub fn theme(self) -> Option { + match self { + Self::System => None, + Self::Light => Some(winit::window::Theme::Light), + Self::Dark => Some(winit::window::Theme::Dark), + } + } } impl Settings { @@ -45,8 +74,8 @@ impl Settings { } impl crate::State { - /// Keeps what the next launch restores: the open notebooks, the one shown, the sidebar - /// and recent fonts. A section opened on its own is not kept. + /// Keeps what the next launch restores: the open notebooks, the one shown, the sidebar, + /// recent fonts and the Options dialog's choices. A section opened on its own is not kept. pub(crate) fn save_settings(&self) { let Some(path) = &self.settings else { return; @@ -68,6 +97,10 @@ impl crate::State { .map(|library| library.location.clone()), sidebar: self.sidebar, recent_fonts: self.recent_fonts.clone(), + user_name: Some(self.author.clone()), + color_scheme: self.color_scheme, + light_pages: self.light_pages, + search_scope: self.search.default, }; if let Err(error) = settings.save(path) { eprintln!("Cannot save the settings in {}: {error}", path.display()); @@ -107,6 +140,10 @@ mod tests { current: Some("/notebooks/Personal".into()), sidebar: true, recent_fonts: vec!["Georgia".into()], + user_name: Some("Snowbound Test".into()), + color_scheme: ColorScheme::Dark, + light_pages: true, + search_scope: crate::search::Scope::Notebook, }; settings.save(&path).unwrap(); assert_eq!(Settings::load(&path), settings); diff --git a/crates/snowbound/src/sidebar.rs b/crates/snowbound/src/sidebar.rs index 16f372e22d87056315888cd75ca6f0418d5589d2..9d852744496672b35aa240ffe4e83336e6ae067b 100644 --- a/crates/snowbound/src/sidebar.rs +++ b/crates/snowbound/src/sidebar.rs @@ -30,6 +30,8 @@ pub enum Action { Fold(String), NewNotebook, OpenNotebook, + /// Opens the Options dialog, which macOS reaches from the application menu instead. + Options, /// A row's context menu, opened here. Menu(Target, [f32; 2]), /// Ends renaming, with the name typed or without. @@ -179,10 +181,14 @@ fn sidebar(ui: &mut Ui, tree: &mut Tree, notebooks: &[Arc], header: f32 ..Spec::default() }, ); + let options = ("options", art::OPTIONS, "Options", Action::Options); for (part, icon, label, chosen) in [ ("new", art::PLUS, "New Notebook", Action::NewNotebook), ("open", art::NOTEBOOK, "Open Existing", Action::OpenNotebook), - ] { + ] + .into_iter() + .chain((!cfg!(target_os = "macos")).then_some(options)) + { let (row, _) = tree_row( ui, tree, @@ -555,6 +561,7 @@ impl crate::State { } Some(Action::NewNotebook) => self.commands.push(crate::Command::NewNotebook), Some(Action::OpenNotebook) => self.commands.push(crate::Command::OpenNotebook), + Some(Action::Options) => self.open_options(), None => {} } } diff --git a/crates/ui/src/lib.rs b/crates/ui/src/lib.rs index cf1bda3ce88990d842a2d166f8b5fd8a585a2115..943435fe3315aa0a77067d0e75181b27e207b909 100644 --- a/crates/ui/src/lib.rs +++ b/crates/ui/src/lib.rs @@ -13,7 +13,7 @@ mod widgets; pub use list::{List, Row, Rows, list}; pub use theme::{Section, Shades, Theme}; -pub use widgets::{button, edit_key, edit_modifiers, scrollbar, text_field}; +pub use widgets::{button, check_box, edit_key, edit_modifiers, scrollbar, text_field}; use draw::{ PathStyle, Primitive, RasterImage, Stroke, @@ -521,7 +521,7 @@ impl Ui { self.scale } - pub(crate) fn modifiers(&self) -> ModifiersState { + pub fn modifiers(&self) -> ModifiersState { self.modifiers } diff --git a/crates/ui/src/popup.rs b/crates/ui/src/popup.rs index 5f4133e03c60a62348519bf3a06e4f883c410af8..89d30c36396bfc0cd5f51d3cff418bdc9702299f 100644 --- a/crates/ui/src/popup.rs +++ b/crates/ui/src/popup.rs @@ -13,7 +13,7 @@ use nucleo_matcher::{ use std::cell::RefCell; use winit::keyboard::{Key, NamedKey}; -const CHECK: &[&str] = &[include_str!("../assets/check.svg")]; +pub(crate) const CHECK: &[&str] = &[include_str!("../assets/check.svg")]; /// Inset of a popup's contents, and its distance from what it drops down from. const PAD: f32 = 4.0; const ROW: f32 = 26.0; @@ -498,7 +498,7 @@ fn state(ui: &mut Ui, id: Id) -> &mut Popup { } /// Takes the presses of `keys` routed this frame to the focus when it is one of `owners`. -fn navigation(ui: &mut Ui, owners: &[Id], keys: &[NamedKey]) -> Vec { +pub fn navigation(ui: &mut Ui, owners: &[Id], keys: &[NamedKey]) -> Vec { let Some(signal) = ui .focus .filter(|focus| owners.contains(focus)) diff --git a/crates/ui/src/widgets.rs b/crates/ui/src/widgets.rs index e546899dcb9bec5b76da1a9a4aa37648a40d11bf..a86f3ea5f8a8a01e0eb9ae8746a60e74b16d0ac4 100644 --- a/crates/ui/src/widgets.rs +++ b/crates/ui/src/widgets.rs @@ -29,6 +29,51 @@ pub fn button(ui: &mut Ui, part: impl Hash, text: &str) -> Signal { ui.leaf(part, spec) } +/// A check box before `label`, `checked` or not; a click on either reports for the caller +/// to toggle. +pub fn check_box(ui: &mut Ui, part: impl Hash, label: &str, checked: bool) -> Signal { + let theme = ui.theme.clone(); + let [height, side] = [theme.font_size * 2.0, 16.0]; + let margin = (height - side) / 2.0; + let id = ui.open( + part, + Spec { + flags: Flags::CLICKABLE, + size: [crate::children(), px(height)], + gap: 8.0, + ..Spec::default() + }, + ); + let hovered = ui.signal(id).hovered; + ui.leaf( + "box", + Spec { + size: [px(side), px(height)], + inset: [0.0, margin, 0.0, margin], + fill: Some(if checked { theme.accent } else { theme.base }), + border: Some(if checked || hovered { + theme.accent + } else { + theme.chip + }), + radius: 3.0, + icon: checked.then_some(crate::popup::CHECK), + color: Some([1.0; 4]), + ..Spec::default() + }, + ); + ui.leaf( + "label", + Spec { + size: [fit(), px(height)], + text: Some(label), + ..Spec::default() + }, + ); + ui.close(); + ui.signal(id) +} + /// 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. diff --git a/fuzz/Cargo.lock b/fuzz/Cargo.lock index 32d47a6ed1adff0bfb3d62445f5a864483d05c86..3c8c3f5f620fe491cbfbff4faccf7b0d6cc6cc95 100644 --- a/fuzz/Cargo.lock +++ b/fuzz/Cargo.lock @@ -82,6 +82,7 @@ name = "canvas" version = "0.1.0" dependencies = [ "draw", + "icu_normalizer", "onestore", "parley", "serde", @@ -392,6 +393,9 @@ dependencies = [ "icu_properties", "icu_provider", "smallvec", + "utf16_iter", + "utf8_iter", + "write16", "zerovec", ] @@ -1030,6 +1034,12 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "utf16_iter" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8232dd3cdaed5356e0f716d285e4b40b932ac434100fe9b7e0e8e935b9e6246" + [[package]] name = "utf8_iter" version = "1.0.4" @@ -1206,6 +1216,12 @@ dependencies = [ "windows-link", ] +[[package]] +name = "write16" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1890f4022759daae28ed4fe62859b1236caebfc61ede2f63ed4e695f3f6d936" + [[package]] name = "writeable" version = "0.6.4" diff --git a/platform/snow-leopard/Cargo.lock b/platform/snow-leopard/Cargo.lock new file mode 100644 index 0000000000000000000000000000000000000000..0268d5f9619a36db8969e61034da6db9e03094df --- /dev/null +++ b/platform/snow-leopard/Cargo.lock @@ -0,0 +1,1907 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "android_system_properties" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" +dependencies = [ + "libc", +] + +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + +[[package]] +name = "ash" +version = "0.38.0+1.3.281" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bb44936d800fea8f016d7f2311c6a4f97aebd5dc86f09906139ec848cf3a46f" +dependencies = [ + "libloading", +] + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "bit-set" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2f926cc3060f09db9ebc5b52823d85268d24bb917e472c0c4bea35780a7d" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51" + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + +[[package]] +name = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytemuck" +version = "1.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" +dependencies = [ + "bytemuck_derive", +] + +[[package]] +name = "bytemuck_derive" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a1f896587b6f2c069c73d2f0913e2d590c3990285cd2f0b6aa02b786b4c679c" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "codespan-reporting" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af491d569909a7e4dee0ad7db7f5341fef5c614d5b8ec8cf765732aba3cff681" +dependencies = [ + "serde", + "termcolor", + "unicode-width", +] + +[[package]] +name = "color_quant" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" + +[[package]] +name = "crc32fast" +version = "1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags", + "objc2", +] + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "dlib" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a" +dependencies = [ + "libloading", +] + +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + +[[package]] +name = "draw" +version = "0.1.0" +dependencies = [ + "bytemuck", + "image", + "linebender_resource_handle", + "parley", + "roxmltree", + "swash", + "wgpu", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "fax" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caf1079563223d5d59d83c85886a56e586cfd5c1a26292e971a0fa266531ac5a" + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "flate2" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" +dependencies = [ + "crc32fast", + "miniz_oxide 0.9.1", + "zlib-rs", +] + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "font-types" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8eb065f3251655b3c90e22e5e363f310fc5332fb3402e37bbc94752283248f6" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "fontique" +version = "0.11.0" +source = "git+https://github.com/linebender/parley?rev=4f67747d3690bb21d25cc70b674ab0d269595759#4f67747d3690bb21d25cc70b674ab0d269595759" +dependencies = [ + "hashbrown 0.17.1", + "linebender_resource_handle", + "memmap2", + "objc2", + "objc2-core-foundation", + "objc2-core-text", + "objc2-foundation", + "parlance", + "read-fonts", + "roxmltree", + "smallvec", + "windows", + "windows-core", + "yeslogic-fontconfig-sys", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "gif" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee8cfcc411d9adbbaba82fb72661cc1bcca13e8bba98b364e62b2dba8f960159" +dependencies = [ + "color_quant", + "weezl", +] + +[[package]] +name = "gl21" +version = "0.0.0" + +[[package]] +name = "gl_generator" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a95dfc23a2b4a9a2f5ab41d194f8bfda3cabec42af4e39f08c339eb2a0c124d" +dependencies = [ + "khronos_api", + "log", + "xml-rs", +] + +[[package]] +name = "glow" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29038e1c483364cc6bb3cf78feee1816002e127c331a1eec55a4d202b9e1adb5" +dependencies = [ + "js-sys", + "slotmap", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "glutin_wgl_sys" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c4ee00b289aba7a9e5306d57c2d05499b2e5dc427f84ac708bd2c090212cf3e" +dependencies = [ + "gl_generator", +] + +[[package]] +name = "gpu-allocator" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51255ea7cfaadb6c5f1528d43e92a82acb2b96c43365989a28b2d44ee38f8795" +dependencies = [ + "ash", + "hashbrown 0.16.1", + "log", + "presser", + "thiserror", + "windows", +] + +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "num-traits", + "zerocopy", +] + +[[package]] +name = "harfrust" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c03d949a14aa089bbb282f7dd76a498a7f684428e4257202efc119ec010376f9" +dependencies = [ + "bitflags", + "bytemuck", + "read-fonts", + "smallvec", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hello-snow-leopard" +version = "0.0.0" + +[[package]] +name = "icu_collections" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" +dependencies = [ + "displaydoc", + "litemap", + "serde", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_locale_fallback" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "251af8e57c9400e3eb58242fe5b8b1152b2a64fdf4cf632f923c38ccee6f2fa9" +dependencies = [ + "icu_locale_core", + "icu_locale_fallback_data", + "icu_provider", + "potential_utf", + "tinystr", + "zerovec", +] + +[[package]] +name = "icu_locale_fallback_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "decf2a22ec8fa68f1a0c1129a3f8583f8f8bc24e8b9ccbe98ead99f62a4dc3a8" + +[[package]] +name = "icu_normalizer" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" + +[[package]] +name = "icu_properties" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" + +[[package]] +name = "icu_provider" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" +dependencies = [ + "displaydoc", + "icu_locale_core", + "serde", + "stable_deref_trait", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_segmenter" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82d07aafccd67af15d02512a6adf5896fbc5ed00f2e99b471d2efa14016db3db" +dependencies = [ + "icu_collections", + "icu_locale_fallback", + "icu_provider", + "icu_segmenter_data", + "potential_utf", + "smallvec", + "utf8_iter", + "zerovec", +] + +[[package]] +name = "icu_segmenter_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae293c039020f9ec10710af98d29ce6aa2051486638b49c9a6409f3b4a9e98ad" + +[[package]] +name = "image" +version = "0.25.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" +dependencies = [ + "bytemuck", + "byteorder-lite", + "color_quant", + "gif", + "moxcms", + "num-traits", + "png", + "tiff", + "zune-core", + "zune-jpeg", +] + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", +] + +[[package]] +name = "jni-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +dependencies = [ + "jni-sys 0.4.1", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "js-sys" +version = "0.3.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce57d20d1ea864ce2ac172ab472d409214f4fd359f0b2a2775abdf522e2af99e" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "khronos-egl" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6aae1df220ece3c0ada96b8153459b67eebe9ae9212258bb0134ae60416fdf76" +dependencies = [ + "libc", + "libloading", + "pkg-config", +] + +[[package]] +name = "khronos_api" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link", +] + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "linebender_resource_handle" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4a5ff6bcca6c4867b1c4fd4ef63e4db7436ef363e0ad7531d1558856bae64f4" + +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "memmap2" +version = "0.9.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0" +dependencies = [ + "libc", +] + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "miniz_oxide" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "moxcms" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" +dependencies = [ + "num-traits", + "pxfm", +] + +[[package]] +name = "naga" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a616d2fb8c89516ac2723a581f69d6c18576046bed761bd6b305e5618e6ae130" +dependencies = [ + "arrayvec", + "bit-set", + "bitflags", + "cfg-if", + "cfg_aliases", + "codespan-reporting", + "half", + "hashbrown 0.17.1", + "indexmap", + "libm", + "log", + "naga-types", + "num-traits", + "once_cell", + "rustc-hash", + "spirv", + "thiserror", + "unicode-ident", +] + +[[package]] +name = "naga-types" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "590afbf58a6f4f62873cd5cff4468061844bafa1cdf399cc954537c22d768d49" +dependencies = [ + "hashbrown 0.17.1", + "indexmap", + "rustc-hash", + "thiserror", +] + +[[package]] +name = "ndk-sys" +version = "0.6.0+11769913" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" +dependencies = [ + "jni-sys 0.3.1", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "objc2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +dependencies = [ + "objc2-encode", +] + +[[package]] +name = "objc2-app-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" +dependencies = [ + "bitflags", + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags", + "dispatch2", + "objc2", +] + +[[package]] +name = "objc2-core-graphics" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" +dependencies = [ + "dispatch2", + "objc2", + "objc2-core-foundation", + "objc2-io-surface", +] + +[[package]] +name = "objc2-core-text" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d" +dependencies = [ + "bitflags", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-io-surface" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" +dependencies = [ + "bitflags", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-metal" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0125f776a10d00af4152d74616409f0d4a2053a6f57fa5b7d6aa2854ac04794" +dependencies = [ + "bitflags", + "block2", + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" +dependencies = [ + "bitflags", + "objc2", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation", + "objc2-metal", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "ordered-float" +version = "5.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c7c9e0d9b23589f26070720bac724174bfec1083e82f7854cdd0267518343c0" +dependencies = [ + "num-traits", +] + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "parlance" +version = "0.1.0" +source = "git+https://github.com/linebender/parley?rev=4f67747d3690bb21d25cc70b674ab0d269595759#4f67747d3690bb21d25cc70b674ab0d269595759" + +[[package]] +name = "parley" +version = "0.11.0" +source = "git+https://github.com/linebender/parley?rev=4f67747d3690bb21d25cc70b674ab0d269595759#4f67747d3690bb21d25cc70b674ab0d269595759" +dependencies = [ + "fontique", + "hashbrown 0.17.1", + "linebender_resource_handle", + "parlance", + "parley_engine", + "skrifa", + "smallvec", +] + +[[package]] +name = "parley_data" +version = "0.11.0" +source = "git+https://github.com/linebender/parley?rev=4f67747d3690bb21d25cc70b674ab0d269595759#4f67747d3690bb21d25cc70b674ab0d269595759" +dependencies = [ + "icu_properties", +] + +[[package]] +name = "parley_engine" +version = "0.11.0" +source = "git+https://github.com/linebender/parley?rev=4f67747d3690bb21d25cc70b674ab0d269595759#4f67747d3690bb21d25cc70b674ab0d269595759" +dependencies = [ + "fontique", + "harfrust", + "hashbrown 0.17.1", + "icu_normalizer", + "icu_properties", + "icu_segmenter", + "linebender_resource_handle", + "parlance", + "parley_data", + "skrifa", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkg-config" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide 0.8.9", +] + +[[package]] +name = "pollster" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3" + +[[package]] +name = "portable-atomic" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" + +[[package]] +name = "portable-atomic-util" +version = "0.2.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10ab3eb7f3becc3a1cbc4f2c6f20267996cfc1a6467a873763411b136a122715" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "serde_core", + "writeable", + "zerovec", +] + +[[package]] +name = "presser" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8cf8e6a8aa66ce33f63993ffc4ea4271eb5b0530a9002db8455ea6050c77bfa" + +[[package]] +name = "probe" +version = "0.0.0" +dependencies = [ + "gl21", + "objc2", + "objc2-app-kit", + "objc2-foundation", + "png", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "profiling" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5" + +[[package]] +name = "pxfm" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea" + +[[package]] +name = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "range-alloc" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca45419789ae5a7899559e9512e58ca889e41f04f1f2445e9f4b290ceccd1d08" + +[[package]] +name = "raw-window-handle" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" + +[[package]] +name = "raw-window-metal" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40d213455a5f1dc59214213c7330e074ddf8114c9a42411eb890c767357ce135" +dependencies = [ + "objc2", + "objc2-core-foundation", + "objc2-foundation", + "objc2-quartz-core", +] + +[[package]] +name = "read-fonts" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "046a7d674daf459825b32f5062056d6882db0d2f5a479fbd76ccfc870ac18709" +dependencies = [ + "bytemuck", + "font-types", + "once_cell", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "reference" +version = "0.0.0" +dependencies = [ + "draw", + "png", + "pollster", + "wgpu", +] + +[[package]] +name = "renderdoc-sys" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832" + +[[package]] +name = "roxmltree" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1964b10c76125c36f8afe190065a4bf9a87bf324842c05701330bba9f1cacbb" +dependencies = [ + "memchr", +] + +[[package]] +name = "rustc-hash" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + +[[package]] +name = "skrifa" +version = "0.44.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819ab7d62b1d3e72d9d9dea5650bac30424f9111364bb94928dbf5ecad1baa68" +dependencies = [ + "bytemuck", + "read-fonts", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "slotmap" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038" +dependencies = [ + "version_check", +] + +[[package]] +name = "smallvec" +version = "1.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9395f0f0eee849a9b707b2f06bb92a6a422090e2123bb2ef8e87a0e61892a8e" + +[[package]] +name = "spirv" +version = "0.4.0+sdk-1.4.341.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9571ea910ebd84c86af4b3ed27f9dbdc6ad06f17c5f96146b2b671e2976744f" +dependencies = [ + "bitflags", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "swash" +version = "0.2.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c2499c2d826531388872b2268718aed907a39bd785ab0dcfe57fab26283f92e" +dependencies = [ + "skrifa", + "yazi", + "zeno", +] + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "synstructure" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "thiserror" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "tiff" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63feaf3343d35b6ca4d50483f94843803b0f51634937cc2ec519fc32232bc52" +dependencies = [ + "fax", + "flate2", + "half", + "quick-error", + "weezl", + "zune-jpeg", +] + +[[package]] +name = "tinystr" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +dependencies = [ + "displaydoc", + "serde_core", + "zerovec", +] + +[[package]] +name = "unicode-ident" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasm-bindgen" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aecb87a33d3b0c5e3b7aa46336eaf486cffafbd281b195e4c8b80d50df2351bf" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.78" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ef4c5d3d2cdf5c54f4231181768f5510842e350db025faf1f7163b1030ed928" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a690d511e3c1a8b3a55e33511e3c2c00c78415cd23650f32b808627f5696b9ed" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "411e4887f0071ef2d2164a9d5fdf2d20efbef78fccd3a78b0c10a1dc5295e48a" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.6", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81941cd78d0c92026c33e5e01312845a4cb1e9af3407f9134b100dd03144103e" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wayland-sys" +version = "0.31.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be" +dependencies = [ + "dlib", + "log", + "once_cell", + "pkg-config", +] + +[[package]] +name = "web-sys" +version = "0.3.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fbddc4a036f00ec4f18c83445bd3115cb306a91da554919a099d9222fe4a7f8" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "weezl" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" + +[[package]] +name = "wgpu" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527ccdf43dd5b2e8676eed9984ce00e2bbb0a1b85b70c1969dcb6cd2eb55ab9e" +dependencies = [ + "arrayvec", + "bitflags", + "bytemuck", + "cfg-if", + "cfg_aliases", + "document-features", + "hashbrown 0.17.1", + "js-sys", + "log", + "naga", + "parking_lot", + "portable-atomic", + "profiling", + "raw-window-handle", + "smallvec", + "static_assertions", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "wgpu-core", + "wgpu-hal", + "wgpu-types", +] + +[[package]] +name = "wgpu-core" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14c018fce9b6270aa203c2fdd56f3cce996713534bd757e4ea58c8560b121f14" +dependencies = [ + "arrayvec", + "bit-set", + "bit-vec", + "bitflags", + "bytemuck", + "cfg_aliases", + "document-features", + "hashbrown 0.17.1", + "indexmap", + "log", + "naga", + "naga-types", + "once_cell", + "parking_lot", + "portable-atomic", + "profiling", + "raw-window-handle", + "rustc-hash", + "smallvec", + "thiserror", + "wgpu-core-deps-apple", + "wgpu-core-deps-emscripten", + "wgpu-core-deps-windows-linux-android", + "wgpu-hal", + "wgpu-naga-bridge", + "wgpu-types", +] + +[[package]] +name = "wgpu-core-deps-apple" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "061f3d319a40d39d00b1ecc2c33b89fe21d4e6fe01859df3500a3a8ecccd6b68" +dependencies = [ + "wgpu-hal", +] + +[[package]] +name = "wgpu-core-deps-emscripten" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98b86cf4abf524a902dd35f18ca6a3f08fc2ae9847c8f10b48e30491b1f0b86" +dependencies = [ + "wgpu-hal", +] + +[[package]] +name = "wgpu-core-deps-windows-linux-android" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7586165fd5f6d881cb9ce4bb71f40d6caab2c0f1837e3fc1d9788a197fb6004f" +dependencies = [ + "wgpu-hal", +] + +[[package]] +name = "wgpu-hal" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6b7fb58561a792bc237628ba0792e332de418fefe145f13b5ed8201e6d52f58" +dependencies = [ + "android_system_properties", + "arrayvec", + "ash", + "bit-set", + "bitflags", + "block2", + "bytemuck", + "cfg-if", + "cfg_aliases", + "glow", + "glutin_wgl_sys", + "gpu-allocator", + "hashbrown 0.17.1", + "js-sys", + "khronos-egl", + "libc", + "libloading", + "log", + "naga", + "naga-types", + "ndk-sys", + "objc2", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation", + "objc2-metal", + "objc2-quartz-core", + "once_cell", + "ordered-float", + "parking_lot", + "portable-atomic", + "portable-atomic-util", + "profiling", + "range-alloc", + "raw-window-handle", + "raw-window-metal", + "renderdoc-sys", + "smallvec", + "static_assertions", + "thiserror", + "wasm-bindgen", + "wayland-sys", + "web-sys", + "wgpu-naga-bridge", + "wgpu-types", + "windows", + "windows-core", + "windows-result", +] + +[[package]] +name = "wgpu-naga-bridge" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f62e73117bb7a62bfd9c5a5841438a823f6566c6442a808ee269d2d055c081" +dependencies = [ + "naga", + "wgpu-types", +] + +[[package]] +name = "wgpu-types" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "99dad6f1fbdbbdb4c278a6508b059d44688f5cebddf78d005a46a31340269286" +dependencies = [ + "bitflags", + "bytemuck", + "js-sys", + "log", + "naga-types", + "raw-window-handle", + "static_assertions", + "web-sys", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "windows" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" +dependencies = [ + "windows-collections", + "windows-core", + "windows-future", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" +dependencies = [ + "windows-core", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" +dependencies = [ + "windows-core", + "windows-link", + "windows-threading", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-numerics" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" +dependencies = [ + "windows-core", + "windows-link", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-threading" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" +dependencies = [ + "windows-link", +] + +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[package]] +name = "xml-rs" +version = "0.8.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7" + +[[package]] +name = "yazi" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01738255b5a16e78bbb83e7fbba0a1e7dd506905cfc53f4622d89015a03fbb5" + +[[package]] +name = "yeslogic-fontconfig-sys" +version = "6.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d8b8abf912b9a29ff112e1671c97c33636903d13a69712037190e6805af4f76" +dependencies = [ + "dlib", + "once_cell", + "pkg-config", +] + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33811428bee40dbceb6d545e95754741d17a6aef9a4849f0fd62e2ba4f412a78" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", + "synstructure", +] + +[[package]] +name = "zeno" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6df3dc4292935e51816d896edcd52aa30bc297907c26167fec31e2b0c6a32524" + +[[package]] +name = "zerocopy" +version = "0.8.59" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6df92bf3d9227be3d53173901ddbffac2babc27ae50f397776ffd6dc33f800cb" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.59" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac4f328cf2f05d084e496c3e9c3f33ed0a183656a16e1fcec4d464d8373aec82" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", + "synstructure", +] + +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "zerovec" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" +dependencies = [ + "serde", + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "zlib-rs" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112" + +[[package]] +name = "zune-core" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56377fd46368984a170bc5aac5567e52ca5da874caa60bea39fcbca78fb658b" + +[[package]] +name = "zune-jpeg" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" +dependencies = [ + "zune-core", +] diff --git a/platform/snow-leopard/Cargo.toml b/platform/snow-leopard/Cargo.toml new file mode 100644 index 0000000000000000000000000000000000000000..75aefbc119126a4760df7784c8ed18d9ead92a58 --- /dev/null +++ b/platform/snow-leopard/Cargo.toml @@ -0,0 +1,13 @@ +# Mac OS X 10.6 work, built with cargo.sh. Its own workspace so the 10.6 target +# never enters the main workspace's builds. +[workspace] +members = ["gl21", "hello", "probe", "reference"] +resolver = "3" + +[workspace.dependencies] +objc2 = "0.6.4" +objc2-foundation = { version = "0.3.2", default-features = false } +objc2-app-kit = { version = "0.3.2", default-features = false } + +[profile.dev.package."*"] +opt-level = 3 diff --git a/platform/snow-leopard/cargo.sh b/platform/snow-leopard/cargo.sh new file mode 100755 index 0000000000000000000000000000000000000000..71c603e9e2a662aab5789acd5484cbb4d997d93a --- /dev/null +++ b/platform/snow-leopard/cargo.sh @@ -0,0 +1,21 @@ +#!/bin/sh +# cargo for Mac OS X 10.6, from any package: `cargo.sh build --release -p notebook ...`. +# Needs nightly with rust-src and the SDK from `remote.sh sdk`. +set -eu +here=$(cd "$(dirname "$0")" && pwd) +sdk=${SNOW_LEOPARD_SDK:-$(cd "$here/../.." && pwd)/target/snow-leopard/MacOSX10.6.sdk} +target=x86_64-apple-macosx10.6 +command=$1 +shift +# Cargo can't see link.sh's inputs; folding their hash into the flags relinks on change. +runtime=$(cat "$here/link.sh" "$here"/rt/* | shasum | cut -c1-12) +# The deployment target is for cc-rs (bundled SQLite); rustc warns that it ignores it. +# rustc would infer ld64 from the name `link.sh`; it is a C compiler driver. +CARGO_TARGET_DIR=${CARGO_TARGET_DIR:-$here/../../target/snow-leopard} \ +SNOW_LEOPARD_SDK=$sdk \ +TARGET_CFLAGS="-isysroot $sdk -mmacosx-version-min=10.6" \ +MACOSX_DEPLOYMENT_TARGET=10.6 \ +exec cargo +nightly -Zbuild-std=std,panic_unwind -Zjson-target-spec \ + --config "target.$target.linker='$here/link.sh'" \ + --config "target.$target.rustflags=['-C','linker-flavor=darwin-cc','-C','metadata=rt-$runtime']" \ + "$command" --target "$here/$target.json" "$@" diff --git a/platform/snow-leopard/gl21/Cargo.toml b/platform/snow-leopard/gl21/Cargo.toml new file mode 100644 index 0000000000000000000000000000000000000000..8a60a52bd918c2c7762d0ba2e9d8cf0e10fe218f --- /dev/null +++ b/platform/snow-leopard/gl21/Cargo.toml @@ -0,0 +1,6 @@ +[package] +name = "gl21" +version = "0.0.0" +edition = "2024" +publish = false +description = "OpenGL 2.1 plus EXT_framebuffer_object, as Mac OS X 10.6 exports them." diff --git a/platform/snow-leopard/gl21/src/draw.glsl b/platform/snow-leopard/gl21/src/draw.glsl new file mode 100644 index 0000000000000000000000000000000000000000..304fa963bc7a6d2db4760c914736c86b30d152dd --- /dev/null +++ b/platform/snow-leopard/gl21/src/draw.glsl @@ -0,0 +1,102 @@ +// GLSL 1.20 port of crates/draw/src/draw.wgsl. GLSL 1.20 has no flat interpolation, so +// the per-quad shape and stroke arrive interpolated between equal values: equality tests +// on them use a tolerance. +#ifdef VERTEX +attribute vec2 position; +attribute vec2 uv; +attribute vec4 color; +attribute vec2 local; +attribute vec4 shape; +attribute float stroke; +#endif +varying vec2 v_uv; +varying vec4 v_color; +varying vec2 v_local; +varying vec4 v_shape; +varying float v_stroke; + +#ifdef VERTEX +void main() { + v_uv = uv; + v_color = color; + v_local = local; + v_shape = shape; + v_stroke = stroke; + gl_Position = vec4(position, 0.0, 1.0); +} +#else +uniform sampler2D atlas; + +bool same(float a, float b) { + return abs(a - b) <= 1e-4 * max(1.0, max(abs(a), abs(b))); +} + +float ellipse_arc(float angle, vec2 radius) { + if (same(radius.x, radius.y)) { + return angle * radius.x; + } + // Four-point Gauss-Legendre quadrature. + vec4 nodes = vec4(-0.86113631, -0.33998104, 0.33998104, 0.86113631); + vec4 weights = vec4(0.34785485, 0.65214515, 0.65214515, 0.34785485); + float half_angle = angle * 0.5; + float scale = max(radius.x, radius.y); + float sum = 0.0; + for (int i = 0; i < 4; i++) { + float t = half_angle * (nodes[i] + 1.0); + sum += weights[i] * length((radius / scale) * vec2(sin(t), cos(t))); + } + return half_angle * sum * scale; +} + +void main() { + vec4 color = texture2D(atlas, v_uv) * v_color; + vec4 shape = v_shape; + if (shape.x > 0.0 && shape.y > 0.0) { + vec2 q = abs(v_local) - shape.xy + shape.zw; + float distance; + if (same(shape.z, shape.w)) { + distance = length(max(q, vec2(0.0))) + min(max(q.x, q.y), 0.0) - shape.z; + } else if (all(greaterThan(q, vec2(0.0)))) { + vec2 radius = max(shape.zw, vec2(0.0001)); + vec2 normalized = q / radius; + float k0 = length(normalized); + distance = k0 * (k0 - 1.0) / max(length(normalized / radius), 1e-20); + } else { + vec2 edge = abs(v_local) - shape.xy; + distance = max(edge.x, edge.y); + } + float coverage = clamp(0.5 - distance, 0.0, 1.0); + float width = abs(v_stroke); + if (width > 1e-6) { + coverage *= clamp(distance + width + 0.5, 0.0, 1.0); + } + if (v_stroke < -1e-6) { + vec2 radius = max(shape.zw - width * 0.5, vec2(0.0)); + if (radius.x <= 1e-6 || radius.y <= 1e-6) { + radius = vec2(0.0); + } + vec2 straight = shape.xy - width * 0.5 - radius; + vec2 p = abs(v_local); + float quarter = straight.x + straight.y + ellipse_arc(1.57079632679, radius); + float along; + if (p.y <= straight.y) { + along = p.y; + } else if (p.x <= straight.x || radius.x == 0.0) { + along = quarter - p.x; + } else { + float angle = atan((p.y - straight.y) / radius.y, (p.x - straight.x) / radius.x); + along = straight.y + ellipse_arc(angle, radius); + } + if (v_local.x < 0.0) { + along = v_local.y < 0.0 ? 2.0 * quarter + along : 2.0 * quarter - along; + } else if (v_local.y < 0.0) { + along = 4.0 * quarter - along; + } + float phase = fract(along / (4.0 * width)) * (4.0 * width); + coverage *= clamp(width + 0.5 - abs(phase - 2.0 * width), 0.0, 1.0); + } + color *= coverage; + } + gl_FragColor = color; +} +#endif diff --git a/platform/snow-leopard/gl21/src/lib.rs b/platform/snow-leopard/gl21/src/lib.rs new file mode 100644 index 0000000000000000000000000000000000000000..24edde8424f1d27956876ea2b201a33623b21e96 --- /dev/null +++ b/platform/snow-leopard/gl21/src/lib.rs @@ -0,0 +1,118 @@ +//! OpenGL 2.1 and the extensions every Mac OS X 10.6 GPU driver exposes, linked straight +//! from OpenGL.framework, which exports them all; no loader is needed. +#![allow(non_snake_case, clippy::missing_safety_doc)] + +pub mod painter; + +use std::ffi::{c_char, c_void}; + +pub type GLenum = u32; +pub type GLuint = u32; +pub type GLint = i32; +pub type GLsizei = i32; +pub type GLfloat = f32; +pub type GLboolean = u8; +pub type GLbitfield = u32; +pub type GLsizeiptr = isize; + +pub const NO_ERROR: GLenum = 0; +pub const TRIANGLES: GLenum = 0x0004; +pub const COLOR_BUFFER_BIT: GLbitfield = 0x4000; +pub const BLEND: GLenum = 0x0BE2; +pub const SCISSOR_TEST: GLenum = 0x0C11; +pub const TEXTURE_2D: GLenum = 0x0DE1; +pub const UNSIGNED_BYTE: GLenum = 0x1401; +pub const FLOAT: GLenum = 0x1406; +pub const RGBA: GLenum = 0x1908; +pub const VENDOR: GLenum = 0x1F00; +pub const RENDERER: GLenum = 0x1F01; +pub const VERSION: GLenum = 0x1F02; +pub const EXTENSIONS: GLenum = 0x1F03; +pub const NEAREST: GLint = 0x2600; +pub const LINEAR: GLint = 0x2601; +pub const TEXTURE_MAG_FILTER: GLenum = 0x2800; +pub const TEXTURE_MIN_FILTER: GLenum = 0x2801; +pub const TEXTURE_WRAP_S: GLenum = 0x2802; +pub const TEXTURE_WRAP_T: GLenum = 0x2803; +pub const CLAMP_TO_EDGE: GLint = 0x812F; +pub const ONE: GLenum = 1; +pub const SRC_ALPHA: GLenum = 0x0302; +pub const ONE_MINUS_SRC_ALPHA: GLenum = 0x0303; +pub const TEXTURE0: GLenum = 0x84C0; +pub const ARRAY_BUFFER: GLenum = 0x8892; +pub const STREAM_DRAW: GLenum = 0x88E0; +pub const FRAGMENT_SHADER: GLenum = 0x8B30; +pub const VERTEX_SHADER: GLenum = 0x8B31; +pub const COMPILE_STATUS: GLenum = 0x8B81; +pub const LINK_STATUS: GLenum = 0x8B82; +pub const INFO_LOG_LENGTH: GLenum = 0x8B84; +pub const SRGB8_ALPHA8: GLint = 0x8C43; +pub const READ_FRAMEBUFFER: GLenum = 0x8CA8; +pub const DRAW_FRAMEBUFFER: GLenum = 0x8CA9; +pub const FRAMEBUFFER_COMPLETE: GLenum = 0x8CD5; +pub const COLOR_ATTACHMENT0: GLenum = 0x8CE0; +pub const FRAMEBUFFER: GLenum = 0x8D40; +pub const FRAMEBUFFER_SRGB: GLenum = 0x8DB9; + +#[link(name = "OpenGL", kind = "framework")] +unsafe extern "C" { + pub fn glGetError() -> GLenum; + pub fn glGetString(name: GLenum) -> *const u8; + pub fn glEnable(cap: GLenum); + pub fn glDisable(cap: GLenum); + pub fn glViewport(x: GLint, y: GLint, width: GLsizei, height: GLsizei); + pub fn glScissor(x: GLint, y: GLint, width: GLsizei, height: GLsizei); + pub fn glClearColor(r: GLfloat, g: GLfloat, b: GLfloat, a: GLfloat); + pub fn glClear(mask: GLbitfield); + pub fn glFinish(); + pub fn glFlush(); + pub fn glBlendFuncSeparate(src_rgb: GLenum, dst_rgb: GLenum, src_a: GLenum, dst_a: GLenum); + pub fn glReadPixels(x: GLint, y: GLint, w: GLsizei, h: GLsizei, format: GLenum, ty: GLenum, data: *mut c_void); + pub fn glPixelStorei(name: GLenum, value: GLint); + + pub fn glGenTextures(n: GLsizei, textures: *mut GLuint); + pub fn glDeleteTextures(n: GLsizei, textures: *const GLuint); + pub fn glBindTexture(target: GLenum, texture: GLuint); + pub fn glActiveTexture(unit: GLenum); + pub fn glTexParameteri(target: GLenum, name: GLenum, value: GLint); + pub fn glTexImage2D(target: GLenum, level: GLint, internal: GLint, w: GLsizei, h: GLsizei, border: GLint, format: GLenum, ty: GLenum, data: *const c_void); + pub fn glTexSubImage2D(target: GLenum, level: GLint, x: GLint, y: GLint, w: GLsizei, h: GLsizei, format: GLenum, ty: GLenum, data: *const c_void); + + pub fn glGenBuffers(n: GLsizei, buffers: *mut GLuint); + pub fn glBindBuffer(target: GLenum, buffer: GLuint); + pub fn glBufferData(target: GLenum, size: GLsizeiptr, data: *const c_void, usage: GLenum); + pub fn glEnableVertexAttribArray(index: GLuint); + pub fn glVertexAttribPointer(index: GLuint, size: GLint, ty: GLenum, normalized: GLboolean, stride: GLsizei, offset: *const c_void); + pub fn glDrawArrays(mode: GLenum, first: GLint, count: GLsizei); + + pub fn glCreateShader(kind: GLenum) -> GLuint; + pub fn glShaderSource(shader: GLuint, count: GLsizei, sources: *const *const c_char, lengths: *const GLint); + pub fn glCompileShader(shader: GLuint); + pub fn glGetShaderiv(shader: GLuint, name: GLenum, value: *mut GLint); + pub fn glGetShaderInfoLog(shader: GLuint, size: GLsizei, length: *mut GLsizei, log: *mut c_char); + pub fn glCreateProgram() -> GLuint; + pub fn glAttachShader(program: GLuint, shader: GLuint); + pub fn glBindAttribLocation(program: GLuint, index: GLuint, name: *const c_char); + pub fn glLinkProgram(program: GLuint); + pub fn glGetProgramiv(program: GLuint, name: GLenum, value: *mut GLint); + pub fn glGetProgramInfoLog(program: GLuint, size: GLsizei, length: *mut GLsizei, log: *mut c_char); + pub fn glUseProgram(program: GLuint); + pub fn glGetUniformLocation(program: GLuint, name: *const c_char) -> GLint; + pub fn glUniform1i(location: GLint, value: GLint); + + pub fn glGenFramebuffersEXT(n: GLsizei, framebuffers: *mut GLuint); + pub fn glDeleteFramebuffersEXT(n: GLsizei, framebuffers: *const GLuint); + pub fn glBindFramebufferEXT(target: GLenum, framebuffer: GLuint); + pub fn glFramebufferTexture2DEXT(target: GLenum, attachment: GLenum, textarget: GLenum, texture: GLuint, level: GLint); + pub fn glCheckFramebufferStatusEXT(target: GLenum) -> GLenum; + pub fn glBlitFramebufferEXT(sx0: GLint, sy0: GLint, sx1: GLint, sy1: GLint, dx0: GLint, dy0: GLint, dx1: GLint, dy1: GLint, mask: GLbitfield, filter: GLenum); +} + +/// A GL string such as `VERSION` or `EXTENSIONS`, empty without a current context. +pub fn string(name: GLenum) -> String { + let text = unsafe { glGetString(name) }; + if text.is_null() { + return String::new(); + } + unsafe { std::ffi::CStr::from_ptr(text.cast()) }.to_string_lossy().into_owned() +} diff --git a/platform/snow-leopard/gl21/src/painter.rs b/platform/snow-leopard/gl21/src/painter.rs new file mode 100644 index 0000000000000000000000000000000000000000..3340b6c1e89dc97cb01356d8ef40144b486ce76a --- /dev/null +++ b/platform/snow-leopard/gl21/src/painter.rs @@ -0,0 +1,272 @@ +//! The submission half of `draw::Renderer` on OpenGL 2.1: the atlas, image textures, one +//! vertex buffer and the ported shader. `draw` keeps building vertices and batches; this +//! draws them into an sRGB framebuffer object, as wgpu draws them into its target. +use crate::*; +use std::{ffi::CString, ops::Range, ptr}; + +/// Laid out as `draw`'s vertex. +#[repr(C)] +#[derive(Clone, Copy, Debug, Default)] +pub struct Vertex { + pub position: [f32; 2], + pub uv: [f32; 2], + pub color: [f32; 4], + pub local: [f32; 2], + pub shape: [f32; 4], + pub stroke: f32, +} + +pub struct Batch<'a> { + pub vertices: Range, + /// None samples the atlas with straight alpha; an image is premultiplied. + pub image: Option<&'a Texture>, + /// Device pixels from the top left: `[x, y, width, height]`. + pub scissor: [u32; 4], +} + +/// An sRGB RGBA texture. +pub struct Texture { + name: GLuint, + size: [u32; 2], +} + +impl Texture { + fn new(size: [u32; 2], filter: GLint, pixels: Option<&[u8]>) -> Self { + let mut name = 0; + unsafe { + glGenTextures(1, &mut name); + glBindTexture(TEXTURE_2D, name); + glTexParameteri(TEXTURE_2D, TEXTURE_MIN_FILTER, filter); + glTexParameteri(TEXTURE_2D, TEXTURE_MAG_FILTER, filter); + glTexParameteri(TEXTURE_2D, TEXTURE_WRAP_S, CLAMP_TO_EDGE); + glTexParameteri(TEXTURE_2D, TEXTURE_WRAP_T, CLAMP_TO_EDGE); + glTexImage2D( + TEXTURE_2D, + 0, + SRGB8_ALPHA8, + size[0] as GLsizei, + size[1] as GLsizei, + 0, + RGBA, + UNSIGNED_BYTE, + pixels.map_or(ptr::null(), |p| p.as_ptr().cast()), + ); + } + Self { name, size } + } + + /// Writes `rgba` rows, top first, at `origin`. + pub fn write(&self, origin: [u32; 2], size: [u32; 2], rgba: &[u8]) { + assert_eq!(rgba.len(), (size[0] * size[1] * 4) as usize); + unsafe { + glBindTexture(TEXTURE_2D, self.name); + glTexSubImage2D( + TEXTURE_2D, + 0, + origin[0] as GLint, + origin[1] as GLint, + size[0] as GLsizei, + size[1] as GLsizei, + RGBA, + UNSIGNED_BYTE, + rgba.as_ptr().cast(), + ); + } + } + + pub fn size(&self) -> [u32; 2] { + self.size + } +} + +impl Drop for Texture { + fn drop(&mut self) { + unsafe { glDeleteTextures(1, &self.name) }; + } +} + +/// An offscreen sRGB colour target; blending happens in linear light. +pub struct Target { + framebuffer: GLuint, + texture: Texture, +} + +impl Target { + pub fn new(size: [u32; 2]) -> Result { + let texture = Texture::new(size, NEAREST, None); + let mut framebuffer = 0; + unsafe { + glGenFramebuffersEXT(1, &mut framebuffer); + glBindFramebufferEXT(FRAMEBUFFER, framebuffer); + glFramebufferTexture2DEXT(FRAMEBUFFER, COLOR_ATTACHMENT0, TEXTURE_2D, texture.name, 0); + let status = glCheckFramebufferStatusEXT(FRAMEBUFFER); + glBindFramebufferEXT(FRAMEBUFFER, 0); + if status != FRAMEBUFFER_COMPLETE { + return Err(format!("framebuffer incomplete: {status:#x}")); + } + } + Ok(Self { framebuffer, texture }) + } + + pub fn size(&self) -> [u32; 2] { + self.texture.size + } + + /// Copies the target to the current context's window framebuffer, same size. + pub fn present(&self) { + let [w, h] = self.texture.size.map(|v| v as GLint); + unsafe { + glBindFramebufferEXT(READ_FRAMEBUFFER, self.framebuffer); + glBindFramebufferEXT(DRAW_FRAMEBUFFER, 0); + glBlitFramebufferEXT(0, 0, w, h, 0, 0, w, h, COLOR_BUFFER_BIT, NEAREST as GLenum); + glBindFramebufferEXT(FRAMEBUFFER, 0); + } + } + + /// sRGB-encoded RGBA rows, top first. + pub fn read_pixels(&self) -> Vec { + let [w, h] = self.texture.size; + let mut pixels = vec![0u8; (w * h * 4) as usize]; + unsafe { + glBindFramebufferEXT(FRAMEBUFFER, self.framebuffer); + glReadPixels(0, 0, w as GLsizei, h as GLsizei, RGBA, UNSIGNED_BYTE, pixels.as_mut_ptr().cast()); + glBindFramebufferEXT(FRAMEBUFFER, 0); + } + pixels.chunks(w as usize * 4).rev().flatten().copied().collect() + } +} + +impl Drop for Target { + fn drop(&mut self) { + unsafe { glDeleteFramebuffersEXT(1, &self.framebuffer) }; + } +} + +pub struct Painter { + program: GLuint, + buffer: GLuint, + pub atlas: Texture, +} + +const ATTRIBUTES: [(&str, GLint, usize); 6] = [ + ("position", 2, 0), + ("uv", 2, 8), + ("color", 4, 16), + ("local", 2, 32), + ("shape", 4, 40), + ("stroke", 1, 56), +]; + +impl Painter { + /// Needs a current context. + pub fn new(atlas_size: u32) -> 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() { + let name = CString::new(*name).unwrap(); + 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!("link: {}", log(program, glGetProgramiv, glGetProgramInfoLog))); + } + program + }; + let atlas = Texture::new([atlas_size; 2], NEAREST, None); + // The atlas's first texel is the white that solid fills sample. + atlas.write([0, 0], [1, 1], &[255; 4]); + let mut buffer = 0; + unsafe { glGenBuffers(1, &mut buffer) }; + Ok(Self { program, buffer, atlas }) + } + + /// An image texture, filtered linearly; pixels are premultiplied rows, top first. + pub fn image(&self, size: [u32; 2], rgba: &[u8]) -> Texture { + Texture::new(size, LINEAR, Some(rgba)) + } + + /// Clears `target` to linear `clear` and draws the batches in order. + pub fn draw(&self, target: &Target, clear: [f32; 4], vertices: &[Vertex], batches: &[Batch<'_>]) { + let [width, height] = target.size().map(|v| v as GLsizei); + 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); + glUseProgram(self.program); + glUniform1i(glGetUniformLocation(self.program, c"atlas".as_ptr()), 0); + glActiveTexture(TEXTURE0); + glBindBuffer(ARRAY_BUFFER, self.buffer); + glBufferData(ARRAY_BUFFER, size_of_val(vertices) as GLsizeiptr, vertices.as_ptr().cast(), STREAM_DRAW); + for (index, (_, size, offset)) in ATTRIBUTES.iter().enumerate() { + glEnableVertexAttribArray(index as GLuint); + glVertexAttribPointer( + index as GLuint, + *size, + FLOAT, + 0, + size_of::() as GLsizei, + ptr::without_provenance(*offset), + ); + } + glEnable(BLEND); + glEnable(SCISSOR_TEST); + for batch in batches { + let [x, y, w, h] = batch.scissor.map(|v| v as GLint); + glScissor(x, height - y - h, w, h); + let (texture, source) = match batch.image { + Some(image) => (image.name, ONE), + None => (self.atlas.name, SRC_ALPHA), + }; + glBlendFuncSeparate(source, ONE_MINUS_SRC_ALPHA, ONE, ONE_MINUS_SRC_ALPHA); + glBindTexture(TEXTURE_2D, texture); + glDrawArrays( + TRIANGLES, + batch.vertices.start as GLint, + batch.vertices.len() as GLsizei, + ); + } + glDisable(SCISSOR_TEST); + glDisable(BLEND); + glBindFramebufferEXT(FRAMEBUFFER, 0); + } + } +} + +unsafe fn compile(kind: GLenum, source: &str) -> Result { + unsafe { + let shader = glCreateShader(kind); + let text = CString::new(source).unwrap(); + glShaderSource(shader, 1, &text.as_ptr(), ptr::null()); + glCompileShader(shader); + let mut compiled = 0; + glGetShaderiv(shader, COMPILE_STATUS, &mut compiled); + if compiled == 0 { + return Err(format!("compile: {}", log(shader, glGetShaderiv, glGetShaderInfoLog))); + } + Ok(shader) + } +} + +unsafe fn log( + object: GLuint, + get: unsafe extern "C" fn(GLuint, GLenum, *mut GLint), + read: unsafe extern "C" fn(GLuint, GLsizei, *mut GLsizei, *mut std::ffi::c_char), +) -> String { + unsafe { + let mut length = 0; + get(object, INFO_LOG_LENGTH, &mut length); + let mut text = vec![0u8; length.max(1) as usize]; + read(object, length, ptr::null_mut(), text.as_mut_ptr().cast()); + String::from_utf8_lossy(&text).trim_end_matches('\0').to_owned() + } +} diff --git a/platform/snow-leopard/hello/Cargo.toml b/platform/snow-leopard/hello/Cargo.toml new file mode 100644 index 0000000000000000000000000000000000000000..b280e5f62f9139bcc718d1845ae6dbd7e1775cd0 --- /dev/null +++ b/platform/snow-leopard/hello/Cargo.toml @@ -0,0 +1,6 @@ +[package] +name = "hello-snow-leopard" +version = "0.0.0" +edition = "2024" +publish = false + diff --git a/platform/snow-leopard/hello/src/main.rs b/platform/snow-leopard/hello/src/main.rs new file mode 100644 index 0000000000000000000000000000000000000000..10089a471a3ec1fd0dc3221e223e6a67dc13cfc0 --- /dev/null +++ b/platform/snow-leopard/hello/src/main.rs @@ -0,0 +1,42 @@ +use std::cell::Cell; +use std::time::{Instant, SystemTime}; + +thread_local! { + static COUNTER: Cell = const { Cell::new(0) }; +} + +fn main() { + let dir = std::env::temp_dir().join(format!("snow-leopard-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let file = dir.join("hello.txt"); + std::fs::write(&file, "hello from rust").unwrap(); + let text = std::fs::read_to_string(&file).unwrap(); + let meta = std::fs::metadata(&file).unwrap(); + let entries = std::fs::read_dir(&dir).unwrap().count(); + std::fs::remove_dir_all(&dir).unwrap(); + + let start = Instant::now(); + let threads: Vec<_> = (0..4) + .map(|i| { + std::thread::spawn(move || { + for _ in 0..=i { + COUNTER.with(|c| c.set(c.get() + 1)); + } + COUNTER.with(Cell::get) + }) + }) + .collect(); + let counts: Vec = threads.into_iter().map(|t| t.join().unwrap()).collect(); + COUNTER.with(|c| c.set(c.get() + 100)); + + let mut map = std::collections::HashMap::new(); + map.insert("random-state", 1); + + let since_epoch = SystemTime::now().duration_since(SystemTime::UNIX_EPOCH).unwrap(); + println!("fs: {text:?} ({} bytes, {entries} entries)", meta.len()); + println!("tls: threads {counts:?}, main {}", COUNTER.with(Cell::get)); + println!("time: {}s since epoch, {:?} elapsed", since_epoch.as_secs(), start.elapsed()); + println!("args: {:?}, HOME={:?}", std::env::args().collect::>(), std::env::var("HOME").ok()); + std::panic::catch_unwind(|| panic!("caught")).unwrap_err(); + println!("unwind: ok"); +} diff --git a/platform/snow-leopard/link.sh b/platform/snow-leopard/link.sh new file mode 100755 index 0000000000000000000000000000000000000000..46fb1a039676f14a8b6396b436239121c4a668b7 --- /dev/null +++ b/platform/snow-leopard/link.sh @@ -0,0 +1,43 @@ +#!/bin/sh +# rustc's linker for the Mac OS X 10.6 targets: links against the 10.6 SDK (see +# `remote.sh sdk`), so an import 10.6 lacks fails here, plus the shims in rt/. +# rustc clamps the deployment target to 10.12; this pins it back to 10.6. +set -eu +here=$(cd "$(dirname "$0")" && pwd) +sdk=${SNOW_LEOPARD_SDK:-$here/../../target/snow-leopard/MacOSX10.6.sdk} +[ -d "$sdk" ] || { echo "link.sh: no 10.6 SDK at $sdk; run remote.sh sdk" >&2; exit 1; } +arch=x86_64 +prev= +for arg; do + shift + case "$arg" in + -mmacosx-version-min=*) arg=-mmacosx-version-min=10.6 ;; + esac + # 10.6 keeps these inside ApplicationServices, which re-exports them to its clients. + if [ "$prev" = -framework ]; then + case "$arg" in + CoreGraphics | CoreText | ImageIO | ColorSync) arg=ApplicationServices ;; + # Until draw's wgpu backend is optional: wgpu names Metal, which 10.6 lacks, + # and nothing a 10.6 binary reaches calls into it. + Metal) arg=Foundation ;; + esac + fi + [ "$prev" = -arch ] && arch=$arg + prev=$arg + set -- "$@" "$arg" +done +work=$(mktemp -d "${TMPDIR:-/tmp}/snow-leopard-link.XXXXXX") +trap 'rm -rf "$work"' EXIT +for source in "$here"/rt/*.c; do + clang -arch "$arch" -isysroot "$sdk" -mmacosx-version-min=10.6 -O2 -Wall \ + -c "$source" -o "$work/$(basename "$source" .c).o" +done +# shims.o overrides libSystem, so it links as an object; the rest fill in functions later +# systems added, from an archive, so only binaries calling them pull in their frameworks. +ar rcs "$work/libsnow_leopard.a" $(ls "$work"/*.o | grep -v /shims.o) +status=0 +clang -isysroot "$sdk" "$@" "$work/shims.o" "$work/libsnow_leopard.a" 2>"$work/log" || status=$? +# rustc's objects claim 10.12; ld-prime's stub deprecation is noise for a frozen SDK. +grep -v -e "was built for newer 'macOS' version (10.12)" -e "dylib stub (MH_DYLIB_STUB)" \ + -e "no platform load command found in .*crt1" "$work/log" >&2 || true +exit $status diff --git a/platform/snow-leopard/probe/Cargo.toml b/platform/snow-leopard/probe/Cargo.toml new file mode 100644 index 0000000000000000000000000000000000000000..baced6a5859ac687c90d4c27ace200730d2a7236 --- /dev/null +++ b/platform/snow-leopard/probe/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "probe" +version = "0.0.0" +edition = "2024" +publish = false + +[dependencies] +gl21 = { path = "../gl21" } +objc2.workspace = true +objc2-foundation = { workspace = true, features = ["std", "NSString", "NSAutoreleasePool", "NSObject", "NSZone", "NSGeometry", "NSDate", "NSRunLoop"] } +objc2-app-kit = { workspace = true, features = ["std", "NSApplication", "NSResponder", "NSWindow", "NSView", "NSOpenGL", "NSOpenGLView", "NSEvent", "NSRunningApplication", "NSGraphics"] } +png = "0.18" + +[[bin]] +name = "probe" +path = "src/main.rs" + +[[bin]] +name = "window-probe" +path = "src/window.rs" + +[[bin]] +name = "render-probe" +path = "src/render.rs" diff --git a/platform/snow-leopard/probe/src/main.rs b/platform/snow-leopard/probe/src/main.rs new file mode 100644 index 0000000000000000000000000000000000000000..12398a9e8421637b004209ccdb21d3145256f50d --- /dev/null +++ b/platform/snow-leopard/probe/src/main.rs @@ -0,0 +1,12 @@ +use objc2::{rc::{Retained, autoreleasepool}, runtime::NSObjectProtocol}; +use objc2_foundation::NSCopying; +use objc2_foundation::{NSObject, NSString}; + +fn main() { + autoreleasepool(|_| { + let text = NSString::from_str("objc2 on Snow Leopard"); + let copy: Retained = text.copy(); + let object = NSObject::new(); + println!("{copy} ({} chars), {object:?}", copy.length()); + }); +} diff --git a/platform/snow-leopard/probe/src/render.rs b/platform/snow-leopard/probe/src/render.rs new file mode 100644 index 0000000000000000000000000000000000000000..ee2acfe02fc9c887e263937a54d8903077fbb13b --- /dev/null +++ b/platform/snow-leopard/probe/src/render.rs @@ -0,0 +1,104 @@ +//! Paints `scene` through the GL 2.1 painter into an offscreen target and writes +//! `render-probe.png`. The vertices follow `draw::Renderer`'s quad and rounded-rectangle +//! encoding at scale 1. +#![allow(deprecated)] // OpenGL is the only GPU API 10.6 has. +mod scene; + +use gl21::painter::{Batch, Painter, Target, Vertex}; +use objc2::MainThreadMarker; +use objc2_app_kit::{NSOpenGLContext, NSOpenGLPFAAccelerated, NSOpenGLPFAColorSize, NSOpenGLPixelFormat}; +use scene::Shape; + +const WHITE_UV: [f32; 4] = [0.5 / ATLAS as f32; 4]; +const ATLAS: u32 = 2048; + +fn quad(vertices: &mut Vec, rect: [f32; 4], uv: [f32; 4], color: [f32; 4]) { + let [w, h] = scene::SIZE.map(|v| v as f32); + let (x0, x1) = (rect[0] * 2.0 / w - 1.0, rect[2] * 2.0 / w - 1.0); + let (y0, y1) = (1.0 - rect[1] * 2.0 / h, 1.0 - rect[3] * 2.0 / h); + let [hx, hy] = [(rect[2] - rect[0]) * 0.5, (rect[3] - rect[1]) * 0.5]; + for (position, uv, local) in [ + ([x0, y0], [uv[0], uv[1]], [-hx, -hy]), + ([x0, y1], [uv[0], uv[3]], [-hx, hy]), + ([x1, y1], [uv[2], uv[3]], [hx, hy]), + ([x0, y0], [uv[0], uv[1]], [-hx, -hy]), + ([x1, y1], [uv[2], uv[3]], [hx, hy]), + ([x1, y0], [uv[2], uv[1]], [hx, -hy]), + ] { + vertices.push(Vertex { position, uv, color, local, ..Default::default() }); + } +} + +fn rounded(vertices: &mut Vec, rect: [f32; 4], radius: [f32; 2], stroke: Option<(f32, bool)>, colors: [[f32; 4]; 2]) { + let start = vertices.len(); + quad(vertices, [rect[0] - 1.0, rect[1] - 1.0, rect[2] + 1.0, rect[3] + 1.0], WHITE_UV, colors[0]); + let half = [(rect[2] - rect[0]) * 0.5, (rect[3] - rect[1]) * 0.5]; + let radius = if radius.contains(&0.0) { [0.0; 2] } else { [radius[0].min(half[0]), radius[1].min(half[1])] }; + let width = stroke.map_or(0.0, |(width, dashed)| { + let width = width.min(half[0].min(half[1])); + if dashed { -width } else { width } + }); + for vertex in &mut vertices[start..] { + vertex.shape = [half[0], half[1], radius[0], radius[1]]; + vertex.stroke = width; + if vertex.local[1] > 0.0 { + vertex.color = colors[1]; + } + } +} + +fn main() { + objc2::rc::autoreleasepool(|_| run()); +} + +fn run() { + let mtm = MainThreadMarker::new().unwrap(); + let attributes = [NSOpenGLPFAAccelerated, NSOpenGLPFAColorSize, 24, 0]; + let format = unsafe { + NSOpenGLPixelFormat::initWithAttributes(mtm.alloc(), std::ptr::NonNull::new(attributes.as_ptr().cast_mut()).unwrap()) + } + .expect("pixel format"); + let context = NSOpenGLContext::initWithFormat_shareContext(mtm.alloc(), &format, None).expect("context"); + context.makeCurrentContext(); + + let painter = Painter::new(ATLAS).unwrap_or_else(|error| panic!("{error}")); + let target = Target::new(scene::SIZE).unwrap(); + let (checker_size, checker) = scene::checker(); + let image = painter.image(checker_size, &checker); + let mut vertices = Vec::new(); + let mut spans = Vec::new(); + for shape in scene::shapes() { + let start = vertices.len() as u32; + let is_image = match shape { + Shape::Rect { rect, color } => { + quad(&mut vertices, rect, WHITE_UV, color); + false + } + Shape::Rounded { rect, radius, stroke, colors } => { + rounded(&mut vertices, rect, radius, stroke, colors); + false + } + Shape::Image { rect } => { + quad(&mut vertices, rect, [0.0, 0.0, 1.0, 1.0], [1.0; 4]); + true + } + }; + spans.push((start..vertices.len() as u32, is_image)); + } + let batches: Vec = spans + .into_iter() + .map(|(vertices, is_image)| Batch { + vertices, + image: is_image.then_some(&image), + scissor: [0, 0, scene::SIZE[0], scene::SIZE[1]], + }) + .collect(); + let started = std::time::Instant::now(); + painter.draw(&target, scene::CLEAR, &vertices, &batches); + let pixels = target.read_pixels(); + println!("drew {} vertices in {:?}; GL error {:#x}", vertices.len(), started.elapsed(), unsafe { gl21::glGetError() }); + let file = std::fs::File::create("render-probe.png").unwrap(); + let mut encoder = png::Encoder::new(file, scene::SIZE[0], scene::SIZE[1]); + encoder.set_color(png::ColorType::Rgba); + encoder.write_header().unwrap().write_image_data(&pixels).unwrap(); +} diff --git a/platform/snow-leopard/probe/src/scene.rs b/platform/snow-leopard/probe/src/scene.rs new file mode 100644 index 0000000000000000000000000000000000000000..c98b8dd064e2816bc9c31347201216fecd5d23d7 --- /dev/null +++ b/platform/snow-leopard/probe/src/scene.rs @@ -0,0 +1,40 @@ +//! One test scene, painted by `draw` over wgpu (the `reference` crate) and by the GL 2.1 +//! painter on 10.6 (`render-probe`), whose PNGs `compare.py` diffs. Device pixels, +//! linear colours. + +pub const SIZE: [u32; 2] = [400, 300]; +pub const CLEAR: [f32; 4] = [0.85, 0.85, 0.85, 1.0]; + +pub enum Shape { + Rect { rect: [f32; 4], color: [f32; 4] }, + /// `stroke` is a width and whether it is dashed. + Rounded { rect: [f32; 4], radius: [f32; 2], stroke: Option<(f32, bool)>, colors: [[f32; 4]; 2] }, + Image { rect: [f32; 4] }, +} + +pub fn shapes() -> Vec { + let red = [0.6, 0.05, 0.05, 1.0]; + let blue = [0.05, 0.1, 0.6, 1.0]; + let green = [0.05, 0.4, 0.1, 1.0]; + let ink = [0.02, 0.02, 0.02, 1.0]; + use Shape::*; + vec![ + Rect { rect: [20.0, 20.0, 120.0, 80.0], color: red }, + Rounded { rect: [140.0, 20.0, 260.0, 80.0], radius: [12.0, 12.0], stroke: None, colors: [blue; 2] }, + Rounded { rect: [280.0, 20.5, 380.0, 80.25], radius: [30.0, 12.0], stroke: None, colors: [green; 2] }, + Rounded { rect: [20.0, 100.0, 120.0, 160.0], radius: [10.0, 10.0], stroke: Some((3.0, false)), colors: [ink; 2] }, + Rounded { rect: [140.0, 100.0, 260.0, 160.0], radius: [16.0, 16.0], stroke: Some((2.0, true)), colors: [ink; 2] }, + Rounded { rect: [280.0, 100.0, 380.0, 160.0], radius: [8.0, 8.0], stroke: None, colors: [[0.9, 0.5, 0.1, 1.0], [0.2, 0.05, 0.4, 1.0]] }, + Image { rect: [20.0, 180.0, 120.0, 280.0] }, + Rect { rect: [60.0, 200.0, 200.0, 260.0], color: [0.1, 0.3, 0.8, 0.5] }, + Rounded { rect: [220.0, 180.0, 380.0, 280.0], radius: [30.0, 10.0], stroke: Some((3.0, true)), colors: [red; 2] }, + ] +} + +/// A 4×4 opaque sRGB checker, stretched and filtered by the image path. +pub fn checker() -> ([u32; 2], Vec) { + let pixels = (0..16) + .flat_map(|i| if (i % 4 + i / 4) % 2 == 0 { [240, 200, 40, 255] } else { [30, 120, 90, 255] }) + .collect(); + ([4, 4], pixels) +} diff --git a/platform/snow-leopard/probe/src/window.rs b/platform/snow-leopard/probe/src/window.rs new file mode 100644 index 0000000000000000000000000000000000000000..4407b743dc4484ff4655f805f48791bff346ae46 --- /dev/null +++ b/platform/snow-leopard/probe/src/window.rs @@ -0,0 +1,133 @@ +//! Opens a window with an OpenGL 2.1 view, paints a test pattern through an sRGB +//! framebuffer object, presents it, and writes the framebuffer to `window-probe.png`. +#![allow(deprecated)] // OpenGL is the only GPU API 10.6 has. +use gl21::*; +use objc2::{MainThreadMarker, rc::Retained}; +use objc2_app_kit::{ + NSApplication, NSApplicationActivationPolicy, NSBackingStoreType, NSOpenGLPFAAccelerated, + NSOpenGLPFAAlphaSize, NSOpenGLPFAColorSize, NSOpenGLPFADoubleBuffer, NSOpenGLPixelFormat, + NSOpenGLView, NSWindow, NSWindowStyleMask, +}; +use objc2_foundation::{NSDate, NSDefaultRunLoopMode, NSPoint, NSRect, NSSize, NSString}; + +const SIZE: [i32; 2] = [480, 320]; + +fn main() { + // 10.6 has no implicit pool on the main thread; AppKit autoreleases from the start. + objc2::rc::autoreleasepool(|_| run()); +} + +fn run() { + let mtm = MainThreadMarker::new().unwrap(); + let app = NSApplication::sharedApplication(mtm); + app.setActivationPolicy(NSApplicationActivationPolicy::Regular); + app.finishLaunching(); + + let frame = NSRect::new(NSPoint::new(200.0, 200.0), NSSize::new(SIZE[0] as f64, SIZE[1] as f64)); + let window = unsafe { + NSWindow::initWithContentRect_styleMask_backing_defer( + mtm.alloc(), + frame, + NSWindowStyleMask::Titled | NSWindowStyleMask::Closable | NSWindowStyleMask::Resizable, + NSBackingStoreType::Buffered, + false, + ) + }; + unsafe { window.setReleasedWhenClosed(false) }; + window.setTitle(&NSString::from_str("Snowbound on Snow Leopard")); + let attributes = [ + NSOpenGLPFADoubleBuffer, + NSOpenGLPFAAccelerated, + NSOpenGLPFAColorSize, + 24, + NSOpenGLPFAAlphaSize, + 8, + 0, + ]; + let format: Retained = unsafe { + NSOpenGLPixelFormat::initWithAttributes( + mtm.alloc(), + std::ptr::NonNull::new(attributes.as_ptr().cast_mut()).unwrap(), + ) + } + .expect("pixel format"); + let view = NSOpenGLView::initWithFrame_pixelFormat(mtm.alloc(), frame, Some(&format)).unwrap(); + window.setContentView(Some(&view)); + window.makeKeyAndOrderFront(None); + app.activateIgnoringOtherApps(true); + + // Let the window server map the window before painting into it. + let until = NSDate::dateWithTimeIntervalSinceNow(0.5); + while let Some(event) = unsafe { + app.nextEventMatchingMask_untilDate_inMode_dequeue( + objc2_app_kit::NSEventMask::Any, + Some(&until), + NSDefaultRunLoopMode, + true, + ) + } { + app.sendEvent(&event); + } + + let context = view.openGLContext().expect("context"); + context.makeCurrentContext(); + println!("GL_VENDOR {}", string(VENDOR)); + println!("GL_RENDERER {}", string(RENDERER)); + println!("GL_VERSION {}", string(VERSION)); + let extensions = string(EXTENSIONS); + for wanted in ["GL_EXT_framebuffer_object", "GL_EXT_framebuffer_blit", "GL_EXT_framebuffer_sRGB", "GL_EXT_texture_sRGB", "GL_ARB_texture_non_power_of_two"] { + println!("{wanted:32} {}", extensions.split(' ').any(|e| e == wanted)); + } + + let pixels = unsafe { paint() }; + unsafe { + glBindFramebufferEXT(DRAW_FRAMEBUFFER, 0); + glBlitFramebufferEXT(0, 0, SIZE[0], SIZE[1], 0, 0, SIZE[0], SIZE[1], COLOR_BUFFER_BIT, NEAREST as GLenum); + } + context.flushBuffer(); + println!("GL error after present: {:#x}", unsafe { glGetError() }); + + let file = std::fs::File::create("window-probe.png").unwrap(); + let mut encoder = png::Encoder::new(file, SIZE[0] as u32, SIZE[1] as u32); + encoder.set_color(png::ColorType::Rgba); + encoder.write_header().unwrap().write_image_data(&pixels).unwrap(); + println!("wrote window-probe.png"); +} + +/// Clears an sRGB framebuffer object and fills three bands with scissored clears in +/// linear colours; returns its pixels top row first. +unsafe fn paint() -> Vec { + unsafe { + let mut texture = 0; + glGenTextures(1, &mut texture); + glBindTexture(TEXTURE_2D, texture); + glTexParameteri(TEXTURE_2D, TEXTURE_MIN_FILTER, NEAREST); + glTexParameteri(TEXTURE_2D, TEXTURE_MAG_FILTER, NEAREST); + glTexImage2D(TEXTURE_2D, 0, SRGB8_ALPHA8, SIZE[0], SIZE[1], 0, RGBA, UNSIGNED_BYTE, std::ptr::null()); + let mut framebuffer = 0; + glGenFramebuffersEXT(1, &mut framebuffer); + glBindFramebufferEXT(FRAMEBUFFER, framebuffer); + glFramebufferTexture2DEXT(FRAMEBUFFER, COLOR_ATTACHMENT0, TEXTURE_2D, texture, 0); + assert_eq!(glCheckFramebufferStatusEXT(FRAMEBUFFER), FRAMEBUFFER_COMPLETE); + glEnable(FRAMEBUFFER_SRGB); + glViewport(0, 0, SIZE[0], SIZE[1]); + glClearColor(0.9, 0.9, 0.9, 1.0); + glClear(COLOR_BUFFER_BIT); + glEnable(SCISSOR_TEST); + // Linear 0.214 is sRGB 128: the middle band reads back as 128 if encoding works. + for (i, color) in [[0.8, 0.1, 0.1], [0.214, 0.214, 0.214], [0.1, 0.2, 0.8]].iter().enumerate() { + glScissor(40 + i as i32 * 140, 60, 120, 200); + glClearColor(color[0], color[1], color[2], 1.0); + glClear(COLOR_BUFFER_BIT); + } + glDisable(SCISSOR_TEST); + let mut pixels = vec![0u8; (SIZE[0] * SIZE[1] * 4) as usize]; + glReadPixels(0, 0, SIZE[0], SIZE[1], RGBA, UNSIGNED_BYTE, pixels.as_mut_ptr().cast()); + glBindFramebufferEXT(READ_FRAMEBUFFER, framebuffer); + let row = (SIZE[0] * 4) as usize; + let flipped: Vec = pixels.chunks(row).rev().flatten().copied().collect(); + let middle = &flipped[(160 * SIZE[0] as usize + 250) * 4..][..4]; + println!("middle band reads {middle:?} (128 means sRGB encode on write)"); + flipped + } +} diff --git a/platform/snow-leopard/reference/Cargo.toml b/platform/snow-leopard/reference/Cargo.toml new file mode 100644 index 0000000000000000000000000000000000000000..8c4c825763e30b8e1e6c2762819568f8ba2682ee --- /dev/null +++ b/platform/snow-leopard/reference/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "reference" +version = "0.0.0" +edition = "2024" +publish = false +description = "Paints the render-probe scene through draw over wgpu, on the dev Mac." + +[dependencies] +draw = { path = "../../../crates/draw" } +wgpu = "30.0.1" +pollster = "0.4" +png = "0.18" diff --git a/platform/snow-leopard/reference/src/main.rs b/platform/snow-leopard/reference/src/main.rs new file mode 100644 index 0000000000000000000000000000000000000000..1c93b2f1f2a76ffa71702512799616dcefc73b7d --- /dev/null +++ b/platform/snow-leopard/reference/src/main.rs @@ -0,0 +1,111 @@ +//! `reference OUT.png` paints the render-probe scene through `draw::Renderer` over wgpu; +//! `reference --compare A.png B.png DIFF.png` reports how two renders differ and writes +//! their difference, amplified. +#[path = "../../probe/src/scene.rs"] +mod scene; + +use draw::{Layer, Primitive, RasterImage, Stroke}; +use scene::Shape; + +fn main() { + let args: Vec = std::env::args().skip(1).collect(); + match args.as_slice() { + [flag, a, b, diff] if flag == "--compare" => compare(a, b, diff), + [out] => paint(out), + _ => panic!("usage: reference OUT.png | reference --compare A.png B.png DIFF.png"), + } +} + +fn read(path: &str) -> ([u32; 2], Vec) { + let mut decoder = png::Decoder::new(std::io::BufReader::new(std::fs::File::open(path).unwrap())); + decoder.set_transformations(png::Transformations::normalize_to_color8()); + let mut reader = decoder.read_info().unwrap(); + let mut pixels = vec![0; reader.output_buffer_size().unwrap()]; + let info = reader.next_frame(&mut pixels).unwrap(); + assert_eq!(info.color_type, png::ColorType::Rgba, "{path}"); + ([info.width, info.height], pixels) +} + +fn compare(a: &str, b: &str, diff: &str) { + let (size, a) = read(a); + let (other, b) = read(b); + assert_eq!(size, other, "sizes differ"); + let delta: Vec = a.iter().zip(&b).map(|(a, b)| a.abs_diff(*b)).collect(); + let worst = delta.iter().max().unwrap(); + let off = delta.chunks(4).filter(|p| p.iter().any(|v| *v > 2)).count(); + println!("max channel difference {worst}; {off} of {} pixels differ by more than 2", delta.len() / 4); + let shown: Vec = delta + .chunks(4) + .flat_map(|p| [p[0], p[1], p[2]].map(|v| v.saturating_mul(16)).into_iter().chain([255])) + .collect(); + write(diff, size, &shown); +} + +fn write(path: &str, [width, height]: [u32; 2], pixels: &[u8]) { + let mut encoder = png::Encoder::new(std::fs::File::create(path).unwrap(), width, height); + encoder.set_color(png::ColorType::Rgba); + encoder.write_header().unwrap().write_image_data(pixels).unwrap(); +} + +fn paint(out: &str) { + 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 format = wgpu::TextureFormat::Rgba8UnormSrgb; + let mut renderer = draw::Renderer::new(device.clone(), queue.clone(), format); + let (checker_size, checker) = scene::checker(); + let image = RasterImage::new(checker_size, checker).unwrap(); + let primitives: Vec = scene::shapes() + .into_iter() + .map(|shape| match shape { + Shape::Rect { rect, color } => Primitive::Rect { rect, color }, + Shape::Rounded { rect, radius, stroke, colors } => match stroke { + None => Primitive::Gradient { rect, radius, colors }, + Some((width, dashed)) => Primitive::RoundedRect { + rect, + radius, + stroke: Some(if dashed { Stroke::Dashed(width) } else { Stroke::Solid(width) }), + color: colors[0], + }, + }, + Shape::Image { rect } => Primitive::Image { image: &image, rect }, + }) + .collect(); + let [width, height] = scene::SIZE; + let texture = device.create_texture(&wgpu::TextureDescriptor { + label: None, + size: wgpu::Extent3d { width, height, depth_or_array_layers: 1 }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format, + usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC, + view_formats: &[], + }); + let layer = Layer { scale: 1.0, origin: [0.0; 2], clip: None, backdrop: None, primitives: &primitives }; + renderer + .draw(&texture.create_view(&Default::default()), scene::SIZE, scene::CLEAR, &[layer]) + .unwrap(); + let row = (width * 4).next_multiple_of(wgpu::COPY_BYTES_PER_ROW_ALIGNMENT); + let buffer = device.create_buffer(&wgpu::BufferDescriptor { + label: None, + size: u64::from(row * height), + usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ, + mapped_at_creation: false, + }); + let mut encoder = device.create_command_encoder(&Default::default()); + encoder.copy_texture_to_buffer( + texture.as_image_copy(), + wgpu::TexelCopyBufferInfo { + buffer: &buffer, + layout: wgpu::TexelCopyBufferLayout { offset: 0, bytes_per_row: Some(row), rows_per_image: None }, + }, + wgpu::Extent3d { width, height, depth_or_array_layers: 1 }, + ); + queue.submit([encoder.finish()]); + buffer.map_async(wgpu::MapMode::Read, .., |result| result.unwrap()); + device.poll(wgpu::PollType::Wait { submission_index: None, timeout: None }).unwrap(); + let mapped = buffer.get_mapped_range(..).unwrap(); + let pixels: Vec = mapped.chunks(row as usize).flat_map(|r| &r[..width as usize * 4]).copied().collect(); + write(out, scene::SIZE, &pixels); +} diff --git a/platform/snow-leopard/remote.sh b/platform/snow-leopard/remote.sh new file mode 100755 index 0000000000000000000000000000000000000000..d71d6b710dd67450ec61f12ce82ee6932568aeeb --- /dev/null +++ b/platform/snow-leopard/remote.sh @@ -0,0 +1,60 @@ +#!/bin/sh +# Drives the Mac OS X 10.6 test machine over ssh (scp doesn't pass its proxy). +# +# remote.sh run [-C DIR] BINARY [ARGS...] +# ship to /tmp/snow-leopard, run in DIR (default there), +# print any new crash log +# remote.sh mirror PATH... copy repo paths to the same absolute paths there, so +# test binaries find fixtures by CARGO_MANIFEST_DIR +# remote.sh pull REMOTE [LOCAL] copy a file back (e.g. a screenshot) +# remote.sh sdk fetch the machine's MacOSX10.6.sdk for link.sh +# remote.sh sh COMMAND... run a shell command there +set -eu +host=${SNOW_LEOPARD_HOST:-osx10.6@10.0.0.1} +here=$(cd "$(dirname "$0")" && pwd) +dir=/tmp/snow-leopard + +case "${1:-}" in +run) + shift + cwd=$dir + if [ "$1" = -C ]; then cwd=$2; shift 2; fi + binary=$1 + shift + name=$(basename "$binary") + ssh "$host" "mkdir -p $dir && cat > $dir/$name && chmod +x $dir/$name" < "$binary" + quoted= + for arg; do quoted="$quoted '$(printf %s "$arg" | sed "s/'/'\\\\''/g")'"; done + # A crash report lands a few seconds after the process dies. + ssh "$host" "touch $dir/.start && cd '$cwd' && status=0 && $dir/$name$quoted || status=\$? + if [ \$status -ge 128 ]; then + sleep 5 + for log in \$(find ~/Library/Logs/CrashReporter -name '${name}_*' -newer $dir/.start); do + echo \"--- \$log\" >&2; sed -n '1,/^Binary Images/p' \"\$log\" >&2 + done + fi + exit \$status" < /dev/null + ;; +mirror) + shift + root=$(cd "$here/../.." && pwd) + (cd "$root" && COPYFILE_DISABLE=1 tar czf - --no-mac-metadata --no-xattrs --no-acls --exclude 'corpus/private' --exclude target "$@") | + ssh "$host" "mkdir -p '$root' && tar xzf - -C '$root'" + ;; +pull) + ssh "$host" "cat '$2'" > "${3:-$(basename "$2")}" + ;; +sdk) + mkdir -p "$here/../../target/snow-leopard" + ssh "$host" 'cd /Developer/SDKs && tar czf - MacOSX10.6.sdk' | + tar xzf - -C "$here/../../target/snow-leopard" + ;; +sh) + shift + ssh "$host" "$@" + ;; +*) + sed -n '2,12s/^# \{0,1\}//p' "$0" >&2 + exit 2 + ;; +esac diff --git a/platform/snow-leopard/rt/coretext.c b/platform/snow-leopard/rt/coretext.c new file mode 100644 index 0000000000000000000000000000000000000000..718b46a140b14664e850577fcd61f44e30ad748b --- /dev/null +++ b/platform/snow-leopard/rt/coretext.c @@ -0,0 +1,9 @@ +// CoreText functions fontique calls that arrived after 10.6. +#include + +// 10.9: fallback font for a string in a language; 10.6 can only ignore the language. +CTFontRef CTFontCreateForStringWithLanguage(CTFontRef font, CFStringRef string, CFRange range, + CFStringRef language) { + (void)language; + return CTFontCreateForString(font, string, range); +} diff --git a/platform/snow-leopard/rt/objc.c b/platform/snow-leopard/rt/objc.c new file mode 100644 index 0000000000000000000000000000000000000000..179e1f47482e0107141306216bbfe4a80f7f0e03 --- /dev/null +++ b/platform/snow-leopard/rt/objc.c @@ -0,0 +1,39 @@ +// The ARC runtime entry points objc2 calls, which libobjc gained in 10.7 (objc_alloc in +// 10.9); on 10.6 they were compiled into each binary from libarclite. Linked from an +// archive, so only binaries that use Objective-C pull in libobjc. +#include +#include +#include + +typedef id (*Send)(id, SEL); +#define SEND(receiver, selector) ((Send)objc_msgSend)((receiver), sel_registerName(selector)) + +extern void *_Block_copy(const void *); + +id objc_retain(id object) { return object ? SEND(object, "retain") : nil; } +void objc_release(id object) { if (object) SEND(object, "release"); } +id objc_autorelease(id object) { return object ? SEND(object, "autorelease") : nil; } +id objc_autoreleaseReturnValue(id object) { return objc_autorelease(object); } +id objc_retainAutorelease(id object) { return objc_autorelease(objc_retain(object)); } +id objc_retainAutoreleaseReturnValue(id object) { return objc_retainAutorelease(object); } +id objc_retainAutoreleasedReturnValue(id object) { return objc_retain(object); } +id objc_retainBlock(id block) { return (id)_Block_copy(block); } + +void objc_storeStrong(id *location, id value) { + id previous = *location; + *location = objc_retain(value); + objc_release(previous); +} + +id objc_alloc(Class cls) { return cls ? SEND((id)cls, "alloc") : nil; } + +id objc_allocWithZone(Class cls) { + return cls ? ((id (*)(id, SEL, void *))objc_msgSend)((id)cls, sel_registerName("allocWithZone:"), NULL) : nil; +} + +// Draining a pool also drains every pool pushed after it, as objc_autoreleasePoolPop does. +void *objc_autoreleasePoolPush(void) { + return SEND(SEND((id)objc_getClass("NSAutoreleasePool"), "alloc"), "init"); +} + +void objc_autoreleasePoolPop(void *pool) { SEND((id)pool, "drain"); } diff --git a/platform/snow-leopard/rt/shims.c b/platform/snow-leopard/rt/shims.c new file mode 100644 index 0000000000000000000000000000000000000000..7d4f24c0b60ae4ddae043401f3a3ca34bd47cae6 --- /dev/null +++ b/platform/snow-leopard/rt/shims.c @@ -0,0 +1,188 @@ +// libSystem functions Rust std and LLVM reach for that Mac OS X 10.6 lacks or treats +// differently. Linked into the executable, so its own references bind here first under +// the two-level namespace; libraries the system loads keep the real ones. +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#define SNOW_O_CLOEXEC 0x01000000 // 10.7 +#define SNOW_F_DUPFD_CLOEXEC 67 // 10.7 +#define SNOW_AT_FDCWD -2 +#define SNOW_AT_SYMLINK_NOFOLLOW 0x0020 +#define SNOW_AT_REMOVEDIR 0x0080 + +// The real function this file overrides, from the next image in load order. +#define REAL(name) \ + static __typeof__(name) *real_##name; \ + if (!real_##name) real_##name = (__typeof__(name) *)dlsym(RTLD_NEXT, #name) + +// 10.6's kernel predates O_CLOEXEC; set the flag after the fact. +int open(const char *path, int flags, ...) { + REAL(open); + va_list ap; + va_start(ap, flags); + int mode = (flags & O_CREAT) ? va_arg(ap, int) : 0; + va_end(ap); + int fd = real_open(path, flags & ~SNOW_O_CLOEXEC, mode); + if (fd >= 0 && (flags & SNOW_O_CLOEXEC)) fcntl(fd, F_SETFD, FD_CLOEXEC); + return fd; +} + +int fcntl(int fd, int cmd, ...) { + REAL(fcntl); + va_list ap; + va_start(ap, cmd); + intptr_t arg = va_arg(ap, intptr_t); + va_end(ap); + if (cmd == SNOW_F_DUPFD_CLOEXEC) { + int copy = real_fcntl(fd, F_DUPFD, arg); + if (copy >= 0) real_fcntl(copy, F_SETFD, FD_CLOEXEC); + return copy; + } + return real_fcntl(fd, cmd, arg); +} + +// The *at family arrived in 10.10. Emulated through the directory's path, so a +// directory renamed mid-walk is not followed as the real calls would. +static int resolve_at(int dirfd, const char *path, char out[PATH_MAX]) { + if (dirfd == SNOW_AT_FDCWD || path[0] == '/') { + if (strlcpy(out, path, PATH_MAX) >= PATH_MAX) return errno = ENAMETOOLONG, -1; + return 0; + } + if (fcntl(dirfd, F_GETPATH, out) == -1) return -1; + if (strlcat(out, "/", PATH_MAX) >= PATH_MAX || strlcat(out, path, PATH_MAX) >= PATH_MAX) + return errno = ENAMETOOLONG, -1; + return 0; +} + +int openat(int dirfd, const char *path, int flags, ...) { + va_list ap; + va_start(ap, flags); + int mode = (flags & O_CREAT) ? va_arg(ap, int) : 0; + va_end(ap); + char full[PATH_MAX]; + if (resolve_at(dirfd, path, full) == -1) return -1; + return open(full, flags, mode); +} + +int unlinkat(int dirfd, const char *path, int flags) { + char full[PATH_MAX]; + if (resolve_at(dirfd, path, full) == -1) return -1; + return (flags & SNOW_AT_REMOVEDIR) ? rmdir(full) : unlink(full); +} + +int fstatat(int dirfd, const char *path, struct stat *st, int flags) + __asm("_fstatat" __DARWIN_SUF_64_BIT_INO_T); +int fstatat(int dirfd, const char *path, struct stat *st, int flags) { + char full[PATH_MAX]; + if (resolve_at(dirfd, path, full) == -1) return -1; + return (flags & SNOW_AT_SYMLINK_NOFOLLOW) ? lstat(full, st) : stat(full, st); +} + +int linkat(int fromfd, const char *from, int tofd, const char *to, int flags) { + char source[PATH_MAX], target[PATH_MAX]; + (void)flags; // std passes 0 + if (resolve_at(fromfd, from, source) == -1 || resolve_at(tofd, to, target) == -1) return -1; + return link(source, target); +} + +// Clones are APFS-only (10.12); std's fs::copy falls back to fcopyfile on ENOTSUP. +int fclonefileat(int fd, int dirfd, const char *to, int flags) { + (void)fd, (void)dirfd, (void)to, (void)flags; + return errno = ENOTSUP, -1; +} + +// Callers keep using `fd` (std's remove_dir_all passes it to openat/unlinkat), so the +// stream takes over that number rather than closing it. +DIR *fdopendir(int fd) __asm("_fdopendir" __DARWIN_SUF_64_BIT_INO_T __DARWIN_SUF_UNIX03); +DIR *fdopendir(int fd) { + char path[PATH_MAX]; + if (fcntl(fd, F_GETPATH, path) == -1) return NULL; + DIR *dir = opendir(path); + if (!dir) return NULL; + if (dup2(dir->__dd_fd, fd) == -1) { + closedir(dir); + return NULL; + } + close(dir->__dd_fd); + dir->__dd_fd = fd; + fcntl(fd, F_SETFD, FD_CLOEXEC); + return dir; +} + +// A function only from 10.8; earlier headers define it as this macro. +#undef dirfd +int dirfd(DIR *dir) { + return dir->__dd_fd; +} + +// clock_gettime arrived in 10.12. std asks for CLOCK_UPTIME_RAW (Instant) and +// CLOCK_REALTIME (SystemTime). +int clock_gettime(int clock, struct timespec *ts) { + switch (clock) { + case 0: { // CLOCK_REALTIME + struct timeval tv; + gettimeofday(&tv, NULL); + ts->tv_sec = tv.tv_sec; + ts->tv_nsec = tv.tv_usec * 1000; + return 0; + } + case 4: case 5: case 6: case 8: case 9: { // monotonic and uptime clocks + static mach_timebase_info_data_t base; + if (!base.denom) mach_timebase_info(&base); + uint64_t ns = mach_absolute_time() * base.numer / base.denom; + ts->tv_sec = ns / 1000000000; + ts->tv_nsec = ns % 1000000000; + return 0; + } + default: + errno = EINVAL; + return -1; + } +} + +static int read_urandom(void *bytes, size_t count) { + static int urandom = -1; + if (urandom < 0) { + int fd = open("/dev/urandom", O_RDONLY | SNOW_O_CLOEXEC); + if (fd < 0) return -1; + if (!__sync_bool_compare_and_swap(&urandom, -1, fd)) close(fd); + } + for (uint8_t *p = bytes; count;) { + ssize_t n = read(urandom, p, count); + if (n < 0 && errno == EINTR) continue; + if (n <= 0) return -1; + p += n; + count -= (size_t)n; + } + return 0; +} + +// CCRandomGenerateBytes (std) arrived in 10.10, getentropy (getrandom) in 10.12. +int32_t CCRandomGenerateBytes(void *bytes, size_t count) { + return read_urandom(bytes, count); +} + +int getentropy(void *bytes, size_t count) { + if (count > 256) return errno = EIO, -1; + return read_urandom(bytes, count); +} + +// LLVM emits these for macOS 10.9+, and rustc hands LLVM at least 10.12. +struct snow_double2 { double sinval, cosval; }; +struct snow_float2 { float sinval, cosval; }; +struct snow_double2 __sincos_stret(double x) { return (struct snow_double2){sin(x), cos(x)}; } +struct snow_float2 __sincosf_stret(float x) { return (struct snow_float2){sinf(x), cosf(x)}; } +double __exp10(double x) { return pow(10.0, x); } +float __exp10f(float x) { return powf(10.0f, x); } diff --git a/platform/snow-leopard/x86_64-apple-macosx10.6.json b/platform/snow-leopard/x86_64-apple-macosx10.6.json new file mode 100644 index 0000000000000000000000000000000000000000..50f81d45d39d9f29169db01138d0a7ffb83fce73 --- /dev/null +++ b/platform/snow-leopard/x86_64-apple-macosx10.6.json @@ -0,0 +1,54 @@ +{ + "abi-return-struct-as-int": true, + "arch": "x86_64", + "archive-format": "darwin", + "binary-format": "mach-o", + "cpu": "core2", + "crt-objects-fallback": "false", + "data-layout": "e-m:o-p270:32:32-p271:32:32-p272:64:64-i64:64-i128:128-f80:128-n8:16:32:64-S128", + "debuginfo-kind": "dwarf-dsym", + "dll-suffix": ".dylib", + "dynamic-linking": true, + "eh-frame-header": false, + "emit-debug-gdb-scripts": false, + "frame-pointer": "always", + "function-sections": false, + "has-rpath": true, + "has-thread-local": false, + "is-like-darwin": true, + "link-env": [ + "ZERO_AR_DATE=1" + ], + "link-env-remove": [ + "IPHONEOS_DEPLOYMENT_TARGET", + "TVOS_DEPLOYMENT_TARGET", + "XROS_DEPLOYMENT_TARGET" + ], + "linker-flavor": "darwin-cc", + "linker-is-gnu": false, + "lld-flavor": "darwin", + "llvm-target": "x86_64-apple-macosx", + "max-atomic-width": 128, + "metadata": { + "description": "x86_64 Mac OS X 10.6 Snow Leopard", + "host_tools": false, + "std": true, + "tier": 3 + }, + "os": "macos", + "split-debuginfo": "packed", + "stack-probes": { + "kind": "inline" + }, + "supported-split-debuginfo": [ + "packed", + "unpacked", + "off" + ], + "target-family": [ + "unix" + ], + "target-mcount": "\u0001mcount", + "target-pointer-width": 64, + "vendor": "apple" +} \ No newline at end of file diff --git a/tools/TESTING.md b/tools/TESTING.md index f839f1e364fa7883959d8152467b8a1d5bf24624..b280d6d6788190e652c68f84e6fb6df92781afa9 100644 --- a/tools/TESTING.md +++ b/tools/TESTING.md @@ -12,9 +12,9 @@ The command runs formatting, all workspace features/targets, doctests, Clippy, diagnostic/example builds, and Python regression tests. Each stage has its own log; `results.json` records executed commands, elapsed times and exit statuses. Only a completed run receives `status: passed`. Existing result directories are -refused. This lane clears inherited `ONESTORE_*` overrides and uses this -checkout's binaries so personal captures or external exporters cannot silently -replace public inputs. +refused. This lane clears inherited `ONESTORE_*` and `SNOWBOUND_*` overrides and +uses this checkout's binaries so personal captures or external exporters cannot +silently replace public inputs. Repeat the same command in a fresh checkout containing only versioned files to verify clean-checkout compatibility. Fixture symlinks must remain symlinks; their @@ -27,6 +27,22 @@ are **not** part of a successful public run. The Python suite tests native/lab harness logic using retained synthetic captures and mocks; it does not claim a new execution of OneNote or a real server interruption. +## Sweeps and soak + +Seeded sweeps (random edit walks, multi-client schedules, differentials across +corpus sections) run a smoke slice by default. `SNOWBOUND_SWEEP=0` runs them in +full with the seeds as written; any other number shifts every sweep's seeds, and +a failure replays under the same value. + +`tools/soak.sh [seconds per fuzz target]` soaks a dedicated machine until +stopped: each round runs the full sweeps under a random shift, in release with +debug assertions, then every `fuzz/` target for the time limit. A failing round +leaves `soak/*.log` ending in the command that replays it; crash inputs stay in +`fuzz/artifacts//`. On a Linux VM, install rustup's stable and nightly +toolchains, `cargo install cargo-fuzz`, and `build-essential pkg-config +libfontconfig-dev`; copy the checkout together with the untracked `evidence/`, +which tests read, and run the script under `tmux`. + ## Private and native verification Private notebooks stay outside versioned fixtures. Materialize a copy before diff --git a/tools/canvas/README.md b/tools/canvas/README.md index 380163c536e2b1c0d20b98b600865c836c55651e..431a78d23750db4b533eb1d44957d19b64e0e25a 100644 --- a/tools/canvas/README.md +++ b/tools/canvas/README.md @@ -50,11 +50,11 @@ snowbound title bar · toolbar · section frame · page tabs · the page as a `--notebook FOLDER` opens a notebook laid out as OneNote lays one out. The title bar shows the application's icon and the notebook's name; below it two rows of toolbar buttons follow the Home ribbon's Basic Text group, the first nine tags and zoom (only zoom acts yet). Readable top-level sections are tabs in the notebook's order, each leaning over the next at 45° with a faint highlight along its top, and the open one on top, casting a soft shadow and framing the page and the page tabs on the right in its colour, which shades slightly down the window; a tab that opens rises to meet the frame as its outline fades in. The frame's top corners and the page's corners are rounded concentric with the window's. The open page's tab is the page's colour and joins it with rounded inside corners; subpages are indented. The interface follows the system's light or dark appearance, and in the dark one the page's paper darkens and text left at OneNote's automatic colour turns light (except on a highlight), resolved when painting so nothing re-lays out; explicit colours, highlights, pictures and ink keep theirs, except that the background art of OneNote 2010's page templates, recognised by the picture's hash, paints from bundled vector recreations drawn for the paper. Sections take their stored colour's hue at a saturation and lightness chosen for each appearance. `--ui-font FONT_FILE_OR_FOLDER` sets the interface's typeface in place of the system's; fonts are not bundled. The section's colour is also the interface accent, easing to the next section's when another opens. The search box, New Page and the page-tab toggle sit above the page tabs. Command-F searches the page titles; Enter opens the first match and Escape clears it. A page that also changed on another computer shows Keep mine / Keep theirs above it. `--section FILE TITLE` opens one section the same way at that page. Section files must be regular files; discovery rejects symbolic links. -Launched without a file, the app reopens the notebooks open when it last closed, at the one shown last, or shows "No notebooks open" with New Notebook and Open Existing. Settings (the open notebooks, the sidebar and recent fonts) are JSON in `~/Library/Application Support/Snowbound/settings.json` or `$XDG_CONFIG_HOME/snowbound`; `--settings FILE` uses another file, `--notebook FOLDER` adds a notebook to them, and `--screenshot` never writes them. Open Existing takes a notebook folder, its `Open Notebook.onetoc2` or a section file, which opens in the notebook its folders make up. A notebook on a mounted SMB share opens through the embedded SMB client, signed in with the password the system keychain holds, and through the mount only when that fails. The notebook button left of the section tabs opens OneNote 2010's navigation bar: the open notebooks with their sections and section groups, the open section marked; dragging a row reorders a folder or, onto a group, moves into it, and right-clicking a row, a section tab or a page tab offers OneNote's commands (Rename, Delete to the notebook's recycle bin, New Section, New Section Group, Close This Notebook; Delete, New Page, Make Subpage, Promote Subpage). Page tabs drag to reorder. The `+` above the page tabs adds a page at the end of the section, titled with its date and time as OneNote titles one, and puts the caret in its title; until its body is typed in, templates offer our recreations of OneNote 2010's art (Ivy first), its page colours and Informal Meeting Notes' content, written as ops (`corpus/notebook-management`). +Launched without a file, the app reopens the notebooks open when it last closed, at the one shown last, or shows "No notebooks open" with New Notebook and Open Existing. Settings (the open notebooks, the sidebar, recent fonts and the user name edits are stored under, which Settings… (⌘,; Options in the sidebar and Ctrl+, on Linux) changes and which starts as the account's full name) are JSON in `~/Library/Application Support/Snowbound/settings.json` or `$XDG_CONFIG_HOME/snowbound`; `--settings FILE` uses another file, `--notebook FOLDER` adds a notebook to them, and `--screenshot` never writes them. Open Existing takes a notebook folder, its `Open Notebook.onetoc2` or a section file, which opens in the notebook its folders make up. A notebook on a mounted SMB share opens through the embedded SMB client, signed in with the password the system keychain holds, and through the mount only when that fails. The notebook button left of the section tabs opens OneNote 2010's navigation bar: the open notebooks with their sections and section groups, the open section marked; dragging a row reorders a folder or, onto a group, moves into it, and right-clicking a row, a section tab or a page tab offers OneNote's commands (Rename, Delete to the notebook's recycle bin, New Section, New Section Group, Close This Notebook; Delete, New Page, Make Subpage, Promote Subpage). Page tabs drag to reorder. The `+` above the page tabs adds a page at the end of the section, titled with its date and time as OneNote titles one, and puts the caret in its title; until its body is typed in, templates offer our recreations of OneNote 2010's art (Ivy first), its page colours and Informal Meeting Notes' content, written as ops (`corpus/notebook-management`). -The `ui` crate builds every frame from the application's state. A cache keyed by stable box ids keeps hover, press, focus, scroll and animation values, and the previous frame's layout routes the frame's input before building, so a frame answers input one layout late and paints with its own. Sizes are solved per axis after building: fixed, label-sized, a fraction of an ancestor, or the sum of children, with overflow shared out by each box's strictness. The page is a custom box: `ui` routes it the pointer, wheel, key and input-method events that land on it, in order, and the host hands them to `PageView` and paints the page in a clipped layer of the same frame. Page scrollbars are `ui` widgets over that box; the canvas reports only its scroll bounds. Text fields edit through `draw::edit`, as the page does, so keys, clicks and drags select and move the same way in both. Work the frame asks for (page requests, saving) runs after the frame is painted and requests the frame that shows it. Opening a section or page reads it on a thread of its own while the current page stays live; the newest read is laid out and replaces the page once the pictures it shows first are drawn, or after 200 ms. +The `ui` crate builds every frame from the application's state. A cache keyed by stable box ids keeps hover, press, focus, scroll and animation values, and the previous frame's layout routes the frame's input before building, so a frame answers input one layout late and paints with its own. Sizes are solved per axis after building: fixed, label-sized, a fraction of an ancestor, or the sum of children, with overflow shared out by each box's strictness. The page is a custom box: `ui` routes it the pointer, wheel, key and input-method events that land on it, in order, and the host hands them to `PageView` and paints the page in a clipped layer of the same frame. Page scrollbars are `ui` widgets over that box; the canvas reports only its scroll bounds. Text fields edit through `draw::edit`, as the page does, so keys, clicks and drags select and move the same way in both. Work the frame asks for (page requests, saving) runs after the frame is painted and requests the frame that shows it. Opening a section or page reads and lays it out on a thread of its own while the current page stays live; the newest replaces the page once the pictures it shows first are drawn, or after 200 ms. -A covered window receives no redraws, so interaction can be scripted: `SNOWBOUND_REPLAY` names a file of `move X Y`, `press [right]`, `release [right]`, `wheel DX DY`, `key NAME`, `type TEXT`, `modifiers [shift] [command]`, `wait MS`, `snapshot PNG_PATH`, `appearance light|dark` and `quit` lines in logical pixels, and each step and every 16 ms of a wait draws a frame. With `--screenshot` as well, the window stays hidden and only the replay's snapshots capture it. Snapshots omit the window's traffic lights, which AppKit draws. Replays edit and save like a user, so point them at a copy of a notebook. +A covered window receives no redraws, so interaction can be scripted: `SNOWBOUND_REPLAY` names a file of `move X Y`, `press [right]`, `release [right]`, `wheel DX DY`, `key NAME`, `type TEXT`, `modifiers [shift] [command]`, `wait MS`, `snapshot PNG_PATH`, `appearance light|dark` and `quit` lines in logical pixels, and each step and every 16 ms of a wait draws a frame. With `--screenshot` as well, the window stays hidden and only the replay's snapshots capture it. Snapshots omit the window's traffic lights, which AppKit draws. Replays edit and save like a user, so point them at a copy of a notebook. Their edits carry the settings' user name, so give fixture runs a `--settings` file holding `{"user_name": "Snowbound Test"}`. ```sh cp -RL SOURCE_NOTEBOOK /tmp/notebook-copy && chmod u+w /tmp/notebook-copy/*.one diff --git a/tools/check_public.py b/tools/check_public.py index 179de351d3f93260fa8dc8042d94c3bcd07bb656..ad81b4d4a49ab2bb474795f1463f88516c9a3155 100644 --- a/tools/check_public.py +++ b/tools/check_public.py @@ -18,7 +18,7 @@ def main(): output = args.output.resolve() output.mkdir(parents=True, exist_ok=False) environment = {key: value for key, value in os.environ.items() - if not key.startswith('ONESTORE_')} + if not key.startswith(('ONESTORE_', 'SNOWBOUND_'))} environment['PYTHONPATH'] = str(root / 'tools') environment['CARGO_TARGET_DIR'] = str(root / 'target') commands = [ diff --git a/tools/soak.sh b/tools/soak.sh new file mode 100755 index 0000000000000000000000000000000000000000..bf8f12835abe01a3e38f44fc5bb46ab029cb59e0 --- /dev/null +++ b/tools/soak.sh @@ -0,0 +1,35 @@ +#!/bin/sh +# Soaks a dedicated machine until stopped: each round runs every seeded sweep in full with +# a fresh seed shift, then each fuzz target for a time limit. A failing round leaves its +# log in soak/ with the command that replays it; libFuzzer keeps crash inputs in +# fuzz/artifacts//. +# +# tools/soak.sh [seconds per fuzz target, default 600] +set -u +cd "$(dirname "$0")/.." +seconds=${1:-600} +mkdir -p soak +# Release speed with the checks debug builds make. +sweeps="cargo test --release --no-fail-fast --config profile.release.debug-assertions=true --config profile.release.overflow-checks=true -p onestore -p notebook -p canvas" +$sweeps --no-run || exit 1 +cargo +nightly fuzz build || exit 1 +while :; do + shift=$(od -An -N4 -tu4 /dev/urandom | tr -d ' ') + log=soak/sweep-$shift.log + if SNOWBOUND_SWEEP=$shift $sweeps >"$log" 2>&1; then + rm "$log" + else + echo "replay: SNOWBOUND_SWEEP=$shift $sweeps" | tee -a "$log" + sed -n 's/^---- \(.*\) stdout ----$/\1/p' "$log" | while read -r name; do + echo "replay: SNOWBOUND_SWEEP=$shift $sweeps -- --exact $name" | tee -a "$log" + done + fi + for target in $(cargo +nightly fuzz list); do + log=soak/fuzz-$target-$(date +%Y%m%d-%H%M%S).log + if cargo +nightly fuzz run "$target" -- -max_total_time="$seconds" >"$log" 2>&1; then + rm "$log" + else + grep 'cargo fuzz run' "$log" | sed 's/^[[:space:]]*/replay: /' | tee -a "$log" + fi + done +done diff --git a/tools/test_concurrent_rust.py b/tools/test_concurrent_rust.py index e787a0584e79b7087ca0f70989c91408e5cff51a..29c5570897b0dd810c0664f45200397fdd5c48ad 100644 --- a/tools/test_concurrent_rust.py +++ b/tools/test_concurrent_rust.py @@ -14,14 +14,14 @@ class OracleTests(unittest.TestCase): executable = output / 'client' executable.write_text(f'#!{sys.executable}\nimport json, os, sys, time\nprint(json.dumps(dict(os.environ)), flush=True)\nwhile not os.path.exists(sys.argv[5]): time.sleep(.001)\n') executable.chmod(0o700) - with running_clients(output, 'unused.one', 2, 1, 1, 7, timeout=1.25, + with running_clients(output, 'unused.one', 2, 1, 1, 7, timeout=600.25, executable=executable, environment={'KEPT': 'value'}): (output / 'start').touch() for actor in ['w0', 'w1', 'r0']: environment = json.loads((output / f'{actor}.jsonl').read_text()) - self.assertEqual(environment['ONESTORE_CLIENT_TIMEOUT_MS'], '1250') + self.assertEqual(environment['ONESTORE_CLIENT_TIMEOUT_MS'], '600250') self.assertEqual(environment['KEPT'], 'value') - self.assertEqual(json.loads((output / 'clients.json').read_text())['timeout_ms'], 1250) + self.assertEqual(json.loads((output / 'clients.json').read_text())['timeout_ms'], 600250) def test_distinct_reader_binary_is_launched_and_recorded(self): with tempfile.TemporaryDirectory() as directory: -- 2.54.0