From 074834705a828432d90e244347ec8f0913adb5e9 Mon Sep 17 00:00:00 2001 From: clover caruso Date: Mon, 28 Sep 2026 23:29:34 -0700 Subject: [PATCH] feat: full toolbar, sync status, page select-all, tooltips, desktop menus, and Snowbound on Mac OS X 10.6 - Toolbar (#17): every spec action wired through the command table, all 17 new icons, table picker, 29 OneNote tags verified against a gallery OneNote wrote, Format Painter, Picture, Screen Clipping, Symbol, Page Color, Full Page View. - Sync status (#27): the top-right shows real sync state with OneNote 2010's wording; popup with details, Work offline, and Sync Now. - Select all (#38): a further select-all selects every outline, with OneNote 2010's delete, type, move, format, copy, and one-step undo; the title box padding scales with zoom. - UI kit: tooltips with shortcuts; combo and search popups widen with their field; perspective on context menus; GNOME (libadwaita) and KDE (Breeze, KWin fade) menus. - Mac OS X 10.6 (#13): draw gains an OpenGL 2.1 backend beside wgpu, winit runs over an AppKit shim, a 10.6 app bundle, and text weight matched to native. Assisted-by: claude-opus-5.5 --- arc/platforms.md | 4 + arc/sync.md | 3 + arc/ui.md | 3 +- corpus/structural-probe/README.md | 3 + corpus/structural-probe/tag-gallery.one | Bin 0 -> 37224 bytes crates/canvas/Cargo.toml | 1 + crates/canvas/src/editor.rs | 248 ++- crates/canvas/src/editor/format.rs | 282 +++- crates/canvas/src/editor/table.rs | 152 ++ crates/canvas/src/interaction/mod.rs | 244 ++- crates/canvas/src/interaction/tests.rs | 364 ++++- crates/canvas/src/layout.rs | 8 +- crates/canvas/src/math.rs | 2 +- crates/canvas/src/outline.rs | 45 +- crates/canvas/tests/ops_differential.rs | 33 +- crates/draw/Cargo.toml | 8 +- .../gl21 => crates/draw}/src/draw.glsl | 0 crates/draw/src/edit.rs | 1 + crates/draw/src/lib.rs | 4 +- crates/draw/src/render.rs | 364 +---- crates/draw/src/render/gl.rs | 469 ++++++ crates/draw/src/render/webgpu.rs | 265 +++ crates/notebook/README.md | 3 + crates/notebook/src/session.rs | 62 +- crates/notebook/src/worker.rs | 40 +- crates/notebook/tests/session.rs | 84 +- crates/snowbound/Cargo.toml | 9 +- crates/snowbound/src/art.rs | 19 - crates/snowbound/src/commands.rs | 144 +- crates/snowbound/src/history.rs | 3 - crates/snowbound/src/library.rs | 35 +- crates/snowbound/src/linux.rs | 292 +++- crates/snowbound/src/macos.rs | 64 +- crates/snowbound/src/main.rs | 761 +++++---- crates/snowbound/src/manage.rs | 21 +- crates/snowbound/src/menubar.rs | 32 + crates/snowbound/src/rename.rs | 1 - crates/snowbound/src/screenshot.rs | 4 +- crates/snowbound/src/search.rs | 6 +- crates/snowbound/src/sidebar.rs | 7 +- crates/snowbound/src/surface.rs | 221 +++ crates/snowbound/src/surface_gl.rs | 114 ++ crates/snowbound/src/sync.rs | 224 +++ crates/ui/Cargo.toml | 3 +- crates/ui/src/layout.rs | 17 +- crates/ui/src/lib.rs | 188 ++- crates/ui/src/popup.rs | 350 +++- crates/ui/src/shell.rs | 47 +- crates/ui/src/tests.rs | 184 ++- crates/ui/src/text.rs | 11 +- crates/ui/src/theme.rs | 88 + crates/ui/src/widgets.rs | 1 + platform/snow-leopard/Cargo.lock | 1416 ++++++++++++++++- platform/snow-leopard/Cargo.toml | 2 +- platform/snow-leopard/console/input.c | 48 + platform/snow-leopard/console/screen.c | 27 + platform/snow-leopard/console/selectors.c | 45 + platform/snow-leopard/gl21/Cargo.toml | 6 - platform/snow-leopard/gl21/src/lib.rs | 118 -- platform/snow-leopard/gl21/src/painter.rs | 272 ---- platform/snow-leopard/link.sh | 4 +- platform/snow-leopard/probe/Cargo.toml | 15 +- platform/snow-leopard/probe/src/context.rs | 15 + platform/snow-leopard/probe/src/main.rs | 2 +- platform/snow-leopard/probe/src/render.rs | 99 +- platform/snow-leopard/probe/src/scene.rs | 80 +- platform/snow-leopard/probe/src/text.rs | 20 + platform/snow-leopard/probe/src/window.rs | 133 -- platform/snow-leopard/reference/Cargo.toml | 1 + platform/snow-leopard/reference/src/main.rs | 51 +- platform/snow-leopard/remote.sh | 73 +- platform/snow-leopard/rt/appkit.c | 146 ++ platform/snow-leopard/rt/objc.c | 11 + tools/canvas/build_macos.py | 43 +- 74 files changed, 6493 insertions(+), 1672 deletions(-) create mode 100644 corpus/structural-probe/tag-gallery.one rename {platform/snow-leopard/gl21 => crates/draw}/src/draw.glsl (100%) create mode 100644 crates/draw/src/render/gl.rs create mode 100644 crates/draw/src/render/webgpu.rs create mode 100644 crates/snowbound/src/surface.rs create mode 100644 crates/snowbound/src/surface_gl.rs create mode 100644 crates/snowbound/src/sync.rs create mode 100644 platform/snow-leopard/console/input.c create mode 100644 platform/snow-leopard/console/screen.c create mode 100644 platform/snow-leopard/console/selectors.c delete mode 100644 platform/snow-leopard/gl21/Cargo.toml delete mode 100644 platform/snow-leopard/gl21/src/lib.rs delete mode 100644 platform/snow-leopard/gl21/src/painter.rs create mode 100644 platform/snow-leopard/probe/src/context.rs create mode 100644 platform/snow-leopard/probe/src/text.rs delete mode 100644 platform/snow-leopard/probe/src/window.rs create mode 100644 platform/snow-leopard/rt/appkit.c diff --git a/arc/platforms.md b/arc/platforms.md index 56cec3eb6d03eacb623f81be9cd1937e41f62564..1acb4b564951137639b95bf4450ceb63fe97faa4 100644 --- a/arc/platforms.md +++ b/arc/platforms.md @@ -59,6 +59,10 @@ keyboard, the toolbar and the macOS menu bar all run commands from it. focused and not: on KDE the colour scheme's header colours from `kdeglobals`, as KWin paints its title bars, and on GNOME winit's Adwaita frame's. A settings portal signal re-reads them when the scheme changes. +- Menus and other popups take the desktop's look and motion: libadwaita's + popover menus on GNOME, shown and hidden at once as GTK 4 does, and Breeze's + on KDE, faded as KWin fades popups and scaled by Plasma's animation speed. + The desktop is read once from `XDG_CURRENT_DESKTOP`; elsewhere the kit's own. - zenity or kdialog provide the pickers and alerts. The XDG settings portal provides the colour scheme. Text conventions come from the C library's locale. Fontconfig is loaded at run time, so builds need no headers for it. diff --git a/arc/sync.md b/arc/sync.md index 42b576effb8da009984b87d006c49e9a808600c0..76ae7fc71e2e8f9876049ae5c2e56e3c881d4e67 100644 --- a/arc/sync.md +++ b/arc/sync.md @@ -163,6 +163,9 @@ last listing lets it open while the server is unreachable, and a section opens from its replica. On reconnect the queue publishes, or rebases and publishes, through the same path as always. A failed directory read keeps the previous catalog, so an unreachable share never looks like an emptied notebook. +Working offline on purpose, as OneNote's Work Offline does, is the same state +chosen: the sync thread stops stepping until Sync Now or until the user works +online again. ## Notebook structure diff --git a/arc/ui.md b/arc/ui.md index 94eda4a8e9373bcac764ed36f11110d42cd21d93..a553d585df2a643383f6986f94e4f912685efe58 100644 --- a/arc/ui.md +++ b/arc/ui.md @@ -25,7 +25,8 @@ frame-rate independent, a retargeted animation continues smoothly from where it is, and it settles fast. Popups open and close on short timed curves, slow enough to follow: a menu grows out of the pointer or its button as it fades in, a combo's field widens into its list, and a dialog swings up into -place over a dimmed window, as Windows opens a window. +place over a dimmed window, as Windows opens a window. On GNOME and KDE, menus instead +look and move as the desktop's own (see [platforms](platforms.md)). ## Why not native widgets diff --git a/corpus/structural-probe/README.md b/corpus/structural-probe/README.md index 5df494f81abf7e1cac83cabf2cb7acee4f708657..a6d15f752a3dd2d174a0ccd578d8c94a708bcaab 100644 --- a/corpus/structural-probe/README.md +++ b/corpus/structural-probe/README.md @@ -8,6 +8,9 @@ synthetic pages. Every page starts with a plain paragraph `Above`. page per case (`c1-bullet`, `c8-split`, ...). - `tags.one`: a section holding one page with the nine Ctrl+1..9 default tags, closed and flushed by OneNote. +- `tag-gallery.one`: one page with each of the 29 default tags of the Home tab's + Tags gallery applied from the gallery in order, one per paragraph (VM `tbar`, + 2026-09-28); two paragraphs also carry Call back. Copied while OneNote was open. - `summaries/-.txt`: the page after each step, from COM `GetPageContent`, one line per paragraph: `L[+indentN] [tags; [x]=completed] [list] qsN text`. diff --git a/corpus/structural-probe/tag-gallery.one b/corpus/structural-probe/tag-gallery.one new file mode 100644 index 0000000000000000000000000000000000000000..73fb2a024318b69e23f0f61c375bc182a03ba7c8 GIT binary patch literal 37224 zcmeHQ30zdw_kRp9Z{9F4ptvO}>bTMxS)Wjn0tzfyI?N4rju!Ih$}yr(oj>g z+{oP&%_Ys$EXz`J$+E1pGBwOn{^$GNT;7}4d(-@oe!qVGxqJ>Y_q=<~J@?%E&N+8^ zZ@%i-b7J~u>*F_PcN%x5#=p+4d-08TjJd<&bM7xQZOZDiiBqNKmzMjNU2rdU?U_;w zZXMtJSxvgG1Xetc623QO2*)SGGj#Q!X^Hp8oNJMuk$U3DhAoTN@2s)SzqI437k!!( z-;qb6Pw+uPcF+R+r%&mF>$+{x=Miu2ZkM^}tzP$!@AG)xf$u)yNYXc+Gh_CS&#AFI zE3S|ZzxJmlH@j2H$ z>U(WW8RkKkz53YgT6mC{4YptR-~H-|>?v)Rh8~>$ev_|DgQO@GX`o00e-{l%9#is} z?e^B6dbq@)Iqbg2-5UMFvNFgU2m%Oz0~j-#7DpcVVh$Yem%d1DfILr7oWMc={KwB% zJ`X;g1c445@C%-?TS_?cz=I#~hf{>MrRWn0`2ivCzl1!#13no$W*qXYd_eC?wU7sY zTpxeJ@*DC950Jmpc>H+O<3~`E03ifI3IMKuedtjxu3H`X(CiwWkcZ5;9-Z^_B(%$i zpbUYT0Qe5kho_?+Z%6*ou;hiHLtCC8KtO?VLvMXy03P*~C1857Ug;MPJb0qq?$hsX z1wW%B-!Aa;8Mt1KAc%m~>%C@rN#M&9fX-0zOc&5W@5onOxNIWwXeaV-RE%nbyx_M>&i! z)DuxHA_sXkwsHffUPm6ZxW4u5xeR7V;@vN%qldz2d&a=JL^*m#IfirtMo+KTIwKZ$yP ze6S;bcz3I{;QJIoqiOUH$q`F4|BpUFvgkN@{~#{&H4mFq=pXuD%RVywV2mOKj?fN* zaIO4tyZv(2QVR!vG%WhSh~>ww*S|Fc{h^g4{8kE`zV@)qZ<(4E^HHUkjKp)B*L$Zw zz{P3(xpbvKB9p}1eKvI`397ySe%WB25ymg55FgmZy8L0ljT z0e;9_LjQ@2-9n;ya`l3^pp+o4bZ`)7!yRdOuUy*kS##nS?yx_f^-JgLXI^+_+2`#q zc}Vr*e?HI`FRouXE`;%j4(DvRD)M=8W!)RKM@7BfdQ0}sox=_~^Pxg^BX@`D25lb6 z$z+8r(3!+#%C@X`FYg{2KP##0p;`8e+rXW*gmFY!HXd+KBW!(S)vOn7nUlqNr3AMOJl zuf=e4Kld=>Yds7B4Lqr_+@bV!tp z7rZye>w~!NG&ID!PwN_@E?j5!qw$? z8$Xn@14W7M1$M8pJ5j#>;KPw{|7kT>dH<16OkBU|O)PaO>&XmJxnY90l>TZlm+C#~^CZLlM5D9%kFTi3fBXZt*eo0&lnN47bh zAc3Ggfd_$sU?st;1X%>D2*#A-g6q4Wm5z|7ilypE`Mx${~?n?TA$n6ZwcThlF#%mq}g< zi)PLwu$1Fw&Y;gnL7e|_vf)d^xuD|ZIU8gWyby~cd79{7Xy{Y*!-Es!B~c|j@keX} zK)fc1b&M~^1&<>TsBNA>oTUQ!q_-S(g;Tr1TWoVaC->ys@#k_PNQ_TQ?Vmh8B)C(` zh|wv-`=*Qx4r>$=99AzZBELQwfcbJ`tUr$2ur-KXeZ{uJ{}v-X7N>Tj?LpM%kQ@4j z4xoGJycyxA5kLNe!6Y_m1xjT&@LdjNo~xdPi1={kl5Y^nYZby*gAE4 z=SR{NvCa5IyD<*}6))BuNPJ5WFuu3xJ9H0uL4jlu=V;P*=vcJ6k(h^3ii8vhhYTFX z=frR>T*QbM>7U*4#r{{sH@GK}4)x^bPW24fUSspAdT*E5*njK(=FJvV3<7$Qr}2g3 zRSpdH&0Sy5(7WOGRr7`dz0A}9!O`v!QB!)}-L+Gw**PL)9nc#*jTvKVyg9B(hcAtJ z2|nEI8{Y!GkN4wSt!-3e#rBws(R}LmvA=x?^v@=9t@WeA4|X3Akal6xj>UcxrUQLn z;AgLyWql{}i&J?4TfZ~T2)qMyo?t3psY|UVx|JRM=^g*|XUAB70-86QE+mdF(dXxT zFMSgoQguV!U6+Bjnk|9*GQXR5Yy5t5>b0ZoJokwLmNlEb{nE#_`NrB~czD&ID^DG* zg3?vYrWr?mN|^a_ddGQRom!AKx{WW;waxq!8>hT<H&0n ziz)Zwma^Z+9<#(2=g00n((^pfT`Z<8>6c<}bvbu3r~2qNyIUN33FzJyVa2QO$Gn$W zwfc+)Ex)h-db?9VM_7ct8yc<1ZSBK1Xt(X>qfdli1G>4z+~VU=UraLk#iT?X3tjqc z%^5&<^)uVkT4!C2t5z}K{)TJqYwqd09Ze1;GC;5szVt4mOifTwG|bWNa769JrCBKiA)TnZOMpQGtBiQ<6a^hu2E zc5%kmLW~FobA2g37)*FJSD>#_$`e2A1`cru;t>`*XvGzb=yBwR1WazoJDMUENKAvYCaz?8&h$n&X9}dx#-taIa-7A3^4R-xd~y6@VI%^ zR#uI?J!}Q|A-=tDFk`_$o-gJaJkWD7@M7Zh@MnWJmJJ$`3zsI4-x>r&l_7+XCXg2l zR=7J$KH?*T34Yf%A92E9g6jN4gfi?Wq;p!z)JfW5^8hxFMC<~&F|*m?Y12}X=FFz+ z);FtT_M+AqTMf~=rd&l)(?LJzJj{A}5?`%Z59I81PbT>RMpgtUPM3$P}kn2^5j>l3&W`?Y9yZJqoccpBCTh&xQKkWQ(ZL zY4#^2F13BD`i>zrYm~YPBF!Oz#WQ!L_~5g-_mB1}8)JW_*1i5WW{C{-q?S~(Pq2;I zzGBUFV|?r7(IXmHT>Zj$T`9)Tum3gcgy$~zn11A$z3tWxebTV$yN2sPq*XicQcZC_ zwp&?^0!B=3n~+f=_U`cE2L|iblv?KXo2;e|@2;%Bcv$1si#(rPJ>dR^T_Dn`U9M@6 z#a4Roi7Vd)KHYNf_M_{9d{3O$t!eAPIxn%B_NIre%IVxMe$UmNb9#9gkAq07cBv-O zE-W2_=26nP|Jj=Rk!fNGiJ=sa4FgYgFo^;bdSdP&7VuC9p(sEhD~6>9IQ$`ag=ySp z@PjXq&Du7D023sewY3I6%*Q0N2A&ihBUlzIHK^lc@O;G}NV6|yJLelLn2Jg)P-23i zBw~Ia(Nz9@))z>mqEw`Tzgz=aaVX3~KG2uHX8j*KuSv8D?*1MCi%W0=BOz|P61OPy z*2SH%vlcF~=B`|Q4st8ww!&Kf|IP34lCf2uZoho;p24G=?{0y5U_L(gQm_AeDc1l0 zI(wqkZ%jXM{E$!F;qtO+sYugFV?`RSgh-20kp_x1@Hf_g*8NYj4m-{>V76Eye+{;< z_92?kS^&3G+KYg7p~`4&7QjUU9%ny7_65WV$*#YyDwo9+*PXp%=mj{_2=8~S2_5mNq&Wn5+6%wC@g~TVKkoYtxBtGGV#HW5C@d`+Av{k@yH2k^71WgGLl0^}; zAZSU@njn^-9YH)ndx8!G*i!3EV9t*fm1h&m(jE%VC$PPAoJWAHkPz#1Ud0aHrVms` zDUv=3JWierijvMG*qhU5B+fxA_0f^??&?EHmsmi$gaXpV6_BoN0qNQlkgin$>0(^c z8K|ED(zsOiLv#V@A`3`|qe}YYMPJS)1*ChbfOOeVnacvv)hi$! z&i5Cj|3V5#SJNe(2gbDKxW*|*eL3~%suqyWRzSK+1*8iuARUg0x~muRtNG^%Euv3x z_dFry-8h@&`b?pF{I$*jYVgOV@}HaqM19!P!n%QUt_@m!_ip{MZKBQzQF|+F{PE_M z*wbhd*1Na!ijxIEtm74XazHHSRXc2pcpfo>yp5O6^9XTd#B`oVydxtf^L%9|#8{q3 zBqWy@!Se`eM~oyPNOO+3gd;Vo&ow|ltUY~s9$}Ttrw7j?#*z_9Jdc1&M#S?x zqAwZIisuoENr)rU#DjI1hltyf<rjXXj}8S%4`M;s|5zBlp+ zDrLl1MjjESjJRav5pK$eLq-ZNl{>v}8+inya*16=FZ2>l#YksfwinA$X$@?Sy#bEyOq>?s*G{CuJTty#)lOGU7{b0THf@xZo`yRF)ARdJBlTWyEoB z0l~YBIN&W{79b;bdJC8)$%swf0_I~fVvV=z)YNS6G8QVKziY3xK2G<%YTjlKdwYXC zZ=>-f&cC`4XBa5hB$%PU*Bq=2#V;87|K1#o2%P3&;GplRbE&J#;sX!pX+gw+Rol%0fHV9D2*1T;+^s;`)Mj33!hWtvomD#N7m zha*ii&sLhlRFlH`70w>Ad-nrZ?Cc)-eo*$G>+uqfv64StK*z>QH)@z1JFzjt_F86z zbCIA`uFF8P0VXcSi z$=}BfyjeOqh}TJ zh9R$gKY)T!Q6VS$j>tz}-p;!6_9Qy@WJmUf1gz-E`&c?Y6uk zk3Y&QK31naenG*gATPB{9iQa|e5mb@T(9%=&}~m2s>i+YBJz-=Qr?dZ_4N-5Mn#1A z&#Pnhiv;_xbynFO`@b&Nx5xb}J=@yHb0y!;h!d>>>_0sV)rVM1_Fw%}6zu;d!RkDN zStlqa$|$i~P(&NxNsnEj+a;dr2e3e~NU%CjlhOsnY+UnsgDlJdk-X&jIOX*Gt%VB^ z{Vl?X7UH8SqshDW6CYjy7nbzseYh9%>G5#MDEb>qUV0?gpOK26dh}*GKx3s6W`bCv zNg*CuNsk`J0`!=qB#F^M4E>jr9-RjTj-W$uA4`w!j1XlWO|_>|lp!ZQdIM2|9(wCQ zkN>7nyP>%>qE{w6|A#myg<_onEZ!m4M{X6b`je<&?m7}TJWMp4qMsDfWH2{ClrESa z*pVJL8YvZ~|9{4xMuEtmbMVJvRt#|+K(eM1sAbk`uQxqA%kHj!AU zpWpH8hBg{=*>@H zNU?q}fWW=Dp+8~|Dh-h0co@^9P#1wQNEjKFRn9xGE~pA1o0s&{X^)GE2}l^3sy*Q@+XEuGJWJmUM&9W%L^)5UMi{=x0onGL)?K)jzo%C5T9Ws zDV1V27GKe&{G`W+rx)e1_$7+s)czEgA?6zeN*2Fh{Rav253MH{-I;%I7u(+27iDh0 z?3ZCP2HRtH zCFk#B#ki462tKMkx(8;fya|H|i5v#1;QIX504KYyj7tU*t$T3^ zc)-d4lyRYp1xS!EGNMAgOkV5;>-(XBy7CS}?QX?=;Gre2iv>uKFfyu#s8#W({iz}6 zo#Ro~o+d8aGg!AhgWYZqcxbi9#R4Tr7#Y<=NzV`abJOzuc{F+RG;S!z_J!b)ligf* z3gE_ze6WNNPq6^@lltXz!pa8Lb5bZykhqIN72uyPjiT@HoQ}len+lFR;v22^Cb$#d9In=4 zS>KkC4}-cQ#>&NnTU^_9i&qY8ETTuI!qFEvkb$K^BFt22XlD z6mCO_r}{-v;OT3z;{KL-8ZC;`K$`2TeGro%X~Z|s4{Hy-u?i+js$2c;D%73pV|6}y z@{^C^Xi`y}038>lA`Se_G@upV&^@pAza?()mo%SKX-4hQk0*BN`rjc$=UzMk9$akT5bTR9K1mjbH~u_>GR6Ke;<3vs~MZeye{N@uFcLgmTAkm`hq4O&C0| zRd(MUFF$|3=5Wx##?wpvpc=`}s$cI1J6p=v3Z2U~-rrYsp3~y1@Ed=X;nf;9Og|+^ zKCJEFJrZ?&ZCWbQe2)Zs*%MwXwbWc=?3AiM#$+!)TE2Q_y*|*7)@zD@chux(yFH`A zq>lsZ#k|sUXy)soD_0X$>{6VBuyIj+L ztL^uPZ7X+g8Ql7{CQZiFI*|R0ZcWNIO{%-<>Y+PdwX~le-q!Hs)X^Z)s$H(BVt~!_ zLC0+2x1U<9sG5*s@cCfASkpMgo5G~h^jjIdOK*ObI2u^P>bCSv+Om$dGG zIh4M1_9KnXfXGRYUm*94$Pb$YG2*I9KXJ8X6clJoOE|mKi2Q>KpY7n$b5u`C4I{i}y*+v%fcrE}j|9IjV%2f}`uz@(AC4lm5KpjUj~BbH*gbxh<|fifevz4SJhA?#A7g<@k@`ygvn=SV$yO_D zmE2cjt%|4e(cSbjFR-E#3-u4afW;uI73NuHk!)4G3qaG{Q}Loyq=6z06jTFR@e=$< zaRS`?#cp)yg?$<axj?4H@2)raSTR>Hdq> zmI%JzX4fw(rgn?GQy#Vpu}irC)#SEhfaz!?=|!{_@gjcf69JI zq$cS1OP*7|6l@%U2eb-M&YxYJqXY?+W{LGT{1OuSTWc5Do&Mf9zIO7phvwEZPM-fJ zd}>Z5*58Q%R;WUbRbvBGZ>4If91(x_{$7>ZUc0|tub0%_rMwSm%9U?7#*lK{w;kZ4 z)#u>`Vxv6=28l%W#}~P%)>DW_VI{r!S}8imiuoGmSDk1sl}hJagK5r(U7ZwqsV-Jz z2h+SuOl*829zw$S)H-(H&iLHhA|iZ8$vJIu76vSTc6b#Vw1~+T{q7~YK$>i+e*qH) zDcwT(eEI{VY?AA6+1;?wsj;;w0`a+i~hSH$H=7_VA~ zSKJw|gCCWvJhV$ldwcG|JDl&Eudwk-KgfzMk;bd~r&%!?uLTsB-6s$i)eiR4|JHc@ EKaJ}yCjbBd literal 0 HcmV?d00001 diff --git a/crates/canvas/Cargo.toml b/crates/canvas/Cargo.toml index ca9046de6dc18c8f2fa821eb53c442b397e39b6b..d70907696ad77a2c5a070402e11fa642ff0ec26a 100644 --- a/crates/canvas/Cargo.toml +++ b/crates/canvas/Cargo.toml @@ -34,6 +34,7 @@ name = "page-probe" path = "src/bin/page_probe.rs" [dev-dependencies] +draw = { path = "../draw", features = ["wgpu"] } accesskit_consumer = "0.39.0" pollster = "0.4" wgpu.workspace = true diff --git a/crates/canvas/src/editor.rs b/crates/canvas/src/editor.rs index 4cf3c269910a0ae0595d09c3f59b8d517e48f8b9..3a65e3bfdc75bb4e44bf269b6ee07125e2118e42 100644 --- a/crates/canvas/src/editor.rs +++ b/crates/canvas/src/editor.rs @@ -77,6 +77,14 @@ pub struct Selection { pub affinities: [Affinity; 2], } +/// What OneNote 2010's Ctrl+A selects beyond text: the focused outline as an object, then every +/// body outline on the page. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Whole { + Outline, + Page, +} + impl From<[TextPosition; 2]> for Selection { fn from(positions: [TextPosition; 2]) -> Self { Self { @@ -105,6 +113,9 @@ pub struct CanvasEditor { /// since, which `refresh` compares a changed stored page with; none once the editor /// holds an edit that could not be stored. stored: Option<(Page, Vec)>, + /// What Select All widened to, holding until the selection is moved or set, and while the + /// focused outline and its selection stay as they were then. + whole: Option<(Whole, ExGuid, Selection)>, } /// Imported page state the editable content does not carry. @@ -433,6 +444,25 @@ impl TextOutline { .join("\n") } + /// All the outline's shown text. + pub fn whole(&self) -> Selection { + let paragraph = self.source_index(self.shaped.paragraphs.len() - 1); + let last = self.document.paragraph(paragraph).unwrap(); + [ + TextPosition { + paragraph: 0, + offset: 0, + }, + TextPosition { + paragraph, + offset: last + .utf16_offset(last.text().len()) + .expect("paragraph text fits UTF-16 offsets"), + }, + ] + .into() + } + /// Highlight rectangles of `selection` in the outline, in its coordinates. pub fn range_rects(&self, selection: Selection) -> Result, EditError> { let [anchor, focus] = selection.positions; @@ -712,6 +742,7 @@ struct Placement { position: [Option; 2], } +#[derive(Clone)] enum RestoreFocus { Outline(ExGuid), Caret { @@ -766,6 +797,8 @@ enum History { selection: Selection, focus: RestoreFocus, }, + /// One undo step of edits to OneNote's page selection, undone last first. + Group(Vec), } impl CanvasEditor { @@ -800,6 +833,7 @@ impl CanvasEditor { pending: None, ops: Ok(Vec::new()), stored: None, + whole: None, }) } @@ -983,7 +1017,7 @@ impl CanvasEditor { } // Undoing an outline's creation focuses the outline focused before, if it is there. let gone = |focus: &RestoreFocus| matches!(focus, RestoreFocus::Outline(id) if !fresh.outlines.iter().any(|o| o.id == *id)); - let reaches = |history: &History| match history { + let reaches_one = |history: &History| match history { History::Date(_) => date_changed, History::Image { .. } | History::Picture { .. } => objects_changed, History::Draft { outlines, .. } => changed.contains(&outlines[0].id), @@ -1000,6 +1034,11 @@ impl CanvasEditor { History::Remove { outline, focus } => changed.contains(outline) || gone(focus), History::Insert { outline, focus, .. } => changed.contains(&outline.id) || gone(focus), History::Restore { source, focus, .. } => changed.contains(&source.id) || gone(focus), + History::Group(_) => unreachable!("groups do not nest"), + }; + let reaches = |history: &History| match history { + History::Group(entries) => entries.iter().any(reaches_one), + history => reaches_one(history), }; fresh.undo = std::mem::take(&mut self.undo) .into_iter() @@ -1347,6 +1386,7 @@ impl CanvasEditor { pending: None, ops: Ok(Vec::new()), stored: None, + whole: None, }) } @@ -2000,33 +2040,24 @@ impl CanvasEditor { } self.finish_composition(); self.preferred_x = None; + self.whole = None; self.active_outline_mut().selection = selection; Ok(()) } pub fn select_all(&mut self) -> Result<(), EditError> { - let paragraph = self - .active_outline() - .source_index(self.active_outline().shaped.paragraphs.len() - 1); - let last = self.active_outline().document.paragraph(paragraph).unwrap(); - self.select( - [ - TextPosition { - paragraph: 0, - offset: 0, - }, - TextPosition { - paragraph, - offset: last.utf16_offset(last.text().len())?, - }, - ] - .into(), - ) + self.select(self.active_outline().whole()) } /// Selects the smallest unit strictly holding the selection, as OneNote 2010's Ctrl+A widens - /// it: the paragraph, its subtree, each enclosing table cell, row and table, then the outline. + /// it: the paragraph, its subtree, each enclosing table cell, row and table, then a body + /// outline as an object (the title's text instead), then every body outline on the page. pub fn widen_selection(&mut self) -> Result<(), EditError> { + match self.whole() { + Some(Whole::Page) => return Ok(()), + Some(Whole::Outline) => return self.select_page(), + None => {} + } let outline = self.active_outline(); let [anchor, focus] = outline.selection.positions; let (start, end) = (anchor.min(focus), anchor.max(focus)); @@ -2055,19 +2086,140 @@ impl CanvasEditor { .into_iter() .map(|range| (first(range.start), last_shown(range))), ) - .chain([( - first(0), - last_shown(0..outline.document.paragraphs().count()), - )]); - match units + .chain(outline.title.then(|| { + ( + first(0), + last_shown(0..outline.document.paragraphs().count()), + ) + })) .filter_map(|(unit_start, unit_end)| Some([unit_start, unit_end?])) - .find(|&unit| unit[0] <= start && end <= unit[1] && unit != [start, end]) - { + .find(|&unit| unit[0] <= start && end <= unit[1] && unit != [start, end]); + let (id, title) = (outline.id, outline.title); + match units { Some(unit) => self.select(unit.into()), - None => Ok(()), + None if title => self.select_page(), + None => self.select_outline(id), } } + /// Selects outline `id` as an object, as a click on its handle does. + pub fn select_outline(&mut self, id: ExGuid) -> Result<(), EditError> { + self.focus_outline(id)?; + let whole = self.active_outline().whole(); + self.select(whole)?; + self.whole = Some((Whole::Outline, id, whole)); + Ok(()) + } + + /// What Select All has selected beyond text, if the selection is still what it chose. + pub fn whole(&self) -> Option { + let (whole, id, selection) = self.whole?; + let outline = self.active_outline(); + (outline.id == id && outline.selection == selection && self.composition.is_none()) + .then_some(whole) + } + + fn page_selected(&self) -> bool { + self.whole() == Some(Whole::Page) + } + + /// Selects every body outline, focusing the last in page order as OneNote does: arrows + /// leave the selection at its ends, and typing replaces its text. + fn select_page(&mut self) -> Result<(), EditError> { + let Some(last) = self.outlines.iter().rev().find(|outline| !outline.title) else { + return Ok(()); + }; + let (id, whole) = (last.id, last.whole()); + self.focus_outline(id)?; + self.select(whole)?; + self.whole = Some((Whole::Page, id, whole)); + Ok(()) + } + + /// Runs `edit` as one undo step. + fn grouped( + &mut self, + edit: impl FnOnce(&mut Self) -> Result, + ) -> Result { + let depth = self.undo.len(); + let result = edit(self); + let entries = self.undo.split_off(depth); + if !entries.is_empty() { + self.undo.push(History::Group(entries)); + } + result + } + + /// Removes the page selection's outlines, last first, but its last when `keep`, whose + /// text stays selected to be replaced. Deleting the page selection leaves a caret where + /// its first outline began, as OneNote 2010's does. + fn remove_page(&mut self, engine: &mut TextEngine, keep: bool) -> Result<(), EditorError> { + self.finish_composition(); + self.whole = None; + let mut body = (0..self.outlines.len()) + .filter(|&index| !self.outlines[index].title) + .collect::>(); + let Some(&first) = body.first() else { + return Ok(()); + }; + let kept = if keep { body.pop() } else { None }; + let caret = TextOutline::new( + engine, + TextDocument::new(vec![Paragraph::new(String::new(), Default::default())])?, + DEFAULT_OUTLINE_WIDTH, + self.outlines[first].origin(), + )?; + let index = self.outlines.len() - body.len(); + let focus = RestoreFocus::Caret { + source: Box::new(caret.snapshot()), + selection: caret.selection, + index, + }; + for &removed in body.iter().rev() { + let outline = self.outlines.remove(removed); + self.record(Ok(vec![PageOp::Delete { object: outline.id }])); + self.undo.push(History::Insert { + index: removed, + outline: Box::new(outline), + focus: focus.clone(), + }); + } + self.redo.clear(); + self.preferred_x = None; + match kept { + Some(kept) => { + self.active = Focus::Outline(kept - body.len()); + self.select_all()?; + } + None => { + self.active = Focus::Caret { + outline: Box::new(caret), + index, + } + } + } + Ok(()) + } + + /// Moves every outline of the page selection by `delta`, as one undo step keeping it. + pub fn move_page(&mut self, delta: [f32; 2]) -> Result<(), EditorError> { + let moves = self + .outlines + .iter() + .filter(|outline| !outline.title) + .map(|outline| { + let [x, y] = outline.origin(); + (outline.id, [x + delta[0], y + delta[1]]) + }) + .collect::>(); + self.grouped(|editor| { + for (id, position) in moves { + editor.move_outline(id, position)?; + } + Ok(editor.select_page()?) + }) + } + pub fn select_at(&mut self, x: f32, y: f32, extend: bool) -> Result<(), EditError> { let mut selection = self.selection_at(x, y, SelectionUnit::Grapheme)?; if extend { @@ -2145,6 +2297,7 @@ impl CanvasEditor { extend: bool, ) -> Result<(), EditorError> { self.finish_composition(); + self.whole = None; if !extend && movement == Movement::Right && self.leave_equation()? { return Ok(()); } @@ -2614,6 +2767,12 @@ impl CanvasEditor { /// Types `text` at the selection; a space ends a typed URL, which becomes a link. pub fn insert(&mut self, engine: &mut TextEngine, text: &str) -> Result<(), EditorError> { + if self.page_selected() { + return self.grouped(|editor| { + editor.remove_page(engine, true)?; + editor.insert(engine, text) + }); + } self.leave_link_code()?; let start = self.active_outline().selection.positions[0] .min(self.active_outline().selection.positions[1]); @@ -2640,6 +2799,12 @@ impl CanvasEditor { text: &str, language: u32, ) -> Result<(), EditorError> { + if self.page_selected() { + return self.grouped(|editor| { + editor.remove_page(engine, true)?; + editor.paste(engine, text, language) + }); + } let lines = text .split('\n') .map(|line| line.strip_suffix('\r').unwrap_or(line)) @@ -2888,6 +3053,10 @@ impl CanvasEditor { /// properties win; Delete at its end joins the paragraph below (`evidence/structural-edits/ /// xml/c4-*`, `c5b-*`). Joins pass over a collapsed paragraph's hidden children. pub fn delete(&mut self, engine: &mut TextEngine, backward: bool) -> Result { + if self.page_selected() { + self.grouped(|editor| editor.remove_page(engine, false))?; + return Ok(true); + } let selection = self.active_outline().selection; let [anchor, focus] = selection.positions; let mut range = anchor.min(focus)..anchor.max(focus); @@ -2975,6 +3144,9 @@ impl CanvasEditor { engine: &mut TextEngine, movement: Movement, ) -> Result { + if self.page_selected() { + return self.delete(engine, false); + } self.finish_composition(); let original = self.selection(); let preferred_x = self.preferred_x; @@ -3040,7 +3212,12 @@ impl CanvasEditor { }; match self.apply_history(engine, history) { Ok(inverse) => { + let group = matches!(inverse, History::Group(_)); self.redo.push(inverse); + // Undoing an edit to the page selection selects the page again. + if group { + self.select_page()?; + } Ok(true) } Err((history, error)) => { @@ -3113,6 +3290,7 @@ impl CanvasEditor { History::Insert { index, .. } | History::Restore { index, .. } => { *index <= self.outlines.len() } + History::Group(_) => true, }; if !valid { return Err((history, EditError::InvalidRange.into())); @@ -3376,6 +3554,19 @@ impl CanvasEditor { self.active = Focus::Outline(index); History::Remove { outline: id, focus } } + History::Group(mut entries) => { + let mut inverses = Vec::with_capacity(entries.len()); + while let Some(entry) = entries.pop() { + match self.apply_history(engine, entry) { + Ok(inverse) => inverses.push(inverse), + Err((entry, error)) => { + entries.push(entry); + return Err((History::Group(entries), error)); + } + } + } + History::Group(inverses) + } }; self.preferred_x = None; Ok(inverse) @@ -3396,6 +3587,9 @@ impl CanvasEditor { if selected.start > selected.end { return Err(EditError::InvalidRange.into()); } + if self.page_selected() { + self.grouped(|editor| editor.remove_page(engine, true))?; + } let range = self.marked_range().unwrap_or_else(|| { self.active_outline().selection.positions[0] .min(self.active_outline().selection.positions[1]) diff --git a/crates/canvas/src/editor/format.rs b/crates/canvas/src/editor/format.rs index e1216345dd3b4d093d096142f9a179cb755a19af..017ffa75775bc9c860c5219426eae7d65e69ad7b 100644 --- a/crates/canvas/src/editor/format.rs +++ b/crates/canvas/src/editor/format.rs @@ -1,13 +1,14 @@ //! OneNote's Home-ribbon commands: character formatting, lists, alignment and note tags. use super::*; +use crate::layout::{DEFAULT_FONT, DEFAULT_FONT_SIZE}; use onestore::document::{Format, Kind, Tag}; use onestore::page::ParagraphContent; use onestore::page::text::new_id; /// The COLORREF OneNote stores for automatic colour, which overrides a style's colour. const AUTOMATIC: u32 = 0xff00_0000; -/// The one checkable note tag shape the page draws. +/// To Do's check box. const CHECKBOX: u16 = 3; /// A toolbar command on the selection, or with only a caret, on the text typed there next. @@ -36,6 +37,9 @@ pub enum Formatting { RemoveTags, /// Checks the selected paragraphs' check boxes, or clears them when all are checked. Check, + /// Format Painter: gives the selection the character formatting and alignment of text + /// picked up with [`CanvasEditor::painted_format`], as OneNote's does. + Paint(Format), } #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -157,8 +161,9 @@ impl ListStyle { } } -/// OneNote 2010's default tags, Ctrl+1 to Ctrl+9, as it stores their definitions -/// (`corpus/structural-probe/tags.one`); declared in that order, their action types. +/// OneNote 2010's default tags, the first nine on Ctrl+1 to Ctrl+9, as it stores their +/// definitions (`corpus/structural-probe/tag-gallery.one`); declared in gallery order, their +/// action types. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum NoteTag { ToDo, @@ -170,6 +175,26 @@ pub enum NoteTag { Contact, Address, PhoneNumber, + WebSiteToVisit, + Idea, + Password, + Critical, + ProjectA, + ProjectB, + MovieToSee, + BookToRead, + MusicToListenTo, + SourceForArticle, + RememberForBlog, + DiscussWithPersonA, + DiscussWithPersonB, + DiscussWithManager, + SendInEmail, + ScheduleMeeting, + CallBack, + ToDoPriority1, + ToDoPriority2, + ClientRequest, } /// What the selection shows on the toolbar. @@ -177,7 +202,7 @@ pub enum NoteTag { pub struct FormatState { /// Attributes every selected character has. pub toggles: Vec, - /// The font and size every selected character shares. + /// The font and size every selected character is laid out in, where they share one. pub font: Option, pub font_size: Option, /// The alignment every selected paragraph shares. @@ -225,7 +250,7 @@ impl Toggle { } impl NoteTag { - pub(crate) const ALL: [Self; 9] = [ + pub const ALL: [Self; 29] = [ Self::ToDo, Self::Important, Self::Question, @@ -235,6 +260,26 @@ impl NoteTag { Self::Contact, Self::Address, Self::PhoneNumber, + Self::WebSiteToVisit, + Self::Idea, + Self::Password, + Self::Critical, + Self::ProjectA, + Self::ProjectB, + Self::MovieToSee, + Self::BookToRead, + Self::MusicToListenTo, + Self::SourceForArticle, + Self::RememberForBlog, + Self::DiscussWithPersonA, + Self::DiscussWithPersonB, + Self::DiscussWithManager, + Self::SendInEmail, + Self::ScheduleMeeting, + Self::CallBack, + Self::ToDoPriority1, + Self::ToDoPriority2, + Self::ClientRequest, ]; /// The tag's name, as OneNote stores and lists it. @@ -249,26 +294,71 @@ impl NoteTag { Self::Contact => "Contact", Self::Address => "Address", Self::PhoneNumber => "Phone number", + Self::WebSiteToVisit => "Web site to visit", + Self::Idea => "Idea", + Self::Password => "Password", + Self::Critical => "Critical", + Self::ProjectA => "Project A", + Self::ProjectB => "Project B", + Self::MovieToSee => "Movie to see", + Self::BookToRead => "Book to read", + Self::MusicToListenTo => "Music to listen to", + Self::SourceForArticle => "Source for article", + Self::RememberForBlog => "Remember for blog", + Self::DiscussWithPersonA => "Discuss with ", + Self::DiscussWithPersonB => "Discuss with ", + Self::DiscussWithManager => "Discuss with manager", + Self::SendInEmail => "Send in e-mail", + Self::ScheduleMeeting => "Schedule meeting", + Self::CallBack => "Call back", + Self::ToDoPriority1 => "To Do priority 1", + Self::ToDoPriority2 => "To Do priority 2", + Self::ClientRequest => "Client request", + } + } + + /// The symbol OneNote draws for the tag; 0 draws none. + pub const fn shape(self) -> u16 { + match self { + Self::ToDo => CHECKBOX, + Self::Important => 13, + Self::Question => 15, + Self::RememberForLater | Self::Definition => 0, + Self::Highlight => 136, + Self::Contact => 118, + Self::Address => 23, + Self::PhoneNumber => 18, + Self::WebSiteToVisit | Self::SourceForArticle => 125, + Self::Idea => 21, + Self::Password => 131, + Self::Critical => 17, + Self::ProjectA => 100, + Self::ProjectB => 101, + Self::MovieToSee => 122, + Self::BookToRead => 132, + Self::MusicToListenTo => 121, + Self::RememberForBlog => 24, + Self::DiscussWithPersonA | Self::DiscussWithPersonB => 94, + Self::DiscussWithManager => 95, + Self::SendInEmail => 106, + Self::ScheduleMeeting | Self::CallBack => 12, + Self::ToDoPriority1 => 28, + Self::ToDoPriority2 => 71, + Self::ClientRequest => 8, } } pub(super) fn definition(self) -> Definition { - let (shape, highlight) = match self { - Self::ToDo => (CHECKBOX, None), - Self::Important => (13, None), - Self::Question => (15, None), - Self::RememberForLater => (0, Some(0x0000_ffff)), - Self::Definition => (0, Some(0x0000_ff00)), - Self::Highlight => (136, None), - Self::Contact => (118, None), - Self::Address => (23, None), - Self::PhoneNumber => (18, None), + let highlight = match self { + Self::RememberForLater => Some(0x0000_ffff), + Self::Definition => Some(0x0000_ff00), + _ => None, }; Definition { kind: Kind::TagDefinition { label: Some(self.label().into()), action_type: Some(self as u16), - shape: Some(shape), + shape: Some(self.shape()), // The highlighting tags also set black text. color: highlight.map(|_| 0), highlight, @@ -278,6 +368,14 @@ impl NoteTag { } } +/// Whether tag symbol `shape` is a check box, which OneNote stores unchecked when applied. +fn checkable(shape: Option) -> bool { + matches!( + shape.and_then(|shape| crate::outline::TagIcon::of(shape, false)), + Some(crate::outline::TagIcon::CheckBox { .. }) + ) +} + /// Where a selection starts and ends, as text leaf identity and byte offset. type Ends = [(ExGuid, usize); 2]; @@ -494,16 +592,37 @@ impl CanvasEditor { Some(&self.definitions.get(tag.definition.as_ref()?)?.kind) } - pub fn format_state(&self) -> Result { + /// What Format Painter picks up: the first selected character's formatting, or with only + /// a caret, what would be typed there. + pub fn painted_format(&self) -> Result { let outline = self.active_outline(); let (container, range, ends) = selected(&outline.document, outline.selection)?; + let [anchor, focus] = outline.selection.positions; + let nodes = &outline.document.container(container)?[range]; + match character_formats(covered(nodes, ends)).first() { + Some(format) if ends[0] != ends[1] => Ok((*format).clone()), + _ => Ok(self.typing_format(anchor.min(focus))?), + } + } + + pub fn format_state(&self) -> Result { + let outline = self.active_outline(); + self.format_state_of(outline, outline.selection) + } + + fn format_state_of( + &self, + outline: &TextOutline, + selection: Selection, + ) -> Result { + let (container, range, ends) = selected(&outline.document, selection)?; let nodes = &outline.document.container(container)?[range]; let paragraphs = covered(nodes, ends) .map(|(node, _)| node) .collect::>(); let caret; let formats = if ends[0] == ends[1] { - let [anchor, focus] = outline.selection.positions; + let [anchor, focus] = selection.positions; caret = self.typing_format(anchor.min(focus))?; vec![&caret] } else { @@ -523,8 +642,16 @@ impl CanvasEditor { .all(|format| toggle.get(format) == Some(true)) }) .collect(), - font: common(formats.iter().map(|format| format.font.clone())), - font_size: common(formats.iter().map(|format| format.font_size)), + font: common( + formats + .iter() + .map(|format| Some(format.font.as_deref().unwrap_or(DEFAULT_FONT).to_owned())), + ), + font_size: common( + formats + .iter() + .map(|format| Some(format.font_size.unwrap_or(DEFAULT_FONT_SIZE))), + ), alignment: common(paragraphs.iter().map(|node| { Some( match node.text().unwrap().text.spans()[0].format.alignment { @@ -555,6 +682,9 @@ impl CanvasEditor { engine: &mut TextEngine, command: Formatting, ) -> Result<(), EditorError> { + if self.page_selected() { + return self.format_page(engine, command); + } let outline = self.active_outline(); let (id, title, selection) = (outline.id, outline.title, outline.selection); let (container, mut range, ends) = selected(&outline.document, selection)?; @@ -696,7 +826,7 @@ impl CanvasEditor { }); } if !remove { - let checkable = shape == Some(CHECKBOX); + let checkable = checkable(shape); // Stored newest first. text.tags.insert( 0, @@ -727,6 +857,7 @@ impl CanvasEditor { | Formatting::FontSize(_) | Formatting::Color(_) | Formatting::Highlight(_) + | Formatting::Paint(_) | Formatting::Clear => { let caret = (ends[0] == ends[1]) .then(|| { @@ -766,6 +897,15 @@ impl CanvasEditor { Formatting::Highlight(color) => { format.highlight = color.or(base.highlight.map(|_| AUTOMATIC)); } + Formatting::Paint(painted) => { + for toggle in Toggle::ALL { + *toggle.slot(format) = toggle.get(painted).or(toggle.get(base)); + } + format.font = painted.font.clone().or_else(|| base.font.clone()); + format.font_size = painted.font_size.or(base.font_size); + format.color = painted.color.or(base.color); + format.highlight = painted.highlight.or(base.highlight); + } _ => { for toggle in Toggle::ALL { *toggle.slot(format) = toggle.get(base); @@ -787,6 +927,13 @@ impl CanvasEditor { let base = &bases[&node.id]; let text = &mut node.text_mut().unwrap().text; *text = restyle(text, range, |format| change(base, format)); + if let Formatting::Paint(painted) = &command + && !title + { + *text = restyle(text, 0..text.text().len(), |format| { + format.alignment = painted.alignment; + }); + } } }); } @@ -808,6 +955,40 @@ impl CanvasEditor { } impl CanvasEditor { + /// A toolbar command on OneNote 2010's page selection: each outline takes it as if selected + /// alone, but a toggle turns on everywhere unless every outline already has it. + fn format_page( + &mut self, + engine: &mut TextEngine, + command: Formatting, + ) -> Result<(), EditorError> { + self.whole = None; + let has = |state: FormatState| match &command { + Formatting::Toggle(toggle) => state.toggles.contains(toggle), + Formatting::Bullets => state.bullets, + Formatting::Numbering => state.numbering, + Formatting::Tag(tag) => state.tags.contains(tag), + _ => false, + }; + let mut outlines = Vec::new(); + for outline in self.outlines.iter().filter(|outline| !outline.title) { + let state = self.format_state_of(outline, outline.whole())?; + outlines.push((outline.id, has(state))); + } + let everywhere = outlines.iter().all(|(_, has)| *has); + self.grouped(|editor| { + for (id, has) in outlines { + if has && !everywhere { + continue; + } + editor.focus_outline(id)?; + editor.select_all()?; + editor.format(engine, command.clone())?; + } + Ok(editor.select_page()?) + }) + } + /// A click on a check box tag: checks the check boxes of paragraph `id` in outline /// `outline`, or clears them once all are checked, as one undo step that keeps the /// selection. @@ -857,10 +1038,7 @@ impl CanvasEditor { let checkable = |tag: &Tag| { matches!( self.tag_kind(tag), - Some(Kind::TagDefinition { - shape: Some(CHECKBOX), - .. - }) + Some(Kind::TagDefinition { shape, .. }) if checkable(*shape) ) }; let checked = self::covered(replacement, ends) @@ -920,7 +1098,7 @@ pub(super) fn nested_list(definition: &Definition, deeper: bool) -> Option Definition { - let font_size = Some(format.font_size.unwrap_or(11.0)); + let font_size = Some(format.font_size.unwrap_or(DEFAULT_FONT_SIZE)); match style { ListStyle::Number(index) => Definition { kind: Kind::List { @@ -1765,9 +1943,9 @@ mod tests { } #[test] - fn the_nine_default_tags_are_onenotes_stored_definitions_and_all_draw() { + fn the_default_tags_are_onenotes_stored_definitions_and_all_draw() { use onestore::{RevisionIndex, Store, document::Document}; - let bytes = include_bytes!("../../../../corpus/structural-probe/tags.one"); + let bytes = include_bytes!("../../../../corpus/structural-probe/tag-gallery.one"); let store = Store::parse(bytes).unwrap(); let index = RevisionIndex::parse(&store).unwrap(); let document = Document::parse(&index).unwrap(); @@ -1789,12 +1967,14 @@ mod tests { assert_eq!(editor.format_state().unwrap().tags, NoteTag::ALL); let paragraph = &editor.active_outline().shaped.paragraphs[0]; use crate::outline::TagIcon; + let icons = paragraph + .tags + .iter() + .map(|tag| tag.icon) + .collect::>(); + assert_eq!(icons.len(), NoteTag::ALL.len() - 2); assert_eq!( - paragraph - .tags - .iter() - .map(|tag| tag.icon) - .collect::>(), + icons[..7], [ TagIcon::CheckBox { checked: false }, TagIcon::Star, @@ -1805,6 +1985,13 @@ mod tests { TagIcon::Phone, ] ); + // As OneNote stores them unchecked: To Do, Discuss with, Schedule meeting, Call back, + // the priorities and Client request. + let checkable = NoteTag::ALL + .into_iter() + .filter(|tag| checkable(Some(tag.shape()))) + .count(); + assert_eq!(checkable, 9); // Remember for later and Definition draw no symbol; the newer one's green marks the text. assert!( paragraph @@ -1885,8 +2072,8 @@ mod tests { let settled = |mut page: Page| { for object in &mut page.objects { if let PageObject::Outline(outline) = object { - for paragraph in &mut outline.paragraphs { - for tag in &mut paragraph.text_mut().unwrap().tags { + for text in outline.paragraphs.iter_mut().filter_map(|p| p.text_mut()) { + for tag in &mut text.tags { tag.extra_set = 0; } } @@ -1943,5 +2130,30 @@ mod tests { tags: vec![NoteTag::ToDo, NoteTag::Question], } ); + // Format Painter, a gallery tag and an inserted table write as well. + let painted = editor.painted_format().unwrap(); + editor.select([at(0, 0), at(0, 2)].into()).unwrap(); + editor + .format(&mut engine, Formatting::Paint(painted)) + .unwrap(); + let state = editor.format_state().unwrap(); + assert_eq!( + (state.toggles, state.font_size, state.alignment), + ( + vec![Toggle::Bold, Toggle::Superscript], + Some(14.0), + Some(Alignment::Center) + ) + ); + editor + .format(&mut engine, Formatting::Tag(NoteTag::CallBack)) + .unwrap(); + editor.select([at(0, 1); 2].into()).unwrap(); + editor.insert_table(&mut engine, 2, 3).unwrap(); + assert_eq!(editor.selection(), Selection::from([at(1, 0); 2])); + let edited = settled(editor.page().unwrap()); + let written = super::ops::saved(&written, space, &mut editor); + let (_, reread) = page(&written); + assert_eq!(settled(reread).objects, edited.objects); } } diff --git a/crates/canvas/src/editor/table.rs b/crates/canvas/src/editor/table.rs index b4cbeadc39302d77886a0b25feb7eb8f83c812f5..7f986d45c2812c3b3b84cac711a5a0bac44abccb 100644 --- a/crates/canvas/src/editor/table.rs +++ b/crates/canvas/src/editor/table.rs @@ -78,6 +78,116 @@ fn cell_range(document: &TextDocument, cell: &TableCell) -> Result Result<(), EditorError> { + let outline = self.active_outline(); + if outline.title || rows == 0 || columns == 0 { + return Ok(()); + } + let [anchor, focus] = outline.selection.positions; + let caret = anchor.max(focus); + let (container, index, node) = outline + .document + .leaf(caret.paragraph) + .ok_or(EditError::InvalidRange)?; + let text = &node.text().unwrap().text; + let end = text.utf16_offset(text.text().len())?; + let format = text.format_at(caret.offset)?; + let cell = || -> Result { + let mut paragraph = crate::document::node( + Paragraph::new(String::new(), format.clone()), + node.format.clone(), + )?; + paragraph.style = node.style; + Ok(TableCell { + id: new_id()?, + layout: Default::default(), + indents: outline.indents.clone(), + shading: None, + paragraphs: vec![paragraph], + unsupported: Vec::new(), + }) + }; + let table = PageParagraph { + id: new_id()?, + parent: node.parent, + level: node.level, + style: None, + format: node.format.clone(), + lists: Vec::new(), + tags: Vec::new(), + media: Default::default(), + collapsed: false, + content: ParagraphContent::Table(onestore::page::Table { + id: new_id()?, + columns: vec![ + TableColumn { + width: 37.11, + locked: false + }; + columns + ], + rows: (0..rows) + .map(|_| { + Ok(TableRow { + id: new_id()?, + cells: (0..columns).map(|_| cell()).collect::>()?, + }) + }) + .collect::>()?, + borders: Some(true), + layout: Default::default(), + tags: Vec::new(), + }), + }; + let parent = |candidate: &PageParagraph| candidate.parent == Some(node.id); + let childless = !outline.document.container(container)?.iter().any(parent); + let (edit, first) = if end == 0 && childless { + let edit = DocumentEdit { + columns: BTreeMap::new(), + container, + range: index..index + 1, + replacement: vec![table], + }; + (edit, caret.paragraph) + } else if caret.offset == 0 && end > 0 { + let edit = DocumentEdit { + columns: BTreeMap::new(), + container, + range: index..index, + replacement: vec![table], + }; + (edit, caret.paragraph) + } else if caret.offset == end { + let edit = DocumentEdit { + columns: BTreeMap::new(), + container, + range: index + 1..index + 1, + replacement: vec![table], + }; + (edit, caret.paragraph + 1) + } else { + let empty = Paragraph::new(String::new(), format.clone()); + let mut edit = outline + .document + .replace(caret..caret, vec![empty.clone(), empty])?; + edit.replacement.insert(1, table); + (edit, caret.paragraph + 1) + }; + let first = TextPosition { + paragraph: first, + offset: 0, + }; + self.commit(engine, edit, [first; 2].into()) + } + pub fn tab(&mut self, engine: &mut TextEngine, backward: bool) -> Result<(), EditorError> { if self.active_outline().title && !backward { return self.leave_title(engine); @@ -399,6 +509,48 @@ mod tests { .collect() } + /// As OneNote 2010 inserts from the Table gallery (lab, 2026-09-28). + #[test] + fn an_inserted_table_takes_an_empty_paragraph_or_splits_at_the_caret() { + let mut engine = TextEngine::default(); + let texts = |editor: &CanvasEditor| { + editor + .active_outline() + .document + .nodes() + .iter() + .map(|node| match &node.content { + ParagraphContent::Table(table) => { + format!("{}x{}", table.columns.len(), table.rows.len()) + } + _ => node.text().unwrap().text.text().to_owned(), + }) + .collect::>() + }; + for (text, offset, expected) in [ + ("", 0, vec!["3x2"]), + ("abc", 0, vec!["3x2", "abc"]), + ("abc", 3, vec!["abc", "3x2"]), + ("abc", 1, vec!["a", "3x2", "bc"]), + ] { + let mut editor = editor(&mut engine, text); + let caret = TextPosition { + paragraph: 0, + offset, + }; + editor.select([caret; 2].into()).unwrap(); + editor.insert_table(&mut engine, 2, 3).unwrap(); + assert_eq!(texts(&editor), expected, "{text:?} at {offset}"); + let first = usize::from(expected[0] != "3x2"); + let (cell, _, _) = editor + .active_outline() + .document + .leaf(editor.selection().positions[0].paragraph) + .unwrap(); + assert!(cell.is_some() && editor.selection().positions[0].paragraph == first); + } + } + #[test] fn tab_creates_splits_and_selects_native_cells() { let mut engine = TextEngine::default(); diff --git a/crates/canvas/src/interaction/mod.rs b/crates/canvas/src/interaction/mod.rs index 01501877f17a72fd5cbf66faa9680f5396064b9f..18d38cc29ee20fe59c5e26c831ef70adfe66be40 100644 --- a/crates/canvas/src/interaction/mod.rs +++ b/crates/canvas/src/interaction/mod.rs @@ -14,7 +14,7 @@ mod tests; use crate::gpu::{Paper, Viewport, page::PageScene}; use crate::{ date::DateField, - editor::{CanvasEditor, DEFAULT_OUTLINE_WIDTH, Formatting, Selection, TextOutline}, + editor::{CanvasEditor, DEFAULT_OUTLINE_WIDTH, Formatting, Selection, TextOutline, Whole}, layout::TextEngine, }; use draw::{ @@ -156,6 +156,7 @@ enum Drag { #[derive(Clone, Copy)] enum PointerFeedback<'a> { Hover(onestore::ExGuid), + /// An outline dragged this far, with the rest of the page selection it belongs to. Move(onestore::ExGuid, [f32; 2]), Resize(&'a TextOutline), /// A picture being moved or resized, drawn at this origin and size. @@ -384,6 +385,18 @@ impl PageView { )) } + /// The dragged outline and how far it would move. + fn drag_delta(&self) -> Option<(onestore::ExGuid, [f32; 2])> { + let (id, [x, y]) = self.outline_preview()?; + let outline = self + .editor + .outlines() + .iter() + .find(|outline| outline.id == id)?; + let [from_x, from_y] = outline.origin(); + Some((id, [x - from_x, y - from_y])) + } + fn image_preview(&self) -> Option<(onestore::ExGuid, [f32; 2], [f32; 2])> { let Drag::Image { id, @@ -701,8 +714,8 @@ impl PageView { .. }) => Some(PointerFeedback::Resize(outline)), _ => self - .outline_preview() - .map(|(id, origin)| PointerFeedback::Move(id, origin)) + .drag_delta() + .map(|(id, delta)| PointerFeedback::Move(id, delta)) .or_else(|| { self.image_preview() .map(|(id, origin, size)| PointerFeedback::Image(id, origin, size)) @@ -901,7 +914,10 @@ impl PageView { } Some(Hit::Handle { id, grab }) => { self.set_object_focus(None); - self.editor.focus_outline(id)?; + // Any outline's handle drags the whole page selection. + if self.editor.whole() != Some(Whole::Page) { + self.editor.focus_outline(id)?; + } self.drag = Some(Drag::Outline { id, grab, @@ -975,7 +991,16 @@ impl PageView { let inside = (0..2).all(|axis| (0.0..self.viewport.size[axis] as f32).contains(&self.pointer[axis])); let moving_image = matches!(self.drag, Some(Drag::Image { handle: [0, 0], .. })); + let clicked = match self.drag { + Some(Drag::Outline { + id, + pending_press: Some(_), + .. + }) => Some(id), + _ => None, + }; let preview = self.outline_preview().filter(|_| inside); + let delta = self.drag_delta().filter(|_| inside); if let Some((id, origin, size)) = self.image_preview().filter(|_| inside || !moving_image) { self.editor .place_image(&mut self.engine, id, origin, size)?; @@ -988,7 +1013,14 @@ impl PageView { self.editor .resize(&mut self.engine, outline.bounds().width() as f32)?; } - if let Some((id, origin)) = preview { + if let Some(id) = clicked { + // A click on an outline's handle selects it as OneNote does. + self.editor.select_outline(id)?; + } else if let Some((_, delta)) = delta + && self.editor.whole() == Some(Whole::Page) + { + self.editor.move_page(delta)?; + } else if let Some((id, origin)) = preview { self.editor.move_outline(id, origin)?; } self.changed() @@ -1075,27 +1107,49 @@ impl PageView { self.edited() } - /// Copy, or Cut with `cut`: the selected text goes to the clipboard. + /// Copy, or Cut with `cut`: the selected text goes to the clipboard, the page selection's + /// outlines top to bottom with a blank line between, as OneNote 2010 copies them. pub fn copy(&mut self, cut: bool) -> Result { - let [anchor, focus] = self.editor.selection().positions; - let selected = self - .editor - .active_outline() - .document() - .slice(anchor.min(focus)..anchor.max(focus))?; - let text = selected - .iter() - .map(|paragraph| { - paragraph - .project() - .map(|projection| projection.text().text().to_owned()) - }) - .collect::, _>>()? - .join("\n"); + let text = |outline: &TextOutline, selection: Selection| -> Result { + let [anchor, focus] = selection.positions; + Ok(outline + .document() + .slice(anchor.min(focus)..anchor.max(focus))? + .iter() + .map(|paragraph| { + paragraph + .project() + .map(|projection| projection.text().text().to_owned()) + }) + .collect::, _>>()? + .join("\n")) + }; + let page = self.editor.whole() == Some(Whole::Page); + let text = if page { + let mut outlines = self + .editor + .outlines() + .iter() + .filter(|outline| !outline.title) + .collect::>(); + outlines.sort_by(|a, b| { + let ([ax, ay], [bx, by]) = (a.origin(), b.origin()); + ay.total_cmp(&by).then(ax.total_cmp(&bx)) + }); + outlines + .into_iter() + .map(|outline| text(outline, outline.whole())) + .collect::>>()? + .join("\n\n") + } else { + text(self.editor.active_outline(), self.editor.selection())? + }; if text.is_empty() { return Ok(Response::default()); } - if cut { + if cut && page { + self.editor.delete(&mut self.engine, false)?; + } else if cut { self.editor.insert(&mut self.engine, "")?; } Ok(Response { @@ -1177,6 +1231,36 @@ impl PageView { self.edited() } + /// Insert, Table: see [`CanvasEditor::insert_table`]. + pub fn insert_table(&mut self, rows: usize, columns: usize) -> Result { + if !self.accepts_text() || self.editor.marked_range().is_some() { + return Ok(Response::default()); + } + self.editor.insert_table(&mut self.engine, rows, columns)?; + self.edited() + } + + /// Insert, Picture: a PNG, JPEG or GIF `size` points large after the caret's paragraph. + pub fn insert_picture(&mut self, bytes: Vec, size: [f32; 2]) -> Result { + if !self.accepts_text() || self.editor.marked_range().is_some() { + return Ok(Response::default()); + } + let image = onestore::page::Image { + id: onestore::page::text::new_id()?, + layout: onestore::document::Layout { + max_width: Some(size[0]), + max_height: Some(size[1]), + ..Default::default() + }, + size: Some(size), + bytes: Some(bytes.into()), + alt: None, + background: false, + }; + self.editor.insert_picture(&mut self.engine, image)?; + self.edited() + } + /// Professional, or Linear with `linear`, on the equation at the caret. pub fn switch_equation(&mut self, linear: bool) -> Result { if linear { @@ -1205,6 +1289,10 @@ impl PageView { /// Select All, widening the selection a unit at a time as OneNote 2010's Ctrl+A does. pub fn widen_selection(&mut self) -> Result { self.editor.widen_selection()?; + // Revealing the last outline would scroll the page selection's start away. + if self.editor.whole() == Some(Whole::Page) { + return self.changed(); + } self.edited() } @@ -1274,6 +1362,27 @@ impl PageView { return self.changed(); } } + if self.editor.whole() == Some(Whole::Page) { + match (Command::from_key(key, self.modifiers), key) { + // OneNote 2010 keeps its page selection on these. + ( + Some( + Command::Move(Movement::Up | Movement::Down) + | Command::MovePage { .. } + | Command::MoveParagraphs { .. }, + ), + _, + ) + | (_, Key::Named(NamedKey::Tab)) => return Ok(Response::default()), + // Enter leaves the caret at the selection's end, as Right does. + (_, Key::Named(NamedKey::Enter)) => { + self.editor + .move_selection(&mut self.engine, Movement::Right, false)?; + return self.edited(); + } + _ => {} + } + } if let Some(Command::MoveParagraphs { up }) = Command::from_key(key, self.modifiers) && self.editor.marked_range().is_none() { @@ -1288,6 +1397,13 @@ impl PageView { Key::Named(NamedKey::ArrowDown) => Some([0.0, 1.0]), _ => None, }; + if let Some(delta) = delta + && self.editor.whole() == Some(Whole::Page) + { + let step = if shift { 10.0 } else { 1.0 }; + self.editor.move_page(delta.map(|axis| axis * step))?; + return self.edited(); + } if let Some(delta) = delta { let outline = self.editor.active_outline(); let origin = outline.origin(); @@ -1604,6 +1720,15 @@ fn page_primitives<'a>( paint: Paint, ) -> Result>> { let mut primitives = Vec::new(); + let active = editor.active_outline().id; + let whole = editor + .whole() + .filter(|_| paint.chrome && object_focus.is_none()); + let selected = |outline: &TextOutline| match whole { + Some(Whole::Page) => !outline.title, + Some(Whole::Outline) => outline.id == active, + None => false, + }; let draw_outline = |id, offset: [f32; 2], primitives: &mut Vec<_>| { // An emptied page outline leaves the editor but keeps its paint slot for undo. let Some(outline) = editor.visible_outlines().find(|outline| outline.id == id) else { @@ -1613,13 +1738,19 @@ fn page_primitives<'a>( Some(PointerFeedback::Resize(resized)) if resized.id == outline.id => resized, _ => outline, }; + let [x, y] = outline.origin(); let origin = match preview { - Some(PointerFeedback::Move(id, origin)) if id == outline.id => origin, - _ => outline.origin(), + Some(PointerFeedback::Move(id, [dx, dy])) + if id == outline.id || whole == Some(Whole::Page) && !outline.title => + { + [x + dx, y + dy] + } + _ => [x, y], }; // OneNote frames the title whether or not it is focused or hovered. if outline.title - || (paint.chrome && object_focus.is_none() && outline.id == editor.active_outline().id) + || selected(outline) + || (paint.chrome && object_focus.is_none() && outline.id == active) || matches!(preview, Some(PointerFeedback::Hover(id) | PointerFeedback::Move(id, _)) if id == outline.id) || matches!(preview, Some(PointerFeedback::Resize(resized)) if resized.id == outline.id) { @@ -1628,14 +1759,16 @@ fn page_primitives<'a>( [origin[0] + offset[0], origin[1] + offset[1]], paint.pixel, paint.colors.paper, + selected(outline), primitives, ); } append_outline( (object_focus.is_none() - && outline.id == editor.active_outline().id + && outline.id == active && !matches!(preview, Some(PointerFeedback::Resize(_)))) .then_some(editor), + selected(outline), outline, [origin[0] + offset[0], origin[1] + offset[1]], paint, @@ -1692,6 +1825,7 @@ fn page_primitives<'a>( { append_outline( Some(editor), + false, outline, outline.origin(), paint, @@ -1892,22 +2026,26 @@ fn snap_to_grid(point: [f32; 2], margin: [f32; 2]) -> [f32; 2] { fn outline_chrome(outline: &TextOutline, pixel: f32) -> ([f32; 4], f32) { let bounds = outline.bounds(); - // Native chrome combines page-scaled gutters with a fixed screen inset. - let inset = 5.0 * pixel; - let body_top = bounds.y0 as f32 - inset; if outline.title { + // The title's box scales with the page, 5 px from its text at 100% (6 px more to the + // left, 2 px less to the right). + let inset = 3.75; let (_, paragraph) = outline.layouts().last().unwrap(); let bottom = outline.origin()[1] + paragraph.origin[1] + paragraph.text.height(); + let top = bounds.y0 as f32 - inset; return ( [ - bounds.x0 as f32 - inset - 6.0 * pixel, - body_top, - bounds.x1 as f32 + inset - 2.0 * pixel, + bounds.x0 as f32 - inset - 4.5, + top, + bounds.x1 as f32 + inset - 1.5, bottom + inset, ], - body_top, + top, ); } + // Native chrome combines page-scaled gutters with a fixed screen inset. + let inset = 5.0 * pixel; + let body_top = bounds.y0 as f32 - inset; // OneNote widens the box leftward to hold the tag column and list markers; text and right // edge stay. let shaped = outline.shaped(); @@ -1933,11 +2071,13 @@ fn outline_chrome(outline: &TextOutline, pixel: f32) -> ([f32; 4], f32) { ) } +/// `selected` greys the body, as OneNote marks an outline Select All has selected whole. fn append_outline_chrome( outline: &TextOutline, origin: [f32; 2], pixel: f32, paper: crate::gpu::Paper, + selected: bool, primitives: &mut Vec>, ) { let [x, y] = origin; @@ -1973,6 +2113,14 @@ fn append_outline_chrome( stroke: None, color: paper.shade(crate::gpu::colorref(0x00e5dee7)), }); + if selected { + primitives.push(Primitive::RoundedRect { + rect: [left, body_top, right, bottom], + radius: [3.0 * pixel; 2], + stroke: None, + color: paper.shade(crate::gpu::colorref(0x00f0f0f0)), + }); + } primitives.push(Primitive::RoundedRect { rect: [left, top, right, bottom], radius: [3.0 * pixel; 2], @@ -2008,8 +2156,11 @@ fn append_outline_chrome( } } +/// `editor` paints its selection and caret in the focused outline; `selected` highlights all +/// the text of another. fn append_outline<'a>( editor: Option<&'a CanvasEditor>, + selected: bool, outline: &'a TextOutline, origin: [f32; 2], paint: Paint, @@ -2052,18 +2203,21 @@ fn append_outline<'a>( }); } } - if let Some(editor) = editor { - for rect in editor.selection_rects()? { - 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: colors.selection, - }); - } + let highlight = match editor { + Some(editor) => editor.selection_rects()?, + None if selected => outline.range_rects(outline.whole())?, + None => Vec::new(), + }; + for rect in highlight { + 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: colors.selection, + }); } for (index, paragraph) in outline.shaped().visible(rows) { outline.shaped().append_paragraph_primitives( diff --git a/crates/canvas/src/interaction/tests.rs b/crates/canvas/src/interaction/tests.rs index 4cc9029e648cbfca39d0f0f094ddcad66a2a8303..268d66f67f3648d663dd8c1a679539cbbd44ddc8 100644 --- a/crates/canvas/src/interaction/tests.rs +++ b/crates/canvas/src/interaction/tests.rs @@ -1,9 +1,11 @@ use super::*; -use crate::document::TextDocument; +use crate::document::{TextDocument, TextPosition}; +use crate::editor::Toggle; use crate::gpu::painted_layout; use onestore::document::Format; use onestore::page::Page; use onestore::page::text::Paragraph; +use std::collections::BTreeSet; use std::sync::Arc; const COLORS: TextColors = TextColors { @@ -143,6 +145,11 @@ fn title_chrome_selects_text_and_exposes_a_named_editable_field() { assert_eq!(editor.active_outline().bounds().width(), 162.0); assert_eq!(editor.active_outline().snapshot().min_width, Some(162.0)); let (native_rect, _) = outline_chrome(editor.active_outline(), 0.75); + // Unlike a body outline's, the title's box scales with zoom (OneNote 2010 at 200%). + assert_eq!( + outline_chrome(editor.active_outline(), 0.375).0, + native_rect + ); for (axis, (actual, expected)) in native_rect .iter() .zip([85.0, 98.0, 315.0, 134.0]) @@ -621,7 +628,7 @@ fn chrome_follows_focus_hover_and_drag_without_changing_hit_geometry() { assert_eq!(borders(None).len(), 1); let hovered = borders(Some(PointerFeedback::Hover(second))); assert_eq!(hovered.len(), 2); - let moved = borders(Some(PointerFeedback::Move(second, [240.0, 50.0]))); + let moved = borders(Some(PointerFeedback::Move(second, [20.0, 50.0]))); assert_eq!(moved[1][0] - hovered[1][0], 20.0); assert_eq!(moved[1][1] - hovered[1][1], 50.0); assert!( @@ -868,6 +875,7 @@ fn table_glyphs_highlights_and_selection_share_cell_paint_bounds() { let mut primitives = Vec::new(); append_outline( Some(&editor), + false, editor.active_outline(), [24.0, 48.0], Paint { @@ -941,6 +949,7 @@ fn editable_tables_paint_borders_before_selection_and_cell_text() { let mut primitives = Vec::new(); append_outline( Some(&editor), + false, editor.active_outline(), [24.0, 48.0], Paint { @@ -2065,3 +2074,354 @@ fn an_empty_preedit_without_a_composition_changes_nothing() { assert!(view.compose(String::new(), None).unwrap().changed); assert!(view.editor.marked_range().is_none()); } + +/// The lab's page: a title, then outlines placed in the order Alpha, Beta, Gamma, Delta, with +/// Delta highest on the page, drawn at 1 point per pixel. +fn page_selection_view() -> (PageView, [onestore::ExGuid; 4]) { + let mut engine = TextEngine::default(); + let outline = |engine: &mut TextEngine, text: &str, origin| { + let paragraphs = text + .split('\n') + .map(|line| Paragraph::new(line.into(), Default::default())) + .collect(); + TextOutline::new( + engine, + TextDocument::new(paragraphs).unwrap(), + 240.0, + origin, + ) + .unwrap() + .snapshot() + }; + let mut title = outline(&mut engine, "Multi", [0.0; 2]); + title.title = true; + let body = [ + ("Alpha one\nAlpha two", [36.0, 90.0]), + ("Beta", [300.0, 180.0]), + ("Gamma long line of text", [90.0, 300.0]), + ("Delta", [380.0, 72.0]), + ] + .map(|(text, origin)| outline(&mut engine, text, origin)); + let ids = body.each_ref().map(|outline| outline.id); + let page = Page { + title: "Multi".into(), + identity: None, + created: None, + margin_origin: [36.0, 14.4], + color: None, + definitions: Default::default(), + objects: std::iter::once(onestore::page::PageObject::Title(onestore::page::Title { + id: onestore::page::text::new_id().unwrap(), + date: None, + layout: Default::default(), + outlines: vec![title], + })) + .chain(body.map(onestore::page::PageObject::Outline)) + .collect(), + }; + let (scene, editor) = PageScene::from_page(page, &mut engine).unwrap(); + let mut view = PageView::new( + editor, + engine, + Some((scene, [0.0; 2])), + [800, 600], + 1.0, + Duration::from_millis(500), + ); + view.viewport.scale = 1.0; + view.viewport.origin = [0.0; 2]; + (view, ids) +} + +const START: TextPosition = TextPosition { + paragraph: 0, + offset: 0, +}; + +fn select_page(view: &mut PageView, from: onestore::ExGuid) { + view.editor.focus_outline(from).unwrap(); + view.editor.select([START; 2].into()).unwrap(); + while view.editor.whole() != Some(Whole::Page) { + let _ = view.widen_selection().unwrap(); + } +} + +fn origins(view: &PageView) -> Vec<[f32; 2]> { + let body = view + .editor + .outlines() + .iter() + .filter(|outline| !outline.title); + body.map(TextOutline::origin).collect() +} + +/// As OneNote 2010 widens Ctrl+A past an outline (lab, 2026-09-28): a body outline's +/// paragraph, the outline as an object, then every body outline, focusing the last. +#[test] +fn select_all_widens_from_an_outline_to_the_page() { + let (mut view, [alpha, beta, _, delta]) = page_selection_view(); + let levels = |view: &mut PageView, from| { + view.editor.focus_outline(from).unwrap(); + view.editor.select([START; 2].into()).unwrap(); + (0..4) + .map(|_| { + let _ = view.widen_selection().unwrap(); + (view.editor.whole(), view.editor.active_outline().id) + }) + .collect::>() + }; + let page = (Some(Whole::Page), delta); + for from in [alpha, beta] { + assert_eq!( + levels(&mut view, from), + [(None, from), (Some(Whole::Outline), from), page, page] + ); + } + // The title has no object level: its text, then the page. + let title = view.editor.outlines()[0].id; + assert_eq!(levels(&mut view, title), [(None, title), page, page, page]); + // The selection it chose ends with any other; an arrow leaves the caret at its start. + let _ = view.key(&Key::Named(NamedKey::ArrowLeft), None).unwrap(); + assert_eq!(view.editor.whole(), None); + assert_eq!(view.editor.active_outline().id, delta); + assert_eq!(view.editor.selection().positions, [START; 2]); +} + +/// Each outline Select All holds shows its frame and a grey body, its text highlighted. +#[test] +fn a_page_selection_frames_and_greys_every_body_outline() { + let (mut view, [alpha, ..]) = page_selection_view(); + let grey = crate::gpu::colorref(0x00f0f0f0); + let paint = |view: &PageView| { + let primitives = view.primitives(COLORS).unwrap(); + let count = + |wanted: &dyn Fn(&Primitive) -> bool| primitives.iter().filter(|p| wanted(p)).count(); + ( + count( + &|p| matches!(p, Primitive::RoundedRect { color, stroke: None, .. } if *color == grey), + ), + count(&|p| matches!(p, Primitive::Rect { color, .. } if *color == COLORS.selection)), + ) + }; + view.editor.focus_outline(alpha).unwrap(); + assert_eq!(paint(&view).0, 0); + let _ = view.widen_selection().unwrap(); + let _ = view.widen_selection().unwrap(); + let (greyed, alpha) = paint(&view); + assert_eq!(greyed, 1); + let _ = view.widen_selection().unwrap(); + // One line each of the others besides Alpha's. + assert_eq!(paint(&view), (4, alpha + 3)); +} + +/// Keys on the page selection (lab): arrows up and down and Tab leave it, Enter and Right +/// put the caret at the last outline's end, Delete and Backspace remove every outline and +/// leave the caret where the first began, and typing replaces them all with the last outline +/// holding the text. Each edit stores as one edit and undoes in one step to the selection. +#[test] +fn keys_act_on_the_page_selection_as_onenote_does() { + let (mut view, [alpha, beta, gamma, delta]) = page_selection_view(); + let end = TextPosition { + paragraph: 0, + offset: 5, + }; + for key in [ + NamedKey::ArrowUp, + NamedKey::ArrowDown, + NamedKey::Tab, + NamedKey::Enter, + ] { + select_page(&mut view, beta); + let _ = view.key(&Key::Named(key), None).unwrap(); + let kept = key != NamedKey::Enter; + assert_eq!(view.editor.whole() == Some(Whole::Page), kept, "{key:?}"); + if !kept { + assert_eq!(view.editor.selection().positions, [end; 2]); + } + } + assert_eq!(view.editor.take_ops().unwrap(), []); + + let stored = origins(&view); + for key in [NamedKey::Delete, NamedKey::Backspace] { + select_page(&mut view, gamma); + let _ = view.key(&Key::Named(key), None).unwrap(); + assert!(view.editor.outlines().iter().all(|outline| outline.title)); + assert_eq!( + view.editor.caret_outline().map(TextOutline::origin), + Some(stored[0]) + ); + let ops = view.editor.take_ops().unwrap(); + let deleted = ops.iter().filter_map(|op| match op { + onestore::op::PageOp::Delete { object } => Some(*object), + _ => None, + }); + assert_eq!( + deleted.collect::>(), + BTreeSet::from([alpha, beta, gamma, delta]) + ); + assert_eq!(ops.len(), 4); + let _ = view.undo(false).unwrap(); + assert_eq!(origins(&view), stored); + assert_eq!(view.editor.whole(), Some(Whole::Page)); + view.editor.take_ops().unwrap(); + } + + select_page(&mut view, alpha); + let _ = view.key(&Key::Character("Z".into()), Some("Z")).unwrap(); + let left = view + .editor + .outlines() + .iter() + .filter(|outline| !outline.title); + assert_eq!( + left.map(|outline| (outline.id, outline.shown_text())) + .collect::>(), + [(delta, "Z".to_owned())] + ); + let _ = view.undo(false).unwrap(); + assert_eq!(origins(&view), stored); + assert_eq!(view.editor.whole(), Some(Whole::Page)); + assert_eq!(view.editor.active_outline().shown_text(), "Delta"); +} + +/// Copy takes the outlines top to bottom with a blank line between, as OneNote's plain text +/// does; Cut removes them as Delete does. +#[test] +fn copy_and_cut_take_the_page_selection() { + let (mut view, [alpha, ..]) = page_selection_view(); + select_page(&mut view, alpha); + let copied = Some(Request::Copy( + "Delta\n\nAlpha one\nAlpha two\n\nBeta\n\nGamma long line of text".into(), + )); + assert_eq!(view.copy(false).unwrap().request, copied); + assert_eq!(view.editor.whole(), Some(Whole::Page)); + assert_eq!(view.copy(true).unwrap().request, copied); + assert!(view.editor.outlines().iter().all(|outline| outline.title)); + assert!(view.editor.caret_outline().is_some()); +} + +/// Dragging any selected outline's handle moves them all by one snapped offset, stored as one +/// edit and undone in one step; a click on a handle selects that outline alone. +#[test] +fn a_dragged_page_selection_moves_every_outline() { + let (mut view, [alpha, beta, ..]) = page_selection_view(); + let stored = origins(&view); + select_page(&mut view, alpha); + let now = Instant::now(); + let bounds = view + .editor + .outlines() + .iter() + .find(|outline| outline.id == beta) + .unwrap() + .bounds(); + let header = [(bounds.x0 + bounds.x1) as f32 / 2.0, bounds.y0 as f32 - 8.0]; + let _ = view.pointer_moved(header).unwrap(); + let _ = view.pointer_pressed(now).unwrap(); + assert_eq!(view.editor.whole(), Some(Whole::Page)); + let _ = view + .pointer_moved([header[0] + 30.0, header[1] + 40.0]) + .unwrap(); + // Every selected outline follows the drag while it lasts. + let dragged = view.primitives(COLORS).unwrap().len(); + assert!(dragged > 0); + let _ = view.pointer_released().unwrap(); + // Beta lands on the grid, 18 pt cells from the margin origin, and the rest follow. + let moved = origins(&view); + assert_eq!(moved[1], [324.0, 212.4]); + for (before, after) in stored.iter().zip(&moved) { + for axis in 0..2 { + let delta = moved[1][axis] - stored[1][axis]; + assert!((after[axis] - before[axis] - delta).abs() < 1e-3); + } + } + assert_eq!(view.editor.whole(), Some(Whole::Page)); + assert_eq!(view.editor.take_ops().unwrap().len(), 4); + let _ = view.undo(false).unwrap(); + assert_eq!(origins(&view), stored); + + // Option-Command-arrows nudge the whole selection. + let _ = view + .modifiers_changed(Modifiers { + command: true, + option: true, + ..Modifiers::default() + }) + .unwrap(); + let _ = view.key(&Key::Named(NamedKey::ArrowRight), None).unwrap(); + let _ = view.modifiers_changed(Modifiers::default()).unwrap(); + assert!( + origins(&view) + .iter() + .zip(&stored) + .all(|(after, before)| *after == [before[0] + 1.0, before[1]]) + ); + + let _ = view.pointer_moved(header).unwrap(); + let _ = view.pointer_pressed(now + Duration::from_secs(1)).unwrap(); + let _ = view.pointer_released().unwrap(); + assert_eq!(view.editor.whole(), Some(Whole::Outline)); + assert_eq!(view.editor.active_outline().id, beta); +} + +/// A toggle on the page selection turns on everywhere unless every outline has it, as one +/// undo step that keeps the selection. +#[test] +fn formatting_the_page_selection_settles_on_one_state() { + let (mut view, [alpha, beta, ..]) = page_selection_view(); + let bold = |view: &mut PageView| { + let ids = view + .editor + .outlines() + .iter() + .filter(|outline| !outline.title) + .map(|outline| outline.id) + .collect::>(); + let focus = view.editor.active_outline().id; + let selection = view.editor.selection(); + let whole = view.editor.whole(); + let states = ids + .into_iter() + .map(|id| { + view.editor.focus_outline(id).unwrap(); + view.editor.select_all().unwrap(); + view.editor + .format_state() + .unwrap() + .toggles + .contains(&Toggle::Bold) + }) + .collect::>(); + if whole == Some(Whole::Page) { + select_page(view, focus); + } else { + view.editor.focus_outline(focus).unwrap(); + view.editor.select(selection).unwrap(); + } + states + }; + view.editor.focus_outline(beta).unwrap(); + view.editor.select_all().unwrap(); + let _ = view.format(Formatting::Toggle(Toggle::Bold)).unwrap(); + assert_eq!(bold(&mut view), [false, true, false, false]); + select_page(&mut view, alpha); + let _ = view.format(Formatting::Toggle(Toggle::Bold)).unwrap(); + assert_eq!(view.editor.whole(), Some(Whole::Page)); + assert_eq!(bold(&mut view), [true; 4]); + let _ = view.format(Formatting::Toggle(Toggle::Bold)).unwrap(); + assert_eq!(bold(&mut view), [false; 4]); + let _ = view.undo(false).unwrap(); + assert_eq!(bold(&mut view), [true; 4]); + assert_eq!(view.editor.whole(), Some(Whole::Page)); +} + +/// Selecting the last outline's text again by other means selects only that text. +#[test] +fn the_page_selection_does_not_return_with_its_text_selection() { + let (mut view, [alpha, ..]) = page_selection_view(); + select_page(&mut view, alpha); + let whole = view.editor.selection(); + let _ = view.key(&Key::Named(NamedKey::ArrowRight), None).unwrap(); + view.editor.select(whole).unwrap(); + assert_eq!(view.editor.whole(), None); +} diff --git a/crates/canvas/src/layout.rs b/crates/canvas/src/layout.rs index 4e06409eef987747230d123d3ba218c2b302be2a..3f4170e52f601a5afda9d6069b80dbbead1c031e 100644 --- a/crates/canvas/src/layout.rs +++ b/crates/canvas/src/layout.rs @@ -102,6 +102,10 @@ impl fmt::Display for LayoutError { impl std::error::Error for LayoutError {} +/// What a run without a font or size of its own is laid out in. +pub const DEFAULT_FONT: &str = "Arial"; +pub const DEFAULT_FONT_SIZE: f32 = 11.0; + /// Metric-compatible faces for the fonts OneNote pages use most, under the SIL Open Font /// Licence files beside them, by the family each stands in for. const BUNDLED: [(&str, &[&[u8]]); 4] = [ @@ -283,7 +287,7 @@ impl TextEngine { for (index, span) in paragraph.spans().iter().enumerate() { let format = &span.format; let link = format.hyperlink == Some(true); - let size = format.font_size.unwrap_or(11.0); + let size = format.font_size.unwrap_or(DEFAULT_FONT_SIZE); if !size.is_finite() || size <= 0.0 { return Err(LayoutError::InvalidFontSize); } @@ -303,7 +307,7 @@ impl TextEngine { StyleProperty::FontFamily(FontFamily::List( [ Some(FontFamilyName::named( - format.font.as_deref().unwrap_or("Arial"), + format.font.as_deref().unwrap_or(DEFAULT_FONT), )), // macOS ships STIX Two Math where Windows has Cambria Math. (format.font.as_deref() == Some("Cambria Math")) diff --git a/crates/canvas/src/math.rs b/crates/canvas/src/math.rs index ce90e57d16e219567a9555baa2cefd49baecf3a7..2991775e5aa3e1a87f79f303220fcbecfa27a8cf 100644 --- a/crates/canvas/src/math.rs +++ b/crates/canvas/src/math.rs @@ -612,7 +612,7 @@ pub(crate) fn built(paragraph: &Paragraph) -> Vec> { pub fn layout(engine: &mut TextEngine, paragraph: &Paragraph) -> Result { let nodes = Math::parse(paragraph).map_err(|_| LayoutError::UnsupportedContent)?; let format = ¶graph.spans()[0].format; - let size = format.font_size.unwrap_or(11.0); + let size = format.font_size.unwrap_or(crate::layout::DEFAULT_FONT_SIZE); let color = format.color.filter(|color| *color != 0xff00_0000); // An equation line is at least as tall as a line of text at its size. let strut = engine.layout( diff --git a/crates/canvas/src/outline.rs b/crates/canvas/src/outline.rs index 5fbfd8545494af30aa3c0e74f6396824ba8117e3..dc52272a8ef26a878618054f036ffe627b39df19 100644 --- a/crates/canvas/src/outline.rs +++ b/crates/canvas/src/outline.rs @@ -231,6 +231,30 @@ pub enum TagIcon { Other, } +impl TagIcon { + /// How the page draws tag symbol `shape`, `checked` where it is a check box; none for + /// shape 0, which marks the text instead. + pub fn of(shape: u16, checked: bool) -> Option { + Some(match shape { + 0 => return None, + // OneNote's check boxes, plain and with a person, arrow, number or bulb. + 3 | 8 | 12 | 28 | 71 | 94 | 95 => Self::CheckBox { checked }, + 13 => Self::Star, + 15 => Self::Question, + 17 => Self::Exclamation, + 18 => Self::Phone, + 23 => Self::Address, + 118 => Self::Contact, + 136 => Self::Highlight, + 100 => Self::RedSquare, + 101 => Self::YellowSquare, + 102 => Self::BlueSquare, + 121 => Self::Music, + _ => Self::Other, + }) + } +} + #[derive(Clone, Debug, PartialEq)] pub struct ParagraphTag { pub icon: TagIcon, @@ -823,25 +847,12 @@ impl ParagraphLayout { let (tag, shape, label) = (tag.0, tag.1, tag.2); let icon = match shape { None => TagIcon::Flag, - Some(Some(0) | None) => continue, - Some(Some(3)) => TagIcon::CheckBox { - checked: tag.status & 1 != 0, + Some(shape) => match TagIcon::of(shape.unwrap_or(0), tag.status & 1 != 0) { + Some(icon) => icon, + None => continue, }, - Some(Some(13)) => TagIcon::Star, - Some(Some(15)) => TagIcon::Question, - Some(Some(17)) => TagIcon::Exclamation, - Some(Some(18)) => TagIcon::Phone, - Some(Some(23)) => TagIcon::Address, - Some(Some(118)) => TagIcon::Contact, - Some(Some(136)) => TagIcon::Highlight, - Some(Some(100)) => TagIcon::RedSquare, - Some(Some(101)) => TagIcon::YellowSquare, - Some(Some(102)) => TagIcon::BlueSquare, - Some(Some(121)) => TagIcon::Music, - // A symbol without artwork here shows as a plain tag. - Some(_) => TagIcon::Other, }; - let size = format.font_size.unwrap_or(11.0); + let size = format.font_size.unwrap_or(crate::layout::DEFAULT_FONT_SIZE); let side = ParagraphTag::side(size); tags.push(ParagraphTag { icon, diff --git a/crates/canvas/tests/ops_differential.rs b/crates/canvas/tests/ops_differential.rs index 0b3095c5bb299036e0d327efb5ea47537653eb9d..0e63ca88888a93b105c2abf1cf6282a314fc983c 100644 --- a/crates/canvas/tests/ops_differential.rs +++ b/crates/canvas/tests/ops_differential.rs @@ -7,7 +7,7 @@ use canvas::{ document::TextPosition, - editor::{Alignment, CanvasEditor, Formatting, NoteTag, Toggle}, + editor::{Alignment, CanvasEditor, Formatting, NoteTag, Toggle, Whole}, layout::TextEngine, }; use draw::edit::Movement; @@ -91,11 +91,15 @@ enum Kind { Equation, Linear, Professional, + PageDelete, + PageType, + PageMove, + PageBold, Undo, Redo, } -const KINDS: [Kind; 48] = [ +const KINDS: [Kind; 52] = [ Kind::Type, Kind::TypeEnd, Kind::Compose, @@ -142,6 +146,10 @@ const KINDS: [Kind; 48] = [ Kind::Equation, Kind::Linear, Kind::Professional, + Kind::PageDelete, + Kind::PageType, + Kind::PageMove, + Kind::PageBold, Kind::Undo, Kind::Redo, ]; @@ -253,6 +261,18 @@ fn perform( |editor: &mut CanvasEditor, engine: &mut TextEngine, random: &mut Random, command| { range(editor, random) && editor.format(engine, command).is_ok() }; + // Select All until it holds every body outline. + let page = |editor: &mut CanvasEditor| { + for _ in 0..16 { + if editor.whole() == Some(Whole::Page) { + return true; + } + if editor.widen_selection().is_err() { + return false; + } + } + false + }; match kind { Kind::Type => place(editor, middle) && editor.insert(engine, "ab").is_ok(), Kind::TypeEnd => place(editor, length) && editor.commit_text(engine, "z".into()).is_ok(), @@ -409,6 +429,15 @@ fn perform( Kind::Professional => { place(editor, middle) && editor.build_equation(engine).unwrap_or(false) } + Kind::PageDelete => page(editor) && editor.delete(engine, false).is_ok(), + Kind::PageType => page(editor) && editor.insert(engine, "Z").is_ok(), + Kind::PageMove => page(editor) && editor.move_page([36.0, 18.0]).is_ok(), + Kind::PageBold => { + page(editor) + && editor + .format(engine, Formatting::Toggle(Toggle::Bold)) + .is_ok() + } Kind::Undo => editor.undo(engine).unwrap_or(false), Kind::Redo => editor.redo(engine).unwrap_or(false), } diff --git a/crates/draw/Cargo.toml b/crates/draw/Cargo.toml index 47db5a4e8f28857c0b01b1fd0fc988a9c287a02e..921a396249999895b00680336faf6bb6a63af64c 100644 --- a/crates/draw/Cargo.toml +++ b/crates/draw/Cargo.toml @@ -5,9 +5,11 @@ edition = "2024" publish = false [features] -default = ["render"] -# The wgpu renderer; `edit` needs only parley. -render = ["dep:bytemuck", "dep:image", "dep:linebender_resource_handle", "dep:roxmltree", "dep:swash", "dep:wgpu"] +default = ["wgpu"] +# The renderer; `edit` needs only parley. It submits through wgpu, or without it through +# OpenGL 2.1, for Mac OS X 10.6. +render = ["dep:bytemuck", "dep:image", "dep:linebender_resource_handle", "dep:roxmltree", "dep:swash"] +wgpu = ["render", "dep:wgpu"] [dependencies] parley.workspace = true diff --git a/platform/snow-leopard/gl21/src/draw.glsl b/crates/draw/src/draw.glsl similarity index 100% rename from platform/snow-leopard/gl21/src/draw.glsl rename to crates/draw/src/draw.glsl diff --git a/crates/draw/src/edit.rs b/crates/draw/src/edit.rs index ae2bdd5c279f939d973cc86e7b6a8f7e8863c2d7..a20da7df81ec0f3236b2bd9026e9ec27002de318 100644 --- a/crates/draw/src/edit.rs +++ b/crates/draw/src/edit.rs @@ -32,6 +32,7 @@ pub enum NamedKey { End, PageUp, PageDown, + F11, /// Shift, Control, Option or Command pressed alone. Modifier, Other, diff --git a/crates/draw/src/lib.rs b/crates/draw/src/lib.rs index 411394fa5389a9bc45b1bfb5344de85504f3ca6a..1db5447092de2b19c8b2d59d54b85d400640a29a 100644 --- a/crates/draw/src/lib.rs +++ b/crates/draw/src/lib.rs @@ -1,6 +1,6 @@ //! What the page and the interface share: painting layers of glyphs, icons, paths, -//! images, rounded rectangles and pen strokes with wgpu, each layer with its own transform -//! and clip, and editing text within a laid-out line. +//! images, rounded rectangles and pen strokes with wgpu or OpenGL, each layer with its +//! own transform and clip, and editing text within a laid-out line. pub mod edit; #[cfg(feature = "render")] diff --git a/crates/draw/src/render.rs b/crates/draw/src/render.rs index 735457a5eab9350a59d1a475e3d8b799591929c5..d80710faf7b46426d73d81802cc37cc65328589b 100644 --- a/crates/draw/src/render.rs +++ b/crates/draw/src/render.rs @@ -1,10 +1,21 @@ +#[cfg(not(feature = "wgpu"))] +mod gl; mod icon; - -pub use icon::Palette; mod text; +#[cfg(feature = "wgpu")] mod translucent; +#[cfg(feature = "wgpu")] +mod webgpu; +#[cfg(not(feature = "wgpu"))] +use gl as backend; +#[cfg(feature = "wgpu")] +use webgpu as backend; + +pub use backend::Target; +pub use icon::Palette; pub use text::{Decoration, Glyph, GlyphRun, Glyphs, paint_parley_run}; +#[cfg(feature = "wgpu")] pub use translucent::Translucent; use bytemuck::{Pod, Zeroable}; @@ -24,6 +35,7 @@ use swash::{ const ATLAS_SIZE: u32 = 2048; const MAX_GLYPHS: usize = 8192; const MAX_VERTICES: usize = 65_536; +const VERTEX_BUFFER_BYTES: u64 = (MAX_VERTICES * size_of::()) as u64; /// Decoded bytes of all images one frame may paint. pub const MAX_IMAGE_BYTES: u64 = 64 * 1024 * 1024; const MAX_IMAGES: usize = 256; @@ -146,7 +158,7 @@ impl RasterImage { } struct CachedImage { - binding: wgpu::BindGroup, + texture: backend::Image, bytes: u64, /// The texture goes when every copy of its image has. pixels: WeakBlob, @@ -190,6 +202,8 @@ enum AtlasKey { phase: [u8; 2], embolden: bool, skew: u32, + /// The ink's sRGB luminance in sixteenths, which `text_coverage` weights by. + tone: u8, }, Icon { sources: &'static [&'static str], @@ -435,15 +449,11 @@ pub struct Occupancy { } pub struct Renderer { + #[cfg(feature = "wgpu")] pub device: wgpu::Device, + #[cfg(feature = "wgpu")] pub queue: wgpu::Queue, - pipeline: wgpu::RenderPipeline, - image_pipeline: wgpu::RenderPipeline, - erase_pipeline: wgpu::RenderPipeline, - atlas: wgpu::Texture, - bind_group: wgpu::BindGroup, - image_sampler: wgpu::Sampler, - vertex_buffer: wgpu::Buffer, + gpu: backend::Gpu, vertices: Vec, glyphs: HashMap>, images: HashMap, @@ -454,150 +464,17 @@ pub struct Renderer { } impl Renderer { - pub fn new(device: wgpu::Device, queue: wgpu::Queue, format: wgpu::TextureFormat) -> Self { - let shader = device.create_shader_module(wgpu::include_wgsl!("draw.wgsl")); - let binding_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { - label: Some("Draw texture"), - entries: &[ - wgpu::BindGroupLayoutEntry { - binding: 0, - visibility: wgpu::ShaderStages::FRAGMENT, - ty: wgpu::BindingType::Texture { - sample_type: wgpu::TextureSampleType::Float { filterable: true }, - view_dimension: wgpu::TextureViewDimension::D2, - multisampled: false, - }, - count: None, - }, - wgpu::BindGroupLayoutEntry { - binding: 1, - visibility: wgpu::ShaderStages::FRAGMENT, - ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), - count: None, - }, - ], - }); - let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { - label: Some("Draw"), - bind_group_layouts: &[Some(&binding_layout)], - ..Default::default() - }); - let make_pipeline = |blend| { - device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { - label: Some("Draw"), - layout: Some(&pipeline_layout), - vertex: wgpu::VertexState { - module: &shader, - entry_point: Some("vertex"), - compilation_options: Default::default(), - buffers: &[Some(wgpu::VertexBufferLayout { - array_stride: size_of::() as u64, - step_mode: wgpu::VertexStepMode::Vertex, - attributes: &wgpu::vertex_attr_array![0 => Float32x2, 1 => Float32x2, 2 => Float32x4, 3 => Float32x2, 4 => Float32x4, 5 => Float32], - })], - }, - fragment: Some(wgpu::FragmentState { - module: &shader, - entry_point: Some("fragment"), - compilation_options: Default::default(), - targets: &[Some(wgpu::ColorTargetState { - format, - blend: Some(blend), - write_mask: wgpu::ColorWrites::ALL, - })], - }), - primitive: Default::default(), - depth_stencil: None, - multisample: Default::default(), - multiview_mask: None, - cache: None, - }) - }; - // Coverage accumulates in alpha, leaving premultiplied colour over a transparent clear. - let pipeline = make_pipeline(wgpu::BlendState { - color: wgpu::BlendState::ALPHA_BLENDING.color, - alpha: wgpu::BlendComponent::OVER, - }); - let image_pipeline = make_pipeline(wgpu::BlendState::PREMULTIPLIED_ALPHA_BLENDING); - let erase = wgpu::BlendComponent { - src_factor: wgpu::BlendFactor::Zero, - dst_factor: wgpu::BlendFactor::OneMinusSrcAlpha, - operation: wgpu::BlendOperation::Add, - }; - let erase_pipeline = make_pipeline(wgpu::BlendState { - color: erase, - alpha: erase, - }); - let atlas = device.create_texture(&wgpu::TextureDescriptor { - label: Some("Glyph atlas"), - size: wgpu::Extent3d { - width: ATLAS_SIZE, - height: ATLAS_SIZE, - depth_or_array_layers: 1, - }, - mip_level_count: 1, - sample_count: 1, - dimension: wgpu::TextureDimension::D2, - format: wgpu::TextureFormat::Rgba8UnormSrgb, - usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST, - view_formats: &[], - }); - let view = atlas.create_view(&Default::default()); - let sampler = device.create_sampler(&wgpu::SamplerDescriptor { - mag_filter: wgpu::FilterMode::Nearest, - min_filter: wgpu::FilterMode::Nearest, - ..Default::default() - }); - let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor { - label: Some("Glyph atlas"), - layout: &pipeline.get_bind_group_layout(0), - entries: &[ - wgpu::BindGroupEntry { - binding: 0, - resource: wgpu::BindingResource::TextureView(&view), - }, - wgpu::BindGroupEntry { - binding: 1, - resource: wgpu::BindingResource::Sampler(&sampler), - }, - ], - }); - let vertex_buffer = device.create_buffer(&wgpu::BufferDescriptor { - label: Some("Draw vertices"), - size: (MAX_VERTICES * size_of::()) as u64, - usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST, - mapped_at_creation: false, - }); - let image_sampler = device.create_sampler(&wgpu::SamplerDescriptor { - label: Some("Draw image filtering"), - mag_filter: wgpu::FilterMode::Linear, - min_filter: wgpu::FilterMode::Linear, - ..Default::default() - }); - queue.write_texture( - atlas.as_image_copy(), - &[255; 4], - wgpu::TexelCopyBufferLayout { - offset: 0, - bytes_per_row: Some(4), - rows_per_image: Some(1), - }, - wgpu::Extent3d { - width: 1, - height: 1, - depth_or_array_layers: 1, - }, - ); - Self { + fn with_gpu( + #[cfg(feature = "wgpu")] device: wgpu::Device, + #[cfg(feature = "wgpu")] queue: wgpu::Queue, + gpu: backend::Gpu, + ) -> Self { + let renderer = Self { + #[cfg(feature = "wgpu")] device, + #[cfg(feature = "wgpu")] queue, - pipeline, - image_pipeline, - erase_pipeline, - atlas, - bind_group, - image_sampler, - vertex_buffer, + gpu, vertices: Vec::new(), glyphs: HashMap::new(), images: HashMap::new(), @@ -605,7 +482,10 @@ impl Renderer { scaler: ScaleContext::with_max_entries(32), pen: [1, 0], row_height: 1, - } + }; + // The atlas's first texel is the white that solid fills sample. + renderer.write_atlas([0, 0], [1, 1], &[255; 4]); + renderer } pub fn clear_glyph_cache(&mut self) { @@ -637,8 +517,8 @@ impl Renderer { batch_capacity_bytes: self.batches.capacity() * size_of::(), glyph_capacity: self.glyphs.capacity(), image_capacity: self.images.capacity(), - atlas_bytes: u64::from(self.atlas.width()) * u64::from(self.atlas.height()) * 4, - vertex_buffer_bytes: self.vertex_buffer.size(), + atlas_bytes: u64::from(ATLAS_SIZE) * u64::from(ATLAS_SIZE) * 4, + vertex_buffer_bytes: VERTEX_BUFFER_BYTES, within_budget: self.glyphs.len() <= MAX_GLYPHS && self.images.len() <= MAX_IMAGES && image_bytes <= MAX_IMAGE_BYTES @@ -649,7 +529,7 @@ impl Renderer { /// Clears `target`, `size` device pixels, to linear `clear` and paints the layers in order. pub fn draw( &mut self, - target: &wgpu::TextureView, + target: &Target, size: [u32; 2], clear: [f32; 4], layers: &[Layer<'_>], @@ -702,39 +582,7 @@ impl Renderer { } } } - self.queue - .write_buffer(&self.vertex_buffer, 0, bytemuck::cast_slice(&self.vertices)); - let mut encoder = self.device.create_command_encoder(&Default::default()); - { - let [r, g, b, a] = clear.map(f64::from); - let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { - label: Some("Draw"), - color_attachments: &[Some(wgpu::RenderPassColorAttachment { - view: target, - depth_slice: None, - resolve_target: None, - ops: wgpu::Operations { - load: wgpu::LoadOp::Clear(wgpu::Color { r, g, b, a }), - store: wgpu::StoreOp::Store, - }, - })], - ..Default::default() - }); - pass.set_vertex_buffer(0, self.vertex_buffer.slice(..)); - for batch in &self.batches { - let [x, y, width, height] = batch.scissor; - pass.set_scissor_rect(x, y, width, height); - let (pipeline, binding) = match batch.blend { - Blend::Over => (&self.pipeline, &self.bind_group), - Blend::Image(id) => (&self.image_pipeline, &self.images[&id].binding), - Blend::Erase => (&self.erase_pipeline, &self.bind_group), - }; - pass.set_pipeline(pipeline); - pass.set_bind_group(0, binding, &[]); - pass.draw(batch.vertices.clone(), 0..1); - } - } - self.queue.submit([encoder.finish()]); + self.submit(target, clear); Ok(()) } @@ -892,11 +740,7 @@ impl Renderer { if self.images.contains_key(&image.id()) { return Ok(()); } - if image - .size - .iter() - .any(|v| *v > self.device.limits().max_texture_dimension_2d) - { + if image.size.iter().any(|v| *v > self.max_texture_dimension()) { return Err(RenderError::ImageTooLarge); } let bytes = image.pixels().len() as u64; @@ -911,51 +755,10 @@ impl Renderer { .ok_or(RenderError::ImageBudget)?; self.images.remove(&id); } - let size = wgpu::Extent3d { - width: image.size[0], - height: image.size[1], - depth_or_array_layers: 1, - }; - let texture = self.device.create_texture(&wgpu::TextureDescriptor { - label: Some("Draw image"), - size, - mip_level_count: 1, - sample_count: 1, - dimension: wgpu::TextureDimension::D2, - format: wgpu::TextureFormat::Rgba8UnormSrgb, - usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST, - view_formats: &[], - }); - self.queue.write_texture( - texture.as_image_copy(), - image.pixels(), - wgpu::TexelCopyBufferLayout { - offset: 0, - bytes_per_row: Some(image.size[0] * 4), - rows_per_image: Some(image.size[1]), - }, - size, - ); - let binding = self.device.create_bind_group(&wgpu::BindGroupDescriptor { - label: Some("Draw image"), - layout: &self.pipeline.get_bind_group_layout(0), - entries: &[ - wgpu::BindGroupEntry { - binding: 0, - resource: wgpu::BindingResource::TextureView( - &texture.create_view(&Default::default()), - ), - }, - wgpu::BindGroupEntry { - binding: 1, - resource: wgpu::BindingResource::Sampler(&self.image_sampler), - }, - ], - }); self.images.insert( image.id(), CachedImage { - binding, + texture: self.upload_image(image), bytes, pixels: image.pixels.downgrade(), }, @@ -980,6 +783,12 @@ impl Renderer { if !size.is_finite() || size > ATLAS_SIZE as f32 { return Err(RenderError::AtlasFull); } + let color = run + .color + .map_or(ink, |color| legible(color, space.backdrop, run.backdrop)); + let [red, green, blue, _] = color; + let luminance = 0.2126 * red + 0.7152 * green + 0.0722 * blue; + let tone = (f32::from(srgb_byte(luminance)) / 255.0 * 16.0).round() as u8; for glyph in run.glyphs { let x = glyph.x * scale + origin[0]; let y = glyph.y * scale + origin[1]; @@ -1000,6 +809,7 @@ impl Renderer { phase, embolden: run.embolden, skew: run.skew.unwrap_or(0.0).to_bits(), + tone, }; let cached = if let Some(cached) = self.glyphs.get(&key) { *cached @@ -1035,7 +845,15 @@ impl Renderer { Angle::from_degrees(0.0), ))); } - let image = render.render(&mut scaler, glyph_id); + let image = render.render(&mut scaler, glyph_id).map(|mut image| { + if image.content == Content::Mask { + let coverage = text_coverage(tone); + for alpha in &mut image.data { + *alpha = coverage[usize::from(*alpha)]; + } + } + image + }); let cached = if let Some(image) = image.filter(|i| i.placement.width > 0 && i.placement.height > 0) { @@ -1068,12 +886,7 @@ impl Renderer { (glyph.x + glyph.width) as f32 / atlas_size, (glyph.y + glyph.height) as f32 / atlas_size, ], - if glyph.color { - [1.0; 4] - } else { - run.color - .map_or(ink, |color| legible(color, space.backdrop, run.backdrop)) - }, + if glyph.color { [1.0; 4] } else { color }, )?; } } @@ -1127,27 +940,7 @@ impl Renderer { .collect(), _ => image.data, }; - self.queue.write_texture( - wgpu::TexelCopyTextureInfo { - origin: wgpu::Origin3d { - x: cached.x, - y: cached.y, - z: 0, - }, - ..self.atlas.as_image_copy() - }, - &rgba, - wgpu::TexelCopyBufferLayout { - offset: 0, - bytes_per_row: Some(p.width * 4), - rows_per_image: Some(p.height), - }, - wgpu::Extent3d { - width: p.width, - height: p.height, - depth_or_array_layers: 1, - }, - ); + self.write_atlas([cached.x, cached.y], [p.width, p.height], &rgba); self.pen[0] += p.width + 1; self.row_height = self.row_height.max(p.height); Ok(cached) @@ -1558,6 +1351,32 @@ fn srgb_byte(value: f32) -> u8 { (srgb * 255.0).round() as u8 } +/// Coverage for text of sRGB luminance `tone` sixteenths that, blended in linear light, +/// darkens a white backdrop as the rasterized coverage would blended sRGB-encoded, as +/// systems blend text: dark text keeps its weight instead of thinning, light text is +/// nearly untouched. +fn text_coverage(tone: u8) -> [u8; 256] { + let encoded = f32::from(tone) / 16.0; + let ink = linear(encoded); + std::array::from_fn(|coverage| { + let coverage = coverage as f32 / 255.0; + let weighted = if ink < 0.999 { + (1.0 - linear(1.0 - coverage * (1.0 - encoded))) / (1.0 - ink) + } else { + coverage + }; + (weighted * 255.0).round() as u8 + }) +} + +fn linear(value: f32) -> f32 { + if value <= 0.04045 { + value / 12.92 + } else { + ((value + 0.055) / 1.055).powf(2.4) + } +} + /// Least OKLab lightness difference between text and the backdrop it stays legible on. const LEGIBLE: f32 = 0.4; @@ -1609,18 +1428,11 @@ pub fn from_oklab([lightness, a, b]: [f32; 3]) -> [f32; 3] { /// Linear RGBA of an opaque sRGB colour. pub fn srgb(red: u8, green: u8, blue: u8) -> [f32; 4] { - let linear = |byte: u8| { - let value = f32::from(byte) / 255.0; - if value <= 0.04045 { - value / 12.92 - } else { - ((value + 0.055) / 1.055).powf(2.4) - } - }; - [linear(red), linear(green), linear(blue), 1.0] + let [red, green, blue] = [red, green, blue].map(|byte| linear(f32::from(byte) / 255.0)); + [red, green, blue, 1.0] } -#[cfg(test)] +#[cfg(all(test, feature = "wgpu"))] mod tests { use super::*; use parley::{ @@ -2032,8 +1844,8 @@ mod tests { let mut moment = 0.0_f64; for y in 212..252 { for x in (40 + 48 * step)..(72 + 48 * step) { - let value = captures[0][(y * 512 + x) * 4]; - let coverage = 1.0 - f64::from(srgb(value, 0, 0)[0]); + // Black text over white leaves the rasterized coverage sRGB-encoded. + let coverage = 1.0 - f64::from(captures[0][(y * 512 + x) * 4]) / 255.0; weight += coverage; moment += coverage * y as f64; } diff --git a/crates/draw/src/render/gl.rs b/crates/draw/src/render/gl.rs new file mode 100644 index 0000000000000000000000000000000000000000..bd758ded1214bd2a1f5b8d8d80700ba27ee664d0 --- /dev/null +++ b/crates/draw/src/render/gl.rs @@ -0,0 +1,469 @@ +//! Submission through OpenGL 2.1 with EXT_framebuffer_object, EXT_framebuffer_blit and +//! the sRGB extensions, which every Mac OS X 10.6 driver has and OpenGL.framework +//! exports. Frames draw into an sRGB framebuffer object, so blending happens in linear +//! light as it does in wgpu's sRGB targets. +#![allow(non_snake_case)] +use super::*; +use std::{ + ffi::{CStr, CString, c_char, c_void}, + mem::offset_of, + ptr, +}; + +type GLenum = u32; +type GLuint = u32; +type GLint = i32; +type GLsizei = i32; + +const TRIANGLES: GLenum = 0x0004; +const COLOR_BUFFER_BIT: GLenum = 0x4000; +const ZERO: GLenum = 0; +const ONE: GLenum = 1; +const SRC_ALPHA: GLenum = 0x0302; +const ONE_MINUS_SRC_ALPHA: GLenum = 0x0303; +const BLEND: GLenum = 0x0BE2; +const SCISSOR_TEST: GLenum = 0x0C11; +const MAX_TEXTURE_SIZE: GLenum = 0x0D33; +const TEXTURE_2D: GLenum = 0x0DE1; +const UNSIGNED_BYTE: GLenum = 0x1401; +const FLOAT: GLenum = 0x1406; +const RGBA: GLenum = 0x1908; +const NEAREST: GLint = 0x2600; +const LINEAR: GLint = 0x2601; +const TEXTURE_MAG_FILTER: GLenum = 0x2800; +const TEXTURE_MIN_FILTER: GLenum = 0x2801; +const TEXTURE_WRAP_S: GLenum = 0x2802; +const TEXTURE_WRAP_T: GLenum = 0x2803; +const CLAMP_TO_EDGE: GLint = 0x812F; +const TEXTURE0: GLenum = 0x84C0; +const ARRAY_BUFFER: GLenum = 0x8892; +const STREAM_DRAW: GLenum = 0x88E0; +const FRAGMENT_SHADER: GLenum = 0x8B30; +const VERTEX_SHADER: GLenum = 0x8B31; +const COMPILE_STATUS: GLenum = 0x8B81; +const LINK_STATUS: GLenum = 0x8B82; +const INFO_LOG_LENGTH: GLenum = 0x8B84; +const SRGB8_ALPHA8: GLint = 0x8C43; +const READ_FRAMEBUFFER: GLenum = 0x8CA8; +const DRAW_FRAMEBUFFER: GLenum = 0x8CA9; +const FRAMEBUFFER_COMPLETE: GLenum = 0x8CD5; +const COLOR_ATTACHMENT0: GLenum = 0x8CE0; +const FRAMEBUFFER: GLenum = 0x8D40; +const FRAMEBUFFER_SRGB: GLenum = 0x8DB9; + +#[cfg_attr(target_os = "macos", link(name = "OpenGL", kind = "framework"))] +unsafe extern "C" { + fn glEnable(cap: GLenum); + fn glDisable(cap: GLenum); + fn glGetIntegerv(name: GLenum, value: *mut GLint); + fn glViewport(x: GLint, y: GLint, width: GLsizei, height: GLsizei); + fn glScissor(x: GLint, y: GLint, width: GLsizei, height: GLsizei); + fn glClearColor(r: f32, g: f32, b: f32, a: f32); + fn glClear(mask: GLenum); + fn glBlendFuncSeparate(src_rgb: GLenum, dst_rgb: GLenum, src_a: GLenum, dst_a: GLenum); + fn glReadPixels( + x: GLint, + y: GLint, + w: GLsizei, + h: GLsizei, + format: GLenum, + ty: GLenum, + data: *mut c_void, + ); + + fn glGenTextures(n: GLsizei, textures: *mut GLuint); + fn glDeleteTextures(n: GLsizei, textures: *const GLuint); + fn glBindTexture(target: GLenum, texture: GLuint); + fn glActiveTexture(unit: GLenum); + fn glTexParameteri(target: GLenum, name: GLenum, value: GLint); + fn glTexImage2D( + target: GLenum, + level: GLint, + internal: GLint, + w: GLsizei, + h: GLsizei, + border: GLint, + format: GLenum, + ty: GLenum, + data: *const c_void, + ); + fn glTexSubImage2D( + target: GLenum, + level: GLint, + x: GLint, + y: GLint, + w: GLsizei, + h: GLsizei, + format: GLenum, + ty: GLenum, + data: *const c_void, + ); + + fn glGenBuffers(n: GLsizei, buffers: *mut GLuint); + fn glBindBuffer(target: GLenum, buffer: GLuint); + fn glBufferData(target: GLenum, size: isize, data: *const c_void, usage: GLenum); + fn glEnableVertexAttribArray(index: GLuint); + fn glVertexAttribPointer( + index: GLuint, + size: GLint, + ty: GLenum, + normalized: u8, + stride: GLsizei, + offset: *const c_void, + ); + fn glDrawArrays(mode: GLenum, first: GLint, count: GLsizei); + + fn glCreateShader(kind: GLenum) -> GLuint; + fn glShaderSource( + shader: GLuint, + count: GLsizei, + sources: *const *const c_char, + lengths: *const GLint, + ); + fn glCompileShader(shader: GLuint); + fn glGetShaderiv(shader: GLuint, name: GLenum, value: *mut GLint); + fn glGetShaderInfoLog(shader: GLuint, size: GLsizei, length: *mut GLsizei, log: *mut c_char); + fn glCreateProgram() -> GLuint; + fn glAttachShader(program: GLuint, shader: GLuint); + fn glBindAttribLocation(program: GLuint, index: GLuint, name: *const c_char); + fn glLinkProgram(program: GLuint); + fn glGetProgramiv(program: GLuint, name: GLenum, value: *mut GLint); + fn glGetProgramInfoLog(program: GLuint, size: GLsizei, length: *mut GLsizei, log: *mut c_char); + fn glUseProgram(program: GLuint); + fn glGetUniformLocation(program: GLuint, name: *const c_char) -> GLint; + fn glUniform1i(location: GLint, value: GLint); + + fn glGenFramebuffersEXT(n: GLsizei, framebuffers: *mut GLuint); + fn glDeleteFramebuffersEXT(n: GLsizei, framebuffers: *const GLuint); + fn glBindFramebufferEXT(target: GLenum, framebuffer: GLuint); + fn glFramebufferTexture2DEXT( + target: GLenum, + attachment: GLenum, + textarget: GLenum, + texture: GLuint, + level: GLint, + ); + fn glCheckFramebufferStatusEXT(target: GLenum) -> GLenum; + fn glBlitFramebufferEXT( + sx0: GLint, + sy0: GLint, + sx1: GLint, + sy1: GLint, + dx0: GLint, + dy0: GLint, + dx1: GLint, + dy1: GLint, + mask: GLenum, + filter: GLenum, + ); +} + +/// An sRGB RGBA texture, deleted with its value. +pub(super) struct Image { + name: GLuint, + size: [u32; 2], +} + +impl Image { + 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(), |pixels| pixels.as_ptr().cast()), + ); + } + Self { name, size } + } +} + +impl Drop for Image { + fn drop(&mut self) { + unsafe { glDeleteTextures(1, &self.name) }; + } +} + +/// An offscreen frame a window shows by `present`, the context's default framebuffer +/// being no sRGB target. +pub struct Target { + framebuffer: GLuint, + texture: Image, +} + +impl Target { + /// Needs the renderer's context current, as every method here does. + pub fn new(size: [u32; 2]) -> Result { + let texture = Image::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 { + glDeleteFramebuffersEXT(1, &framebuffer); + return Err(format!("Framebuffer incomplete: {status:#x}")); + } + } + Ok(Self { + framebuffer, + texture, + }) + } + + pub fn size(&self) -> [u32; 2] { + self.texture.size + } + + /// Copies the frame to the context's window, which is the same size. + pub fn present(&self) { + let [width, height] = self.size().map(|side| side as GLint); + unsafe { + glBindFramebufferEXT(READ_FRAMEBUFFER, self.framebuffer); + glBindFramebufferEXT(DRAW_FRAMEBUFFER, 0); + glBlitFramebufferEXT( + 0, + 0, + width, + height, + 0, + 0, + width, + height, + COLOR_BUFFER_BIT, + NEAREST as GLenum, + ); + glBindFramebufferEXT(FRAMEBUFFER, 0); + } + } + + /// sRGB-encoded RGBA rows, top first. + pub fn read_pixels(&self) -> Vec { + let [width, height] = self.size(); + let mut pixels = vec![0; (width * height * 4) as usize]; + unsafe { + glBindFramebufferEXT(FRAMEBUFFER, self.framebuffer); + glReadPixels( + 0, + 0, + width as GLsizei, + height as GLsizei, + RGBA, + UNSIGNED_BYTE, + pixels.as_mut_ptr().cast(), + ); + glBindFramebufferEXT(FRAMEBUFFER, 0); + } + pixels + .chunks(width as usize * 4) + .rev() + .flatten() + .copied() + .collect() + } +} + +impl Drop for Target { + fn drop(&mut self) { + unsafe { glDeleteFramebuffersEXT(1, &self.framebuffer) }; + } +} + +pub(super) struct Gpu { + program: GLuint, + buffer: GLuint, + atlas: Image, + max_texture: u32, +} + +/// Each attribute's name, component count and offset, bound to its index in the shader. +const ATTRIBUTES: [(&CStr, GLint, usize); 6] = [ + (c"position", 2, offset_of!(Vertex, position)), + (c"uv", 2, offset_of!(Vertex, uv)), + (c"color", 4, offset_of!(Vertex, color)), + (c"local", 2, offset_of!(Vertex, local)), + (c"shape", 4, offset_of!(Vertex, shape)), + (c"stroke", 1, offset_of!(Vertex, stroke)), +]; + +impl Renderer { + /// Draws with the OpenGL context current on this thread, which must stay current + /// whenever the renderer or a `Target` is used. + pub fn new() -> Result { + let source = include_str!("../draw.glsl"); + let program = unsafe { + let vertex = compile( + VERTEX_SHADER, + &format!("#version 120\n#define VERTEX\n{source}"), + )?; + let fragment = compile(FRAGMENT_SHADER, &format!("#version 120\n{source}"))?; + let program = glCreateProgram(); + glAttachShader(program, vertex); + glAttachShader(program, fragment); + for (index, (name, _, _)) in ATTRIBUTES.iter().enumerate() { + glBindAttribLocation(program, index as GLuint, name.as_ptr()); + } + glLinkProgram(program); + let mut linked = 0; + glGetProgramiv(program, LINK_STATUS, &mut linked); + if linked == 0 { + return Err(format!( + "Linking the draw shader failed: {}", + log(program, glGetProgramiv, glGetProgramInfoLog) + )); + } + program + }; + let mut buffer = 0; + let mut max_texture = 0; + unsafe { + glGenBuffers(1, &mut buffer); + glGetIntegerv(MAX_TEXTURE_SIZE, &mut max_texture); + } + Ok(Self::with_gpu(Gpu { + program, + buffer, + atlas: Image::new([ATLAS_SIZE; 2], NEAREST, None), + max_texture: max_texture as u32, + })) + } + + /// The widest and tallest texture the driver takes, in pixels. + pub fn max_texture_dimension(&self) -> u32 { + self.gpu.max_texture + } + + pub(super) fn write_atlas(&self, origin: [u32; 2], size: [u32; 2], rgba: &[u8]) { + unsafe { + glBindTexture(TEXTURE_2D, self.gpu.atlas.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(super) fn upload_image(&self, image: &RasterImage) -> Image { + Image::new(image.size, LINEAR, Some(image.pixels())) + } + + /// Clears `target` to linear `clear` and draws the prepared batches. + pub(super) fn submit(&mut self, target: &Target, clear: [f32; 4]) { + let [width, height] = target.size().map(|side| side as GLsizei); + let gpu = &self.gpu; + 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(gpu.program); + glUniform1i(glGetUniformLocation(gpu.program, c"atlas".as_ptr()), 0); + glActiveTexture(TEXTURE0); + glBindBuffer(ARRAY_BUFFER, gpu.buffer); + glBufferData( + ARRAY_BUFFER, + size_of_val(self.vertices.as_slice()) as isize, + self.vertices.as_ptr().cast(), + STREAM_DRAW, + ); + for (index, (_, components, offset)) in ATTRIBUTES.iter().enumerate() { + glEnableVertexAttribArray(index as GLuint); + glVertexAttribPointer( + index as GLuint, + *components, + FLOAT, + 0, + size_of::() as GLsizei, + ptr::without_provenance(*offset), + ); + } + glEnable(BLEND); + glEnable(SCISSOR_TEST); + for batch in &self.batches { + let [x, y, w, h] = batch.scissor.map(|value| value as GLint); + glScissor(x, height - y - h, w, h); + let (texture, [src_rgb, dst_rgb, src_alpha, dst_alpha]) = match batch.blend { + Blend::Over => ( + &gpu.atlas, + [SRC_ALPHA, ONE_MINUS_SRC_ALPHA, ONE, ONE_MINUS_SRC_ALPHA], + ), + Blend::Image(id) => ( + &self.images[&id].texture, + [ONE, ONE_MINUS_SRC_ALPHA, ONE, ONE_MINUS_SRC_ALPHA], + ), + Blend::Erase => ( + &gpu.atlas, + [ZERO, ONE_MINUS_SRC_ALPHA, ZERO, ONE_MINUS_SRC_ALPHA], + ), + }; + glBlendFuncSeparate(src_rgb, dst_rgb, src_alpha, dst_alpha); + glBindTexture(TEXTURE_2D, texture.name); + 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).expect("The shader has no NUL"); + 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!( + "Compiling the draw shader failed: {}", + 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 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/crates/draw/src/render/webgpu.rs b/crates/draw/src/render/webgpu.rs new file mode 100644 index 0000000000000000000000000000000000000000..53fe78bde98dcb7e76089c2be856be2af72aa76a --- /dev/null +++ b/crates/draw/src/render/webgpu.rs @@ -0,0 +1,265 @@ +//! Submission through wgpu: Metal, Vulkan or GL, whichever the platform offers. +use super::*; + +/// What a frame is drawn into. +pub type Target = wgpu::TextureView; + +pub(super) type Image = wgpu::BindGroup; + +pub(super) struct Gpu { + pipeline: wgpu::RenderPipeline, + image_pipeline: wgpu::RenderPipeline, + erase_pipeline: wgpu::RenderPipeline, + atlas: wgpu::Texture, + bind_group: wgpu::BindGroup, + image_sampler: wgpu::Sampler, + vertex_buffer: wgpu::Buffer, +} + +impl Renderer { + pub fn new(device: wgpu::Device, queue: wgpu::Queue, format: wgpu::TextureFormat) -> Self { + let shader = device.create_shader_module(wgpu::include_wgsl!("../draw.wgsl")); + let binding_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("Draw texture"), + entries: &[ + wgpu::BindGroupLayoutEntry { + binding: 0, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Texture { + sample_type: wgpu::TextureSampleType::Float { filterable: true }, + view_dimension: wgpu::TextureViewDimension::D2, + multisampled: false, + }, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 1, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), + count: None, + }, + ], + }); + let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some("Draw"), + bind_group_layouts: &[Some(&binding_layout)], + ..Default::default() + }); + let make_pipeline = |blend| { + device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some("Draw"), + layout: Some(&pipeline_layout), + vertex: wgpu::VertexState { + module: &shader, + entry_point: Some("vertex"), + compilation_options: Default::default(), + buffers: &[Some(wgpu::VertexBufferLayout { + array_stride: size_of::() as u64, + step_mode: wgpu::VertexStepMode::Vertex, + attributes: &wgpu::vertex_attr_array![0 => Float32x2, 1 => Float32x2, 2 => Float32x4, 3 => Float32x2, 4 => Float32x4, 5 => Float32], + })], + }, + fragment: Some(wgpu::FragmentState { + module: &shader, + entry_point: Some("fragment"), + compilation_options: Default::default(), + targets: &[Some(wgpu::ColorTargetState { + format, + blend: Some(blend), + write_mask: wgpu::ColorWrites::ALL, + })], + }), + primitive: Default::default(), + depth_stencil: None, + multisample: Default::default(), + multiview_mask: None, + cache: None, + }) + }; + // Coverage accumulates in alpha, leaving premultiplied colour over a transparent clear. + let pipeline = make_pipeline(wgpu::BlendState { + color: wgpu::BlendState::ALPHA_BLENDING.color, + alpha: wgpu::BlendComponent::OVER, + }); + let image_pipeline = make_pipeline(wgpu::BlendState::PREMULTIPLIED_ALPHA_BLENDING); + let erase = wgpu::BlendComponent { + src_factor: wgpu::BlendFactor::Zero, + dst_factor: wgpu::BlendFactor::OneMinusSrcAlpha, + operation: wgpu::BlendOperation::Add, + }; + let erase_pipeline = make_pipeline(wgpu::BlendState { + color: erase, + alpha: erase, + }); + let atlas = device.create_texture(&wgpu::TextureDescriptor { + label: Some("Glyph atlas"), + size: wgpu::Extent3d { + width: ATLAS_SIZE, + height: ATLAS_SIZE, + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: wgpu::TextureFormat::Rgba8UnormSrgb, + usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST, + view_formats: &[], + }); + let view = atlas.create_view(&Default::default()); + let sampler = device.create_sampler(&wgpu::SamplerDescriptor { + mag_filter: wgpu::FilterMode::Nearest, + min_filter: wgpu::FilterMode::Nearest, + ..Default::default() + }); + let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("Glyph atlas"), + layout: &pipeline.get_bind_group_layout(0), + entries: &[ + wgpu::BindGroupEntry { + binding: 0, + resource: wgpu::BindingResource::TextureView(&view), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: wgpu::BindingResource::Sampler(&sampler), + }, + ], + }); + let vertex_buffer = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("Draw vertices"), + size: VERTEX_BUFFER_BYTES, + usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + let image_sampler = device.create_sampler(&wgpu::SamplerDescriptor { + label: Some("Draw image filtering"), + mag_filter: wgpu::FilterMode::Linear, + min_filter: wgpu::FilterMode::Linear, + ..Default::default() + }); + Self::with_gpu( + device, + queue, + Gpu { + pipeline, + image_pipeline, + erase_pipeline, + atlas, + bind_group, + image_sampler, + vertex_buffer, + }, + ) + } + + /// The widest and tallest texture the device takes, in pixels. + pub fn max_texture_dimension(&self) -> u32 { + self.device.limits().max_texture_dimension_2d + } + + pub(super) fn write_atlas(&self, origin: [u32; 2], size: [u32; 2], rgba: &[u8]) { + self.queue.write_texture( + wgpu::TexelCopyTextureInfo { + origin: wgpu::Origin3d { + x: origin[0], + y: origin[1], + z: 0, + }, + ..self.gpu.atlas.as_image_copy() + }, + rgba, + wgpu::TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(size[0] * 4), + rows_per_image: Some(size[1]), + }, + wgpu::Extent3d { + width: size[0], + height: size[1], + depth_or_array_layers: 1, + }, + ); + } + + pub(super) fn upload_image(&self, image: &RasterImage) -> Image { + let size = wgpu::Extent3d { + width: image.size[0], + height: image.size[1], + depth_or_array_layers: 1, + }; + let texture = self.device.create_texture(&wgpu::TextureDescriptor { + label: Some("Draw image"), + size, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: wgpu::TextureFormat::Rgba8UnormSrgb, + usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST, + view_formats: &[], + }); + self.queue.write_texture( + texture.as_image_copy(), + image.pixels(), + wgpu::TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(image.size[0] * 4), + rows_per_image: Some(image.size[1]), + }, + size, + ); + self.device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("Draw image"), + layout: &self.gpu.pipeline.get_bind_group_layout(0), + entries: &[ + wgpu::BindGroupEntry { + binding: 0, + resource: wgpu::BindingResource::TextureView( + &texture.create_view(&Default::default()), + ), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: wgpu::BindingResource::Sampler(&self.gpu.image_sampler), + }, + ], + }) + } + + /// Clears `target` to linear `clear` and draws the prepared batches. + pub(super) fn submit(&mut self, target: &Target, clear: [f32; 4]) { + let gpu = &self.gpu; + self.queue + .write_buffer(&gpu.vertex_buffer, 0, bytemuck::cast_slice(&self.vertices)); + let mut encoder = self.device.create_command_encoder(&Default::default()); + { + let [r, g, b, a] = clear.map(f64::from); + let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("Draw"), + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + view: target, + depth_slice: None, + resolve_target: None, + ops: wgpu::Operations { + load: wgpu::LoadOp::Clear(wgpu::Color { r, g, b, a }), + store: wgpu::StoreOp::Store, + }, + })], + ..Default::default() + }); + pass.set_vertex_buffer(0, gpu.vertex_buffer.slice(..)); + for batch in &self.batches { + let [x, y, width, height] = batch.scissor; + pass.set_scissor_rect(x, y, width, height); + let (pipeline, binding) = match batch.blend { + Blend::Over => (&gpu.pipeline, &gpu.bind_group), + Blend::Image(id) => (&gpu.image_pipeline, &self.images[&id].texture), + Blend::Erase => (&gpu.erase_pipeline, &gpu.bind_group), + }; + pass.set_pipeline(pipeline); + pass.set_bind_group(0, binding, &[]); + pass.draw(batch.vertices.clone(), 0..1); + } + } + self.queue.submit([encoder.finish()]); + } +} diff --git a/crates/notebook/README.md b/crates/notebook/README.md index 49d75c26c3f7cbed4de98cff537b0790f7e08fe5..588e4860384735a2ff38b03e5616b67f99b7abab 100644 --- a/crates/notebook/README.md +++ b/crates/notebook/README.md @@ -60,6 +60,9 @@ failures; `notify` runs on a background thread whenever an event waits. removes; `release(id, archive, Resolution)` ends an uncertain attempt. The author an edit names is the host's: the app passes the account's full name. `import_page` creates a page holding a copy of another; `delete_pages` removes pages. +`sync_status()` gives when the section file was last reached, why it could not be since +and how many edits wait for it. `set_offline(true)` works offline as OneNote does: the +worker stops connecting and edits queue until `wake()` (Sync Now) or `set_offline(false)`. `Section::resume(file, replica, notify)` starts from an owned `Replica` without consulting the remote file; with the `smb` feature, `Section::resume_smb(path, diff --git a/crates/notebook/src/session.rs b/crates/notebook/src/session.rs index 1676e109cf9d78e8dd2f60168555bd97e5778ecf..977238d3233776afdaf4e444e6d646197328ee0a 100644 --- a/crates/notebook/src/session.rs +++ b/crates/notebook/src/session.rs @@ -1028,6 +1028,20 @@ pub enum Event { type Notify = Arc; +/// How a section's synchronization stands, for the host to show. +#[derive(Debug)] +pub struct SyncStatus { + /// FILETIME of the last synchronization step that reached the section file. + pub synced: Option, + /// Why the last step failed, until one reaches the section file again. + pub error: Option, + /// Edits the section file does not hold yet, uncertain attempts included. + pub queued: u64, +} + +/// The last attempt's outcome; `None` before the first. +type Observed = Option>; + /// A section file with its replica and background publication. `Send + Sync`: edits /// arrive from any thread without waiting, and `notify` wakes the host when events wait. pub struct Section { @@ -1037,6 +1051,7 @@ pub struct Section { events: Mutex>, sender: Sender, notify: Notify, + observed: Arc>, } impl Section { @@ -1129,9 +1144,24 @@ impl Section { notify(); } }); + let observed: Arc> = Arc::new(Mutex::new(None)); let worker = { let (sender, notify) = (sender.clone(), Arc::clone(¬ify)); + let observed = Arc::clone(&observed); replica.start_sync(Duration::from_secs(2), connect, move |result| { + let error = result.as_ref().err().map(|error| match error { + Error::RemoteIo(error) => io::Error::new(error.kind(), error.to_string()), + Error::Remote(error) => io::Error::new(error.error.kind(), error.to_string()), + error => io::Error::other(error.to_string()), + }); + // Reaching the file as the last attempt did is no news to the host. + let mut changed = false; + if let Ok(mut observed) = observed.lock() { + changed = observed + .as_ref() + .is_none_or(|last| last.is_some() != error.is_some()); + *observed = Some(error); + } let mut events = Vec::new(); match result { Ok(synced) => { @@ -1157,7 +1187,8 @@ impl Section { ))), Err(error) => events.push(Event::Failed(error.to_string())), } - if !events.is_empty() && events.into_iter().all(|event| sender.send(event).is_ok()) + if (changed || !events.is_empty()) + && events.into_iter().all(|event| sender.send(event).is_ok()) { notify(); } @@ -1170,6 +1201,7 @@ impl Section { events: Mutex::new(events), sender, notify, + observed, }) } @@ -1332,13 +1364,39 @@ impl Section { .unwrap_or_default() } - /// Requests a synchronization attempt now. + /// Requests a synchronization attempt now, working offline included (Sync Now). pub fn wake(&self) { if let Some(worker) = &self.worker { worker.wake(); } } + /// Stops or resumes synchronizing: working offline, edits queue until `wake` or until + /// working online again (OneNote's Work Offline). + pub fn set_offline(&self, offline: bool) { + if let Some(worker) = &self.worker { + worker.set_offline(offline); + } + } + + /// When the section file was last reached, why it could not be since, and what waits + /// for it. `notify` runs when it is first reached and when an error comes or goes. + pub fn sync_status(&self) -> Result { + let queued = self.replica.recovery_summary()?.queued_edits; + let observed = self + .observed + .lock() + .map_err(|_| io::Error::other("Synchronization observer panicked"))?; + Ok(SyncStatus { + synced: self.worker.as_ref().and_then(SyncWorker::synced), + error: observed + .as_ref() + .and_then(Option::as_ref) + .map(|error| io::Error::new(error.kind(), error.to_string())), + queued, + }) + } + /// The replica, which other threads may read pages from while the section is open. pub fn replica(&self) -> &Arc { &self.replica diff --git a/crates/notebook/src/worker.rs b/crates/notebook/src/worker.rs index 8f17ad73ac1236a192454bae4d01e0eb87b46945..a683045309d6e0de2e50bf3ae7f195474ae69422 100644 --- a/crates/notebook/src/worker.rs +++ b/crates/notebook/src/worker.rs @@ -4,7 +4,7 @@ use std::{ hash::BuildHasher, sync::{ Arc, - atomic::{AtomicBool, Ordering}, + atomic::{AtomicBool, AtomicU64, Ordering}, mpsc::{self, SyncSender}, }, thread::{self, JoinHandle}, @@ -13,6 +13,11 @@ use std::{ pub(super) struct Signal { stopped: AtomicBool, + offline: AtomicBool, + /// FILETIME of the last step or poll that reached the remote; 0 before one has. + synced: AtomicU64, + /// Asked for by `SyncWorker::wake`: steps run while offline until one leaves nothing to do. + requested: AtomicBool, sender: SyncSender<()>, } @@ -31,9 +36,23 @@ pub struct SyncWorker { } impl SyncWorker { - /// Requests a retry, for example after a network reachability change. + /// Requests a retry, for example after a network reachability change. Working + /// offline, it synchronizes once, as OneNote's Sync Now does. /// A pending contention backoff finishes before processing the notification. pub fn wake(&self) { + self.signal.requested.store(true, Ordering::Release); + self.signal.wake(); + } + + /// When a step or poll last reached the remote. + pub fn synced(&self) -> Option { + Some(self.signal.synced.load(Ordering::Acquire)).filter(|time| *time != 0) + } + + /// Working offline, the worker neither connects nor steps: local edits stay queued + /// until `wake`, or until working online again. + pub fn set_offline(&self, offline: bool) { + self.signal.offline.store(offline, Ordering::Release); self.signal.wake(); } @@ -94,6 +113,9 @@ impl Replica { let (sender, receiver) = mpsc::sync_channel(1); let signal = Arc::new(Signal { stopped: AtomicBool::new(false), + offline: AtomicBool::new(false), + synced: AtomicU64::new(0), + requested: AtomicBool::new(false), sender, }); let replica = Arc::clone(self); @@ -108,9 +130,19 @@ impl Replica { // hears that the remote is reachable and what state the queue is in. let mut reported = false; while !worker_signal.stopped.load(Ordering::Acquire) { + if worker_signal.offline.load(Ordering::Acquire) + && !worker_signal.requested.load(Ordering::Acquire) + { + remote = None; + reported = false; + let _ = receiver.recv(); + continue; + } let result = match remote.as_mut() { Some(remote) => { if reported && replica.settled(remote).unwrap_or(false) { + worker_signal.requested.store(false, Ordering::Release); + worker_signal.synced.store(crate::now(), Ordering::Release); let _ = receiver.recv_timeout(interval); continue; } @@ -126,6 +158,9 @@ impl Replica { Err(error) => Err(Error::RemoteIo(error)), }, }; + if result.is_ok() { + worker_signal.synced.store(crate::now(), Ordering::Release); + } observe(&result); match result { Ok(Synced { @@ -168,6 +203,7 @@ impl Replica { Err(Error::Io(ref error)) if error.kind() == io::ErrorKind::WouldBlock => {} Err(error) => return Err(error), } + worker_signal.requested.store(false, Ordering::Release); if !worker_signal.stopped.load(Ordering::Acquire) { let _ = receiver.recv_timeout(interval); } diff --git a/crates/notebook/tests/session.rs b/crates/notebook/tests/session.rs index cc4533b87b20d334718003189505e36eadc01eaa..a63954015f49167dc63246a4c4ed89b743220d4f 100644 --- a/crates/notebook/tests/session.rs +++ b/crates/notebook/tests/session.rs @@ -2,7 +2,7 @@ mod ops; use notebook::{ EditStatus, Recovery, Resolution, - session::{Event, Notebook, Section}, + session::{Event, Notebook, Section, SyncStatus}, }; use onestore::{ ExGuid, @@ -99,6 +99,18 @@ fn published(section: &Section, id: u64) { } } +fn status_until(section: &Section, accept: impl Fn(&SyncStatus) -> bool) -> SyncStatus { + let deadline = Instant::now() + Duration::from_secs(20); + loop { + let status = section.sync_status().unwrap(); + if accept(&status) { + return status; + } + assert!(Instant::now() < deadline, "the status stayed {status:?}"); + std::thread::sleep(Duration::from_millis(20)); + } +} + fn stored_page(file: &Path, space: ExGuid) -> Page { model_ops::page_of(&onestore::read_file(file).unwrap(), space) } @@ -917,3 +929,73 @@ fn page_versions_restore_and_delete_through_the_session() { assert_eq!(page.objects, old.objects); section.close().unwrap(); } + +#[test] +fn working_offline_queues_edits_until_sync_now_or_working_online() { + let directory = tempfile::tempdir().unwrap(); + let file = directory.path().join("notes.one"); + let cache = directory.path().join("cache"); + std::fs::write( + &file, + onestore::create_section("notes.one", "Original", "Author").unwrap(), + ) + .unwrap(); + let (section, _) = open(&file, &cache); + let space = section.pages().unwrap()[0].0; + let before = section.page(space).unwrap(); + let reached = status_until(§ion, |status| status.synced.is_some()); + assert!(reached.error.is_none()); + assert_eq!(reached.queued, 0); + + section.set_offline(true); + let id = typed(§ion, space, &before, 0..0, "Offline "); + std::thread::sleep(Duration::from_millis(500)); + assert_eq!(section.status(id).unwrap(), Some(EditStatus::Pending)); + assert_eq!(section.sync_status().unwrap().queued, 1); + assert_eq!(stored_page(&file, space), before); + + // Sync Now publishes while working offline. + section.wake(); + published(§ion, id); + let after = edited(&before, "Offline "); + assert_same(stored_page(&file, space), &after); + let synced = status_until(§ion, |status| status.queued == 0); + assert!(synced.synced > reached.synced); + + let next = typed(§ion, space, &after, 0..0, "Again "); + std::thread::sleep(Duration::from_millis(500)); + assert_eq!(section.status(next).unwrap(), Some(EditStatus::Pending)); + section.set_offline(false); + published(§ion, next); + status_until(§ion, |status| status.queued == 0); + section.close().unwrap(); +} + +#[test] +fn a_missing_section_file_reports_its_error_until_it_returns() { + let directory = tempfile::tempdir().unwrap(); + let file = directory.path().join("notes.one"); + let away = directory.path().join("away.one"); + let cache = directory.path().join("cache"); + std::fs::write( + &file, + onestore::create_section("notes.one", "Original", "Author").unwrap(), + ) + .unwrap(); + let (section, notified) = open(&file, &cache); + status_until(§ion, |status| status.synced.is_some()); + std::fs::rename(&file, &away).unwrap(); + section.wake(); + let failed = status_until(§ion, |status| status.error.is_some()); + assert_eq!( + failed.error.map(|error| error.kind()), + Some(std::io::ErrorKind::NotFound) + ); + let before = notified.load(Ordering::SeqCst); + std::fs::rename(&away, &file).unwrap(); + section.wake(); + let back = status_until(§ion, |status| status.error.is_none()); + assert!(back.synced > failed.synced); + assert!(notified.load(Ordering::SeqCst) > before); + section.close().unwrap(); +} diff --git a/crates/snowbound/Cargo.toml b/crates/snowbound/Cargo.toml index fbc91fd37a76cfd9143e1d15ed1c8d0f94fecc92..9f9c14840888de0374a2236efefe3e40e9f418bd 100644 --- a/crates/snowbound/Cargo.toml +++ b/crates/snowbound/Cargo.toml @@ -4,9 +4,14 @@ version = "0.1.0" edition = "2024" publish = false +[features] +default = ["wgpu"] +# Paints through wgpu; without it through OpenGL 2.1, for Mac OS X 10.6. +wgpu = ["draw/wgpu", "dep:wgpu"] + [dependencies] canvas = { path = "../canvas", features = ["interaction"] } -draw = { path = "../draw" } +draw = { path = "../draw", default-features = false, features = ["render"] } ui = { path = "../ui" } png = "0.18" onestore = { path = "../onestore" } @@ -14,7 +19,7 @@ notebook = { path = "../notebook", features = ["smb"] } serde = { version = "1", features = ["derive"] } serde_json = "1" pollster = "0.4" -wgpu.workspace = true +wgpu = { workspace = true, optional = true } parley.workspace = true winit = "0.30.13" arboard = { version = "3.6.1", default-features = false } diff --git a/crates/snowbound/src/art.rs b/crates/snowbound/src/art.rs index d8eeda3ad352ea299d88601b9c19b932e8417e65..8d7e2165ec2a107e3f483a6d666cd672a3d6183e 100644 --- a/crates/snowbound/src/art.rs +++ b/crates/snowbound/src/art.rs @@ -46,41 +46,22 @@ pub const UNDO: &[&str] = art!("icons/undo"); pub const ZOOM_IN: &[&str] = art!("icons/zoom-in"); pub const ZOOM_OUT: &[&str] = art!("icons/zoom-out"); -// The toolbar layout's new buttons (resources/toolbar-spec.md); each loses its `expect` -// when its button lands. -#[expect(dead_code, reason = "Its toolbar button has not landed")] pub const BACK: &[&str] = art!("icons/back"); -#[expect(dead_code, reason = "Its toolbar button has not landed")] pub const COPY: &[&str] = art!("icons/copy"); -#[expect(dead_code, reason = "Its toolbar button has not landed")] pub const CUT: &[&str] = art!("icons/cut"); -#[expect(dead_code, reason = "Its toolbar button has not landed")] pub const DATE_TIME: &[&str] = art!("icons/date-time"); -#[expect(dead_code, reason = "Its toolbar button has not landed")] pub const FIND_TAGS: &[&str] = art!("icons/find-tags"); -#[expect(dead_code, reason = "Its toolbar button has not landed")] pub const FORMAT_PAINTER: &[&str] = art!("icons/format-painter"); -#[expect(dead_code, reason = "Its toolbar button has not landed")] pub const FORWARD: &[&str] = art!("icons/forward"); -#[expect(dead_code, reason = "Its toolbar button has not landed")] pub const FULL_PAGE_VIEW: &[&str] = art!("icons/full-page-view"); -#[expect(dead_code, reason = "Its toolbar button has not landed")] pub const INSERT_SPACE: &[&str] = art!("icons/insert-space"); -#[expect(dead_code, reason = "Its toolbar button has not landed")] pub const PAGE_COLOR: &[&str] = art!("icons/page-color"); -#[expect(dead_code, reason = "Its toolbar button has not landed")] pub const PASTE: &[&str] = art!("icons/paste"); -#[expect(dead_code, reason = "Its toolbar button has not landed")] pub const RECORD_AUDIO: &[&str] = art!("icons/record-audio"); -#[expect(dead_code, reason = "Its toolbar button has not landed")] pub const RECORD_VIDEO: &[&str] = art!("icons/record-video"); -#[expect(dead_code, reason = "Its toolbar button has not landed")] pub const RULE_LINES: &[&str] = art!("icons/rule-lines"); -#[expect(dead_code, reason = "Its toolbar button has not landed")] pub const SCREEN_CLIPPING: &[&str] = art!("icons/screen-clipping"); -#[expect(dead_code, reason = "Its toolbar button has not landed")] pub const SPELLING: &[&str] = art!("icons/spelling"); -#[expect(dead_code, reason = "Its toolbar button has not landed")] pub const SYMBOL: &[&str] = art!("icons/symbol"); pub const TAG_REMEMBER: &[&str] = art!("tags/remember"); diff --git a/crates/snowbound/src/commands.rs b/crates/snowbound/src/commands.rs index a44af8ef835cc27c19606a76c134295a01dc911d..ceed601ca702cb39cfa1d52f35ba322151512888 100644 --- a/crates/snowbound/src/commands.rs +++ b/crates/snowbound/src/commands.rs @@ -25,6 +25,7 @@ pub enum Id { Cut, Copy, Paste, + FormatPainter, SelectAll, Find, Search, @@ -36,14 +37,21 @@ pub enum Id { Sidebar, PageList, DarkPages, + FullPageView, + PageColor, Table, Picture, + ScreenClipping, Attachment, Link, + InsertSpace, Equation, + Symbol, Date, Time, DateTime, + RecordAudio, + RecordVideo, Toggle(Toggle), Highlight, FontColor, @@ -69,6 +77,10 @@ pub enum Choice { /// A COLORREF, or `None` for automatic. Color(Option), List(Option), + /// An empty table of rows by columns. + Table([usize; 2]), + /// An index into `PAGE_COLORS`, or `None` for no colour. + PageColor(Option), } #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] @@ -161,6 +173,9 @@ macro_rules! tag { ($tag:expr, $digit:literal) => { row!(Id::Tag($tag), $tag.label(), &[cmd($digit)], &[cmd($digit)]) }; + ($tag:expr) => { + row!(Id::Tag($tag), $tag.label(), NONE, NONE) + }; } const NONE: &[Chord] = &[]; @@ -206,6 +221,7 @@ pub const COMMANDS: &[Command] = &[ row!(Id::Cut, "Cut", &[cmd('x')], &[cmd('x')]), row!(Id::Copy, "Copy", &[cmd('c')], &[cmd('c')]), row!(Id::Paste, "Paste", &[cmd('v')], &[cmd('v')]), + row!(Id::FormatPainter, "Format Painter", NONE, NONE), row!(Id::SelectAll, "Select All", &[cmd('a')], &[cmd('a')]), row!(Id::Find, "Find on This Page", &[cmd('f')], &[cmd('f')]), row!(Id::Search, "Search Notebooks", &[cmd('e')], &[cmd('e')]), @@ -239,16 +255,27 @@ pub const COMMANDS: &[Command] = &[ row!(Id::Sidebar, "Sidebar", &[cmd('s').control()], NONE), row!(Id::PageList, "Page List", NONE, NONE), row!(Id::DarkPages, "Dark Pages", NONE, NONE), + // AppKit's Enter Full Screen takes Control-Command-F. + row!( + Id::FullPageView, + "Full Page View", + NONE, + &[named(NamedKey::F11)] + ), + row!(Id::PageColor, "Page Color", NONE, NONE), row!(Id::Table, "Table", NONE, NONE), row!(Id::Picture, "Picture…", NONE, NONE), + row!(Id::ScreenClipping, "Screen Clipping", NONE, NONE), row!(Id::Attachment, "Attach File…", NONE, NONE), row!(Id::Link, "Link…", &[cmd('k')], &[cmd('k')]), + row!(Id::InsertSpace, "Insert Space", NONE, NONE), row!( Id::Equation, "Equation", &[key('=').control()], &[key('=').option()], ), + row!(Id::Symbol, "Symbol", NONE, NONE), row!(Id::Date, "Date", NONE, &[key('d').option().shift()]), row!(Id::Time, "Time", NONE, &[key('t').option().shift()]), row!( @@ -257,6 +284,8 @@ pub const COMMANDS: &[Command] = &[ NONE, &[key('f').option().shift()] ), + row!(Id::RecordAudio, "Record Audio", NONE, NONE), + row!(Id::RecordVideo, "Record Video", NONE, NONE), row!(Id::Toggle(Toggle::Bold), "Bold", &[cmd('b')], &[cmd('b')]), row!( Id::Toggle(Toggle::Italic), @@ -338,6 +367,26 @@ pub const COMMANDS: &[Command] = &[ tag!(NoteTag::Contact, '7'), tag!(NoteTag::Address, '8'), tag!(NoteTag::PhoneNumber, '9'), + tag!(NoteTag::WebSiteToVisit), + tag!(NoteTag::Idea), + tag!(NoteTag::Password), + tag!(NoteTag::Critical), + tag!(NoteTag::ProjectA), + tag!(NoteTag::ProjectB), + tag!(NoteTag::MovieToSee), + tag!(NoteTag::BookToRead), + tag!(NoteTag::MusicToListenTo), + tag!(NoteTag::SourceForArticle), + tag!(NoteTag::RememberForBlog), + tag!(NoteTag::DiscussWithPersonA), + tag!(NoteTag::DiscussWithPersonB), + tag!(NoteTag::DiscussWithManager), + tag!(NoteTag::SendInEmail), + tag!(NoteTag::ScheduleMeeting), + tag!(NoteTag::CallBack), + tag!(NoteTag::ToDoPriority1), + tag!(NoteTag::ToDoPriority2), + tag!(NoteTag::ClientRequest), row!( Id::RemoveTags, "Remove Tag", @@ -393,7 +442,9 @@ pub fn title(choice: Choice) -> String { .iter() .find(|(listed, _)| *listed == color) .map_or_else(String::new, |(_, name)| (*name).to_owned()), - Choice::Color(_) | Choice::List(_) => unreachable!("Only the toolbar offers these"), + Choice::Color(_) | Choice::List(_) | Choice::Table(_) | Choice::PageColor(_) => { + unreachable!("Only the toolbar offers these") + } } } @@ -574,6 +625,8 @@ impl State { Choice::Font(name) => return checked(format.font.as_ref() == Some(name)), Choice::Size(size) => return checked(format.font_size == Some(*size)), Choice::Highlight(_) | Choice::Color(_) | Choice::List(_) => return enabled(text), + Choice::Table(_) => Id::Table, + Choice::PageColor(_) => Id::PageColor, }; match id { Id::Settings | Id::NewNotebook | Id::OpenNotebook | Id::Help => enabled(!modal), @@ -595,6 +648,10 @@ impl State { checked: session.shown_history == Some(session.space), }) } + Id::FullPageView => Status { + enabled: !welcome && !modal, + checked: self.full_page, + }, Id::Undo => enabled(writable && !field && self.view.editor.can_undo()), Id::Redo => enabled(writable && !field && self.view.editor.can_redo()), Id::Cut => enabled(writable && selected), @@ -616,8 +673,18 @@ impl State { enabled: !modal, checked: !self.light_pages, }, - Id::Table | Id::Picture | Id::Attachment => enabled(false), - Id::Link + Id::FormatPainter => Status { + enabled: text, + checked: self.painter.is_some(), + }, + Id::PageColor => enabled(writable && session.is_some()), + Id::ScreenClipping => enabled(text && cfg!(target_os = "macos")), + // Attach File awaits #23, Record Audio and Video #36, and Insert Space a drag mode. + Id::Attachment | Id::RecordAudio | Id::RecordVideo | Id::InsertSpace => enabled(false), + Id::Table + | Id::Picture + | Id::Symbol + | Id::Link | Id::Equation | Id::Date | Id::Time @@ -638,7 +705,7 @@ impl State { /// Applies `formatting`, remembering its font, colour or list style as the toolbar's /// last pick. - fn format(&mut self, formatting: Formatting) -> Result<(), Box> { + pub(crate) fn format(&mut self, formatting: Formatting) -> Result<(), Box> { let kept = self.toolbar.clone(); let pens = &mut self.toolbar; match &formatting { @@ -661,6 +728,26 @@ impl State { Ok(()) } + /// Puts an encoded picture after the caret's paragraph at its size on a 96 dpi screen, + /// as OneNote 2010 inserts one. + pub(crate) fn insert_picture(&mut self, bytes: Vec) -> Result<(), Box> { + let pixels = match draw::RasterImage::measure(&bytes) { + Ok(pixels) => pixels, + Err(_) => { + platform::alert( + "Couldn't insert the picture", + "Choose a PNG, JPEG or GIF picture.", + ); + return Ok(()); + } + }; + let response = self + .view + .insert_picture(bytes, pixels.map(|side| side as f32 * 0.75))?; + self.respond(response); + Ok(()) + } + /// The notebook the open section, or the notebook showing none, belongs to. fn notebook(&self) -> Option<&Arc> { self.session @@ -682,6 +769,15 @@ impl State { Choice::Highlight(color) => return format(self, Formatting::Highlight(color)), Choice::Color(color) => return format(self, Formatting::Color(color)), Choice::List(style) => return format(self, Formatting::List(style)), + Choice::Table([rows, columns]) => { + let response = self.view.insert_table(rows, columns)?; + self.respond(response); + return Ok(()); + } + Choice::PageColor(color) => { + let color = color.map(|index| canvas::template::PAGE_COLORS[index].1); + return self.color_page(color); + } }; let field = self.ui.focused().filter(|focus| *focus != page()); let response = match id { @@ -740,6 +836,13 @@ impl State { return Ok(()); } Id::Paste => Work::Page(Request::Paste), + Id::FormatPainter => { + self.painter = match self.painter { + Some(_) => None, + None => Some(self.view.editor.painted_format()?), + }; + return Ok(()); + } Id::SelectAll => { match field { Some(field) => self.ui.focus_all(field), @@ -779,13 +882,44 @@ impl State { self.pages_open = !self.pages_open; return Ok(()); } + Id::FullPageView => { + self.full_page = !self.full_page; + return Ok(()); + } + // The menu bar and keys open the toolbar's gallery. + Id::Table | Id::PageColor => { + let name = if id == Id::Table { + "table" + } else { + "page color" + }; + self.ui.open_popup(crate::toolbar_popup(name)); + return Ok(()); + } + Id::Picture => { + let Some(path) = platform::pick_picture() else { + return Ok(()); + }; + return self.insert_picture(std::fs::read(path)?); + } + Id::ScreenClipping => { + #[cfg(target_os = "macos")] + platform::clip_screen(self.proxy.clone()); + return Ok(()); + } + Id::Symbol => { + platform::character_palette(); + return Ok(()); + } Id::DarkPages => { self.light_pages = !self.light_pages; self.follow_color_scheme(); self.save_settings(); return Ok(()); } - Id::Table | Id::Picture | Id::Attachment => return Ok(()), + Id::Attachment | Id::RecordAudio | Id::RecordVideo | Id::InsertSpace => { + return Ok(()); + } Id::Link => { self.open_link_dialog(); return Ok(()); diff --git a/crates/snowbound/src/history.rs b/crates/snowbound/src/history.rs index 5ee3889c89a2a7de7973ceb4990793978806f195..e1cbe2581fb5fd6749d1be89a1cc234c6e32d445 100644 --- a/crates/snowbound/src/history.rs +++ b/crates/snowbound/src/history.rs @@ -165,7 +165,6 @@ impl State { session .section .restore_version(page, version, &self.author)?; - session.status = "Saving"; session.refresh_history()?; self.commands.push(Command::OpenPage(page)); Ok(()) @@ -178,7 +177,6 @@ impl State { ) -> Result<(), Box> { let session = self.session.as_mut().ok_or("No section is open")?; session.section.delete_versions(&[(page, vec![version])])?; - session.status = "Saving"; session.refresh_history()?; self.commands.push(Command::OpenPage(page)); Ok(()) @@ -221,7 +219,6 @@ impl State { .collect(); if !all.is_empty() { session.section.delete_versions(&all)?; - session.status = "Saving"; } session.refresh_history()?; if session.version.is_some() { diff --git a/crates/snowbound/src/library.rs b/crates/snowbound/src/library.rs index 32e4c564dd6b3db98f800dc4530af7e084d0fda6..1e690a1676a34de0855ee524ea01fb13ce9f196c 100644 --- a/crates/snowbound/src/library.rs +++ b/crates/snowbound/src/library.rs @@ -7,10 +7,25 @@ use std::{ error::Error, io, path::{Path, PathBuf}, - sync::Arc, + sync::{ + Arc, + atomic::{AtomicBool, Ordering}, + }, time::Duration, }; +/// OneNote's Work Offline, which like OneNote's holds for every notebook. +static OFFLINE: AtomicBool = AtomicBool::new(false); + +pub fn offline() -> bool { + OFFLINE.load(Ordering::Relaxed) +} + +/// Works offline or online again; sections opened from now on follow. +pub fn set_offline(offline: bool) { + OFFLINE.store(offline, Ordering::Relaxed); +} + /// How long an SMB request may take before the share counts as unreachable. const TIMEOUT: Duration = Duration::from_secs(10); /// The largest section file read whole over SMB. @@ -243,7 +258,7 @@ impl Library { path: &str, notify: impl Fn() + Send + 'static, ) -> Result> { - Ok(match (&self.notebook, &self.server) { + let section = match (&self.notebook, &self.server) { (Ok(Some(notebook)), Some(server)) => { let identity = self .catalog_section(notebook.catalog(), path) @@ -268,7 +283,21 @@ impl Library { (Ok(Some(notebook)), None) => notebook.section(path, notify)?, (Ok(None), _) => Section::open(path, &self.cache, notify)?, (Err(error), _) => return Err(error.clone().into()), - }) + }; + section.set_offline(offline()); + Ok(section) + } + + /// How the notebook's files are reached. + pub fn transport(&self) -> String { + match (&self.server, &self.notice) { + (Some(server), _) => format!( + "Snowbound’s SMB client, {}/{}", + server.mount.server, server.mount.share + ), + (None, Some(_)) => "The system’s mount of the share".to_owned(), + (None, None) => "Folder on this computer".to_owned(), + } } /// The file identity, in hex, of the section at catalog `path`. diff --git a/crates/snowbound/src/linux.rs b/crates/snowbound/src/linux.rs index 908d32b96c827f1ef0d9f158f7239bc8174c5b91..8b8d1ecd4700c393c5812cbb75178941e240c9e1 100644 --- a/crates/snowbound/src/linux.rs +++ b/crates/snowbound/src/linux.rs @@ -45,7 +45,7 @@ pub fn event_loop(_headless: bool) -> Result, EventL QUIT.set(event_loop.create_proxy()) .expect("Only one application event loop is created"); watch_settings(event_loop.create_proxy()); - if std::env::var("XDG_CURRENT_DESKTOP").is_ok_and(|desktop| desktop.contains("KDE")) { + if desktop() == Desktop::Kde { follow_breeze_radius(event_loop.create_proxy()); } Ok(event_loop) @@ -198,14 +198,10 @@ pub fn install_backdrop(_: &Window) -> bool { /// suit `appearance`: KWin's from the KDE colour scheme, or on GNOME the Adwaita header bar /// that winit's frame and GNOME's both draw. pub fn titlebar(appearance: Theme) -> Option<[[f32; 4]; 2]> { - let desktops = std::env::var("XDG_CURRENT_DESKTOP").unwrap_or_default(); - let on = |name: &str| desktops.split(':').any(|desktop| desktop == name); - let fills = if on("KDE") { - kde_titlebar(&kconfig("kdeglobals")) - } else if on("GNOME") { - adwaita_titlebar(appearance) - } else { - return None; + let fills = match desktop() { + Desktop::Kde => kde_titlebar(&kconfig("kdeglobals")), + Desktop::Gnome => adwaita_titlebar(appearance), + Desktop::Other => return None, } .map(|[red, green, blue]| draw::srgb(red, green, blue)); // Past mid grey, as against a colour scheme chosen in Options, the theme's own strip. @@ -214,6 +210,184 @@ pub fn titlebar(appearance: Theme) -> Option<[[f32; 4]; 2]> { (dark == (appearance == Theme::Dark)).then_some(fills) } +#[derive(Clone, Copy, Debug, PartialEq)] +enum Desktop { + Gnome, + Kde, + Other, +} + +/// The desktop the session runs, whose look and motion the chrome follows. +fn desktop() -> Desktop { + static DESKTOP: OnceLock = OnceLock::new(); + *DESKTOP.get_or_init(|| { + let desktops = std::env::var("XDG_CURRENT_DESKTOP").unwrap_or_default(); + let on = |name: &str| desktops.split(':').any(|desktop| desktop == name); + if on("KDE") { + Desktop::Kde + } else if on("GNOME") { + Desktop::Gnome + } else { + Desktop::Other + } + }) +} + +/// Menus in `appearance` as the desktop draws its own: libadwaita's popover menus on GNOME +/// and Breeze's on KDE, each moving as that desktop moves them. +pub fn menu(appearance: Theme) -> Option { + match desktop() { + Desktop::Gnome => { + // GNOME's interface font, as "Family Size". + let points = portal_setting("org.gnome.desktop.interface", "font-name") + .and_then(|font| String::try_from(font).ok()) + .and_then(|font| font.rsplit_once(' ')?.1.parse().ok()); + Some(adwaita_menu(appearance, points.unwrap_or(11.0))) + } + Desktop::Kde => Some(breeze_menu( + appearance, + &kconfig("kdeglobals"), + &kconfig("kwinrc"), + )), + Desktop::Other => None, + } +} + +/// libadwaita 1.7's `popover.menu` at `points`, which GTK 4 shows and hides at once. +fn adwaita_menu(appearance: Theme, points: f32) -> ui::Menu { + let dark = appearance == Theme::Dark; + let fill = if dark { [0x36, 0x36, 0x3a] } else { [0xff; 3] }; + // `--popover-fg-color`, and `currentColor` mixed with transparency at each strength. + let (ink, strength) = if dark { + ([0xff; 3], 1.0) + } else { + ([0, 0, 6], 0.8) + }; + let ink = |alpha: f32| linear(over(ink, strength * alpha, fill)); + ui::Menu { + fill: linear(fill), + border: linear(over([0; 3], 0.14, fill)), + shadows: vec![ + css_shadow(0.09, 5.0, 1.0, 1.0), + css_shadow(0.05, 14.0, 2.0, 3.0), + ], + text: ink(1.0), + // The accelerator's `--dim-opacity`, and `--disabled-opacity`. + dim: ink(0.55), + disabled: ink(0.5), + highlight: ink(0.1), + highlight_border: None, + // `$border_color`. + rule: ink(0.15), + font_size: points * 4.0 / 3.0, + radius: 15.0, + pad: 6.0, + row: 32.0, + row_radius: 9.0, + row_pad: 12.0, + rule_band: 13.0, + rule_inset: 0.0, + motion: ui::PopupMotion::Cut, + } +} + +/// Breeze's `QMenu` in `appearance`, coloured by kdeglobals `files` where the scheme suits +/// it, and faded by KWin's Fading Popups unless kwinrc `kwin` turns them off. +fn breeze_menu(appearance: Theme, files: &[String], kwin: &[String]) -> ui::Menu { + let entries = kconfig_entries(files); + let color = |group, key| kde_color(entries.get(&(group, key))?); + // Breeze Light's or Breeze Dark's window, its text, a view and the focus decoration. + let stock = if appearance == Theme::Dark { + [[32, 35, 38], [252; 3], [20, 22, 24], [61, 174, 233]] + } else { + [[239, 240, 241], [35, 38, 41], [255; 3], [61, 174, 233]] + }; + let scheme = [ + ("Colors:Window", "BackgroundNormal"), + ("Colors:Window", "ForegroundNormal"), + ("Colors:View", "BackgroundNormal"), + ("Colors:View", "DecorationFocus"), + ]; + let mut colors: [[u8; 3]; 4] = std::array::from_fn(|index| { + let (group, key) = scheme[index]; + color(group, key).unwrap_or(stock[index]) + }); + let window = linear(colors[0]); + let dark = 0.2126 * window[0] + 0.7152 * window[1] + 0.0722 * window[2] < 0.18; + if dark != (appearance == Theme::Dark) { + colors = stock; + } + let [window, text, view, focus] = colors; + // As `Helper::frameBackgroundColor`, `frameOutlineColor` and `focusOutlineColor` mix them. + let fill = over(view, 0.3, window); + let outline = linear(over(text, 0.2, window)); + let kwin = kconfig_entries(kwin); + let factor = entries + .get(&("KDE", "AnimationDurationFactor")) + .and_then(|factor| factor.parse().ok()) + .unwrap_or(1.0_f32); + let fades = kwin + .get(&("Plugins", "fadingpopupsEnabled")) + .is_none_or(|enabled| *enabled != "false"); + let points = entries + .get(&("General", "font")) + .and_then(|font| font.split(',').nth(1)?.parse().ok()) + .unwrap_or(10.0_f32); + ui::Menu { + fill: linear(fill), + border: outline, + // `ShadowHelper`'s default large shadow, which KWin draws around the menu. + shadows: vec![ + css_shadow(0.22, 20.0, 5.0, 0.0), + css_shadow(0.12, 10.0, 2.0, 0.0), + ], + text: linear(text), + // Accelerators draw at 70% opacity; disabled text approximates the scheme's fade. + dim: linear(over(text, 0.7, fill)), + disabled: linear(over(text, 0.35, fill)), + highlight: linear(over(focus, 0.3, fill)), + highlight_border: Some(linear(over(text, 0.15, focus))), + rule: outline, + font_size: points * 4.0 / 3.0, + radius: 5.0, + pad: 4.0, + row: 26.0, + row_radius: 5.0, + // `MenuItem_MarginWidth` and `MenuItem_ExtraLeftMargin`. + row_pad: 9.0, + rule_band: 7.0, + rule_inset: 5.0, + motion: if fades && factor > 0.0 { + ui::PopupMotion::Fade([0.15 * factor, 0.6 * factor]) + } else { + ui::PopupMotion::Cut + }, + } +} + +/// `top` over `under` at `alpha`, blended in sRGB as GTK and Qt paint. +fn over(top: [u8; 3], alpha: f32, under: [u8; 3]) -> [u8; 3] { + std::array::from_fn(|channel| { + let [top, under] = [top[channel], under[channel]].map(f32::from); + (under + (top - under) * alpha).round() as u8 + }) +} + +fn linear([red, green, blue]: [u8; 3]) -> [f32; 4] { + draw::srgb(red, green, blue) +} + +/// A black `box-shadow` at `alpha`, as dark over white as GTK and Qt blend it in sRGB. +fn css_shadow(alpha: f32, blur: f32, drop: f32, spread: f32) -> ui::Shadow { + let [left, ..] = linear(over([0; 3], alpha, [0xff; 3])); + ui::Shadow { + color: [0.0, 0.0, 0.0, 1.0 - left], + blur, + drop, + spread, + } +} + /// KConfig file `name` from the system's configuration directories up to the user's, in /// rising precedence. fn kconfig(name: &str) -> Vec { @@ -373,8 +547,10 @@ pub fn update_menu(_: impl FnOnce() -> Vec) {} pub fn clear_marked_text(_: &Window) {} +#[cfg(feature = "wgpu")] pub fn configure_presentation(_: &wgpu::Surface<'_>) {} +#[cfg(feature = "wgpu")] pub fn commit_presentation(_: &Window) {} /// GTK's default double-click time. @@ -426,6 +602,16 @@ fn watch_settings(proxy: EventLoopProxy) { } fn portal_color_scheme() -> Option { + let scheme = portal_setting("org.freedesktop.appearance", "color-scheme")?; + match u32::try_from(scheme).ok()? { + 1 => Some(true), + 2 => Some(false), + _ => None, + } +} + +/// Setting `key` in `namespace` from the settings portal. +fn portal_setting(namespace: &str, key: &str) -> Option { use zbus::zvariant::{OwnedValue, Value}; // A missing portal must not hold up the window for D-Bus's 25 s default. let connection = zbus::blocking::connection::Builder::session() @@ -440,7 +626,7 @@ fn portal_color_scheme() -> Option { "org.freedesktop.portal.Settings", ) .ok()?; - let key = ("org.freedesktop.appearance", "color-scheme"); + let key = (namespace, key); // Portals before ReadOne answer Read, with the value in a second variant. let reply: OwnedValue = proxy .call("ReadOne", &key) @@ -450,11 +636,7 @@ fn portal_color_scheme() -> Option { while let Value::Value(inner) = value { value = *inner; } - match u32::try_from(value).ok()? { - 1 => Some(true), - 2 => Some(false), - _ => None, - } + value.try_into_owned().ok() } pub fn cache_dir() -> Option { @@ -793,6 +975,41 @@ fn dialog( .then(|| String::from_utf8_lossy(&output.stdout).trim().to_owned())) } +/// Asks for a picture to insert. +pub fn pick_picture() -> Option { + let patterns = crate::PICTURE_TYPES + .map(|kind| format!("*.{kind}")) + .join(" "); + let asked = dialog( + [ + "--file-selection", + "--title=Insert Picture", + &format!("--file-filter=Pictures | {patterns}"), + ], + [ + "--getopenfilename", + ".", + &patterns, + "--title", + "Insert Picture", + ], + ); + asked + .unwrap_or_else(|error| { + eprintln!("{error}"); + None + }) + .filter(|path| !path.is_empty()) + .map(PathBuf::from) +} + +/// Opens GNOME's character map, whose picks the user copies in. +pub fn character_palette() { + if let Err(error) = Command::new("gnome-characters").spawn() { + eprintln!("Cannot open the character map: {error}"); + } +} + /// Opens `target`, a folder or a link's URL, with the desktop's handler. pub fn reveal(target: impl AsRef) { let target = target.as_ref(); @@ -963,6 +1180,51 @@ mod tests { ); } + #[test] + fn breeze_menus_take_the_scheme_s_colours_and_kwin_s_fade() { + let srgb = |[red, green, blue]: [u8; 3]| draw::srgb(red, green, blue); + let menu = breeze_menu(Theme::Light, &[], &[]); + // As measured from Dolphin's context menu under Breeze Light. + assert_eq!(menu.fill, srgb([0xf4, 0xf5, 0xf5])); + assert_eq!(menu.border, srgb([0xc6, 0xc8, 0xc9])); + assert_eq!(menu.highlight, srgb([0xbd, 0xe0, 0xf1])); + assert_eq!(menu.highlight_border, Some(srgb([0x39, 0x9a, 0xcc]))); + assert_eq!(menu.font_size, 10.0 * 4.0 / 3.0); + assert_eq!(menu.motion, ui::PopupMotion::Fade([0.15, 0.6])); + + let settings = "[General]\nfont=Noto Sans,12,-1,5,400,0,0,0,0,0\n\ + [KDE]\nAnimationDurationFactor=0.5\n\ + [Colors:Window]\nBackgroundNormal=32,35,38\nForegroundNormal=252,252,252\n"; + let menu = breeze_menu(Theme::Dark, &[settings.into()], &[]); + assert_eq!(menu.text, srgb([252; 3])); + assert_eq!(menu.font_size, 16.0); + assert_eq!(menu.motion, ui::PopupMotion::Fade([0.075, 0.3])); + assert_eq!( + breeze_menu(Theme::Light, &[settings.into()], &[]).fill, + srgb([0xf4, 0xf5, 0xf5]), + "a dark scheme under a light appearance takes Breeze Light's" + ); + + let instant = "[KDE]\nAnimationDurationFactor=0\n"; + let unfaded = "[Plugins]\nfadingpopupsEnabled=false\n"; + for (kdeglobals, kwinrc) in [(instant, ""), ("", unfaded)] { + let motion = breeze_menu(Theme::Light, &[kdeglobals.into()], &[kwinrc.into()]).motion; + assert_eq!(motion, ui::PopupMotion::Cut); + } + } + + #[test] + fn adwaita_menus_mix_the_popover_s_ink_as_its_stylesheet_does() { + let srgb = |[red, green, blue]: [u8; 3]| draw::srgb(red, green, blue); + let light = adwaita_menu(Theme::Light, 11.0); + assert_eq!(light.text, srgb([51, 51, 56])); + assert_eq!(light.highlight, srgb([235; 3])); + assert_eq!(light.border, srgb([219; 3])); + let dark = adwaita_menu(Theme::Dark, 11.0); + assert_eq!(dark.highlight, srgb([74, 74, 78])); + assert_eq!(dark.motion, ui::PopupMotion::Cut); + } + #[test] fn kconfig_colours_read_as_channels_or_hex() { assert_eq!(kde_color("222,224,226"), Some([222, 224, 226])); diff --git a/crates/snowbound/src/macos.rs b/crates/snowbound/src/macos.rs index fcadb67f6c1546741bc2fd46e41478c5c35a6ae2..21943c1d8afc1c134dafce241069afd41ce70632 100644 --- a/crates/snowbound/src/macos.rs +++ b/crates/snowbound/src/macos.rs @@ -116,9 +116,15 @@ impl Clipboard { /// AppKit draws the traffic lights. pub fn window_controls(_: &mut ui::Ui, _: &Window) {} -/// The app draws the title bar around the traffic lights. -pub fn system_titlebar(_: &Window) -> bool { - false +/// Whether AppKit draws the title bar: before 10.10 content can't extend under it, and +/// otherwise the app draws it around the traffic lights. +pub fn system_titlebar(window: &Window) -> bool { + let window = ns_window(window); + unsafe { + let frame: NSRect = msg_send![&window, frame]; + let content: NSRect = msg_send![&window, contentRectForFrameRect: frame]; + content.size.height < frame.size.height + } } unsafe extern "C" fn no_document_drag( @@ -163,6 +169,11 @@ pub fn represent(window: &Window, file: Option<&std::path::Path>) { } } +/// None: the kit's own menus stand in for AppKit's. +pub fn menu(_: winit::window::Theme) -> Option { + None +} + /// Nothing the backdrop leaves to colour. pub fn titlebar(_: winit::window::Theme) -> Option<[[f32; 4]; 2]> { None @@ -178,6 +189,10 @@ unsafe extern "C" fn hit_nothing(_: &AnyObject, _: Sel, _: NSPoint) -> *mut AnyO /// through it to the view it lies in. pub fn install_backdrop(window: &Window) -> bool { MainThreadMarker::new().expect("Views belong to the main thread"); + // Before 10.10 AppKit has no materials. + let Some(effect) = AnyClass::get("NSVisualEffectView") else { + return false; + }; let RawWindowHandle::AppKit(handle) = window.window_handle().expect("Live AppKit window").as_raw() else { @@ -186,7 +201,6 @@ pub fn install_backdrop(window: &Window) -> bool { unsafe { let view = &*handle.ns_view.as_ptr().cast::(); let class = BACKDROP_CLASS.get_or_init(|| { - let effect = AnyClass::get("NSVisualEffectView").expect("AppKit is linked"); let mut class = ClassBuilder::new("SnowboundBackdrop", effect).expect("Unique backdrop class"); class.add_method( @@ -351,6 +365,46 @@ pub fn pick_new(title: &str, name: &str) -> Option { } } +/// Asks for a picture to insert, with the system's open panel. +pub fn pick_picture() -> Option { + let mtm = MainThreadMarker::new().expect("Panels belong to the main thread"); + unsafe { + let panel = objc2_app_kit::NSOpenPanel::openPanel(mtm); + let types = crate::PICTURE_TYPES.map(NSString::from_str); + #[allow(deprecated, reason = "Allowed content types need macOS 11")] + panel.setAllowedFileTypes(Some(&objc2_foundation::NSArray::from_vec(types.into()))); + panel.setPrompt(Some(&NSString::from_str("Insert"))); + if panel.runModal() != objc2_app_kit::NSModalResponseOK { + return None; + } + let path = panel.URLs().firstObject()?.path()?; + Some(path.to_string().into()) + } +} + +/// Lets the user drag out part of the screen, then sends it as a PNG. +pub fn clip_screen(proxy: EventLoopProxy) { + std::thread::spawn(move || { + let path = std::env::temp_dir().join(format!("snowbound-clip-{}.png", std::process::id())); + let taken = std::process::Command::new("/usr/sbin/screencapture") + .arg("-i") + .arg(&path) + .status() + .is_ok_and(|status| status.success()); + // Escape leaves no file. + if taken && let Ok(bytes) = std::fs::read(&path) { + let _ = std::fs::remove_file(&path); + let _ = proxy.send_event(crate::UserEvent::Picture(bytes)); + } + }); +} + +/// Shows the system's character palette, whose picks arrive as inserted text. +pub fn character_palette() { + let mtm = MainThreadMarker::new().expect("AppKit belongs to the main thread"); + NSApplication::sharedApplication(mtm).orderFrontCharacterPalette(None); +} + /// Tells the user something they asked for could not be done: `message`, then what to do. pub fn alert(message: &str, detail: &str) { let mtm = MainThreadMarker::new().expect("Window events run on the main thread"); @@ -598,6 +652,7 @@ mod date_tests { } } +#[cfg(feature = "wgpu")] pub fn configure_presentation(surface: &wgpu::Surface<'_>) { MainThreadMarker::new().expect("Layer presentation belongs to the main thread"); // Retain wgpu's surface ownership while synchronizing presentation with AppKit resize transactions. @@ -612,6 +667,7 @@ pub fn configure_presentation(surface: &wgpu::Surface<'_>) { /// Commits a frame presented with the transaction: winit redraws after Core Animation's /// commit observer, so otherwise the frame waits for the next event. A live resize leaves /// the commit to AppKit, which pairs the frame with the window's new size. +#[cfg(feature = "wgpu")] pub fn commit_presentation(window: &Window) { let resizing: bool = unsafe { msg_send![&ns_window(window), inLiveResize] }; if !resizing { diff --git a/crates/snowbound/src/main.rs b/crates/snowbound/src/main.rs index c5f65d1df87bbbc346118f69ef8ad86fe1a9748b..86b103d0d1cb6d9985a185e3bf9d1c7c58c329a3 100644 --- a/crates/snowbound/src/main.rs +++ b/crates/snowbound/src/main.rs @@ -20,6 +20,9 @@ mod screenshot; mod search; mod settings; mod sidebar; +#[cfg_attr(not(feature = "wgpu"), path = "surface_gl.rs")] +mod surface; +mod sync; mod templates; use canvas::gpu::page::PageScene; @@ -33,6 +36,7 @@ use canvas::{ use canvas::{ gpu::{colorref, tag_sources}, outline::TagIcon, + template::PAGE_COLORS, }; use draw::Renderer; use library::Library; @@ -63,6 +67,8 @@ const TAB_ROW: f32 = 28.0; /// Width of the section colour around the page. const FRAME: f32 = 6.0; const PAGE_LIST: f32 = 240.0; +/// Extensions of the pictures Insert, Picture offers: those the page both stores and draws. +const PICTURE_TYPES: [&str; 4] = ["png", "jpg", "jpeg", "gif"]; /// Font sizes the size box offers, OneNote's list in points. /// Fonts the font box offers first, OneNote's default leading. const FONTS: [&str; 4] = ["Calibri", "Arial", "Times New Roman", "Courier New"]; @@ -116,6 +122,9 @@ enum UserEvent { /// Text AppKit inserts outside key events, such as the character palette's. #[cfg_attr(not(target_os = "macos"), allow(dead_code))] InsertText(String), + /// A picture to insert at the caret, such as a screen clipping. + #[cfg_attr(not(target_os = "macos"), allow(dead_code))] + Picture(Vec), Accessibility(accesskit_winit::Event), /// The section's synchronization thread reported an event. Sync, @@ -233,7 +242,8 @@ struct Session { /// Spaces, titles and outline levels in section order. pages: Vec<(ExGuid, String, u32)>, space: ExGuid, - status: &'static str, + /// The section's sync status as last read. + sync: notebook::session::SyncStatus, /// Each page's conflict pages, the versions a merge could not take. conflicts: Vec<(ExGuid, Vec)>, /// The page whose conflict pages the list shows beneath it. @@ -268,16 +278,6 @@ enum Bar { } impl Session { - /// Saving's state, and that the notebook opened through the system's mount of its - /// share where Snowbound's own SMB client could not sign in. - fn status(&self) -> String { - match (&self.library.notice, self.status) { - (None, status) => status.to_owned(), - (Some(_), "") => "Via mounted share".to_owned(), - (Some(_), status) => format!("{status} · Via mounted share"), - } - } - fn title(&self) -> &str { match self.conflict(self.space) { Some((_, version)) => &version.title, @@ -491,9 +491,7 @@ struct State { trail: navigation::Trail, /// Asks for a frame when a worker thread finishes something the page shows. redraw: std::task::Waker, - instance: wgpu::Instance, - surface: wgpu::Surface<'static>, - config: wgpu::SurfaceConfiguration, + surface: surface::Surface, renderer: Renderer, ui: Ui, view: PageView, @@ -520,8 +518,6 @@ struct State { text_menu: Option<(canvas::interaction::Context, [f32; 2])>, color_scheme: settings::ColorScheme, light_pages: bool, - /// Carries frames to the window where the system's backdrop shows through the strip. - translucent: Option, /// The strip's fill with the window focused and not, continuing the system's title bar. titlebar: [[f32; 4]; 2], /// A section or group being renamed in the sidebar. @@ -541,6 +537,10 @@ struct State { search: search::Search, /// Whether the page list is shown beside the page. pages_open: bool, + /// Full Page View: the sidebar, section tabs and page list hidden around the page. + full_page: bool, + /// What Format Painter picked up, which the next selection made on the page takes. + painter: Option, /// Installed font families, for the font box; listing them is slow. fonts: Vec, /// Fonts picked from the font box this session, latest first. @@ -631,32 +631,8 @@ impl State { let access_adapter = accesskit_winit::Adapter::with_event_loop_proxy(event_loop, &window, proxy.clone()); window.set_visible(visible); - let instance = wgpu::Instance::new( - wgpu::InstanceDescriptor::new_with_display_handle_from_env(Box::new(window.clone())), - ); - let surface = instance.create_surface(window.clone())?; - platform::configure_presentation(&surface); - let adapter = instance - .request_adapter(&wgpu::RequestAdapterOptions { - compatible_surface: Some(&surface), - ..Default::default() - }) - .await?; - let (device, queue) = adapter.request_device(&Default::default()).await?; + let (surface, renderer) = surface::Surface::new(window.clone(), backdrop).await?; let size = window.inner_size(); - let mut config = surface - .get_default_config(&adapter, size.width, size.height) - .ok_or("No supported canvas surface")?; - let translucent = backdrop.then(|| { - // What wgpu calls post-multiplied is Core Animation's non-opaque layer, which - // composites colour premultiplied in sRGB, as `Translucent` leaves it. - config.alpha_mode = wgpu::CompositeAlphaMode::PostMultiplied; - let window_format = config.format.remove_srgb_suffix(); - config.view_formats.push(window_format); - draw::Translucent::new(&device, config.format, window_format) - }); - surface.configure(&device, &config); - let renderer = Renderer::new(device, queue, config.format); let mut engine = TextEngine::default(); for path in substitutes { let target = engine @@ -754,7 +730,7 @@ impl State { let dpr = window.scale_factor() as f32; window.set_ime_allowed(true); window.request_redraw(); - eprintln!("Canvas GPU: {:?}; scale factor {dpr}", adapter.get_info()); + eprintln!("Scale factor {dpr}"); window.set_theme(stored.color_scheme.theme()); let appearance = stored .color_scheme @@ -791,9 +767,7 @@ impl State { window, redraw, proxy, - instance, surface, - config, renderer, ui, view: PageView::new( @@ -819,7 +793,6 @@ impl State { options: None, color_scheme: stored.color_scheme, light_pages: stored.light_pages, - translucent, titlebar, renaming: None, sectionless, @@ -828,6 +801,8 @@ impl State { thumbnails: templates::Thumbnails::default(), search, pages_open: true, + full_page: false, + painter: None, fonts, recent_fonts: stored.recent_fonts, toolbar: stored.toolbar, @@ -928,6 +903,7 @@ impl State { let (section, open_tab, open_page) = self.build()?; self.options_dialog(); self.link_dialog()?; + self.sync_popup()?; self.text_menu()?; self.ui.end(); self.edges(section, open_tab, open_page); @@ -946,7 +922,7 @@ impl State { /// Takes `appearance`'s colours. fn set_appearance(&mut self, appearance: winit::window::Theme) { - self.ui.theme = theme(appearance, self.light_pages, self.translucent.is_some()); + self.ui.theme = theme(appearance, self.light_pages, self.surface.translucent()); self.titlebar = platform::titlebar(appearance).unwrap_or([self.ui.theme.strip; 2]); } @@ -1027,10 +1003,15 @@ impl State { ..Spec::default() }, ); + let tabs = self.ui.id("tabs"); + let height = self + .ui + .animate(tabs, if self.full_page { 0.0 } else { TAB_ROW }); let tab_row = self.ui.open( "tabs", Spec { - size: [fill(), px(TAB_ROW)], + flags: Flags::CLIP, + size: [fill(), px(height)], fill: Some(theme.strip), pad: [FRAME, 0.0], ..Spec::default() @@ -1216,6 +1197,12 @@ impl State { self.ui.close(); self.ui.close(); self.context_menu(); + // No tab stands on the frame in Full Page View. + let open_tab = if self.full_page { + frame().child("no tab") + } else { + open_tab + }; Ok((section, open_tab, open_page)) } @@ -1332,7 +1319,7 @@ impl State { } /// The window's title bar beside the traffic lights: the application's icon, the - /// notebook's name and the saving status. + /// notebook's name and the sync status. fn title_bar(&mut self, theme: &Theme) { self.ui.open_as( strip(), @@ -1374,23 +1361,14 @@ impl State { }, ); if let Some(session) = &self.session { - self.ui.leaf( - "status", - Spec { - size: [fit(), px(TITLE)], - text: Some(&session.status()), - color: Some(theme.text_dim), - pad: [12.0, 0.0], - ..Spec::default() - }, - ); + sync::control(&mut self.ui, session, theme, TITLE); } platform::window_controls(&mut self.ui, &self.window); self.ui.close(); } - /// Two rows of formatting, tag, insert and zoom buttons, grouped as OneNote's Home - /// ribbon groups them. + /// Two rows of navigation, clipboard, formatting, tag, insert and view buttons, grouped + /// as OneNote's ribbon groups them. fn toolbar(&mut self, theme: &Theme) -> Result<(), Box> { use canvas::editor::{ Alignment, BULLET_LIBRARY, ListStyle, NUMBER_LIBRARY, NoteTag, Toggle, @@ -1398,13 +1376,28 @@ impl State { use commands::{Choice, Id as Cmd, shortcut}; // A focused picture has no text to show a format for. let state = self.format_state(); + let statuses: Vec<_> = commands::COMMANDS + .iter() + .map(|command| { + ( + command.id, + self.status(&Choice::Command(command.id), &state), + ) + }) + .collect(); + let status_of = |id| { + statuses + .iter() + .find(|(listed, _)| *listed == id) + .map_or_else(commands::Status::default, |(_, status)| *status) + }; let fonts = &self.fonts; let recent_fonts = &self.recent_fonts; let pens = &self.toolbar; let engine = &self.view.engine; - // Under the system's title bar the save status joins the toolbar. + // Under the system's title bar the sync status joins the toolbar. let status = platform::system_titlebar(&self.window) - .then(|| self.session.as_ref().map(Session::status)) + .then_some(self.session.as_ref()) .flatten(); let ui = &mut self.ui; let text = theme.text; @@ -1420,16 +1413,80 @@ impl State { ..Spec::default() }, ); - group(ui, "history", |ui| { - if ui::shell::tool_button(ui, "undo", art::UNDO, text, false).clicked { - choice = Some(Choice::Command(Cmd::Undo)); - } - if ui::shell::tool_button(ui, "redo", art::REDO, text, false).clicked { - choice = Some(Choice::Command(Cmd::Redo)); + group(ui, "navigate", |ui| { + for (index, buttons) in [ + [ + ("back", art::BACK, Cmd::Back), + ("forward", art::FORWARD, Cmd::Forward), + ], + [ + ("undo", art::UNDO, Cmd::Undo), + ("redo", art::REDO, Cmd::Redo), + ], + ] + .into_iter() + .enumerate() + { + row(ui, index, |ui| { + for (part, icon, id) in buttons { + choice = tool(ui, part, icon, text, id, status_of(id)).or(choice.take()); + } + }); } }); - divider(ui, "history", theme); - let popup = |name: &str| Id::ROOT.child(("popup", name)); + divider(ui, "navigate", theme); + group(ui, "clipboard", |ui| { + row(ui, 0, |ui| { + let paste = ui.id("paste"); + if !status_of(Cmd::Paste).enabled { + ui::shell::unavailable(ui, "paste", art::PASTE, text, true); + } else if ui::shell::split_button( + ui, + "paste", + art::PASTE, + None, + false, + toolbar_popup("paste"), + ) + .clicked + { + choice = Some(Choice::Command(Cmd::Paste)); + } + tip(ui, Cmd::Paste); + // Pasting keeps only the text until #25. + let options = [ + ("Keep Source Formatting", false), + ("Merge Formatting", false), + ("Keep Text Only", true), + ("Picture", false), + ]; + let mut items = vec![ui::popup::Item { + text: "Paste Options", + heading: true, + ..Default::default() + }]; + items.extend(options.map(|(text, enabled)| ui::popup::Item { + text, + disabled: !enabled, + ..Default::default() + })); + let anchor = ui::Anchor::Below(ui.rect(paste).unwrap_or_default()); + if ui::popup::menu(ui, toolbar_popup("paste"), anchor, &items, None).is_some() { + choice = Some(Choice::Command(Cmd::Paste)); + } + choice = tool(ui, "cut", art::CUT, text, Cmd::Cut, status_of(Cmd::Cut)) + .or(choice.take()); + }); + row(ui, 1, |ui| { + for (part, icon, id) in [ + ("copy", art::COPY, Cmd::Copy), + ("painter", art::FORMAT_PAINTER, Cmd::FormatPainter), + ] { + choice = tool(ui, part, icon, text, id, status_of(id)).or(choice.take()); + } + }); + }); + divider(ui, "clipboard", theme); let font = state.font.clone().unwrap_or_default(); let size = state .font_size @@ -1438,7 +1495,7 @@ impl State { row(ui, 0, |ui| { let combo = ui.id("font"); if ui::shell::combo(ui, "font", &font, 120.0).pressed { - ui.open_popup(popup("font")); + ui.open_popup(toolbar_popup("font")); } // Each family is named with the substitute it shows in, and picked by its // own name; a family of None heads a group. Typing searches the full list. @@ -1449,7 +1506,7 @@ impl State { ) }; let mut choices: Vec<(String, Option<&str>, bool)> = Vec::new(); - if ui::popup::query(ui, popup("font")).is_none_or(str::is_empty) { + if ui::popup::query(ui, toolbar_popup("font")).is_none_or(str::is_empty) { choices.extend(FONTS.map(|name| (label(name), Some(name), false))); if !recent_fonts.is_empty() { choices.push(("Recent".to_owned(), None, true)); @@ -1477,18 +1534,18 @@ impl State { .collect(); let anchor = ui::Anchor::Over(ui.rect(combo).unwrap_or_default()); if let Some(index) = - ui::popup::menu(ui, popup("font"), anchor, &items, Some("Font")) + ui::popup::menu(ui, toolbar_popup("font"), anchor, &items, Some("Font")) && let Some(name) = choices[index].1 { choice = Some(Choice::Font(name.to_owned())); } let combo = ui.id("size"); if ui::shell::combo(ui, "size", &size, 44.0).pressed { - ui.open_popup(popup("size")); + ui.open_popup(toolbar_popup("size")); } // A size typed in the field joins the list, in half points as stored. let mut sizes = SIZES.to_vec(); - if let Some(typed) = ui::popup::query(ui, popup("size")) + if let Some(typed) = ui::popup::query(ui, toolbar_popup("size")) .and_then(|query| query.trim().parse::().ok()) .map(|typed| (typed * 2.0).round() / 2.0) .filter(|typed| onestore::FONT_SIZES.contains(typed) && !sizes.contains(typed)) @@ -1506,7 +1563,8 @@ impl State { }) .collect(); let anchor = ui::Anchor::Over(ui.rect(combo).unwrap_or_default()); - if let Some(index) = ui::popup::menu(ui, popup("size"), anchor, &items, Some(&size)) + if let Some(index) = + ui::popup::menu(ui, toolbar_popup("size"), anchor, &items, Some(&size)) { choice = Some(Choice::Size(sizes[index])); } @@ -1518,24 +1576,26 @@ impl State { art::BULLETS, None, state.bullets, - popup("bullets"), + toolbar_popup("bullets"), ) .clicked { choice = Some(Choice::Command(Cmd::Bullets)); } + tip(ui, Cmd::Bullets); if ui::shell::split_button( ui, "numbering", art::NUMBERING, None, state.numbering, - popup("numbering"), + toolbar_popup("numbering"), ) .clicked { choice = Some(Choice::Command(Cmd::Numbering)); } + tip(ui, Cmd::Numbering); let current = |bullet| match state.list { Some(ListStyle::Bullet(place)) if bullet => Some(place), Some(ListStyle::Number(place)) if !bullet => Some(place), @@ -1544,7 +1604,7 @@ impl State { let anchor = ui::Anchor::Below(ui.rect(bullets).unwrap_or_default()); if let Some(place) = list_gallery( ui, - popup("bullets"), + toolbar_popup("bullets"), anchor, ["Recently Used Bullets", "Bullet Library"], (&pens.bullets, BULLET_LIBRARY.len()), @@ -1570,7 +1630,7 @@ impl State { let anchor = ui::Anchor::Below(ui.rect(numbering).unwrap_or_default()); if let Some(place) = list_gallery( ui, - popup("numbering"), + toolbar_popup("numbering"), anchor, ["Recently Used Number Formats", "Numbering Library"], (&pens.numbering, NUMBER_LIBRARY.len()), @@ -1613,9 +1673,16 @@ impl State { ) { choice = Some(Choice::List(place.map(ListStyle::Number))); } - if ui::shell::tool_button(ui, "clear", art::CLEAR_FORMATTING, text, false).clicked { - choice = Some(Choice::Command(Cmd::ClearFormatting)); - } + let clear = Cmd::ClearFormatting; + choice = tool( + ui, + "clear", + art::CLEAR_FORMATTING, + text, + clear, + status_of(clear), + ) + .or(choice.take()); }); row(ui, 1, |ui| { for (part, icon, toggle) in [ @@ -1624,12 +1691,16 @@ impl State { ("underline", art::UNDERLINE, Toggle::Underline), ("strikethrough", art::STRIKETHROUGH, Toggle::Strikethrough), ] { - if ui::shell::tool_button(ui, part, icon, text, on(toggle)).clicked { - choice = Some(Choice::Command(Cmd::Toggle(toggle))); - } + let id = Cmd::Toggle(toggle); + choice = tool(ui, part, icon, text, id, status_of(id)).or(choice.take()); } let script = ui.id("script"); let superscript = on(Toggle::Superscript); + let command = Cmd::Toggle(if superscript { + Toggle::Superscript + } else { + Toggle::Subscript + }); if ui::shell::split_button( ui, "script", @@ -1640,16 +1711,13 @@ impl State { }, None, on(Toggle::Subscript) || superscript, - popup("script"), + toolbar_popup("script"), ) .clicked { - choice = Some(Choice::Command(Cmd::Toggle(if superscript { - Toggle::Superscript - } else { - Toggle::Subscript - }))); + choice = Some(Choice::Command(command)); } + tip(ui, command); let scripts = [ (Toggle::Subscript, art::SUBSCRIPT), (Toggle::Superscript, art::SUPERSCRIPT), @@ -1667,7 +1735,9 @@ impl State { }) .collect(); let anchor = ui::Anchor::Below(ui.rect(script).unwrap_or_default()); - if let Some(index) = ui::popup::menu(ui, popup("script"), anchor, &items, None) { + if let Some(index) = + ui::popup::menu(ui, toolbar_popup("script"), anchor, &items, None) + { choice = Some(Choice::Command(Cmd::Toggle(scripts[index].0))); } // Each button applies its menu's last pick: the highlighter shows it in its @@ -1691,19 +1761,22 @@ impl State { ), ] { let split = ui.id(part); - let button = ui::shell::split_button(ui, part, icon, bar, false, popup(part)); + let button = + ui::shell::split_button(ui, part, icon, bar, false, toolbar_popup(part)); let highlight = part == "highlight"; + let command = if highlight { + Cmd::Highlight + } else { + Cmd::FontColor + }; if button.clicked { - choice = Some(Choice::Command(if highlight { - Cmd::Highlight - } else { - Cmd::FontColor - })); + choice = Some(Choice::Command(command)); } + tip(ui, command); let colors: Vec<_> = swatches.iter().map(|color| colorref(*color)).collect(); let anchor = ui::Anchor::Below(ui.rect(split).unwrap_or_default()); if let Some(chosen) = - ui::popup::colors(ui, popup(part), anchor, none, &colors, columns) + ui::popup::colors(ui, toolbar_popup(part), anchor, none, &colors, columns) { let color = chosen.and_then(|chosen| { swatches @@ -1719,11 +1792,11 @@ impl State { }); } } - if ui::shell::tool_button(ui, "outdent", art::OUTDENT, text, false).clicked { - choice = Some(Choice::Command(Cmd::Outdent)); - } - if ui::shell::tool_button(ui, "indent", art::INDENT, text, false).clicked { - choice = Some(Choice::Command(Cmd::Indent)); + for (part, icon, id) in [ + ("outdent", art::OUTDENT, Cmd::Outdent), + ("indent", art::INDENT, Cmd::Indent), + ] { + choice = tool(ui, part, icon, text, id, status_of(id)).or(choice.take()); } let alignments = [ (Alignment::Left, art::ALIGN_LEFT), @@ -1735,7 +1808,8 @@ impl State { .iter() .find(|(alignment, _)| Some(*alignment) == state.alignment) .unwrap_or(&alignments[0]); - let anchor = ui::shell::menu_button(ui, "align", current.1, popup("align")); + let anchor = ui::shell::menu_button(ui, "align", current.1, toolbar_popup("align")); + tip(ui, Cmd::Align(current.0)); // The current alignment starts highlighted, keeping its icon under the button's. let items: Vec<_> = alignments .iter() @@ -1748,50 +1822,38 @@ impl State { ..Default::default() }) .collect(); - if let Some(index) = ui::popup::menu(ui, popup("align"), anchor, &items, None) { + if let Some(index) = + ui::popup::menu(ui, toolbar_popup("align"), anchor, &items, None) + { choice = Some(Choice::Command(Cmd::Align(alignments[index].0))); } }); }); divider(ui, "text", theme); - let tags: [(&[&str], NoteTag); 9] = [ - ( - tag_sources(TagIcon::CheckBox { checked: false }), - NoteTag::ToDo, - ), - (tag_sources(TagIcon::Star), NoteTag::Important), - (tag_sources(TagIcon::Question), NoteTag::Question), - (art::TAG_REMEMBER, NoteTag::RememberForLater), - (art::TAG_DEFINITION, NoteTag::Definition), - (tag_sources(TagIcon::Highlight), NoteTag::Highlight), - (tag_sources(TagIcon::Contact), NoteTag::Contact), - (tag_sources(TagIcon::Address), NoteTag::Address), - (tag_sources(TagIcon::Phone), NoteTag::PhoneNumber), - ]; - let all_tags = &tags; + let tags = NoteTag::ALL.map(|tag| (tag_icon(tag), tag)); group(ui, "tags", |ui| { - for (index, tags) in tags.chunks(5).enumerate() { + // OneNote's first nine, Ctrl+1 to Ctrl+9, then the gallery's arrow. + for (index, shown) in tags[..9].chunks(5).enumerate() { row(ui, index, |ui| { - for (column, (icon, tag)) in tags.iter().enumerate() { - let lit = state.tags.contains(tag); - if ui::shell::tool_button(ui, column, icon, [1.0; 4], lit).clicked { - choice = Some(Choice::Command(Cmd::Tag(*tag))); - } + for (column, (icon, tag)) in shown.iter().enumerate() { + let id = Cmd::Tag(*tag); + choice = + tool(ui, column, icon, [1.0; 4], id, status_of(id)).or(choice.take()); } if index == 1 { let more = ui.id("more"); - let open = ui.popup_open(popup("tags")); + let open = ui.popup_open(toolbar_popup("tags")); if ui::shell::tool_button(ui, "more", ui::shell::CHEVRON, text, open) .pressed { - ui.open_popup(popup("tags")); + ui.open_popup(toolbar_popup("tags")); } - let shortcuts: Vec<_> = all_tags + let shortcuts: Vec<_> = tags .iter() .map(|(_, tag)| shortcut(Cmd::Tag(*tag))) .collect(); let remove = shortcut(Cmd::RemoveTags); - let mut items: Vec<_> = all_tags + let mut items: Vec<_> = tags .iter() .zip(&shortcuts) .map(|((icon, tag), shortcut)| ui::popup::Item { @@ -1802,19 +1864,33 @@ impl State { ..Default::default() }) .collect(); - items.push(ui::popup::Item { - text: commands::command(Cmd::RemoveTags).title, - shortcut: &remove, - separated: true, - ..Default::default() - }); + // Customize Tags awaits #8 and Find Tags its summary pane, #28. + items.extend([ + ui::popup::Item { + text: "Customize Tags…", + separated: true, + disabled: true, + ..Default::default() + }, + ui::popup::Item { + text: commands::command(Cmd::RemoveTags).title, + shortcut: &remove, + ..Default::default() + }, + ui::popup::Item { + text: "Find Tags", + icon: Some(art::FIND_TAGS), + separated: true, + disabled: true, + ..Default::default() + }, + ]); let anchor = ui::Anchor::Below(ui.rect(more).unwrap_or_default()); if let Some(chosen) = - ui::popup::menu(ui, popup("tags"), anchor, &items, None) + ui::popup::menu(ui, toolbar_popup("tags"), anchor, &items, None) { choice = Some(Choice::Command( - all_tags - .get(chosen) + tags.get(chosen) .map_or(Cmd::RemoveTags, |(_, tag)| Cmd::Tag(*tag)), )); } @@ -1824,30 +1900,42 @@ impl State { }); divider(ui, "tags", theme); group(ui, "insert", |ui| { - for (index, buttons) in [ - &[ - ("table", art::TABLE, Cmd::Table), + row(ui, 0, |ui| { + if status_of(Cmd::Table).enabled { + let anchor = + ui::shell::menu_button(ui, "table", art::TABLE, toolbar_popup("table")); + tip(ui, Cmd::Table); + if let Some([columns, rows]) = + ui::popup::table_picker(ui, toolbar_popup("table"), anchor, [10, 8]) + { + choice = Some(Choice::Table([rows, columns])); + } + } else { + ui::shell::unavailable(ui, "table", art::TABLE, text, true); + } + for (part, icon, id) in [ ("picture", art::PICTURE, Cmd::Picture), + ("clipping", art::SCREEN_CLIPPING, Cmd::ScreenClipping), ("file", art::ATTACHMENT, Cmd::Attachment), ("link", art::LINK, Cmd::Link), - ][..], - &[ + ("space", art::INSERT_SPACE, Cmd::InsertSpace), + ] { + choice = tool(ui, part, icon, text, id, status_of(id)).or(choice.take()); + } + }); + row(ui, 1, |ui| { + for (part, icon, id) in [ ("date", art::CALENDAR, Cmd::Date), ("time", art::CLOCK, Cmd::Time), + ("date and time", art::DATE_TIME, Cmd::DateTime), ("equation", art::EQUATION, Cmd::Equation), - ], - ] - .into_iter() - .enumerate() - { - row(ui, index, |ui| { - for (part, icon, id) in buttons { - if ui::shell::tool_button(ui, part, icon, text, false).clicked { - choice = Some(Choice::Command(*id)); - } - } - }); - } + ("symbol", art::SYMBOL, Cmd::Symbol), + ("audio", art::RECORD_AUDIO, Cmd::RecordAudio), + ("video", art::RECORD_VIDEO, Cmd::RecordVideo), + ] { + choice = tool(ui, part, icon, text, id, status_of(id)).or(choice.take()); + } + }); }); ui.leaf( "space", @@ -1856,25 +1944,93 @@ impl State { ..Spec::default() }, ); - if let Some(status) = status { - ui.leaf( - "status", - Spec { - size: [fit(), px(ui::shell::TOOL)], - text: Some(&status), - color: Some(theme.text_dim), - center: true, - ..Spec::default() - }, - ); + if let Some(session) = status { + sync::control(ui, session, theme, ui::shell::TOOL); } let zoom = self.view.zoom(); let label = format!("{:.0}%", zoom * 100.0); - group(ui, "zoom", |ui| { + group(ui, "view", |ui| { row(ui, 0, |ui| { - if ui::shell::tool_button(ui, "out", art::ZOOM_OUT, text, false).clicked { - choice = Some(Choice::Command(Cmd::ZoomOut)); + if status_of(Cmd::PageColor).enabled { + let anchor = ui::shell::menu_button( + ui, + "page color", + art::PAGE_COLOR, + toolbar_popup("page color"), + ); + tip(ui, Cmd::PageColor); + let swatches: Vec<_> = PAGE_COLORS + .iter() + .map(|(_, color)| colorref(*color)) + .collect(); + if let Some(chosen) = ui::popup::colors( + ui, + toolbar_popup("page color"), + anchor, + "No Color", + &swatches, + 4, + ) { + let index = chosen.and_then(|chosen| { + swatches.iter().position(|swatch| *swatch == chosen) + }); + choice = Some(Choice::PageColor(index)); + } + } else { + ui::shell::unavailable(ui, "page color", art::PAGE_COLOR, text, true); } + // Nothing reads or writes rule lines yet. + ui::shell::unavailable(ui, "rule lines", art::RULE_LINES, text, true); + let full = Cmd::FullPageView; + choice = tool( + ui, + "full page", + art::FULL_PAGE_VIEW, + text, + full, + status_of(full), + ) + .or(choice.take()); + let more = ui.id("more"); + let open = ui.popup_open(toolbar_popup("view")); + if ui::shell::tool_button(ui, "more", ui::shell::CHEVRON, text, open).pressed { + ui.open_popup(toolbar_popup("view")); + } + let views = [Cmd::Sidebar, Cmd::PageList, Cmd::DarkPages]; + let mut items: Vec<_> = views + .iter() + .map(|id| ui::popup::Item { + text: commands::command(*id).title, + checked: status_of(*id).checked, + disabled: !status_of(*id).enabled, + ..Default::default() + }) + .collect(); + // Spelling awaits a platform spell checker. + items.push(ui::popup::Item { + text: "Spelling", + icon: Some(art::SPELLING), + separated: true, + disabled: true, + ..Default::default() + }); + let anchor = ui::Anchor::Below(ui.rect(more).unwrap_or_default()); + if let Some(chosen) = + ui::popup::menu(ui, toolbar_popup("view"), anchor, &items, None) + { + choice = Some(Choice::Command(views[chosen])); + } + }); + row(ui, 1, |ui| { + choice = tool( + ui, + "out", + art::ZOOM_OUT, + text, + Cmd::ZoomOut, + status_of(Cmd::ZoomOut), + ) + .or(choice.take()); let level = ui.leaf( "level", Spec { @@ -1890,9 +2046,16 @@ impl State { if level.clicked { choice = Some(Choice::Command(Cmd::ActualSize)); } - if ui::shell::tool_button(ui, "in", art::ZOOM_IN, text, false).clicked { - choice = Some(Choice::Command(Cmd::ZoomIn)); - } + tip(ui, Cmd::ActualSize); + choice = tool( + ui, + "in", + art::ZOOM_IN, + text, + Cmd::ZoomIn, + status_of(Cmd::ZoomIn), + ) + .or(choice.take()); }); }); ui.close(); @@ -1919,6 +2082,7 @@ impl State { if ui::shell::tool_button(&mut self.ui, "new", art::PLUS, theme.text, false).clicked { self.commands.push(Command::NewPage { under: None }); } + tip(&mut self.ui, commands::Id::NewPage); let toggle = if self.pages_open { art::SIDEBAR_COLLAPSE } else { @@ -1927,6 +2091,7 @@ impl State { if ui::shell::tool_button(&mut self.ui, "toggle", toggle, theme.text, false).clicked { self.pages_open = !self.pages_open; } + tip(&mut self.ui, commands::Id::PageList); self.ui.close(); } @@ -1935,9 +2100,8 @@ impl State { fn page_list(&mut self, theme: &Theme, section: &ui::Section, tabs: Id) -> Option { let session = self.session.as_ref()?; let panel = self.ui.id("panel"); - let width = self - .ui - .animate(panel, if self.pages_open { PAGE_LIST } else { 0.0 }); + let open = self.pages_open && !self.full_page; + let width = self.ui.animate(panel, if open { PAGE_LIST } else { 0.0 }); self.ui.open( "panel", Spec { @@ -2007,7 +2171,16 @@ impl State { if pressed { self.view.pointer_pressed(at)? } else { - self.view.pointer_released()? + let response = self.view.pointer_released()?; + let [anchor, focus] = self.view.editor.selection().positions; + if anchor != focus + && let Some(painted) = self.painter.take() + { + self.respond(response); + self.format(canvas::editor::Formatting::Paint(painted))?; + continue; + } + response } } ui::Event::Button { @@ -2097,7 +2270,6 @@ impl State { Command::DeleteVersion { page, version } => { let session = self.session.as_mut().ok_or("No section is open")?; session.section.delete_pages(&[version])?; - session.status = "Saving"; session.refresh_conflicts()?; self.commands.push(Command::OpenPage(page)); } @@ -2550,7 +2722,6 @@ impl State { .collect(), }, )?; - session.status = "Saving"; // The page list shows the title as it is typed. if self.view.editor.active_outline().title { session.pages = session.section.pages()?; @@ -2588,9 +2759,6 @@ impl State { let _ = self.view.editor.take_ops(); self.view.editor.cancel_composition(&mut self.view.engine)?; platform::clear_marked_text(&self.window); - if let Some(session) = &mut self.session { - session.status = "Not saving"; - } self.refresh()?; platform::alert( "Change not saved", @@ -2604,13 +2772,13 @@ impl State { let Some(session) = &mut self.session else { return Ok(()); }; - let shown = session.status; + let shown = sync::label(&session.sync); let mut listed = false; let mut changed = false; let mut rejected = None; for event in session.section.events() { use notebook::session::Event; - session.status = match event { + match event { Event::Changed(spaces) => { listed = true; changed |= spaces.contains(&session.space); @@ -2619,7 +2787,6 @@ impl State { session.section.replica(), spaces, ); - continue; } Event::Rejected { spaces, error } => { if spaces.contains(&session.space) { @@ -2627,21 +2794,13 @@ impl State { } else { eprintln!("Saving failed: {error}"); } - "Not saving" } - Event::Attempt { - status: notebook::EditStatus::Published { .. }, - .. - } => "Saved", - Event::Attempt { .. } => "Saving", - Event::Unreachable(_) => "Offline", - Event::Failed(error) => { - eprintln!("Synchronization stopped: {error}"); - "Not saving" - } - }; + Event::Failed(error) => eprintln!("Synchronization stopped: {error}"), + Event::Attempt { .. } | Event::Unreachable(_) => {} + } } - if listed || changed || shown != session.status { + session.sync = session.section.sync_status()?; + if listed || changed || shown != sync::label(&session.sync) { self.window.request_redraw(); } if listed { @@ -2782,67 +2941,18 @@ impl State { if let Some(path) = self.snapshot.take() { self.snapshot(&path)?; } - if self.occluded || [self.config.width, self.config.height].contains(&0) { + if self.occluded { return Ok(()); } - let mut reconfigure = false; - let frame = match self.surface.get_current_texture() { - wgpu::CurrentSurfaceTexture::Success(frame) => frame, - wgpu::CurrentSurfaceTexture::Suboptimal(frame) => { - reconfigure = true; - frame - } - wgpu::CurrentSurfaceTexture::Outdated => { - self.surface.configure(&self.renderer.device, &self.config); - self.window.request_redraw(); - return Ok(()); - } - wgpu::CurrentSurfaceTexture::Lost => { - self.surface = self.instance.create_surface(self.window.clone())?; - platform::configure_presentation(&self.surface); - self.surface.configure(&self.renderer.device, &self.config); - self.window.request_redraw(); - return Ok(()); - } - wgpu::CurrentSurfaceTexture::Timeout => { - trace_input(&"Surface timeout"); - return Ok(()); - } - wgpu::CurrentSurfaceTexture::Occluded => { - trace_input(&"Surface occluded"); - return Ok(()); - } - wgpu::CurrentSurfaceTexture::Validation => { - return Err("Canvas surface validation failed".into()); - } + let Some(frame) = self.surface.frame(&self.renderer)? else { + return Ok(()); }; lap("acquire", start); - let size = [self.config.width, self.config.height]; - let target = self - .translucent - .as_mut() - .map(|translucent| translucent.target(&self.renderer.device, size)); - self.paint( - target - .as_ref() - .unwrap_or(&frame.texture.create_view(&Default::default())), - )?; - if let Some(translucent) = &self.translucent { - let window = frame.texture.create_view(&wgpu::TextureViewDescriptor { - format: Some(self.config.format.remove_srgb_suffix()), - ..Default::default() - }); - translucent.present(&self.renderer.device, &self.renderer.queue, &window); - } - self.window.pre_present_notify(); + self.paint(&frame.target)?; let start = Instant::now(); - self.renderer.queue.present(frame); - platform::commit_presentation(&self.window); + self.surface.present(&self.renderer, frame); lap("present", start); trace_input(&"Present submitted"); - if reconfigure { - self.surface.configure(&self.renderer.device, &self.config); - } Ok(()) } @@ -2850,7 +2960,7 @@ impl State { /// largest texture, so the surface renders scaled rather than failing. fn scale(&self) -> f32 { let size = self.window.inner_size(); - let most = self.renderer.device.limits().max_texture_dimension_2d as f32; + let most = self.renderer.max_texture_dimension() as f32; let longest = size.width.max(size.height) as f32; self.window.scale_factor() as f32 * (most / longest).min(1.0) } @@ -2865,7 +2975,7 @@ impl State { } /// Paints the interface with the page in its box. - fn paint(&mut self, target: &wgpu::TextureView) -> Result<(), Box> { + fn paint(&mut self, target: &draw::Target) -> Result<(), Box> { let start = Instant::now(); let paper = self.paper(); self.view.update_pictures(paper, &self.redraw); @@ -2904,12 +3014,7 @@ impl State { trace_input(&("Draw", viewport.origin, viewport.scale)); let start = Instant::now(); self.renderer - .draw( - target, - [self.config.width, self.config.height], - self.ui.theme.strip, - &layers, - ) + .draw(target, self.surface.size, self.ui.theme.strip, &layers) .map_err(|error| format!("Canvas drawing failed: {error:?}"))?; lap("render", start); Ok(()) @@ -2917,68 +3022,10 @@ impl State { /// Writes the frame to a PNG, so a covered window can be reviewed. fn snapshot(&mut self, path: &Path) -> Result<(), Box> { - let size = [self.config.width, self.config.height]; - let device = &self.renderer.device; - let texture = device.create_texture(&wgpu::TextureDescriptor { - label: Some("Snapshot"), - size: wgpu::Extent3d { - width: size[0], - height: size[1], - depth_or_array_layers: 1, - }, - mip_level_count: 1, - sample_count: 1, - dimension: wgpu::TextureDimension::D2, - format: self.config.format, - usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC, - view_formats: &[], - }); - let row = (size[0] * 4).next_multiple_of(wgpu::COPY_BYTES_PER_ROW_ALIGNMENT); - let buffer = device.create_buffer(&wgpu::BufferDescriptor { - label: Some("Snapshot readback"), - size: u64::from(row) * u64::from(size[1]), - usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ, - mapped_at_creation: false, - }); - self.paint(&texture.create_view(&Default::default()))?; - let mut encoder = self - .renderer - .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: Some(size[1]), - }, - }, - texture.size(), - ); - self.renderer.queue.submit([encoder.finish()]); - buffer.map_async(wgpu::MapMode::Read, .., |_| {}); - self.renderer.device.poll(wgpu::PollType::Wait { - submission_index: None, - timeout: Some(std::time::Duration::from_secs(5)), - })?; - let bgra = matches!( - self.config.format, - wgpu::TextureFormat::Bgra8Unorm | wgpu::TextureFormat::Bgra8UnormSrgb - ); - let pixels: Vec = buffer - .get_mapped_range(..)? - .chunks_exact(row as usize) - .flat_map(|line| line[..size[0] as usize * 4].chunks_exact(4)) - .flat_map(|pixel| { - if bgra { - [pixel[2], pixel[1], pixel[0], pixel[3]] - } else { - [pixel[0], pixel[1], pixel[2], pixel[3]] - } - }) - .collect(); + let size = self.surface.size; + let offscreen = self.surface.offscreen(&self.renderer)?; + self.paint(&offscreen.target)?; + let pixels = self.surface.read(&self.renderer, offscreen)?; let partial = path.with_extension("partial"); let mut encoder = png::Encoder::new(std::fs::File::create(&partial)?, size[0], size[1]); encoder.set_color(png::ColorType::Rgba); @@ -3062,12 +3109,13 @@ impl State { } /// The interface's colours in `appearance`, on white pages when `light_pages`, and over the -/// system's material where it shows through as a `backdrop`. +/// system's material where it shows through as a `backdrop`; menus as the desktop's. fn theme(appearance: winit::window::Theme, light_pages: bool, backdrop: bool) -> Theme { let mut theme = match appearance { winit::window::Theme::Dark => Theme::dark(), winit::window::Theme::Light => Theme::light(), }; + theme.desktop_menu = platform::menu(appearance); if light_pages { let light = Theme::light(); [theme.paper, theme.paper_ink] = [light.paper, light.paper_ink]; @@ -3667,6 +3715,47 @@ fn row(ui: &mut Ui, part: usize, buttons: impl FnOnce(&mut Ui)) { ui.close(); } +/// A tooltip naming command `id` and its chord on the box built last. It names the command, +/// not a dialog it opens, so it leaves off the menu's ellipsis. +/// A tool button for command `id`, lit while `status` checks it and faded while it does not +/// apply; returns the command when clicked. +fn tool( + ui: &mut Ui, + part: impl std::hash::Hash, + icon: &'static [&'static str], + tint: [f32; 4], + id: commands::Id, + status: commands::Status, +) -> Option { + if !status.enabled { + ui::shell::unavailable(ui, part, icon, tint, false); + return None; + } + let clicked = ui::shell::tool_button(ui, part, icon, tint, status.checked).clicked; + tip(ui, id); + clicked.then_some(commands::Choice::Command(id)) +} + +/// The artwork the toolbar shows for `tag`. +fn tag_icon(tag: canvas::editor::NoteTag) -> &'static [&'static str] { + use canvas::editor::NoteTag; + match tag { + NoteTag::RememberForLater => art::TAG_REMEMBER, + NoteTag::Definition => art::TAG_DEFINITION, + tag => tag_sources(TagIcon::of(tag.shape(), false).unwrap_or(TagIcon::Other)), + } +} + +/// The toolbar's popup `name`, which the menu bar opens too. +fn toolbar_popup(name: &str) -> Id { + Id::ROOT.child(("popup", name)) +} + +fn tip(ui: &mut Ui, id: commands::Id) { + let title = commands::command(id).title.trim_end_matches('…'); + ui::popup::tooltip(ui, title, &commands::shortcut(id), None); +} + /// The line between two toolbar groups. fn divider(ui: &mut Ui, part: &str, theme: &Theme) { ui.leaf( @@ -3701,6 +3790,7 @@ fn read_session( let page = section.page(space)?; let conflicts = section.conflicts()?; let history = section.versions()?; + let sync = section.sync_status()?; Ok(( Session { section, @@ -3709,7 +3799,7 @@ fn read_session( tab, pages, space, - status: "", + sync, conflicts, shown: None, change: None, @@ -3792,6 +3882,15 @@ impl App { impl ApplicationHandler for App { fn user_event(&mut self, event_loop: &ActiveEventLoop, event: UserEvent) { let event = match event { + UserEvent::Picture(bytes) => { + if let Some(state) = &mut self.state { + if let Err(error) = state.insert_picture(bytes) { + eprintln!("{error}"); + } + state.window.request_redraw(); + } + return; + } UserEvent::InsertText(text) => { if let Some(state) = &mut self.state { if state.ui.focused() == Some(page()) { @@ -3943,11 +4042,9 @@ impl ApplicationHandler for App { WindowEvent::Resized(size) => { if size.width > 0 && size.height > 0 { let ratio = state.scale() / scale; - state.config.width = (size.width as f32 * ratio) as u32; - state.config.height = (size.height as f32 * ratio) as u32; - state - .surface - .configure(&state.renderer.device, &state.config); + state.surface.size = + [size.width, size.height].map(|side| (side as f32 * ratio) as u32); + state.surface.configure(&state.renderer); } // Present inside AppKit's resize transaction; a redraw on the next turn // lets the window show the previous frame at the new size. diff --git a/crates/snowbound/src/manage.rs b/crates/snowbound/src/manage.rs index ed3821065879429ac6aba98cc65eacb82bbcad35..02176bd808972887171c8a834e0e9fcb99c73e18 100644 --- a/crates/snowbound/src/manage.rs +++ b/crates/snowbound/src/manage.rs @@ -116,7 +116,6 @@ impl State { ops, }, )?; - session.status = "Saving"; self.title_focus = Some(space); self.commands.push(Command::OpenPage(space)); Ok(()) @@ -130,11 +129,26 @@ impl State { ) -> Result<(), Box> { self.persist()?; let session = self.session.as_ref().ok_or("No section is open")?; - let space = session.space; - let ops = template_ops(&session.section.page(space)?, choice)?; + let ops = template_ops(&session.section.page(session.space)?, choice)?; + self.edit_page(ops) + } + + /// View, Page Color: gives the open page `color`, a COLORREF, or none; its art stays. + pub(crate) fn color_page(&mut self, color: Option) -> Result<(), Box> { + if self.view.editor.page_color() == color { + return Ok(()); + } + self.persist()?; + self.edit_page(vec![PageOp::Color(color)]) + } + + /// Applies `ops` to the open page as one edit and shows the result. + fn edit_page(&mut self, ops: Vec) -> Result<(), Box> { if ops.is_empty() { return Ok(()); } + let session = self.session.as_ref().ok_or("No section is open")?; + let space = session.space; session.section.apply( &self.author, Edit { @@ -410,7 +424,6 @@ impl State { ops: vec![Op::Section(SectionOp::Pages(edits))], }, )?; - session.status = "Saving"; session.pages = session.section.pages()?; Ok(()) } diff --git a/crates/snowbound/src/menubar.rs b/crates/snowbound/src/menubar.rs index ac86bb610f7382c2a744a4172606d6a76480408a..bc91d02ebf868128c48c5eac58cdf18214b0eb75 100644 --- a/crates/snowbound/src/menubar.rs +++ b/crates/snowbound/src/menubar.rs @@ -74,6 +74,7 @@ const MENUS: &[Item] = &[ C(Id::Cut), C(Id::Copy), C(Id::Paste), + C(Id::FormatPainter), C(Id::SelectAll), S, C(Id::Find), @@ -98,8 +99,11 @@ const MENUS: &[Item] = &[ C(Id::ZoomOut), C(Id::ActualSize), S, + C(Id::FullPageView), C(Id::Sidebar), C(Id::PageList), + S, + C(Id::PageColor), C(Id::DarkPages), ], ), @@ -108,8 +112,11 @@ const MENUS: &[Item] = &[ &[ C(Id::Table), C(Id::Picture), + C(Id::ScreenClipping), C(Id::Link), + S, C(Id::Equation), + C(Id::Symbol), S, C(Id::Date), C(Id::Time), @@ -158,6 +165,31 @@ const MENUS: &[Item] = &[ C(Id::Tag(NoteTag::Contact)), C(Id::Tag(NoteTag::Address)), C(Id::Tag(NoteTag::PhoneNumber)), + Item::Menu( + "More Tags", + &[ + C(Id::Tag(NoteTag::WebSiteToVisit)), + C(Id::Tag(NoteTag::Idea)), + C(Id::Tag(NoteTag::Password)), + C(Id::Tag(NoteTag::Critical)), + C(Id::Tag(NoteTag::ProjectA)), + C(Id::Tag(NoteTag::ProjectB)), + C(Id::Tag(NoteTag::MovieToSee)), + C(Id::Tag(NoteTag::BookToRead)), + C(Id::Tag(NoteTag::MusicToListenTo)), + C(Id::Tag(NoteTag::SourceForArticle)), + C(Id::Tag(NoteTag::RememberForBlog)), + C(Id::Tag(NoteTag::DiscussWithPersonA)), + C(Id::Tag(NoteTag::DiscussWithPersonB)), + C(Id::Tag(NoteTag::DiscussWithManager)), + C(Id::Tag(NoteTag::SendInEmail)), + C(Id::Tag(NoteTag::ScheduleMeeting)), + C(Id::Tag(NoteTag::CallBack)), + C(Id::Tag(NoteTag::ToDoPriority1)), + C(Id::Tag(NoteTag::ToDoPriority2)), + C(Id::Tag(NoteTag::ClientRequest)), + ], + ), S, C(Id::RemoveTags), ], diff --git a/crates/snowbound/src/rename.rs b/crates/snowbound/src/rename.rs index dda84c0138aedcdf97554ac69d44ec7c9bd12f68..fc2130037ceca6bf51ea7688dce3df524508c632 100644 --- a/crates/snowbound/src/rename.rs +++ b/crates/snowbound/src/rename.rs @@ -193,7 +193,6 @@ impl State { }], }, )?; - session.status = "Saving"; session.pages = session.section.pages()?; self.edited(vec![space]); self.refresh() diff --git a/crates/snowbound/src/screenshot.rs b/crates/snowbound/src/screenshot.rs index c8ea79036a29fef8ab33db0d46d310e769afe278..63891890d1946382b462b585ec5f9b076930d25c 100644 --- a/crates/snowbound/src/screenshot.rs +++ b/crates/snowbound/src/screenshot.rs @@ -28,9 +28,9 @@ impl State { .ok_or("SNOWBOUND_SCREENSHOT_SIZE must be WIDTHxHEIGHT in points.")?, Err(_) => SIZE, }; - let most = self.renderer.device.limits().max_texture_dimension_2d as f32; + let most = self.renderer.max_texture_dimension() as f32; let scale = SCALE.min(most / size[0].max(size[1])); - [self.config.width, self.config.height] = size.map(|side| (side * scale).max(1.0) as u32); + self.surface.size = size.map(|side| (side * scale).max(1.0) as u32); self.renderer.clear_glyph_cache(); self.app_icon = platform::app_icon((16.0 * scale) as u32); let response = self.view.scale_factor_changed(scale)?; diff --git a/crates/snowbound/src/search.rs b/crates/snowbound/src/search.rs index b58ca772a9f50f666cdb67b074b9d445c945fe5f..2743619831fe59b7f599fb620836ba275e407446 100644 --- a/crates/snowbound/src/search.rs +++ b/crates/snowbound/src/search.rs @@ -788,9 +788,6 @@ impl State { let [left, top, right, bottom] = rect; let anchor = Anchor::Over([left - 4.0, top - 4.0, right + 4.0, bottom + 4.0]); let width = RESULTS.max(right - left); - // The box takes the toolbar's box's place at once and widens as the dropdown comes in. - let open = self.ui.opening(results(), anchor).unwrap_or(1.0); - let field_width = right - left + (width - 8.0 - (right - left)) * open; self.ui.open_as( results(), Spec { @@ -809,8 +806,9 @@ impl State { self.ui.open( "box", Spec { + // Takes the toolbar's box's place at once, widening with the dropdown. flags: Flags::STILL, - size: [px(field_width), px(ui::shell::TOOL)], + size: [fill(), px(ui::shell::TOOL)], fill: Some(theme.base), border: Some(theme.accent), radius: 4.0, diff --git a/crates/snowbound/src/sidebar.rs b/crates/snowbound/src/sidebar.rs index bc5a740cab7dbb8b202396d91547cd6b4367fdf4..f3829c792f407e3aae56bf84034e225000b1712a 100644 --- a/crates/snowbound/src/sidebar.rs +++ b/crates/snowbound/src/sidebar.rs @@ -616,7 +616,11 @@ impl crate::State { } let width = self.ui.animate( self.ui.id("sidebar"), - if self.sidebar { WIDTH } else { 0.0 }, + if self.sidebar && !self.full_page { + WIDTH + } else { + 0.0 + }, ); self.ui.open( "sidebar", @@ -735,6 +739,7 @@ impl crate::State { self.sidebar = !self.sidebar; self.save_settings(); } + crate::tip(&mut self.ui, crate::commands::Id::Sidebar); self.ui.close(); } diff --git a/crates/snowbound/src/surface.rs b/crates/snowbound/src/surface.rs new file mode 100644 index 0000000000000000000000000000000000000000..0826b0ad18aca018973124dc185dc8faee1208eb --- /dev/null +++ b/crates/snowbound/src/surface.rs @@ -0,0 +1,221 @@ +//! Frames reach the window through wgpu. + +use crate::{platform, trace_input}; +use draw::Renderer; +use std::{error::Error, sync::Arc}; +use winit::window::Window; + +pub struct Surface { + /// Device pixels frames and snapshots are drawn at; `configure` gives the window it. + pub size: [u32; 2], + window: Arc, + instance: wgpu::Instance, + surface: wgpu::Surface<'static>, + config: wgpu::SurfaceConfiguration, + /// Carries frames to the window where the system's backdrop shows through the strip. + translucent: Option, +} + +pub struct Frame { + pub target: wgpu::TextureView, + texture: wgpu::SurfaceTexture, + reconfigure: bool, +} + +pub struct Offscreen { + pub target: wgpu::TextureView, + texture: wgpu::Texture, +} + +impl Surface { + /// With `backdrop`, frames leave the system's material showing through their + /// transparent pixels. + pub async fn new( + window: Arc, + backdrop: bool, + ) -> Result<(Self, Renderer), Box> { + let instance = wgpu::Instance::new( + wgpu::InstanceDescriptor::new_with_display_handle_from_env(Box::new(window.clone())), + ); + let surface = instance.create_surface(window.clone())?; + platform::configure_presentation(&surface); + let adapter = instance + .request_adapter(&wgpu::RequestAdapterOptions { + compatible_surface: Some(&surface), + ..Default::default() + }) + .await?; + let (device, queue) = adapter.request_device(&Default::default()).await?; + let size = window.inner_size(); + let mut config = surface + .get_default_config(&adapter, size.width, size.height) + .ok_or("No supported canvas surface")?; + let translucent = backdrop.then(|| { + // What wgpu calls post-multiplied is Core Animation's non-opaque layer, which + // composites colour premultiplied in sRGB, as `Translucent` leaves it. + config.alpha_mode = wgpu::CompositeAlphaMode::PostMultiplied; + let window_format = config.format.remove_srgb_suffix(); + config.view_formats.push(window_format); + draw::Translucent::new(&device, config.format, window_format) + }); + surface.configure(&device, &config); + eprintln!("Canvas GPU: {:?}", adapter.get_info()); + let renderer = Renderer::new(device, queue, config.format); + Ok(( + Self { + size: [config.width, config.height], + window, + instance, + surface, + config, + translucent, + }, + renderer, + )) + } + + /// Whether frames leave the system's backdrop showing. + pub fn translucent(&self) -> bool { + self.translucent.is_some() + } + + pub fn configure(&mut self, renderer: &Renderer) { + [self.config.width, self.config.height] = self.size; + self.surface.configure(&renderer.device, &self.config); + } + + /// The window's next frame, or none to skip this one. + pub fn frame(&mut self, renderer: &Renderer) -> Result, Box> { + if self.size.contains(&0) { + return Ok(None); + } + let (texture, reconfigure) = match self.surface.get_current_texture() { + wgpu::CurrentSurfaceTexture::Success(texture) => (texture, false), + wgpu::CurrentSurfaceTexture::Suboptimal(texture) => (texture, true), + wgpu::CurrentSurfaceTexture::Outdated => { + self.configure(renderer); + self.window.request_redraw(); + return Ok(None); + } + wgpu::CurrentSurfaceTexture::Lost => { + self.surface = self.instance.create_surface(self.window.clone())?; + platform::configure_presentation(&self.surface); + self.configure(renderer); + self.window.request_redraw(); + return Ok(None); + } + wgpu::CurrentSurfaceTexture::Timeout => { + trace_input(&"Surface timeout"); + return Ok(None); + } + wgpu::CurrentSurfaceTexture::Occluded => { + trace_input(&"Surface occluded"); + return Ok(None); + } + wgpu::CurrentSurfaceTexture::Validation => { + return Err("Canvas surface validation failed".into()); + } + }; + let target = match &mut self.translucent { + Some(translucent) => translucent.target(&renderer.device, self.size), + None => texture.texture.create_view(&Default::default()), + }; + Ok(Some(Frame { + target, + texture, + reconfigure, + })) + } + + pub fn present(&mut self, renderer: &Renderer, frame: Frame) { + if let Some(translucent) = &self.translucent { + let window = frame + .texture + .texture + .create_view(&wgpu::TextureViewDescriptor { + format: Some(self.config.format.remove_srgb_suffix()), + ..Default::default() + }); + translucent.present(&renderer.device, &renderer.queue, &window); + } + self.window.pre_present_notify(); + renderer.queue.present(frame.texture); + platform::commit_presentation(&self.window); + if frame.reconfigure { + self.configure(renderer); + } + } + + /// A target the size of the window's frames that `read` reads back. + pub fn offscreen(&self, renderer: &Renderer) -> Result> { + let texture = renderer.device.create_texture(&wgpu::TextureDescriptor { + label: Some("Snapshot"), + size: wgpu::Extent3d { + width: self.size[0], + height: self.size[1], + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: self.config.format, + usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC, + view_formats: &[], + }); + Ok(Offscreen { + target: texture.create_view(&Default::default()), + texture, + }) + } + + /// The offscreen target's sRGB RGBA rows, top first. + pub fn read( + &self, + renderer: &Renderer, + offscreen: Offscreen, + ) -> Result, Box> { + let size = self.size; + let row = (size[0] * 4).next_multiple_of(wgpu::COPY_BYTES_PER_ROW_ALIGNMENT); + let buffer = renderer.device.create_buffer(&wgpu::BufferDescriptor { + label: Some("Snapshot readback"), + size: u64::from(row) * u64::from(size[1]), + usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ, + mapped_at_creation: false, + }); + let mut encoder = renderer.device.create_command_encoder(&Default::default()); + encoder.copy_texture_to_buffer( + offscreen.texture.as_image_copy(), + wgpu::TexelCopyBufferInfo { + buffer: &buffer, + layout: wgpu::TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(row), + rows_per_image: Some(size[1]), + }, + }, + offscreen.texture.size(), + ); + renderer.queue.submit([encoder.finish()]); + buffer.map_async(wgpu::MapMode::Read, .., |_| {}); + renderer.device.poll(wgpu::PollType::Wait { + submission_index: None, + timeout: Some(std::time::Duration::from_secs(5)), + })?; + let bgra = matches!( + self.config.format, + wgpu::TextureFormat::Bgra8Unorm | wgpu::TextureFormat::Bgra8UnormSrgb + ); + Ok(buffer + .get_mapped_range(..)? + .chunks_exact(row as usize) + .flat_map(|line| line[..size[0] as usize * 4].chunks_exact(4)) + .flat_map(|pixel| { + if bgra { + [pixel[2], pixel[1], pixel[0], pixel[3]] + } else { + [pixel[0], pixel[1], pixel[2], pixel[3]] + } + }) + .collect()) + } +} diff --git a/crates/snowbound/src/surface_gl.rs b/crates/snowbound/src/surface_gl.rs new file mode 100644 index 0000000000000000000000000000000000000000..77952dd0ddb3c3d87597f23d630ffd094943c90b --- /dev/null +++ b/crates/snowbound/src/surface_gl.rs @@ -0,0 +1,114 @@ +//! Frames reach the window through an OpenGL context on its view, where wgpu has no +//! backend: Mac OS X 10.6. Frames draw into an sRGB target and are copied to the view. + +use draw::{Renderer, Target}; +use objc2::{ + ffi::NSInteger, + msg_send, msg_send_id, + rc::{Allocated, Retained}, + runtime::{AnyClass, AnyObject}, +}; +use std::{error::Error, sync::Arc}; +use winit::{ + raw_window_handle::{HasWindowHandle, RawWindowHandle}, + window::Window, +}; + +pub struct Surface { + /// Device pixels frames and snapshots are drawn at; `configure` gives the window it. + pub size: [u32; 2], + context: Retained, + /// The frame target, kept between frames. + target: Option, +} + +pub struct Frame { + pub target: Target, +} + +pub type Offscreen = Frame; + +impl Surface { + /// No backdrop shows through an OpenGL view. + pub async fn new( + window: Arc, + _backdrop: bool, + ) -> Result<(Self, Renderer), Box> { + let RawWindowHandle::AppKit(handle) = window.window_handle()?.as_raw() else { + unreachable!() + }; + // NSOpenGLPFADoubleBuffer, NSOpenGLPFAAccelerated, NSOpenGLPFAColorSize 24, + // NSOpenGLPFAAlphaSize 8. + let attributes: [u32; 7] = [5, 73, 8, 24, 11, 8, 0]; + let context = unsafe { + let class = |name| AnyClass::get(name).expect("AppKit is linked"); + let format: Allocated = msg_send_id![class("NSOpenGLPixelFormat"), alloc]; + let format: Option> = + msg_send_id![format, initWithAttributes: attributes.as_ptr()]; + let format = format.ok_or("No accelerated OpenGL pixel format")?; + let context: Allocated = msg_send_id![class("NSOpenGLContext"), alloc]; + let context: Option> = msg_send_id![ + context, + initWithFormat: &*format, + shareContext: std::ptr::null::() + ]; + let context = context.ok_or("No OpenGL context")?; + let view = handle.ns_view.as_ptr().cast::(); + let _: () = msg_send![&context, setView: view]; + let _: () = msg_send![&context, makeCurrentContext]; + // NSOpenGLCPSwapInterval: present on the display's refresh. + let interval: i32 = 1; + let _: () = msg_send![&context, setValues: &interval, forParameter: 222 as NSInteger]; + context + }; + let renderer = Renderer::new()?; + eprintln!("Canvas GPU: OpenGL"); + let size = window.inner_size(); + Ok(( + Self { + size: [size.width, size.height], + context, + target: None, + }, + renderer, + )) + } + + pub fn translucent(&self) -> bool { + false + } + + pub fn configure(&mut self, _: &Renderer) { + let _: () = unsafe { msg_send![&self.context, update] }; + } + + /// The window's next frame, or none to skip this one. + pub fn frame(&mut self, _: &Renderer) -> Result, Box> { + if self.size.contains(&0) { + return Ok(None); + } + let target = match self.target.take() { + Some(target) if target.size() == self.size => target, + _ => Target::new(self.size)?, + }; + Ok(Some(Frame { target })) + } + + pub fn present(&mut self, _: &Renderer, frame: Frame) { + frame.target.present(); + let _: () = unsafe { msg_send![&self.context, flushBuffer] }; + self.target = Some(frame.target); + } + + /// A target the size of the window's frames that `read` reads back. + pub fn offscreen(&self, _: &Renderer) -> Result> { + Ok(Frame { + target: Target::new(self.size)?, + }) + } + + /// The offscreen target's sRGB RGBA rows, top first. + pub fn read(&self, _: &Renderer, offscreen: Offscreen) -> Result, Box> { + Ok(offscreen.target.read_pixels()) + } +} diff --git a/crates/snowbound/src/sync.rs b/crates/snowbound/src/sync.rs new file mode 100644 index 0000000000000000000000000000000000000000..c9f2df01cd32ceef017a397095582c3b6af006db --- /dev/null +++ b/crates/snowbound/src/sync.rs @@ -0,0 +1,224 @@ +//! The sync status at the top right, and the popup it opens: OneNote's Shared Notebook +//! Synchronization for the open section. + +use crate::{Session, State, filetime, library, platform}; +use notebook::session::SyncStatus; +use std::{error::Error, io::ErrorKind}; +use ui::{Anchor, Axis, Flags, Id, Overflow, Spec, Theme, Ui, children, fill, fit, px}; + +const WIDTH: f32 = 380.0; + +fn id() -> Id { + Id::ROOT.child("sync-status") +} + +fn button() -> Id { + Id::ROOT.child("sync-button") +} + +/// The status's label, and what the reader can do about an error. +fn describe(sync: &SyncStatus) -> (&'static str, Option<&'static str>) { + use ErrorKind::*; + if library::offline() { + return ( + "Working offline", + Some("Changes stay on this computer until you sync."), + ); + } + match sync.error.as_ref().map(std::io::Error::kind) { + Some(PermissionDenied | ReadOnlyFilesystem) => ( + "Unable to sync", + Some( + "You can’t change this notebook where it’s stored. Changes stay on this computer.", + ), + ), + Some(WouldBlock | ResourceBusy) => ( + "Section in use", + Some("Someone else is saving this section. Sync continues when they finish."), + ), + Some( + NotFound | ConnectionRefused | ConnectionReset | ConnectionAborted | NotConnected + | TimedOut | HostUnreachable | NetworkUnreachable | NetworkDown | BrokenPipe + | AddrNotAvailable, + ) => ( + "Not connected", + Some("Changes stay on this computer and sync when the notebook is back."), + ), + Some(_) => ("Unable to sync", None), + None if sync.synced.is_none() || sync.queued > 0 => ("Syncing…", None), + None => ("Up to date", None), + } +} + +pub(crate) fn label(sync: &SyncStatus) -> &'static str { + describe(sync).0 +} + +/// A FILETIME as OneNote's "Last sync": the time, and the date too before today. +fn when(time: u64) -> String { + let [_, clock] = platform::date_text(time); + let day = platform::short_date(time); + if day == platform::short_date(filetime()) { + clock + } else { + format!("{day} {clock}") + } +} + +/// The status beside the window's controls, which opens the popup. +pub(crate) fn control(ui: &mut Ui, session: &Session, theme: &Theme, height: f32) { + let strong = ui.popup_open(id()) || session.sync.error.is_some() && !library::offline(); + ui.open_as( + button(), + Spec { + flags: Flags::CLICKABLE, + size: [fit(), px(height)], + text: Some(label(&session.sync)), + color: Some(if strong { theme.text } else { theme.text_dim }), + hover_fill: Some(theme.hover()), + radius: 4.0, + pad: [8.0, 0.0], + center: true, + ..Spec::default() + }, + ); + ui.close(); + if ui.signal(button()).clicked { + if ui.popup_open(id()) { + ui.close_popup(id()); + } else { + ui.open_popup(id()); + } + } +} + +impl State { + /// Builds the sync status popup while it is open. + pub(crate) fn sync_popup(&mut self) -> Result<(), Box> { + let ui = &mut self.ui; + let Some(session) = &mut self.session else { + ui.close_popup(id()); + return Ok(()); + }; + if !ui.popup_open(id()) { + return Ok(()); + } + // Read afresh while shown, so the last sync time follows each poll. + session.sync = session.section.sync_status()?; + let theme = ui.theme.clone(); + let offline = library::offline(); + let (progress, advice) = describe(&session.sync); + 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: [14.0, 10.0], + gap: 2.0, + anchor: Some(Anchor::Below(ui.rect(button()).unwrap_or_default())), + ..Spec::default() + }, + ); + let text = |ui: &mut Ui, part: &str, text: &str, color: [f32; 4], bold: bool| { + ui.leaf( + part, + Spec { + size: [fill(), fit()], + text: Some(text), + color: Some(color), + bold, + overflow: Overflow::Wrap, + pad: [0.0, 3.0], + ..Spec::default() + }, + ); + }; + let row = |ui: &mut Ui, key: &str, value: &str| { + ui.open( + key, + Spec { + size: [fill(), children()], + gap: 8.0, + ..Spec::default() + }, + ); + ui.leaf( + "key", + Spec { + size: [px(92.0), px(theme.font_size * 1.6)], + text: Some(key), + color: Some(theme.text_dim), + ..Spec::default() + }, + ); + ui.leaf( + "value", + Spec { + size: [fill(), px(theme.font_size * 1.6)], + text: Some(value), + overflow: Overflow::Ellipsis, + ..Spec::default() + }, + ); + ui.close(); + }; + text(ui, "title", "Notebook Sync Status", theme.text, true); + row(ui, "Notebook", &session.library.name); + row(ui, "Location", &session.library.location); + row(ui, "Connection", &session.library.transport()); + row(ui, "Section", &session.tabs[session.tab].name); + row(ui, "Progress", progress); + if let Some(synced) = session.sync.synced { + row(ui, "Last sync", &when(synced)); + } + if session.sync.queued > 0 { + let queued = match session.sync.queued { + 1 => "1 change".to_owned(), + count => format!("{count} changes"), + }; + row(ui, "Not yet synced", &queued); + } + if let Some(advice) = advice { + text(ui, "advice", advice, theme.text, false); + } + if let Some(error) = &session.sync.error { + text(ui, "error", &error.to_string(), theme.text_dim, false); + } + if let Some(notice) = &session.library.notice { + let notice = format!("Snowbound’s SMB client couldn’t sign in: {notice}"); + text(ui, "notice", ¬ice, theme.text_dim, false); + } + ui.open( + "controls", + Spec { + size: [fill(), children()], + pad: [0.0, 6.0], + gap: 8.0, + ..Spec::default() + }, + ); + let toggled = ui::check_box(ui, "offline", "Work offline", offline).clicked; + ui.leaf( + "space", + Spec { + size: [fill(), px(1.0)], + ..Spec::default() + }, + ); + let now = ui::button(ui, "sync-now", "Sync Now").clicked; + ui.close(); + ui.close(); + if toggled { + library::set_offline(!offline); + session.section.set_offline(!offline); + } + if now { + session.section.wake(); + } + Ok(()) + } +} diff --git a/crates/ui/Cargo.toml b/crates/ui/Cargo.toml index 2f5c320d99baa8f47856e2d7ac541575a84248f6..395e08b6528a584d932018cbbb7be38ab3b69a42 100644 --- a/crates/ui/Cargo.toml +++ b/crates/ui/Cargo.toml @@ -5,12 +5,13 @@ edition = "2024" publish = false [dependencies] -draw = { path = "../draw" } +draw = { path = "../draw", default-features = false, features = ["render"] } nucleo-matcher = "0.3.1" parley.workspace = true winit = "0.30.13" [dev-dependencies] +draw = { path = "../draw", features = ["wgpu"] } pollster = "0.4" png = "0.18" wgpu.workspace = true diff --git a/crates/ui/src/layout.rs b/crates/ui/src/layout.rs index 47d2ace50b0de315fcc97f200c6610456687633b..370ae8ca52d625db55e386c6e6b8350c2fe7e8f9 100644 --- a/crates/ui/src/layout.rs +++ b/crates/ui/src/layout.rs @@ -1,4 +1,4 @@ -use crate::{Axis, Built, Flags, ICON, ICON_GAP, Id, Overflow, Size, State, text::Texts}; +use crate::{Anchor, Axis, Built, Flags, ICON, ICON_GAP, Id, Overflow, Size, State, text::Texts}; use std::collections::HashMap; /// Sizes each box on both axes, then places it: standalone sizes, sizes taken from @@ -19,7 +19,13 @@ pub(crate) fn solve( } for node in nodes.iter_mut() { node.computed[axis] = match node.size[axis].size { - Size::Pixels(pixels) => pixels, + Size::Pixels(pixels) => match node.anchor { + Some(Anchor::Over(rect)) if axis == 0 => { + let from = rect[2] - rect[0]; + from + (pixels - from) * node.open + } + _ => pixels, + }, Size::Text => { let content = if axis == 0 { node.content_width() @@ -89,7 +95,12 @@ pub(crate) fn solve( let mut cursor = nodes[index].pad[axis]; for child in nodes[index].children.clone() { nodes[child].relative[axis] = if let Some(anchor) = nodes[child].anchor { - anchor.place(axis, nodes[child].computed[axis], window) + let shown = nodes[child].computed[axis]; + let size = match nodes[child].size[axis].size { + Size::Pixels(pixels) => pixels, + _ => shown, + }; + anchor.place(axis, size, shown, window) } else if nodes[child].flags.contains(Flags::FLOAT) { nodes[child].position[axis] } else if along(&nodes[index], axis) { diff --git a/crates/ui/src/lib.rs b/crates/ui/src/lib.rs index d2bc912281ba83f4a893025fe1a25c2ea26ba913..c18e6ec16f52c902a02532a95b43ddbff115acf2 100644 --- a/crates/ui/src/lib.rs +++ b/crates/ui/src/lib.rs @@ -12,7 +12,7 @@ mod theme; mod widgets; pub use list::{List, Row, Rows, list}; -pub use theme::{Section, Shades, Theme}; +pub use theme::{Menu, PopupMotion, Section, Shades, Shadow, Theme}; pub use widgets::{button, check_box, edit_key, edit_modifiers, scrollbar, text_field}; use draw::{ @@ -46,6 +46,12 @@ const ICON_GAP: f32 = 6.0; /// Seconds a dialog and other popups take to open and to close. const DIALOG: [f32; 2] = [0.24, 0.16]; const POPUP: [f32; 2] = [0.16, 0.12]; +/// Seconds the pointer rests on a control before its tooltip shows, as Windows' tooltips +/// wait a double-click interval; after one shows, others show at once for `TIP_WARM` +/// seconds, and one showing cold fades in over `TIP_FADE`. +const TIP_DELAY: f32 = 0.5; +const TIP_WARM: f32 = 0.5; +const TIP_FADE: f32 = 0.1; /// A box's identity across frames: its parent's id combined with a builder-chosen part. #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] @@ -200,8 +206,9 @@ pub enum Anchor { impl Anchor { /// Where a popup `size` long on `axis` starts in a window `room` long: past the - /// anchor on the axis it opens along, level with it otherwise. - fn place(self, axis: usize, size: f32, room: f32) -> f32 { + /// anchor on the axis it opens along, level with it otherwise. Shown only `shown` long + /// as it opens, it keeps the edge it would have at full size. + fn place(self, axis: usize, size: f32, shown: f32, room: f32) -> f32 { let (rect, along) = match self { Anchor::Below(rect) => (rect, Some(1)), Anchor::Right(rect) => (rect, Some(0)), @@ -218,15 +225,16 @@ impl Anchor { if first + size <= room { first } else if second >= 0.0 { - second + second + size - shown } else { - first.min(room - size).max(0.0) + first.min(room - size).max(0.0) + size - shown } } /// How a popup laid out at `rect` beside the anchor shows `open` of the way open: it - /// grows out of the anchor as it fades in, and a dialog swings up into place. - fn motion(self, rect: [f32; 4], open: f32) -> Motion { + /// grows out of the anchor as it fades in where it `grows`, and a dialog swings up into + /// place. + fn motion(self, rect: [f32; 4], open: f32, grows: bool) -> Motion { let pivot = match self { Anchor::Below(anchor) => [anchor[0], anchor[3]], Anchor::Right(anchor) => [anchor[2], anchor[1]], @@ -235,8 +243,11 @@ impl Anchor { Anchor::Dialog => [(rect[0] + rect[2]) / 2.0, rect[1]], }; let (from, tilt) = match self { + _ if !grows => (1.0, 0.0), Anchor::Dialog => (0.95, 0.4), - Anchor::Over(_) => (0.94, 0.25), + // Over a box, the popup widens out of it in layout instead of growing. + Anchor::Over(_) => (1.0, 0.2), + Anchor::Point(_) => (0.94, 0.2), _ => (0.94, 0.0), }; Motion { @@ -290,7 +301,8 @@ pub struct Spec<'a> { pub fill: Option<[f32; 4]>, /// The fill's colour at the bottom, fading from `fill` at the top. pub gradient: Option<[f32; 4]>, - /// A soft shadow of the box's outline, painted beneath it. + /// A soft shadow of the box's outline, painted beneath it; a popup other than a dialog + /// casts the theme's menu shadows instead. pub shadow: Option<[f32; 4]>, /// How far the box draws inside its leading, top, trailing and bottom edges: fill, /// border, shadow, icon and label; the pointer still finds the whole box. @@ -445,6 +457,8 @@ struct Built { offset: [f32; 2], cursor: Option, anchor: Option, + /// How far open a popup over a box is, as it widens out of the box. + open: f32, motion: Option, /// Rectangles relative to the box, painted over its fill. marks: Vec<([f32; 4], [f32; 4], f32)>, @@ -489,6 +503,15 @@ struct Popup { opened: Instant, } +/// The tooltip of the box under the pointer, the last frame it was built, and when it +/// shows: never while None, after a press or the wheel, until the pointer leaves the box. +#[derive(Clone, Copy)] +struct Tip { + id: Id, + frame: u64, + due: Option, +} + /// A popup's painting as it last showed, fading out since it closed. struct Closing { id: Id, @@ -591,6 +614,9 @@ pub struct Ui { /// `popups_painted`. painted: Vec<(Id, Anchor, [f32; 4], std::ops::Range)>, closing: Vec, + tip: Option, + /// When a tooltip last showed. + warm: Option, animating: bool, /// This frame routed input, whose effects the builder may only have seen after boxes /// built before it read their state; one more frame shows them. @@ -633,6 +659,8 @@ impl Ui { popups_painted: 0, painted: Vec::new(), closing: Vec::new(), + tip: None, + warm: None, animating: false, routed: false, caret: None, @@ -742,6 +770,12 @@ impl Ui { } fn route(&mut self, event: Event) { + if matches!(event, Event::Button { pressed: true, .. } | Event::Wheel(_)) { + if let Some(tip) = &mut self.tip { + tip.due = None; + } + self.warm = None; + } match event { Event::PointerMoved(point) => { self.pointer = Some(point); @@ -997,10 +1031,18 @@ impl Ui { } else { *self.stack.last().unwrap() }; + let open = match spec.anchor { + Some(anchor @ Anchor::Over(_)) if self.popup_motion(anchor) == PopupMotion::Grow => { + self.opening(id, anchor).unwrap_or(1.0) + } + _ => 1.0, + }; let index = self.nodes.len(); self.states.entry(id).or_default().touched = self.frame; - self.nodes - .push(Built::new(id, parent, spec, label, self.theme.text)); + self.nodes.push(Built { + open, + ..Built::new(id, parent, spec, label, self.theme.text) + }); self.nodes[parent].children.push(index); self.stack.push(index); id @@ -1116,6 +1158,13 @@ impl Ui { { self.close_from(gone); } + if let Some(tip) = self.tip.filter(|tip| tip.frame != self.frame) { + if tip.due.is_some_and(|due| due <= self.now) { + self.warm = Some(self.now); + } + self.tip = None; + } + let tip = self.tip.map(|tip| tip.id.child("tooltip")); let last = std::mem::take(&mut self.display); let last_popups = self.popups_painted; self.hits.clear(); @@ -1129,9 +1178,13 @@ impl Ui { continue; }; let (id, rect) = (node.id, node.rect); + if Some(id) == tip { + continue; + } if let Some(open) = self.opening(id, anchor) { self.scrim(anchor, open); - self.nodes[index].motion = Some(anchor.motion(rect, open)); + let grows = self.popup_motion(anchor) == PopupMotion::Grow; + self.nodes[index].motion = Some(anchor.motion(rect, open, grows)); } let from = self.display.len() - self.popups_painted; self.paint(index, None, None); @@ -1167,23 +1220,47 @@ impl Ui { closed: self.now, }); } - let closing = std::mem::take(&mut self.closing); - for closing in &closing { - let shut = self.progress(closing.closed, closing.anchor.durations()[1]); - let open = (1.0 - shut).powi(3); + for closing in std::mem::take(&mut self.closing) { + let motion = self.popup_motion(closing.anchor); + let open = match motion { + PopupMotion::Grow => { + (1.0 - self.progress(closing.closed, closing.anchor.durations()[1])).powi(3) + } + PopupMotion::Cut => 0.0, + PopupMotion::Fade([_, close]) => { + (1.0 - self.progress(closing.closed, close)).powi(4) + } + }; + if open == 0.0 { + continue; + } self.scrim(closing.anchor, open); - let motion = closing.anchor.motion(closing.rect, open); + let grows = motion == PopupMotion::Grow; + let motion = closing.anchor.motion(closing.rect, open, grows); self.display.push(Display::Motion(Some(motion))); self.display.extend(closing.display.iter().cloned()); self.display.push(Display::Motion(None)); + if !self.painted.iter().any(|(shown, ..)| *shown == closing.id) { + self.closing.push(closing); + } + } + // Over everything, closing popups included; it goes at once, without closing. + if let Some(index) = self.nodes[0] + .children + .iter() + .copied() + .find(|index| Some(self.nodes[*index].id) == tip) + && let Some(due) = self.tip.and_then(|tip| tip.due) + { + let opacity = self.progress(due, TIP_FADE); + self.nodes[index].motion = Some(Motion { + zoom: 1.0, + pivot: [0.0; 2], + tilt: 0.0, + opacity, + }); + self.paint(index, None, None); } - self.closing = closing - .into_iter() - .filter(|closing| { - self.progress(closing.closed, closing.anchor.durations()[1]) < 1.0 - && !self.painted.iter().any(|(shown, ..)| *shown == closing.id) - }) - .collect(); self.modal = if self.popups.is_empty() { 0 } else { beneath }; for id in [&mut self.hover, &mut self.active, &mut self.focus] { if id.is_some_and(|id| !self.states.contains_key(&id)) { @@ -1195,7 +1272,20 @@ impl Ui { /// How far open popup `id` beside `anchor` shows this frame, from 0 to 1, while open. pub fn opening(&mut self, id: Id, anchor: Anchor) -> Option { let opened = self.popups.iter().find(|popup| popup.id == id)?.opened; - Some(1.0 - (1.0 - self.progress(opened, anchor.durations()[0])).powi(3)) + Some(match self.popup_motion(anchor) { + PopupMotion::Grow => 1.0 - (1.0 - self.progress(opened, anchor.durations()[0])).powi(3), + PopupMotion::Cut => 1.0, + PopupMotion::Fade([open, _]) => self.progress(opened, open), + }) + } + + /// How popups beside `anchor` open and close; dialogs always swing as Snowbound's own. + fn popup_motion(&self, anchor: Anchor) -> PopupMotion { + if anchor == Anchor::Dialog { + PopupMotion::Grow + } else { + self.theme.menu().motion + } } /// How far through an animation `duration` seconds long that began at `start` this @@ -1255,18 +1345,43 @@ impl Ui { rect[3] - bottom, ]; let size = [painted[2] - painted[0], painted[3] - painted[1]]; - if let Some(shadow) = node.shadow { - let [spread, drop] = if node.anchor.is_some() { - POPUP_SHADOW - } else { - SHADOW + if let Some(color) = node.shadow { + let (own, menu); + let shadows: &[Shadow] = match node.anchor { + Some(Anchor::Dialog) | None => { + let [blur, drop] = if node.anchor.is_some() { + POPUP_SHADOW + } else { + SHADOW + }; + own = [Shadow { + color, + blur, + drop, + spread: 0.0, + }]; + &own + } + Some(_) => { + menu = self.theme.menu(); + &menu.shadows + } }; - self.display.push(Display::Path { - data: outline(node.shape, size, node.radius), - origin: [painted[0], painted[1] + drop], - style: PathStyle::Shadow(spread), - colors: [shadow; 2], - }); + for &Shadow { + color, + blur, + drop, + spread, + } in shadows + { + let grown = size.map(|side| side + 2.0 * spread); + self.display.push(Display::Path { + data: outline(node.shape, grown, node.radius + spread), + origin: [painted[0] - spread, painted[1] + drop - spread], + style: PathStyle::Shadow(blur), + colors: [color; 2], + }); + } } if node.shape == Shape::Rounded { if fill.is_some() || border.is_some() { @@ -1581,6 +1696,7 @@ impl Built { offset: spec.offset, cursor: spec.cursor, anchor: spec.anchor, + open: 1.0, motion: None, marks: Vec::new(), computed: [0.0; 2], diff --git a/crates/ui/src/popup.rs b/crates/ui/src/popup.rs index 9a5b4fddce88cb899e178a17c7d6a085b2f1ac69..9e1baddcdfbc25b4ae97207dafb8405680e4e2b7 100644 --- a/crates/ui/src/popup.rs +++ b/crates/ui/src/popup.rs @@ -3,32 +3,28 @@ //! frame it is, closing the popup. use crate::{ - Anchor, Axis, Event, Flags, ICON, ICON_GAP, Id, List, Popup, Row, Rows, Spec, Ui, children, - fill, fit, list::GUTTER, mix, px, text_field, + Anchor, Axis, Event, Flags, ICON, ICON_GAP, Id, List, Menu, Overflow, Popup, Row, Rows, Spec, + TIP_DELAY, TIP_FADE, TIP_WARM, Tip, Ui, children, fill, fit, list::GUTTER, mix, px, text_field, }; use nucleo_matcher::{ Config, Matcher, Utf32Str, pattern::{AtomKind, CaseMatching, Normalization, Pattern}, }; -use std::cell::RefCell; +use std::{cell::RefCell, time::Duration}; use winit::keyboard::{Key, NamedKey}; 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; -/// A palette's rows and filter field, and a menu's more compact rows. +pub(crate) const PAD: f32 = 4.0; +/// A palette's rows and filter field, and Snowbound's more compact menu rows. const ROW: f32 = 26.0; pub(crate) const MENU_ROW: f32 = 22.0; -/// Inset of a menu row's icon and text. -const ROW_PAD: f32 = 8.0; -/// How far a menu's icons sit inside its leading edge. -pub(crate) const ICON_INSET: f32 = PAD + ROW_PAD; /// Rows a list shows before it scrolls. const ROWS: f32 = 12.0; -/// Height of the rule between groups. -const RULE: f32 = 9.0; const NARROWEST: f32 = 140.0; const PALETTE: f32 = 560.0; +/// Where a tooltip's description wraps. +const TIP_WIDTH: f32 = 280.0; /// A colour grid's cell, around its swatch. const CELL: f32 = 22.0; /// Seconds a popup takes to ease to the height of its results. @@ -83,10 +79,16 @@ pub fn menu( if !ui.popup_open(id) { return None; } + let style = ui.theme.menu(); + let mut measure = |text| { + ui.texts + .label(text, style.font_size, false, None, ui.frame) + .size[0] + }; let [text, shortcut] = items.iter().fold([0.0_f32; 2], |[text, shortcut], item| { [ - text.max(ui.measure(item.text)[0]), - shortcut.max(ui.measure(item.shortcut)[0]), + text.max(measure(item.text)), + shortcut.max(measure(item.shortcut)), ] }); let icons = items.iter().any(|item| item.icon.is_some() || item.checked); @@ -101,10 +103,10 @@ pub fn menu( } else { 0.0 } - + 2.0 * (PAD + 8.0); + + 2.0 * (style.pad + style.row_pad); let least = match anchor { Anchor::Below(rect) => rect[2] - rect[0], - Anchor::Over(rect) => rect[2] - rect[0] + 2.0 * PAD, + Anchor::Over(rect) => rect[2] - rect[0] + 2.0 * style.pad, _ => 0.0, }; choose( @@ -114,10 +116,98 @@ pub fn menu( items, filter, width.max(least).max(NARROWEST), - MENU_ROW, + style.row, ) } +/// Shows `title`, with the `keys` that run it and a `description` under it, in a tooltip +/// below the box built last while the pointer rests on it or on a box inside it: after a +/// delay, or at once while another has just shown. A press or the wheel hides it until the +/// pointer leaves. +pub fn tooltip(ui: &mut Ui, title: &str, keys: &str, description: Option<&str>) { + let Some(&index) = ui.nodes[*ui.stack.last().unwrap()].children.last() else { + return; + }; + // The boxes built since are the box's own. + let hovered = ui + .hover + .is_some_and(|hover| ui.nodes[index..].iter().any(|node| node.id == hover)); + if !hovered || ui.active.is_some() { + return; + } + let id = ui.nodes[index].id; + let now = ui.now; + let showing = ui + .tip + .is_some_and(|tip| tip.due.is_some_and(|due| due <= now)); + let warm = showing + || ui + .warm + .is_some_and(|warm| now.saturating_duration_since(warm).as_secs_f32() < TIP_WARM); + let due = match ui.tip { + Some(tip) if tip.id == id => tip.due, + // Shown at once, already faded in. + _ if warm => Some( + now.checked_sub(Duration::from_secs_f32(TIP_FADE)) + .unwrap_or(now), + ), + _ => Some(now + Duration::from_secs_f32(TIP_DELAY)), + }; + ui.tip = Some(Tip { + id, + frame: ui.frame, + due, + }); + let Some(due) = due else { + return; + }; + if now < due { + ui.wake = Some(ui.wake.map_or(due, |wake| wake.min(due))); + return; + } + let title = if keys.is_empty() { + title.to_owned() + } else { + format!("{title} ({keys})") + }; + let [left, top, right, bottom] = ui.rect(id).unwrap_or_default(); + let theme = &ui.theme; + let spec = Spec { + axis: Axis::Y, + fill: Some(theme.popup), + border: Some(theme.chip), + shadow: Some(theme.shadow), + radius: 4.0, + pad: [8.0, 5.0], + gap: 3.0, + anchor: Some(Anchor::Below([left, top, right, bottom + PAD])), + ..Spec::default() + }; + ui.open_as(id.child("tooltip"), spec); + ui.leaf( + "title", + Spec { + size: [fit(), fit()], + text: Some(&title), + bold: true, + ..Spec::default() + }, + ); + if let Some(description) = description { + let width = ui.measure(description)[0].min(TIP_WIDTH); + ui.leaf( + "description", + Spec { + size: [px(width), fit()], + text: Some(description), + overflow: Overflow::Wrap, + ..Spec::default() + }, + ); + } + ui.close(); +} + /// Builds popup `id` as a command palette across the top of the window while it is open, /// listing `commands` that match what is typed, most relevant first. Returns the index of /// the command chosen. @@ -157,19 +247,11 @@ fn choose( let mut query = std::mem::take(&mut popup.query); let mut highlight = popup.highlight; let theme = ui.theme.clone(); - // Over a combo box, the field takes the box's place at once and widens as the popup - // comes in around it. - let (flags, size) = match anchor { - Anchor::Over(rect) => { - let open = ui.opening(id, anchor).unwrap_or(1.0); - let from = rect[2] - rect[0]; - let to = width - 2.0 * PAD; - ( - Flags::STILL, - [px(from + (to - from) * open), px(rect[3] - rect[1])], - ) - } - _ => (Flags::default(), [fill(), px(ROW)]), + let style = theme.menu(); + // Over a combo box, the field takes the box's place at once, widening with the popup. + let (flags, height) = match anchor { + Anchor::Over(rect) => (Flags::STILL, rect[3] - rect[1]), + _ => (Flags::default(), ROW), }; surface(ui, id, anchor, width); if let Some(placeholder) = filter { @@ -181,7 +263,7 @@ fn choose( placeholder, Spec { flags, - size, + size: [fill(), px(height)], fill: Some(theme.base), border: Some(theme.accent), radius: 4.0, @@ -193,7 +275,7 @@ fn choose( highlight = None; } } - let matches = Matches::new(items, &query); + let matches = Matches::new(items, &query, style.rule_band); // Unfiltered, the current or checked item starts highlighted; filtered, the best match. highlight = highlight.or_else(|| { let first = if query.is_empty() { @@ -207,7 +289,11 @@ fn choose( first.map(|row| matches.key(row)) }); let window = ui.rect(Id::ROOT).map_or(0.0, |window| window[3]); - let field = if filter.is_some() { ROW + PAD } else { 0.0 }; + let field = if filter.is_some() { + ROW + style.pad + } else { + 0.0 + }; let content = matches.count() as f32 * row + matches.space_before(matches.count()); let view = content .max(row) @@ -236,7 +322,8 @@ fn choose( Spec { size: [fill(), px(row)], text: Some("No matches"), - color: Some(theme.text_dim), + font_size: Some(style.font_size), + color: Some(style.dim), pad: [8.0, 0.0], ..Spec::default() }, @@ -254,12 +341,12 @@ fn choose( id.child("rows"), Spec { size: [fill(), px(view)], - fill: Some(theme.popup), + fill: Some(style.fill), ..Spec::default() }, list, &mut highlight, - |ui, row| menu_row(ui, &theme, &matches, row), + |ui, row| menu_row(ui, &style, &matches, row), ); let entered = keys.contains(&NamedKey::Enter); clicked.or_else(|| { @@ -282,7 +369,7 @@ fn choose( /// Builds a menu's row: the item's icon or check, its text and its shortcut, on the /// highlight when selected, under a rule when it starts a group. -fn menu_row(ui: &mut Ui, theme: &crate::Theme, matches: &Matches, row: Row) { +fn menu_row(ui: &mut Ui, style: &Menu, matches: &Matches, row: Row) { let item = &matches.items[row.key as usize]; if row.index > 0 && matches.ruled && item.separated { ui.leaf( @@ -290,24 +377,28 @@ fn menu_row(ui: &mut Ui, theme: &crate::Theme, matches: &Matches, row: Row) { Spec { flags: Flags::FLOAT, size: [fill(), px(1.0)], - position: [0.0, -(RULE + 1.0) / 2.0], - fill: Some(theme.chip), + position: [0.0, -(style.rule_band + 1.0) / 2.0], + inset: [style.rule_inset, 0.0, style.rule_inset, 0.0], + fill: Some(style.rule), ..Spec::default() }, ); } - let color = if item.disabled || item.heading { - theme.text_dim + let color = if item.heading { + style.dim + } else if item.disabled { + style.disabled } else { - theme.text + style.text }; ui.open( "item", Spec { size: [fill(), fill()], - fill: row.selected.then(|| theme.hover()), - radius: 4.0, - pad: [ROW_PAD, 0.0], + fill: row.selected.then_some(style.highlight), + border: style.highlight_border.filter(|_| row.selected), + radius: style.row_radius, + pad: [style.row_pad, 0.0], gap: ICON_GAP, ..Spec::default() }, @@ -324,7 +415,7 @@ fn menu_row(ui: &mut Ui, theme: &crate::Theme, matches: &Matches, row: Row) { size: [px(ICON), fill()], icon: if item.checked { Some(CHECK) } else { item.icon }, color: Some(if item.disabled { - mix(tint, theme.popup, 0.5) + mix(tint, style.fill, 0.5) } else { tint }), @@ -338,7 +429,7 @@ fn menu_row(ui: &mut Ui, theme: &crate::Theme, matches: &Matches, row: Row) { size: [fill(), fill()], text: Some(item.text), font: item.font, - font_size: item.heading.then_some(theme.font_size - 2.0), + font_size: Some(style.font_size - if item.heading { 2.0 } else { 0.0 }), bold: item.heading, color: Some(color), ..Spec::default() @@ -350,7 +441,8 @@ fn menu_row(ui: &mut Ui, theme: &crate::Theme, matches: &Matches, row: Row) { Spec { size: [fit(), fill()], text: Some(item.shortcut), - color: Some(theme.text_dim), + font_size: Some(style.font_size), + color: Some(style.dim), ..Spec::default() }, ); @@ -419,7 +511,12 @@ pub fn colors( let theme = ui.theme.clone(); let lit = |index: usize| (highlight == Some(index)).then(|| theme.hover()); - surface(ui, id, anchor, columns as f32 * CELL + 2.0 * PAD); + surface( + ui, + id, + anchor, + columns as f32 * CELL + 2.0 * ui.theme.menu().pad, + ); ui.open_as( cell(0), Spec { @@ -473,6 +570,130 @@ pub fn colors( None } +/// Builds popup `id` as a grid of `size` columns and rows beside `anchor` while it is open, +/// lit from its corner to the cell pointed at, as Office's table picker is. Returns the +/// columns and rows chosen. +pub fn table_picker(ui: &mut Ui, id: Id, anchor: Anchor, size: [usize; 2]) -> Option<[usize; 2]> { + if !ui.popup_open(id) { + return None; + } + let cell = |index: usize| id.child(("cell", index)); + let keys = navigation( + ui, + &[id], + &[ + NamedKey::ArrowLeft, + NamedKey::ArrowRight, + NamedKey::ArrowUp, + NamedKey::ArrowDown, + NamedKey::Enter, + ], + ); + let [columns, rows] = size; + let mut highlight = state(ui, id).highlight.map(|cell| cell as usize); + let mut chosen = None; + for index in 0..columns * rows { + let signal = ui.signal(cell(index)); + if signal.hovered && ui.moved { + highlight = Some(index); + } + if signal.clicked { + chosen = Some(index); + } + } + for key in keys { + let at = highlight.unwrap_or(0); + let [column, row] = [at % columns, at / columns]; + highlight = Some(match key { + NamedKey::Enter => { + chosen = chosen.or(highlight); + continue; + } + _ if highlight.is_none() => 0, + NamedKey::ArrowLeft => at - usize::from(column > 0), + NamedKey::ArrowRight => at + usize::from(column + 1 < columns), + NamedKey::ArrowUp => at - if row > 0 { columns } else { 0 }, + _ => at + if row + 1 < rows { columns } else { 0 }, + }); + } + let extent = |index: usize| [index % columns + 1, index / columns + 1]; + if let Some(index) = chosen { + ui.close_popup(id); + return Some(extent(index)); + } + let theme = ui.theme.clone(); + let reach = highlight.map_or([0, 0], extent); + surface( + ui, + id, + anchor, + columns as f32 * CELL + 2.0 * ui.theme.menu().pad, + ); + let heading = match highlight { + Some(_) => format!("{}x{} Table", reach[0], reach[1]), + None => "Insert Table".to_owned(), + }; + ui.leaf( + "heading", + Spec { + size: [fill(), px(ROW)], + text: Some(&heading), + bold: true, + pad: [8.0, 0.0], + ..Spec::default() + }, + ); + ui.open( + "grid", + Spec { + axis: Axis::Y, + size: [fill(), children()], + ..Spec::default() + }, + ); + for row in 0..rows { + ui.open( + ("row", row), + Spec { + size: [fill(), px(CELL)], + ..Spec::default() + }, + ); + for column in 0..columns { + let lit = column < reach[0] && row < reach[1]; + ui.open_as( + cell(row * columns + column), + Spec { + flags: Flags::CLICKABLE, + size: [px(CELL), px(CELL)], + pad: [3.0, 3.0], + ..Spec::default() + }, + ); + ui.leaf( + "square", + Spec { + size: [fill(), fill()], + fill: Some(if lit { theme.hover() } else { theme.base }), + border: Some(if lit { + theme.accent + } else { + mix(theme.text, theme.popup, 0.7) + }), + radius: 2.0, + ..Spec::default() + }, + ); + ui.close(); + } + ui.close(); + } + ui.close(); + ui.close(); + state(ui, id).highlight = highlight.map(|cell| cell as u64); + None +} + /// Builds popup `id` as a gallery beside `anchor` while it is open: `groups` of cells, each /// a heading and a count, in rows of `columns` cells `size` large, with cell `current` /// outlined. `cell` builds a cell's contents from its index, counted through the groups. @@ -533,7 +754,12 @@ pub fn gallery( return Some(index); } let theme = ui.theme.clone(); - surface(ui, id, anchor, columns as f32 * size[0] + 2.0 * PAD); + surface( + ui, + id, + anchor, + columns as f32 * size[0] + 2.0 * ui.theme.menu().pad, + ); let mut index = 0; for (group, (heading, cells)) in groups.iter().enumerate() { ui.leaf( @@ -584,29 +810,30 @@ pub fn gallery( /// Opens popup `id`'s panel `width` wide beside `anchor`; the caller closes it. fn surface(ui: &mut Ui, id: Id, anchor: Anchor, width: f32) { + let style = ui.theme.menu(); + let pad = style.pad; let anchor = match anchor { Anchor::Below([left, top, right, bottom]) => { Anchor::Below([left, top - PAD, right, bottom + PAD]) } // Level with the row it opens from, or with its contents over the box. Anchor::Right([left, top, right, bottom]) => { - Anchor::Right([left, top - PAD, right, bottom + PAD]) + Anchor::Right([left, top - pad, right, bottom + pad]) } Anchor::Over([left, top, right, bottom]) => { - Anchor::Over([left - PAD, top - PAD, right + PAD, bottom + PAD]) + Anchor::Over([left - pad, top - pad, right + pad, bottom + pad]) } point => point, }; - let theme = &ui.theme; let spec = Spec { axis: Axis::Y, size: [px(width), children()], - fill: Some(theme.popup), - border: Some(theme.chip), - shadow: Some(theme.shadow), - radius: 6.0, - pad: [PAD; 2], - gap: PAD, + fill: Some(style.fill), + border: Some(style.border), + shadow: Some(ui.theme.shadow), + radius: style.radius, + pad: [pad; 2], + gap: pad, anchor: Some(anchor), ..Spec::default() }; @@ -661,11 +888,13 @@ pub(crate) struct Matches<'a> { /// Rules above each row and the end, counted from the top; only while unfiltered. rules: Vec, ruled: bool, + /// Height of the band a rule takes. + rule: f32, icons: bool, } impl<'a> Matches<'a> { - pub(crate) fn new(items: &'a [Item<'a>], query: &str) -> Self { + pub(crate) fn new(items: &'a [Item<'a>], query: &str, rule: f32) -> Self { let pattern = Pattern::new( query, CaseMatching::Ignore, @@ -708,6 +937,7 @@ impl<'a> Matches<'a> { rows, rules, ruled, + rule, icons: items.iter().any(|item| item.icon.is_some() || item.checked), } } @@ -732,6 +962,6 @@ impl Rows for Matches<'_> { } fn space_before(&self, index: usize) -> f32 { - self.rules[index] as f32 * RULE + self.rules[index] as f32 * self.rule } } diff --git a/crates/ui/src/shell.rs b/crates/ui/src/shell.rs index d2a39ec6547b7f45f8430ae5e44ccd1208c50337..9ad1b98bda38377fc5f5238fe6f0865f67f97b0d 100644 --- a/crates/ui/src/shell.rs +++ b/crates/ui/src/shell.rs @@ -222,6 +222,49 @@ pub fn tool_button( ) } +/// A tool button, with a menu arrow where `menu`, whose command does not apply now: `icon` +/// tinted by `tint` shows faded, and it takes no clicks. +pub fn unavailable( + ui: &mut Ui, + part: impl Hash, + icon: &'static [&'static str], + tint: [f32; 4], + menu: bool, +) { + let faded = |[red, green, blue, alpha]: [f32; 4]| [red, green, blue, alpha * 0.35]; + let arrow = faded(ui.theme.text_dim); + ui.open( + part, + Spec { + size: [children(), px(TOOL)], + ..Spec::default() + }, + ); + ui.leaf( + "icon", + Spec { + size: [px(TOOL), px(TOOL)], + icon: Some(icon), + color: Some(faded(tint)), + center: true, + ..Spec::default() + }, + ); + if menu { + ui.leaf( + "arrow", + Spec { + size: [px(ARROW), px(TOOL)], + icon: Some(CHEVRON), + color: Some(arrow), + center: true, + ..Spec::default() + }, + ); + } + ui.close(); +} + /// A button showing `icon`, lit while `on`, joined to an arrow that opens popup `menu`, /// with the colour it applies as a bar under the icon when given. Hovering the button fills /// it alone; hovering the arrow, or its menu being open, outlines both as one control. @@ -359,7 +402,9 @@ pub fn menu_button( ui.open_popup(menu); } let [left, top, right, bottom] = ui.rect(id).unwrap_or_default(); - let shift = crate::popup::ICON_INSET - (TOOL - crate::ICON) / 2.0; + let menu = ui.theme.menu(); + // The menu's icons line up under the button's. + let shift = menu.pad + menu.row_pad - (TOOL - crate::ICON) / 2.0; Anchor::Below([left - shift, top, right - shift, bottom]) } diff --git a/crates/ui/src/tests.rs b/crates/ui/src/tests.rs index 49ddfaa4a61eff58962bed9db000659b8151db3b..d22a277bb472bc580e86370558cbd7017b355a90 100644 --- a/crates/ui/src/tests.rs +++ b/crates/ui/src/tests.rs @@ -777,6 +777,74 @@ fn click(ui: &mut Ui, point: [f32; 2]) { press(ui, at, false); } +/// Two tool buttons with tooltips, the second at the window's trailing edge; returns +/// their tooltips' rectangles. +fn tip_frame(ui: &mut Ui) -> [Option<[f32; 4]>; 2] { + let mut tips = [Id::ROOT; 2]; + frame(ui, |ui| { + for (index, x) in [0.0, 380.0].into_iter().enumerate() { + let id = ui.open( + ("tool", index), + Spec { + flags: Flags::CLICKABLE | Flags::FLOAT, + size: [px(20.0), px(20.0)], + position: [x, 0.0], + ..Spec::default() + }, + ); + ui.close(); + popup::tooltip(ui, "Bold", "Ctrl+B", Some("Makes the selected text bold.")); + tips[index] = id.child("tooltip"); + } + }); + tips.map(|tip| ui.rect(tip)) +} + +/// Frames the pointer rests through for `millis`, returning the tooltips as they end. +fn rest(ui: &mut Ui, millis: u32) -> [Option<[f32; 4]>; 2] { + let mut tips = [None; 2]; + for _ in 0..millis / 16 { + tips = tip_frame(ui); + } + tips +} + +#[test] +fn tooltips_wait_then_switch_at_once_and_hide_on_press_until_left() { + let mut ui = Ui::new(Theme::dark(), DOUBLE_CLICK); + tip_frame(&mut ui); + ui.event(Event::PointerMoved([10.0, 10.0])); + assert_eq!(rest(&mut ui, 400), [None; 2]); + assert!(ui.wake_at().is_some(), "the delay wakes the host"); + let [Some(first), None] = rest(&mut ui, 200) else { + panic!("the first tooltip shows after its delay"); + }; + assert_eq!(first[..2], [0.0, 20.0 + popup::PAD]); + + ui.event(Event::PointerMoved([390.0, 10.0])); + let [None, Some(second)] = tip_frame(&mut ui) else { + panic!("moving to another shows its tooltip at once"); + }; + assert_eq!(second[2], 400.0, "kept inside the window"); + + press(&mut ui, Instant::now(), true); + press(&mut ui, Instant::now(), false); + assert_eq!( + rest(&mut ui, 1000), + [None; 2], + "a press hides it while hovered" + ); + ui.event(Event::PointerMoved([200.0, 10.0])); + rest(&mut ui, 100); + ui.event(Event::PointerMoved([10.0, 10.0])); + assert_eq!( + rest(&mut ui, 100), + [None; 2], + "after a press it waits again" + ); + assert!(rest(&mut ui, 500)[0].is_some()); +} + #[test] fn popups_open_beside_their_anchor_and_flip_to_stay_in_the_window() { let mut ui = open_menu(None); @@ -1062,6 +1130,40 @@ fn colour_grids_move_in_two_dimensions_and_choose_a_swatch_or_none() { assert_eq!(build(&mut ui), Some(None)); } +#[test] +fn table_pickers_choose_columns_and_rows_by_keys_or_a_click() { + let mut ui = Ui::new(Theme::dark(), DOUBLE_CLICK); + let grid = Id::ROOT.child("table"); + let build = |ui: &mut Ui| { + let mut chosen = None; + frame(ui, |ui| { + chosen = popup::table_picker(ui, grid, BELOW, [4, 3]); + }); + chosen + }; + build(&mut ui); + ui.open_popup(grid); + build(&mut ui); + for named in [ + NamedKey::ArrowDown, + NamedKey::ArrowRight, + NamedKey::ArrowRight, + NamedKey::ArrowDown, + NamedKey::ArrowDown, + NamedKey::Enter, + ] { + ui.event(key(named)); + } + assert_eq!(build(&mut ui), Some([3, 3])); + ui.open_popup(grid); + for _ in 0..40 { + build(&mut ui); + } + let rect = ui.rect(grid.child(("cell", 5_usize))).unwrap(); + click(&mut ui, [rect[0] + 4.0, rect[1] + 4.0]); + assert_eq!(build(&mut ui), Some([2, 2])); +} + #[test] fn popups_opened_within_a_popup_stay_above_it() { let mut ui = open_menu(None); @@ -1369,7 +1471,7 @@ fn fonts() -> Vec> { } fn ranked(items: &[popup::Item], query: &str) -> Vec<&'static str> { - let order = popup::Matches::new(items, query).order; + let order = popup::Matches::new(items, query, 9.0).order; order.into_iter().map(|index| FONTS[index]).collect() } @@ -1836,6 +1938,39 @@ fn a_combo_s_field_holds_its_place_as_its_list_opens_and_its_rows_take_clicks() assert_eq!(build(&mut ui), Some(3)); } +#[test] +fn a_popup_over_a_box_by_the_window_s_edge_widens_away_from_it() { + let mut ui = Ui::new(Theme::light(), DOUBLE_CLICK); + let fonts = fonts(); + let anchor = Anchor::Over([330.0, 10.0, 390.0, 30.0]); + let build = |ui: &mut Ui| { + frame(ui, |ui| { + _ = popup::menu(ui, menu_id(), anchor, &fonts, Some("Font")) + }) + }; + build(&mut ui); + ui.open_popup(menu_id()); + build(&mut ui); + let field = menu_id().child("filter"); + let edges = + |ui: &Ui| [ui.rect(field).unwrap(), ui.rect(menu_id()).unwrap()].map(|rect| rect[2]); + let first = ui.rect(field).unwrap(); + assert_eq!( + edges(&ui), + [390.0, 394.0], + "the trailing edges start on the box" + ); + assert!(first[0] > 290.0, "the field starts near the box"); + for _ in 0..20 { + build(&mut ui); + } + assert_eq!(edges(&ui), [390.0, 394.0], "the trailing edges hold"); + assert!( + ui.rect(field).unwrap()[0] < first[0] - 30.0, + "the field widens leftward" + ); +} + fn built(ui: &Ui, id: Id) -> &Built { ui.nodes.iter().find(|node| node.id == id).unwrap() } @@ -2024,3 +2159,50 @@ fn galleries_choose_across_their_groups_by_keys_and_clicks() { click(&mut ui, point); assert_eq!(build(&mut ui), Some(1)); } + +#[test] +fn desktop_menus_cut_or_fade_without_growing() { + let motion = |ui: &Ui| { + ui.layers().iter().find_map(|layer| match layer { + Layer::Primitives(primitives) => primitives.motion, + Layer::Custom { .. } => None, + }) + }; + let at = Anchor::Point([50.0, 50.0]); + let styled = |motion| { + let mut theme = Theme::light(); + theme.desktop_menu = Some(Menu { + motion, + ..theme.menu() + }); + let mut ui = Ui::new(theme, DOUBLE_CLICK); + menu_frame(&mut ui, at, None); + ui.open_popup(menu_id()); + menu_frame(&mut ui, at, None); + ui + }; + + let mut ui = styled(PopupMotion::Cut); + let shown = motion(&ui).unwrap(); + assert_eq!([shown.opacity, shown.zoom, shown.tilt], [1.0, 1.0, 0.0]); + ui.close_popup(menu_id()); + menu_frame(&mut ui, at, None); + assert_eq!(motion(&ui), None, "gone the frame it closes"); + assert!(!ui.wants_frame()); + + let mut ui = styled(PopupMotion::Fade([0.15, 0.6])); + let first = motion(&ui).unwrap(); + assert!(first.opacity < 0.2 && first.zoom == 1.0 && first.tilt == 0.0); + for _ in 0..10 { + menu_frame(&mut ui, at, None); + } + assert_eq!(motion(&ui).map(|motion| motion.opacity), Some(1.0)); + ui.close_popup(menu_id()); + // 96 ms into the fade out, OutQuart leaves about half. + for _ in 0..7 { + menu_frame(&mut ui, at, None); + } + let closing = motion(&ui).unwrap(); + assert!((closing.opacity - (1.0 - 0.096 / 0.6_f32).powi(4)).abs() < 0.01); + assert!(ui.wants_frame()); +} diff --git a/crates/ui/src/text.rs b/crates/ui/src/text.rs index 58bfd76704203cec8b6944bb01838d38801a1a68..e0b7ff1a3fd46deafdd1628fb2c2a7e2a862ab42 100644 --- a/crates/ui/src/text.rs +++ b/crates/ui/src/text.rs @@ -47,12 +47,17 @@ impl Texts { .context .ranged_builder(&mut self.fonts, text, 1.0, false); // Fontconfig configurations without a system-ui alias still have a sans-serif. - let system = - [GenericFamily::SystemUi, GenericFamily::SansSerif].map(FontFamilyName::Generic); + // Before 10.10 macOS's interface font is Lucida Grande, which fontique's system-ui + // doesn't name. + let system = [ + Some(FontFamilyName::Generic(GenericFamily::SystemUi)), + cfg!(target_os = "macos").then(|| FontFamilyName::named("Lucida Grande")), + Some(FontFamilyName::Generic(GenericFamily::SansSerif)), + ]; let families: Vec<_> = font .into_iter() .map(FontFamilyName::named) - .chain(system) + .chain(system.into_iter().flatten()) .collect(); builder.push_default(StyleProperty::FontFamily(FontFamily::List(Cow::Owned( families, diff --git a/crates/ui/src/theme.rs b/crates/ui/src/theme.rs index 0ece01aa5c88ea8fa95b2407f6ea88e24e834530..c26f6151e5a190eedac19e1bdee3343a96093eab 100644 --- a/crates/ui/src/theme.rs +++ b/crates/ui/src/theme.rs @@ -32,6 +32,62 @@ pub struct Theme { pub shadow: [f32; 4], /// Saturation and lightness each of a section's colours takes from its hue. pub shades: Shades, + /// Menus as the desktop draws its own, where Snowbound follows it; see `Theme::menu`. + pub desktop_menu: Option, +} + +/// How menus and the other popups look and move. Colours are linear RGBA; sizes are +/// logical pixels. +#[derive(Clone, Debug, PartialEq)] +pub struct Menu { + pub fill: [f32; 4], + pub border: [f32; 4], + /// The layers of the shadow a popup casts, painted in order. + pub shadows: Vec, + pub text: [f32; 4], + /// Shortcuts and headings. + pub dim: [f32; 4], + pub disabled: [f32; 4], + /// The row the pointer or keyboard is on, and its outline. + pub highlight: [f32; 4], + pub highlight_border: Option<[f32; 4]>, + /// The line between groups of rows. + pub rule: [f32; 4], + pub font_size: f32, + pub radius: f32, + /// Inset of the popup's contents. + pub pad: f32, + pub row: f32, + pub row_radius: f32, + /// Inset of a row's icon and text inside its highlight. + pub row_pad: f32, + /// Height of the band between groups, and how far the rule across it stays from the + /// rows' ends. + pub rule_band: f32, + pub rule_inset: f32, + pub motion: PopupMotion, +} + +/// A soft shadow blurred `blur` wide, `drop` below its box and grown `spread` past it, as +/// CSS's `box-shadow` takes them. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Shadow { + pub color: [f32; 4], + pub blur: f32, + pub drop: f32, + pub spread: f32, +} + +/// How popups other than dialogs open and close. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum PopupMotion { + /// Growing out of what they open from as they fade, Snowbound's own. + Grow, + /// At once, as GTK 4 shows its popovers. + Cut, + /// Opacity alone: in linearly over the first seconds, out over the second easing as + /// OutQuart, as KWin's Fading Popups. + Fade([f32; 2]), } /// The platform's caret and selection colours in a light or `dark` appearance. @@ -101,6 +157,7 @@ impl Theme { edge: [0.30, 0.16], accent: [0.55, 0.55], }, + desktop_menu: None, } } @@ -129,6 +186,7 @@ impl Theme { edge: [0.30, 0.52], accent: [0.60, 0.45], }, + desktop_menu: None, } } @@ -145,6 +203,36 @@ impl Theme { } } + /// Menus as the desktop draws them, or Snowbound's own. + pub fn menu(&self) -> Menu { + use crate::popup::{MENU_ROW, PAD}; + self.desktop_menu.clone().unwrap_or_else(|| Menu { + fill: self.popup, + border: self.chip, + shadows: vec![Shadow { + color: self.shadow, + blur: crate::POPUP_SHADOW[0], + drop: crate::POPUP_SHADOW[1], + spread: 0.0, + }], + text: self.text, + dim: self.text_dim, + disabled: self.text_dim, + highlight: self.hover(), + highlight_border: None, + rule: self.chip, + font_size: self.font_size, + radius: 6.0, + pad: PAD, + row: MENU_ROW, + row_radius: 4.0, + row_pad: 8.0, + rule_band: 9.0, + rule_inset: 0.0, + motion: PopupMotion::Grow, + }) + } + /// Hovered controls: the accent over the base. pub fn hover(&self) -> [f32; 4] { crate::mix(self.base, self.accent, 0.35) diff --git a/crates/ui/src/widgets.rs b/crates/ui/src/widgets.rs index f2089b465fcfd5fa378a6a6276981a955215a761..77449a4de9155c0649b8153cd89cc4f548948bb6 100644 --- a/crates/ui/src/widgets.rs +++ b/crates/ui/src/widgets.rs @@ -348,6 +348,7 @@ pub fn edit_key(key: &Key) -> edit::Key { NamedKey::End => Edit::End, NamedKey::PageUp => Edit::PageUp, NamedKey::PageDown => Edit::PageDown, + NamedKey::F11 => Edit::F11, NamedKey::Alt | NamedKey::AltGraph | NamedKey::Control diff --git a/platform/snow-leopard/Cargo.lock b/platform/snow-leopard/Cargo.lock index 0268d5f9619a36db8969e61034da6db9e03094df..6b780e9f613a15440d2a7dc3fc4b7e0fa6f22e44 100644 --- a/platform/snow-leopard/Cargo.lock +++ b/platform/snow-leopard/Cargo.lock @@ -2,18 +2,72 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "ab_glyph" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01c0457472c38ea5bd1c3b5ada5e368271cb550be7a4ca4a0b4634e9913f6cc2" +dependencies = [ + "ab_glyph_rasterizer", + "owned_ttf_parser", +] + +[[package]] +name = "ab_glyph_rasterizer" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618" + [[package]] name = "adler2" version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "getrandom 0.3.4", + "once_cell", + "version_check", + "zerocopy", +] + [[package]] name = "allocator-api2" version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" +[[package]] +name = "android-activity" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f2a1bb052857d5dd49572219344a7332b31b76405648eabac5bc68978251bcd" +dependencies = [ + "android-properties", + "bitflags 2.13.1", + "cc", + "jni", + "libc", + "log", + "ndk", + "ndk-context", + "ndk-sys", + "num_enum", + "thiserror 2.0.21", +] + +[[package]] +name = "android-properties" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7eb209b1518d6bb87b283c20095f5228ecda460da70b44f0802523dea6da04" + [[package]] name = "android_system_properties" version = "0.1.6" @@ -23,12 +77,24 @@ dependencies = [ "libc", ] +[[package]] +name = "arrayref" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" + [[package]] name = "arrayvec" version = "0.7.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" +[[package]] +name = "as-raw-xcb-connection" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175571dd1d178ced59193a6fc02dde1b972eb0bc56c892cde9beeceac5bf0f6b" + [[package]] name = "ash" version = "0.38.0+1.3.281" @@ -38,6 +104,12 @@ dependencies = [ "libloading", ] +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + [[package]] name = "autocfg" version = "1.5.1" @@ -59,19 +131,34 @@ version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51" +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + [[package]] name = "bitflags" version = "2.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" +[[package]] +name = "block2" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c132eebf10f5cad5289222520a4a058514204aed6d791f1cf4fe8088b82d15f" +dependencies = [ + "objc2 0.5.2", +] + [[package]] name = "block2" version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" dependencies = [ - "objc2", + "objc2 0.6.4", ] [[package]] @@ -106,6 +193,50 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "calloop" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b99da2f8558ca23c71f4fd15dc57c906239752dd27ff3c00a1d56b685b7cbfec" +dependencies = [ + "bitflags 2.13.1", + "log", + "polling", + "rustix 0.38.44", + "slab", + "thiserror 1.0.69", +] + +[[package]] +name = "calloop-wayland-source" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95a66a987056935f7efce4ab5668920b5d0dac4a7c99991a67395f13702ddd20" +dependencies = [ + "calloop", + "rustix 0.38.44", + "wayland-backend", + "wayland-client", +] + +[[package]] +name = "cc" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f360145194ee8e21db5ee7f3fcd4fe52210864c75c985dae33218202c8bbe040" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + [[package]] name = "cfg-if" version = "1.0.5" @@ -135,6 +266,65 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" +[[package]] +name = "combine" +version = "4.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfc320937d09e6de266b31b9afb480f197d7a861be86be7cb2ea7e5d1bfffc5e" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "core-graphics" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c07782be35f9e1140080c6b96f0d44b739e2278479f64e02fdab4e32dfd8b081" +dependencies = [ + "bitflags 1.3.2", + "core-foundation", + "core-graphics-types", + "foreign-types", + "libc", +] + +[[package]] +name = "core-graphics-types" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf" +dependencies = [ + "bitflags 1.3.2", + "core-foundation", + "libc", +] + [[package]] name = "crc32fast" version = "1.5.2" @@ -144,20 +334,38 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "crossbeam-utils" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" + [[package]] name = "crunchy" version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" +[[package]] +name = "cursor-icon" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f" + +[[package]] +name = "dispatch" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd0c93bb4b0c6d9b77f4435b0ae98c24d17f1c45b2ff844c6151a07256ca923b" + [[package]] name = "dispatch2" version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" dependencies = [ - "bitflags", - "objc2", + "bitflags 2.13.1", + "objc2 0.6.4", ] [[package]] @@ -189,6 +397,18 @@ dependencies = [ "litrs", ] +[[package]] +name = "downcast-rs" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" + +[[package]] +name = "dpi" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" + [[package]] name = "draw" version = "0.1.0" @@ -208,6 +428,16 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "fax" version = "0.2.7" @@ -223,6 +453,12 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "find-msvc-tools" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" + [[package]] name = "flate2" version = "1.1.10" @@ -257,10 +493,10 @@ dependencies = [ "hashbrown 0.17.1", "linebender_resource_handle", "memmap2", - "objc2", + "objc2 0.6.4", "objc2-core-foundation", "objc2-core-text", - "objc2-foundation", + "objc2-foundation 0.3.2", "parlance", "read-fonts", "roxmltree", @@ -270,6 +506,33 @@ dependencies = [ "yeslogic-fontconfig-sys", ] +[[package]] +name = "foreign-types" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" +dependencies = [ + "foreign-types-macros", + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-macros" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea5190182e6915eb873ddbc16e23b711b6eb1f9c00a0d0a3a91b5f6228475225" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "foreign-types-shared" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" + [[package]] name = "futures-core" version = "0.3.34" @@ -294,6 +557,39 @@ dependencies = [ "slab", ] +[[package]] +name = "gethostname" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" +dependencies = [ + "rustix 1.1.5", + "windows-link", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", +] + [[package]] name = "gif" version = "0.14.2" @@ -304,10 +600,6 @@ dependencies = [ "weezl", ] -[[package]] -name = "gl21" -version = "0.0.0" - [[package]] name = "gl_generator" version = "0.14.0" @@ -350,7 +642,7 @@ dependencies = [ "hashbrown 0.16.1", "log", "presser", - "thiserror", + "thiserror 2.0.21", "windows", ] @@ -372,7 +664,7 @@ version = "0.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c03d949a14aa089bbb282f7dd76a498a7f684428e4257202efc119ec010376f9" dependencies = [ - "bitflags", + "bitflags 2.13.1", "bytemuck", "read-fonts", "smallvec", @@ -402,6 +694,12 @@ dependencies = [ name = "hello-snow-leopard" version = "0.0.0" +[[package]] +name = "hermit-abi" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17592d60ebacc7d5e169f4663c5f84f9161cc90328abcfe8456f41e4dfcb284" + [[package]] name = "icu_collections" version = "2.3.0" @@ -558,6 +856,36 @@ dependencies = [ "hashbrown 0.17.1", ] +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys 0.4.1", + "log", + "simd_cesu8", + "thiserror 2.0.21", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.119", +] + [[package]] name = "jni-sys" version = "0.3.1" @@ -586,6 +914,16 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "jobserver" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +dependencies = [ + "getrandom 0.4.3", + "libc", +] + [[package]] name = "js-sys" version = "0.3.105" @@ -636,12 +974,36 @@ version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" +[[package]] +name = "libredox" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61ff90caf6077a803a240f62fdbe88645a890bbca49ef8174c3cb0404362171d" +dependencies = [ + "bitflags 2.13.1", + "libc", + "plain", + "redox_syscall 0.9.4", +] + [[package]] name = "linebender_resource_handle" version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d4a5ff6bcca6c4867b1c4fd4ef63e4db7436ef363e0ad7531d1558856bae64f4" +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + [[package]] name = "litemap" version = "0.8.3" @@ -722,7 +1084,7 @@ checksum = "a616d2fb8c89516ac2723a581f69d6c18576046bed761bd6b305e5618e6ae130" dependencies = [ "arrayvec", "bit-set", - "bitflags", + "bitflags 2.13.1", "cfg-if", "cfg_aliases", "codespan-reporting", @@ -736,7 +1098,7 @@ dependencies = [ "once_cell", "rustc-hash", "spirv", - "thiserror", + "thiserror 2.0.21", "unicode-ident", ] @@ -749,9 +1111,30 @@ dependencies = [ "hashbrown 0.17.1", "indexmap", "rustc-hash", - "thiserror", + "thiserror 2.0.21", ] +[[package]] +name = "ndk" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" +dependencies = [ + "bitflags 2.13.1", + "jni-sys 0.3.1", + "log", + "ndk-sys", + "num_enum", + "raw-window-handle", + "thiserror 1.0.69", +] + +[[package]] +name = "ndk-context" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" + [[package]] name = "ndk-sys" version = "0.6.0+11769913" @@ -761,6 +1144,16 @@ dependencies = [ "jni-sys 0.3.1", ] +[[package]] +name = "nucleo-matcher" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf33f538733d1a5a3494b836ba913207f14d9d4a1d3cd67030c5061bdd2cac85" +dependencies = [ + "memchr", + "unicode-segmentation", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -771,6 +1164,44 @@ dependencies = [ "libm", ] +[[package]] +name = "num_enum" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "objc-sys" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb91bdd390c7ce1a8607f35f3ca7151b65afc0ff5ff3b34fa350f7d7c7e4310" + +[[package]] +name = "objc2" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46a785d4eeff09c14c487497c162e92766fbb3e4059a71840cecc03d9a50b804" +dependencies = [ + "objc-sys", + "objc2-encode", +] + [[package]] name = "objc2" version = "0.6.4" @@ -780,15 +1211,66 @@ dependencies = [ "objc2-encode", ] +[[package]] +name = "objc2-app-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4e89ad9e3d7d297152b17d39ed92cd50ca8063a89a9fa569046d41568891eff" +dependencies = [ + "bitflags 2.13.1", + "block2 0.5.1", + "libc", + "objc2 0.5.2", + "objc2-core-data", + "objc2-core-image", + "objc2-foundation 0.2.2", + "objc2-quartz-core 0.2.2", +] + [[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", + "objc2 0.6.4", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-cloud-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74dd3b56391c7a0596a295029734d3c1c5e7e510a4cb30245f8221ccea96b009" +dependencies = [ + "bitflags 2.13.1", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-core-location", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-contacts" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5ff520e9c33812fd374d8deecef01d4a840e7b41862d849513de77e44aa4889" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-core-data" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "617fbf49e071c178c0b24c080767db52958f716d9eabdf0890523aeae54773ef" +dependencies = [ + "bitflags 2.13.1", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", ] [[package]] @@ -797,9 +1279,9 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ - "bitflags", + "bitflags 2.13.1", "dispatch2", - "objc2", + "objc2 0.6.4", ] [[package]] @@ -809,18 +1291,42 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" dependencies = [ "dispatch2", - "objc2", + "objc2 0.6.4", "objc2-core-foundation", "objc2-io-surface", ] +[[package]] +name = "objc2-core-image" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55260963a527c99f1819c4f8e3b47fe04f9650694ef348ffd2227e8196d34c80" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", + "objc2-metal 0.2.2", +] + +[[package]] +name = "objc2-core-location" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "000cfee34e683244f284252ee206a27953279d370e309649dc3ee317b37e5781" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.2", + "objc2-contacts", + "objc2-foundation 0.2.2", +] + [[package]] name = "objc2-core-text" version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d" dependencies = [ - "bitflags", + "bitflags 2.13.1", "objc2-core-foundation", ] @@ -830,14 +1336,27 @@ version = "4.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" +[[package]] +name = "objc2-foundation" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8" +dependencies = [ + "bitflags 2.13.1", + "block2 0.5.1", + "dispatch", + "libc", + "objc2 0.5.2", +] + [[package]] name = "objc2-foundation" version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" dependencies = [ - "bitflags", - "objc2", + "bitflags 2.13.1", + "objc2 0.6.4", "objc2-core-foundation", ] @@ -847,21 +1366,58 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" dependencies = [ - "bitflags", - "objc2", + "bitflags 2.13.1", + "objc2 0.6.4", "objc2-core-foundation", ] +[[package]] +name = "objc2-link-presentation" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1a1ae721c5e35be65f01a03b6d2ac13a54cb4fa70d8a5da293d7b0020261398" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.2", + "objc2-app-kit 0.2.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-metal" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6" +dependencies = [ + "bitflags 2.13.1", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + [[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", + "bitflags 2.13.1", + "block2 0.6.2", + "objc2 0.6.4", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a" +dependencies = [ + "bitflags 2.13.1", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", + "objc2-metal 0.2.2", ] [[package]] @@ -870,12 +1426,67 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" dependencies = [ - "bitflags", - "objc2", + "bitflags 2.13.1", + "objc2 0.6.4", "objc2-core-foundation", "objc2-core-graphics", - "objc2-foundation", - "objc2-metal", + "objc2-foundation 0.3.2", + "objc2-metal 0.3.2", +] + +[[package]] +name = "objc2-symbols" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a684efe3dec1b305badae1a28f6555f6ddd3bb2c2267896782858d5a78404dc" +dependencies = [ + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-ui-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8bb46798b20cd6b91cbd113524c490f1686f4c4e8f49502431415f3512e2b6f" +dependencies = [ + "bitflags 2.13.1", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-cloud-kit", + "objc2-core-data", + "objc2-core-image", + "objc2-core-location", + "objc2-foundation 0.2.2", + "objc2-link-presentation", + "objc2-quartz-core 0.2.2", + "objc2-symbols", + "objc2-uniform-type-identifiers", + "objc2-user-notifications", +] + +[[package]] +name = "objc2-uniform-type-identifiers" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44fa5f9748dbfe1ca6c0b79ad20725a11eca7c2218bceb4b005cb1be26273bfe" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-user-notifications" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76cfcbf642358e8689af64cee815d139339f3ed8ad05103ed5eaf73db8d84cb3" +dependencies = [ + "bitflags 2.13.1", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-core-location", + "objc2-foundation 0.2.2", ] [[package]] @@ -884,6 +1495,16 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "orbclient" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5df339f526ea9a60e371768d50efc2f2508c7203290731565d1f7a6f71d21747" +dependencies = [ + "libc", + "libredox", +] + [[package]] name = "ordered-float" version = "5.5.0" @@ -893,6 +1514,15 @@ dependencies = [ "num-traits", ] +[[package]] +name = "owned_ttf_parser" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36820e9051aca1014ddc75770aab4d68bc1e9e632f0f5627c4086bc216fb583b" +dependencies = [ + "ttf-parser", +] + [[package]] name = "parking_lot" version = "0.12.5" @@ -911,7 +1541,7 @@ checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" dependencies = [ "cfg-if", "libc", - "redox_syscall", + "redox_syscall 0.5.18", "smallvec", "windows-link", ] @@ -960,6 +1590,32 @@ dependencies = [ "skrifa", ] +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "pin-project-lite" version = "0.2.17" @@ -972,19 +1628,39 @@ version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + [[package]] name = "png" version = "0.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" dependencies = [ - "bitflags", + "bitflags 2.13.1", "crc32fast", "fdeflate", "flate2", "miniz_oxide 0.8.9", ] +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix 1.1.5", + "windows-sys 0.61.2", +] + [[package]] name = "pollster" version = "0.4.0" @@ -1027,11 +1703,21 @@ checksum = "e8cf8e6a8aa66ce33f63993ffc4ea4271eb5b0530a9002db8455ea6050c77bfa" name = "probe" version = "0.0.0" dependencies = [ - "gl21", - "objc2", - "objc2-app-kit", - "objc2-foundation", + "draw", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", + "objc2-foundation 0.3.2", "png", + "ui", +] + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit", ] [[package]] @@ -1061,6 +1747,15 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" +[[package]] +name = "quick-xml" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1" +dependencies = [ + "memchr", +] + [[package]] name = "quote" version = "1.0.47" @@ -1070,6 +1765,18 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + [[package]] name = "range-alloc" version = "0.1.5" @@ -1088,10 +1795,10 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40d213455a5f1dc59214213c7330e074ddf8114c9a42411eb890c767357ce135" dependencies = [ - "objc2", + "objc2 0.6.4", "objc2-core-foundation", - "objc2-foundation", - "objc2-quartz-core", + "objc2-foundation 0.3.2", + "objc2-quartz-core 0.3.2", ] [[package]] @@ -1105,13 +1812,31 @@ dependencies = [ "once_cell", ] +[[package]] +name = "redox_syscall" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4722d768eff46b75989dd134e5c353f0d6296e5aaa3132e776cbdb56be7731aa" +dependencies = [ + "bitflags 1.3.2", +] + [[package]] name = "redox_syscall" version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags", + "bitflags 2.13.1", +] + +[[package]] +name = "redox_syscall" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "737970939a87c6fa31e7acad13307bccbb017a073b695b6089a2c484f929e20e" +dependencies = [ + "bitflags 2.13.1", ] [[package]] @@ -1121,6 +1846,7 @@ dependencies = [ "draw", "png", "pollster", + "ui", "wgpu", ] @@ -1145,18 +1871,87 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags 2.13.1", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.59.0", +] + +[[package]] +name = "rustix" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" +dependencies = [ + "bitflags 2.13.1", + "errno", + "libc", + "linux-raw-sys 0.12.1", + "windows-sys 0.61.2", +] + [[package]] name = "rustversion" version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "scoped-tls" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" + [[package]] name = "scopeguard" version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "sctk-adwaita" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6277f0217056f77f1d8f49f2950ac6c278c0d607c45f5ee99328d792ede24ec" +dependencies = [ + "ab_glyph", + "log", + "memmap2", + "smithay-client-toolkit", + "tiny-skia", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + [[package]] name = "serde" version = "1.0.229" @@ -1187,12 +1982,34 @@ dependencies = [ "syn 3.0.6", ] +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + [[package]] name = "simd-adler32" version = "0.3.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" +[[package]] +name = "simd_cesu8" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + [[package]] name = "skrifa" version = "0.44.0" @@ -1224,13 +2041,47 @@ version = "1.16.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f9395f0f0eee849a9b707b2f06bb92a6a422090e2123bb2ef8e87a0e61892a8e" +[[package]] +name = "smithay-client-toolkit" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3457dea1f0eb631b4034d61d4d8c32074caa6cd1ab2d59f2327bd8461e2c0016" +dependencies = [ + "bitflags 2.13.1", + "calloop", + "calloop-wayland-source", + "cursor-icon", + "libc", + "log", + "memmap2", + "rustix 0.38.44", + "thiserror 1.0.69", + "wayland-backend", + "wayland-client", + "wayland-csd-frame", + "wayland-cursor", + "wayland-protocols", + "wayland-protocols-wlr", + "wayland-scanner", + "xkeysym", +] + +[[package]] +name = "smol_str" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd538fb6910ac1099850255cf94a94df6551fbdd602454387d0adb2d1ca6dead" +dependencies = [ + "serde", +] + [[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", + "bitflags 2.13.1", ] [[package]] @@ -1245,6 +2096,12 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" +[[package]] +name = "strict-num" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731" + [[package]] name = "swash" version = "0.2.10" @@ -1298,13 +2155,33 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + [[package]] name = "thiserror" version = "2.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" dependencies = [ - "thiserror-impl", + "thiserror-impl 2.0.21", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", ] [[package]] @@ -1332,6 +2209,31 @@ dependencies = [ "zune-jpeg", ] +[[package]] +name = "tiny-skia" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83d13394d44dae3207b52a326c0c85a8bf87f1541f23b0d143811088497b09ab" +dependencies = [ + "arrayref", + "arrayvec", + "bytemuck", + "cfg-if", + "log", + "tiny-skia-path", +] + +[[package]] +name = "tiny-skia-path" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c9e7fc0c2e86a30b117d0462aa261b72b7a99b7ebd7deb3a14ceda95c5bdc93" +dependencies = [ + "arrayref", + "bytemuck", + "strict-num", +] + [[package]] name = "tinystr" version = "0.8.4" @@ -1343,12 +2245,80 @@ dependencies = [ "zerovec", ] +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.25.15+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1340ea94a5856333492c9064b02c778b191dd2c853778d9609debdcdfea3a614" +dependencies = [ + "indexmap", + "toml_datetime", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" +dependencies = [ + "winnow", +] + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-core", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" + +[[package]] +name = "ttf-parser" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31" + +[[package]] +name = "ui" +version = "0.1.0" +dependencies = [ + "draw", + "nucleo-matcher", + "parley", + "winit", +] + [[package]] name = "unicode-ident" version = "1.0.26" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + [[package]] name = "unicode-width" version = "0.2.2" @@ -1367,6 +2337,25 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + [[package]] name = "wasm-bindgen" version = "0.2.128" @@ -1422,6 +2411,103 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "wayland-backend" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38a91b4eaddff87b1cd1074985e3713da4af2c49742d1b356b2c01670a67a078" +dependencies = [ + "cc", + "downcast-rs", + "rustix 1.1.5", + "scoped-tls", + "smallvec", + "wayland-sys", +] + +[[package]] +name = "wayland-client" +version = "0.31.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c36a0f861ad76d0901f2800b46321410d9f73f2ea88aac0650d86c32688073" +dependencies = [ + "bitflags 2.13.1", + "rustix 1.1.5", + "wayland-backend", + "wayland-scanner", +] + +[[package]] +name = "wayland-csd-frame" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625c5029dbd43d25e6aa9615e88b829a5cad13b2819c4ae129fdbb7c31ab4c7e" +dependencies = [ + "bitflags 2.13.1", + "cursor-icon", + "wayland-backend", +] + +[[package]] +name = "wayland-cursor" +version = "0.31.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a52d18780be9b1314328a3de5f930b73d2200112e3849ca6cb11822793fb34d" +dependencies = [ + "rustix 1.1.5", + "wayland-client", + "xcursor", +] + +[[package]] +name = "wayland-protocols" +version = "0.32.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23d0c813de3daa2ed6520af85a3bd49b0e722a3078506899aa9686fea58dc4b6" +dependencies = [ + "bitflags 2.13.1", + "wayland-backend", + "wayland-client", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-plasma" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b6d8cf1eb2c1c31ed1f5643c88a6e53538129d4af80030c8cabd1f9fa884d91" +dependencies = [ + "bitflags 2.13.1", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-wlr" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb04e52f7836d7c7976c78ca0250d61e33873c34156a2a1fc9474828ec268234" +dependencies = [ + "bitflags 2.13.1", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-scanner" +version = "0.31.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "338e30461b3a2b67d70eb30a6d89f8e0c93a833e07d2ae89085cd070c4a00ac0" +dependencies = [ + "proc-macro2", + "quick-xml", + "quote", +] + [[package]] name = "wayland-sys" version = "0.31.11" @@ -1444,6 +2530,16 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "weezl" version = "0.1.12" @@ -1457,7 +2553,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "527ccdf43dd5b2e8676eed9984ce00e2bbb0a1b85b70c1969dcb6cd2eb55ab9e" dependencies = [ "arrayvec", - "bitflags", + "bitflags 2.13.1", "bytemuck", "cfg-if", "cfg_aliases", @@ -1489,7 +2585,7 @@ dependencies = [ "arrayvec", "bit-set", "bit-vec", - "bitflags", + "bitflags 2.13.1", "bytemuck", "cfg_aliases", "document-features", @@ -1505,7 +2601,7 @@ dependencies = [ "raw-window-handle", "rustc-hash", "smallvec", - "thiserror", + "thiserror 2.0.21", "wgpu-core-deps-apple", "wgpu-core-deps-emscripten", "wgpu-core-deps-windows-linux-android", @@ -1551,8 +2647,8 @@ dependencies = [ "arrayvec", "ash", "bit-set", - "bitflags", - "block2", + "bitflags 2.13.1", + "block2 0.6.2", "bytemuck", "cfg-if", "cfg_aliases", @@ -1568,12 +2664,12 @@ dependencies = [ "naga", "naga-types", "ndk-sys", - "objc2", + "objc2 0.6.4", "objc2-core-foundation", "objc2-core-graphics", - "objc2-foundation", - "objc2-metal", - "objc2-quartz-core", + "objc2-foundation 0.3.2", + "objc2-metal 0.3.2", + "objc2-quartz-core 0.3.2", "once_cell", "ordered-float", "parking_lot", @@ -1586,7 +2682,7 @@ dependencies = [ "renderdoc-sys", "smallvec", "static_assertions", - "thiserror", + "thiserror 2.0.21", "wasm-bindgen", "wayland-sys", "web-sys", @@ -1613,7 +2709,7 @@ version = "30.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "99dad6f1fbdbbdb4c278a6508b059d44688f5cebddf78d005a46a31340269286" dependencies = [ - "bitflags", + "bitflags 2.13.1", "bytemuck", "js-sys", "log", @@ -1629,7 +2725,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -1733,6 +2829,24 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets", +] + [[package]] name = "windows-sys" version = "0.61.2" @@ -1742,6 +2856,22 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + [[package]] name = "windows-threading" version = "0.2.1" @@ -1751,12 +2881,184 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winit" +version = "0.30.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6755fa58a9f8350bd1e472d4c3fcc25f824ec358933bba33306d0b63df5978d" +dependencies = [ + "ahash", + "android-activity", + "atomic-waker", + "bitflags 2.13.1", + "block2 0.5.1", + "bytemuck", + "calloop", + "cfg_aliases", + "concurrent-queue", + "core-foundation", + "core-graphics", + "cursor-icon", + "dpi", + "js-sys", + "libc", + "memmap2", + "ndk", + "objc2 0.5.2", + "objc2-app-kit 0.2.2", + "objc2-foundation 0.2.2", + "objc2-ui-kit", + "orbclient", + "percent-encoding", + "pin-project", + "raw-window-handle", + "redox_syscall 0.4.1", + "rustix 0.38.44", + "sctk-adwaita", + "smithay-client-toolkit", + "smol_str", + "tracing", + "unicode-segmentation", + "wasm-bindgen", + "wasm-bindgen-futures", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-protocols-plasma", + "web-sys", + "web-time", + "windows-sys 0.52.0", + "x11-dl", + "x11rb", + "xkbcommon-dl", +] + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +dependencies = [ + "memchr", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + [[package]] name = "writeable" version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" +[[package]] +name = "x11-dl" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38735924fedd5314a6e548792904ed8c6de6636285cb9fec04d5b1db85c1516f" +dependencies = [ + "libc", + "once_cell", + "pkg-config", +] + +[[package]] +name = "x11rb" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" +dependencies = [ + "as-raw-xcb-connection", + "gethostname", + "libc", + "libloading", + "once_cell", + "rustix 1.1.5", + "x11rb-protocol", +] + +[[package]] +name = "x11rb-protocol" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" + +[[package]] +name = "xcursor" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "163b33ed8786455e2fa5d72f554057ce3f3182425434f756cd39c99839d88e23" + +[[package]] +name = "xkbcommon-dl" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d039de8032a9a8856a6be89cea3e5d12fdd82306ab7c94d74e6deab2460651c5" +dependencies = [ + "bitflags 2.13.1", + "dlib", + "log", + "once_cell", + "xkeysym", +] + +[[package]] +name = "xkeysym" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56" + [[package]] name = "xml-rs" version = "0.8.29" diff --git a/platform/snow-leopard/Cargo.toml b/platform/snow-leopard/Cargo.toml index 75aefbc119126a4760df7784c8ed18d9ead92a58..b610297ec49e9e4dcfbdd876585e825dcdb12c71 100644 --- a/platform/snow-leopard/Cargo.toml +++ b/platform/snow-leopard/Cargo.toml @@ -1,7 +1,7 @@ # 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"] +members = ["hello", "probe", "reference"] resolver = "3" [workspace.dependencies] diff --git a/platform/snow-leopard/console/input.c b/platform/snow-leopard/console/input.c new file mode 100644 index 0000000000000000000000000000000000000000..d48e3faa059ea6ea2ed1da71ccfe699d2d1efdea --- /dev/null +++ b/platform/snow-leopard/console/input.c @@ -0,0 +1,48 @@ +// Posts input to the logged-in session as the HID system would, for driving the app +// over ssh: `input click X Y`, `input type TEXT`, `input key KEYCODE [command]`. +// Points are the display's, from its top left. +#include +#include +#include +#include + +static void post(CGEventRef event) { + CGEventPost(kCGHIDEventTap, event); + CFRelease(event); + usleep(20000); +} + +static void mouse(CGEventType type, CGPoint at) { + post(CGEventCreateMouseEvent(NULL, type, at, kCGMouseButtonLeft)); +} + +static void key(CGKeyCode code, CGEventFlags flags, const UniChar *text, UniCharCount length) { + for (int down = 1; down >= 0; down--) { + CGEventRef event = CGEventCreateKeyboardEvent(NULL, code, down); + CGEventSetFlags(event, flags); + if (length) CGEventKeyboardSetUnicodeString(event, length, text); + post(event); + } +} + +int main(int argc, char **argv) { + if (argc == 4 && !strcmp(argv[1], "click")) { + CGPoint at = CGPointMake(atof(argv[2]), atof(argv[3])); + mouse(kCGEventMouseMoved, at); + mouse(kCGEventLeftMouseDown, at); + mouse(kCGEventLeftMouseUp, at); + } else if (argc == 3 && !strcmp(argv[1], "type")) { + CFStringRef text = CFStringCreateWithCString(NULL, argv[2], kCFStringEncodingUTF8); + for (CFIndex i = 0; i < CFStringGetLength(text); i++) { + UniChar c = CFStringGetCharacterAtIndex(text, i); + key(0, 0, &c, 1); + } + CFRelease(text); + } else if ((argc == 3 || argc == 4) && !strcmp(argv[1], "key")) { + key(atoi(argv[2]), argc == 4 && !strcmp(argv[3], "command") ? kCGEventFlagMaskCommand : 0, NULL, 0); + } else { + fprintf(stderr, "usage: input click X Y | input type TEXT | input key KEYCODE [command]\n"); + return 2; + } + return 0; +} diff --git a/platform/snow-leopard/console/screen.c b/platform/snow-leopard/console/screen.c new file mode 100644 index 0000000000000000000000000000000000000000..75ad8522d2dae12173a2360033e32653e987fc96 --- /dev/null +++ b/platform/snow-leopard/console/screen.c @@ -0,0 +1,27 @@ +// Writes the main display to a PNG, for a session reached over ssh, where +// screencapture exits 0 without writing a file. Wakes a sleeping display first: asleep, +// it shows its last frame. +#include +#include +#include +#include +#include + +int main(int argc, char **argv) { + if (argc != 2) { + fprintf(stderr, "usage: screen OUT.png\n"); + return 2; + } + UpdateSystemActivity(UsrActivity); + sleep(2); + CGImageRef image = CGDisplayCreateImage(CGMainDisplayID()); + if (!image) { + fprintf(stderr, "screen: no image of the display\n"); + return 1; + } + CFURLRef url = CFURLCreateFromFileSystemRepresentation(NULL, (const UInt8 *)argv[1], strlen(argv[1]), false); + CGImageDestinationRef file = CGImageDestinationCreateWithURL(url, CFSTR("public.png"), 1, NULL); + CGImageDestinationAddImage(file, image, NULL); + int ok = CGImageDestinationFinalize(file); + return ok ? 0 : 1; +} diff --git a/platform/snow-leopard/console/selectors.c b/platform/snow-leopard/console/selectors.c new file mode 100644 index 0000000000000000000000000000000000000000..77b7cfb4ac08b785b7238561676dc4ff86106c30 --- /dev/null +++ b/platform/snow-leopard/console/selectors.c @@ -0,0 +1,45 @@ +// Reads selector names on stdin and prints those no class on this system implements, +// instance or class side: calls a 10.6 binary could make that would raise at run time. +// `strings BINARY` supplies candidates; names that were never selectors print too. +// Method lists are read directly: asking a class resolves methods, which some raise. +#include +#include +#include +#include +#include + +static int compare(const void *a, const void *b) { + return strcmp(*(const char *const *)a, *(const char *const *)b); +} + +int main(void) { + const char *frameworks[] = {"AppKit", "Carbon", "QuartzCore", "Security", "OpenGL"}; + for (size_t i = 0; i < sizeof frameworks / sizeof *frameworks; i++) { + char path[256]; + snprintf(path, sizeof path, "/System/Library/Frameworks/%s.framework/%s", frameworks[i], frameworks[i]); + dlopen(path, RTLD_NOW); + } + int count = objc_getClassList(NULL, 0); + Class *classes = malloc(sizeof(Class) * count); + objc_getClassList(classes, count); + size_t names = 0, capacity = 1 << 16; + const char **known = malloc(sizeof(char *) * capacity); + for (int i = 0; i < count * 2; i++) { + Class cls = i < count ? classes[i] : object_getClass((id)classes[i - count]); + unsigned methods = 0; + Method *list = class_copyMethodList(cls, &methods); + for (unsigned m = 0; m < methods; m++) { + if (names == capacity) known = realloc(known, sizeof(char *) * (capacity *= 2)); + known[names++] = sel_getName(method_getName(list[m])); + } + free(list); + } + qsort(known, names, sizeof(char *), compare); + char line[1024]; + while (fgets(line, sizeof line, stdin)) { + line[strcspn(line, "\n")] = 0; + const char *name = line; + if (!bsearch(&name, known, names, sizeof(char *), compare)) puts(line); + } + return 0; +} diff --git a/platform/snow-leopard/gl21/Cargo.toml b/platform/snow-leopard/gl21/Cargo.toml deleted file mode 100644 index 8a60a52bd918c2c7762d0ba2e9d8cf0e10fe218f..0000000000000000000000000000000000000000 --- a/platform/snow-leopard/gl21/Cargo.toml +++ /dev/null @@ -1,6 +0,0 @@ -[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/lib.rs b/platform/snow-leopard/gl21/src/lib.rs deleted file mode 100644 index 24edde8424f1d27956876ea2b201a33623b21e96..0000000000000000000000000000000000000000 --- a/platform/snow-leopard/gl21/src/lib.rs +++ /dev/null @@ -1,118 +0,0 @@ -//! 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 deleted file mode 100644 index 3340b6c1e89dc97cb01356d8ef40144b486ce76a..0000000000000000000000000000000000000000 --- a/platform/snow-leopard/gl21/src/painter.rs +++ /dev/null @@ -1,272 +0,0 @@ -//! 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/link.sh b/platform/snow-leopard/link.sh index 46fb1a039676f14a8b6396b436239121c4a668b7..30f6afbe323c27af38bbef0eaca2698880ebdcc7 100755 --- a/platform/snow-leopard/link.sh +++ b/platform/snow-leopard/link.sh @@ -17,8 +17,8 @@ for arg; do 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. + # Test binaries whose dev-dependencies take draw's wgpu backend (ui, canvas) + # name Metal, which 10.6 lacks; nothing they run on 10.6 calls into it. Metal) arg=Foundation ;; esac fi diff --git a/platform/snow-leopard/probe/Cargo.toml b/platform/snow-leopard/probe/Cargo.toml index baced6a5859ac687c90d4c27ace200730d2a7236..005ce235a4f6d7e68317cf4d23cef72fa3634213 100644 --- a/platform/snow-leopard/probe/Cargo.toml +++ b/platform/snow-leopard/probe/Cargo.toml @@ -5,20 +5,21 @@ edition = "2024" publish = false [dependencies] -gl21 = { path = "../gl21" } +draw = { path = "../../../crates/draw", default-features = false, features = ["render"] } 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"] } +objc2-foundation = { workspace = true, features = ["std", "NSString", "NSObject", "NSZone"] } +objc2-app-kit = { workspace = true, features = ["std", "NSOpenGL"] } png = "0.18" +ui = { path = "../../../crates/ui" } [[bin]] name = "probe" path = "src/main.rs" -[[bin]] -name = "window-probe" -path = "src/window.rs" - [[bin]] name = "render-probe" path = "src/render.rs" + +[[bin]] +name = "text-probe" +path = "src/text.rs" diff --git a/platform/snow-leopard/probe/src/context.rs b/platform/snow-leopard/probe/src/context.rs new file mode 100644 index 0000000000000000000000000000000000000000..ba9a9f5bf3cba35833a58f03729e1277931e22ce --- /dev/null +++ b/platform/snow-leopard/probe/src/context.rs @@ -0,0 +1,15 @@ +//! An offscreen OpenGL context, current on this thread. +use objc2::{MainThreadMarker, rc::Retained}; +use objc2_app_kit::{NSOpenGLContext, NSOpenGLPFAAccelerated, NSOpenGLPFAColorSize, NSOpenGLPixelFormat}; + +pub fn current() -> Retained { + 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(); + context +} diff --git a/platform/snow-leopard/probe/src/main.rs b/platform/snow-leopard/probe/src/main.rs index 12398a9e8421637b004209ccdb21d3145256f50d..4ad94fd2da2b7682704e5dd31ece4520d8832633 100644 --- a/platform/snow-leopard/probe/src/main.rs +++ b/platform/snow-leopard/probe/src/main.rs @@ -1,4 +1,4 @@ -use objc2::{rc::{Retained, autoreleasepool}, runtime::NSObjectProtocol}; +use objc2::rc::{Retained, autoreleasepool}; use objc2_foundation::NSCopying; use objc2_foundation::{NSObject, NSString}; diff --git a/platform/snow-leopard/probe/src/render.rs b/platform/snow-leopard/probe/src/render.rs index ee2acfe02fc9c887e263937a54d8903077fbb13b..3a34a038281a508bb4cb96a5d02230b22c971894 100644 --- a/platform/snow-leopard/probe/src/render.rs +++ b/platform/snow-leopard/probe/src/render.rs @@ -1,102 +1,23 @@ -//! 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. +//! Paints `scene` through `draw` over OpenGL into an offscreen target and writes +//! `render-probe.png`. #![allow(deprecated)] // OpenGL is the only GPU API 10.6 has. +mod context; 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 _context = context::current(); + let mut renderer = draw::Renderer::new().unwrap_or_else(|error| panic!("{error}")); + let target = draw::Target::new(scene::SIZE).unwrap(); + let checker = scene::checker(); + let primitives = scene::primitives(&checker); let started = std::time::Instant::now(); - painter.draw(&target, scene::CLEAR, &vertices, &batches); + renderer.draw(&target, scene::SIZE, scene::CLEAR, &[scene::layer(&primitives)]).unwrap(); let pixels = target.read_pixels(); - println!("drew {} vertices in {:?}; GL error {:#x}", vertices.len(), started.elapsed(), unsafe { gl21::glGetError() }); + println!("drew in {:?}", started.elapsed()); 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); diff --git a/platform/snow-leopard/probe/src/scene.rs b/platform/snow-leopard/probe/src/scene.rs index c98b8dd064e2816bc9c31347201216fecd5d23d7..7edafc16e304c378376193e93650217846f68e94 100644 --- a/platform/snow-leopard/probe/src/scene.rs +++ b/platform/snow-leopard/probe/src/scene.rs @@ -1,40 +1,74 @@ -//! 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. +//! One scene painted by `draw` over wgpu on the dev Mac (`reference`) and over OpenGL on +//! 10.6 (`render-probe`), whose PNGs `reference --compare` diffs. Device pixels, linear +//! colours. +#![allow(dead_code)] // Each binary paints part of it. +use draw::{Layer, Primitive, RasterImage, Stroke}; 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] }, +/// A 4×4 opaque sRGB checker, stretched and filtered by the image path. +pub fn checker() -> RasterImage { + let pixels = (0..16) + .flat_map(|i| if (i % 4 + i / 4) % 2 == 0 { [240, 200, 40, 255] } else { [30, 120, 90, 255] }) + .collect(); + RasterImage::new([4, 4], pixels).unwrap() } -pub fn shapes() -> Vec { +pub fn primitives(checker: &RasterImage) -> 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::*; + use Primitive::*; 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] }, + Gradient { rect: [140.0, 20.0, 260.0, 80.0], radius: [12.0, 12.0], colors: [blue; 2] }, + Gradient { rect: [280.0, 20.5, 380.0, 80.25], radius: [30.0, 12.0], colors: [green; 2] }, + RoundedRect { rect: [20.0, 100.0, 120.0, 160.0], radius: [10.0, 10.0], stroke: Some(Stroke::Solid(3.0)), color: ink }, + RoundedRect { rect: [140.0, 100.0, 260.0, 160.0], radius: [16.0, 16.0], stroke: Some(Stroke::Dashed(2.0)), color: ink }, + Gradient { rect: [280.0, 100.0, 380.0, 160.0], radius: [8.0, 8.0], colors: [[0.9, 0.5, 0.1, 1.0], [0.2, 0.05, 0.4, 1.0]] }, + Image { image: checker, 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] }, + RoundedRect { rect: [220.0, 180.0, 380.0, 280.0], radius: [30.0, 10.0], stroke: Some(Stroke::Dashed(3.0)), color: red }, ] } -/// 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) +pub fn layer<'a>(primitives: &'a [Primitive<'a>]) -> Layer<'a> { + Layer { scale: 1.0, origin: [0.0; 2], clip: None, backdrop: None, motion: None, primitives } +} + +/// Lines of interface text through `ui`: black on white like a Cocoa label, the same in +/// Arial, then the light theme's text on its strip. Logical pixels. +pub const TEXT_SIZE: [u32; 2] = [640, 150]; +pub const TEXT: [&str; 2] = [ + "Garden Kitchen Journal Reading Trips Home", + "Search All Notebooks Up to date 100% Calibri", +]; + +pub fn interface(scale: f32) -> ui::Ui { + let theme = ui::Theme::light(); + let strip = theme.strip; + let mut ui = ui::Ui::new(theme, std::time::Duration::from_millis(500)); + ui.begin(TEXT_SIZE.map(|side| side as f32), scale, std::time::Instant::now()); + ui.open("page", ui::Spec { size: [ui::fill(), ui::fill()], axis: ui::Axis::Y, fill: Some([1.0; 4]), pad: [10.0, 6.0], gap: 6.0, ..Default::default() }); + let black = Some([0.0, 0.0, 0.0, 1.0]); + for (index, text) in TEXT.into_iter().enumerate() { + ui.leaf(index, ui::Spec { size: [ui::fit(), ui::px(24.0)], text: Some(text), color: black, ..Default::default() }); + } + ui.leaf("arial", ui::Spec { size: [ui::fit(), ui::px(24.0)], text: Some(TEXT[0]), font: Some("Arial"), color: black, ..Default::default() }); + ui.leaf("strip", ui::Spec { size: [ui::fill(), ui::px(24.0)], text: Some(TEXT[1]), fill: Some(strip), ..Default::default() }); + ui.close(); + ui.end(); + ui +} + +pub fn interface_layers<'a>(layers: &'a [ui::Layer<'a>], scale: f32) -> Vec> { + layers + .iter() + .map(|layer| match layer { + ui::Layer::Primitives(primitives) => primitives.layer(scale), + ui::Layer::Custom { .. } => unreachable!(), + }) + .collect() } diff --git a/platform/snow-leopard/probe/src/text.rs b/platform/snow-leopard/probe/src/text.rs new file mode 100644 index 0000000000000000000000000000000000000000..ecf1496afb3510dbc82219a7be7d7bc0392e248a --- /dev/null +++ b/platform/snow-leopard/probe/src/text.rs @@ -0,0 +1,20 @@ +//! Paints `scene::interface` through `draw` over OpenGL and writes `text-probe.png`, to +//! set beside a Cocoa label's pixels. +#![allow(deprecated)] // OpenGL is the only GPU API 10.6 has. +mod context; +mod scene; + +fn main() { + objc2::rc::autoreleasepool(|_| { + let _context = context::current(); + let mut renderer = draw::Renderer::new().unwrap_or_else(|error| panic!("{error}")); + let target = draw::Target::new(scene::TEXT_SIZE).unwrap(); + let ui = scene::interface(1.0); + let layers = ui.layers(); + renderer.draw(&target, scene::TEXT_SIZE, [1.0; 4], &scene::interface_layers(&layers, 1.0)).unwrap(); + let file = std::fs::File::create("text-probe.png").unwrap(); + let mut encoder = png::Encoder::new(file, scene::TEXT_SIZE[0], scene::TEXT_SIZE[1]); + encoder.set_color(png::ColorType::Rgba); + encoder.write_header().unwrap().write_image_data(&target.read_pixels()).unwrap(); + }); +} diff --git a/platform/snow-leopard/probe/src/window.rs b/platform/snow-leopard/probe/src/window.rs deleted file mode 100644 index 4407b743dc4484ff4655f805f48791bff346ae46..0000000000000000000000000000000000000000 --- a/platform/snow-leopard/probe/src/window.rs +++ /dev/null @@ -1,133 +0,0 @@ -//! 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 index 8c4c825763e30b8e1e6c2762819568f8ba2682ee..530db8cde088bfb605129a31592ca38dc399aced 100644 --- a/platform/snow-leopard/reference/Cargo.toml +++ b/platform/snow-leopard/reference/Cargo.toml @@ -10,3 +10,4 @@ draw = { path = "../../../crates/draw" } wgpu = "30.0.1" pollster = "0.4" png = "0.18" +ui = { path = "../../../crates/ui" } diff --git a/platform/snow-leopard/reference/src/main.rs b/platform/snow-leopard/reference/src/main.rs index 1c93b2f1f2a76ffa71702512799616dcefc73b7d..62d1c09f85b28b2bb6a272f47c4394d6cb55a955 100644 --- a/platform/snow-leopard/reference/src/main.rs +++ b/platform/snow-leopard/reference/src/main.rs @@ -1,18 +1,26 @@ -//! `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. +//! `reference OUT.png` paints the render-probe scene through `draw` over wgpu, +//! `reference --text SCALE OUT.png` the text-probe scene; `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"), + [flag, scale, out] if flag == "--text" => { + let scale: f32 = scale.parse().unwrap(); + let ui = scene::interface(scale); + let layers = ui.layers(); + let size = scene::TEXT_SIZE.map(|side| (side as f32 * scale) as u32); + paint(out, size, [1.0; 4], &scene::interface_layers(&layers, scale)); + } + [out] => { + let checker = scene::checker(); + let primitives = scene::primitives(&checker); + paint(out, scene::SIZE, scene::CLEAR, &[scene::layer(&primitives)]); + } + _ => panic!("usage: reference [--text SCALE] OUT.png | reference --compare A.png B.png DIFF.png"), } } @@ -47,31 +55,13 @@ fn write(path: &str, [width, height]: [u32; 2], pixels: &[u8]) { encoder.write_header().unwrap().write_image_data(pixels).unwrap(); } -fn paint(out: &str) { +fn paint(out: &str, size: [u32; 2], clear: [f32; 4], layers: &[draw::Layer<'_>]) { 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 [width, height] = size; let texture = device.create_texture(&wgpu::TextureDescriptor { label: None, size: wgpu::Extent3d { width, height, depth_or_array_layers: 1 }, @@ -82,9 +72,8 @@ fn paint(out: &str) { 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]) + .draw(&texture.create_view(&Default::default()), size, clear, layers) .unwrap(); let row = (width * 4).next_multiple_of(wgpu::COPY_BYTES_PER_ROW_ALIGNMENT); let buffer = device.create_buffer(&wgpu::BufferDescriptor { @@ -107,5 +96,5 @@ fn paint(out: &str) { 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); + write(out, size, &pixels); } diff --git a/platform/snow-leopard/remote.sh b/platform/snow-leopard/remote.sh index d71d6b710dd67450ec61f12ce82ee6932568aeeb..96abff474fbdbbd1bdc0fa10f31412d1abee664b 100755 --- a/platform/snow-leopard/remote.sh +++ b/platform/snow-leopard/remote.sh @@ -4,6 +4,14 @@ # remote.sh run [-C DIR] BINARY [ARGS...] # ship to /tmp/snow-leopard, run in DIR (default there), # print any new crash log +# remote.sh start BINARY [ARGS...] ship and leave running there, output in NAME.log; +# BINARY may be an .app bundle +# remote.sh stop NAME end what start started (a bundle by its executable) +# remote.sh screen LOCAL.png wake the display and capture it (screencapture +# writes nothing from ssh) +# remote.sh input ARGS... click X Y | type TEXT | key KEYCODE [command] +# remote.sh selectors BINARY selector-like names BINARY holds that nothing on +# 10.6 implements # remote.sh mirror PATH... copy repo paths to the same absolute paths there, so # test binaries find fixtures by CARGO_MANIFEST_DIR # remote.sh pull REMOTE [LOCAL] copy a file back (e.g. a screenshot) @@ -14,6 +22,33 @@ host=${SNOW_LEOPARD_HOST:-osx10.6@10.0.0.1} here=$(cd "$(dirname "$0")" && pwd) dir=/tmp/snow-leopard +ship() { + base=$(basename "$1") + if [ -d "$1" ]; then + (cd "$(dirname "$1")" && COPYFILE_DISABLE=1 tar czf - --no-mac-metadata --no-xattrs "$base") | + ssh "$host" "mkdir -p $dir && rm -rf '$dir/${base:?}' && tar xzf - -C $dir" + else + ssh "$host" "mkdir -p $dir && cat > $dir/$base && chmod +x $dir/$base" < "$1" + fi +} + +# Builds a console/ tool against the 10.6 SDK and ships it. +tool() { + sdk=$here/../../target/snow-leopard/MacOSX10.6.sdk + out=$here/../../target/snow-leopard/$1 + if [ ! "$out" -nt "$here/console/$1.c" ]; then + clang -arch x86_64 -isysroot "$sdk" -mmacosx-version-min=10.6 -O2 -Wall \ + -framework ApplicationServices -framework CoreServices -lobjc \ + "$here/console/$1.c" -o "$out" 2>&1 | grep -v -e "MH_DYLIB_STUB" -e "no platform load" >&2 || true + fi + ship "$out" +} + +quote() { + quoted= + for arg; do quoted="$quoted '$(printf %s "$arg" | sed "s/'/'\\\\''/g")'"; done +} + case "${1:-}" in run) shift @@ -22,9 +57,8 @@ run) 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 + ship "$binary" + quote "$@" # 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 @@ -35,6 +69,37 @@ run) fi exit \$status" < /dev/null ;; +start) + shift + name=$(basename "$1") + program=./$name + if [ -d "$1" ]; then + name=$(/usr/libexec/PlistBuddy -c 'Print CFBundleExecutable' "$1/Contents/Info.plist") + program=./$(basename "$1")/Contents/MacOS/$name + fi + ship "$1" + shift + quote "$@" + ssh "$host" "cd $dir && (nohup $program$quoted < /dev/null > $name.log 2>&1 &)" + ;; +stop) + ssh "$host" "kill \$(ps axo pid,command | awk '{ sub(\".*/\", \"\", \$2) } \$2 == \"$2\" { print \$1 }')" + ;; +screen) + tool screen + ssh "$host" "$dir/screen $dir/screen.png && cat $dir/screen.png" > "$2" + ;; +input) + shift + tool input + quote "$@" + ssh "$host" "$dir/input$quoted" + ;; +selectors) + tool selectors + strings -a "$2" | grep -E '^[a-z][a-z]+([A-Z][a-z0-9]+)*[A-Z]?[A-Za-z0-9]*(:([A-Za-z][A-Za-z0-9]*:)*)?$' | + grep -E '[a-z]{2}[A-Z][a-z]|:$' | sort -u | ssh "$host" "$dir/selectors 2>/dev/null" + ;; mirror) shift root=$(cd "$here/../.." && pwd) @@ -54,7 +119,7 @@ sh) ssh "$host" "$@" ;; *) - sed -n '2,12s/^# \{0,1\}//p' "$0" >&2 + sed -n '2,20s/^# \{0,1\}//p' "$0" >&2 exit 2 ;; esac diff --git a/platform/snow-leopard/rt/appkit.c b/platform/snow-leopard/rt/appkit.c new file mode 100644 index 0000000000000000000000000000000000000000..51983705345d64edb17839edd9680e37cf9c7987 --- /dev/null +++ b/platform/snow-leopard/rt/appkit.c @@ -0,0 +1,146 @@ +// AppKit that winit, AccessKit and the app call and 10.6 lacks, added at load time with +// the neutral answer: scale 1, no precise scrolling, no appearances, no tabbing, no full +// screen. `remote.sh selectors` lists what a binary names that 10.6 lacks. Linked from +// the archive by the appearance name winit imports, so only AppKit binaries carry it. +// class_addMethod adds nothing where the system already has the method. +#include +#include +#include + +// 10.9's appearance name; it names the stub appearance below. +const CFStringRef NSAppearanceNameAqua = CFSTR("NSAppearanceNameAqua"); + +// Accessibility names from 10.7 to 10.13, which AccessKit imports. 10.6's VoiceOver +// asks through the older attribute protocol, which never sees them. +const CFStringRef NSAccessibilityAnnouncementKey = CFSTR("AXAnnouncementKey"); +const CFStringRef NSAccessibilityAnnouncementRequestedNotification = CFSTR("AXAnnouncementRequested"); +const CFStringRef NSAccessibilityLanguageTextAttribute = CFSTR("AXLanguageText"); +const CFStringRef NSAccessibilityPriorityKey = CFSTR("AXPriorityKey"); +const CFStringRef NSAccessibilitySwitchSubrole = CFSTR("AXSwitch"); +const CFStringRef NSAccessibilityTabButtonSubrole = CFSTR("AXTabButton"); +const CFStringRef NSAccessibilityTextAlignmentAttribute = CFSTR("AXTextAlignment"); +const CFStringRef NSAccessibilityToggleSubrole = CFSTR("AXToggle"); + +extern void NSAccessibilityPostNotification(id element, CFStringRef notification); + +void NSAccessibilityPostNotificationWithUserInfo(id element, CFStringRef notification, id info) { + NSAccessibilityPostNotification(element, notification); +} + +typedef id (*Send)(id, SEL); +#define SEND(receiver, selector) ((Send)objc_msgSend)((id)(receiver), sel_registerName(selector)) +typedef double (*SendDouble)(id, SEL); + +static double one(id self, SEL _cmd) { return 1.0; } +static void ignore(id self, SEL _cmd, id value) {} +static BOOL no(id self, SEL _cmd) { return NO; } +static long zero(id self, SEL _cmd) { return 0; } +static id nothing(id self, SEL _cmd) { return nil; } + +static double delta_x(id self, SEL _cmd) { return ((SendDouble)objc_msgSend)(self, sel_registerName("deltaX")); } +static double delta_y(id self, SEL _cmd) { return ((SendDouble)objc_msgSend)(self, sel_registerName("deltaY")); } + +// 10.7: the window's frame origin is where its base coordinates start. +static CGRect rect_to_screen(id self, SEL _cmd, CGRect rect) { + CGRect frame = ((CGRect(*)(id, SEL))objc_msgSend_stret)(self, sel_registerName("frame")); + rect.origin.x += frame.origin.x; + rect.origin.y += frame.origin.y; + return rect; +} + +static CGPoint point_from_screen(id self, SEL _cmd, CGPoint point) { + CGRect frame = ((CGRect(*)(id, SEL))objc_msgSend_stret)(self, sel_registerName("frame")); + return CGPointMake(point.x - frame.origin.x, point.y - frame.origin.y); +} + +static id srgb_color(id self, SEL _cmd, CGFloat red, CGFloat green, CGFloat blue, CGFloat alpha) { + CGFloat components[] = {red, green, blue, alpha}; + return ((id (*)(id, SEL, id, CGFloat *, long))objc_msgSend)( + self, sel_registerName("colorWithColorSpace:components:count:"), + SEND(objc_getClass("NSColorSpace"), "sRGBColorSpace"), components, 4); +} + +// 10.10's label is what 10.6 calls the description attribute. +static void set_accessibility_label(id self, SEL _cmd, id label) { + ((void (*)(id, SEL, id, CFStringRef))objc_msgSend)( + self, sel_registerName("accessibilitySetOverrideValue:forAttribute:"), label, CFSTR("AXDescription")); +} + +static id appearance_class; + +static id stub_appearance(id self, SEL _cmd) { + static id shared; + if (!shared) shared = SEND(SEND(appearance_class, "alloc"), "init"); + return shared; +} + +static id aqua(id self, SEL _cmd) { return (id)NSAppearanceNameAqua; } +static id best_match(id self, SEL _cmd, id names) { return (id)NSAppearanceNameAqua; } + +// 10.6 answers component accessors only for the named RGB spaces; later systems answer +// them for any RGB colour space, such as the sRGB the app converts system colours to. +static CGFloat component(id self, int index) { + CGFloat components[4] = {0}; + ((void (*)(id, SEL, CGFloat *))objc_msgSend)(self, sel_registerName("getComponents:"), components); + return components[index]; +} +static CGFloat red(id self, SEL _cmd) { return component(self, 0); } +static CGFloat green(id self, SEL _cmd) { return component(self, 1); } +static CGFloat blue(id self, SEL _cmd) { return component(self, 2); } + +static id black(id self, SEL _cmd) { return SEND(objc_getClass("NSColor"), "blackColor"); } +static id secondary_selection(id self, SEL _cmd) { + return SEND(objc_getClass("NSColor"), "secondarySelectedControlColor"); +} + +#define RECT "{CGRect={CGPoint=dd}{CGSize=dd}}" + +static void add(const char *class_name, int meta, const char *selector, void *imp, const char *types) { + Class cls = (Class)objc_getClass(class_name); + if (meta) cls = object_getClass((id)cls); + class_addMethod(cls, sel_registerName(selector), (IMP)imp, types); +} + +__attribute__((constructor)) static void polyfill(void) { + add("NSScreen", 0, "backingScaleFactor", one, "d@:"); + add("NSWindow", 0, "backingScaleFactor", one, "d@:"); + add("NSWindow", 0, "convertRectToScreen:", rect_to_screen, RECT "@:" RECT); + add("NSWindow", 0, "convertPointFromScreen:", point_from_screen, "{CGPoint=dd}@:{CGPoint=dd}"); + add("NSWindow", 0, "performWindowDragWithEvent:", ignore, "v@:@"); + add("NSWindow", 0, "toggleFullScreen:", ignore, "v@:@"); + add("NSWindow", 0, "setTabbingMode:", ignore, "v@:q"); + add("NSWindow", 0, "setTabbingIdentifier:", ignore, "v@:@"); + add("NSWindow", 1, "setAllowsAutomaticWindowTabbing:", ignore, "v@:c"); + add("NSWindow", 0, "setTitlebarAppearsTransparent:", ignore, "v@:c"); + add("NSWindow", 0, "setTitleVisibility:", ignore, "v@:q"); + add("NSView", 0, "setWantsBestResolutionOpenGLSurface:", ignore, "v@:c"); + add("NSView", 0, "setAccessibilityLabel:", set_accessibility_label, "v@:@"); + add("NSDatePicker", 0, "setPresentsCalendarOverlay:", ignore, "v@:c"); + add("NSEvent", 0, "hasPreciseScrollingDeltas", no, "c@:"); + add("NSEvent", 0, "scrollingDeltaX", delta_x, "d@:"); + add("NSEvent", 0, "scrollingDeltaY", delta_y, "d@:"); + add("NSEvent", 0, "momentumPhase", zero, "Q@:"); + add("NSEvent", 0, "phase", zero, "Q@:"); + add("NSColorSpaceColor", 0, "redComponent", red, "d@:"); + add("NSColorSpaceColor", 0, "greenComponent", green, "d@:"); + add("NSColorSpaceColor", 0, "blueComponent", blue, "d@:"); + add("NSColor", 1, "colorWithSRGBRed:green:blue:alpha:", srgb_color, "@@:dddd"); + add("NSColor", 1, "textInsertionPointColor", black, "@@:"); + add("NSColor", 1, "unemphasizedSelectedTextBackgroundColor", secondary_selection, "@@:"); + + // One appearance, Aqua, which windows have and nothing changes. + appearance_class = (id)objc_getClass("NSAppearance"); + if (!appearance_class) { + Class cls = objc_allocateClassPair((Class)objc_getClass("NSObject"), "NSAppearance", 0); + objc_registerClassPair(cls); + appearance_class = (id)cls; + } + add("NSAppearance", 0, "name", aqua, "@@:"); + add("NSAppearance", 0, "bestMatchFromAppearancesWithNames:", best_match, "@@:@"); + add("NSAppearance", 1, "appearanceNamed:", nothing, "@@:@"); + add("NSAppearance", 1, "currentAppearance", nothing, "@@:"); + add("NSAppearance", 1, "setCurrentAppearance:", ignore, "v@:@"); + add("NSWindow", 0, "appearance", nothing, "@@:"); + add("NSWindow", 0, "setAppearance:", ignore, "v@:@"); + add("NSWindow", 0, "effectiveAppearance", stub_appearance, "@@:"); +} diff --git a/platform/snow-leopard/rt/objc.c b/platform/snow-leopard/rt/objc.c index 179e1f47482e0107141306216bbfe4a80f7f0e03..fbba059992c4c43d00e4db18ce79b2557eafd960 100644 --- a/platform/snow-leopard/rt/objc.c +++ b/platform/snow-leopard/rt/objc.c @@ -37,3 +37,14 @@ void *objc_autoreleasePoolPush(void) { } void objc_autoreleasePoolPop(void *pool) { SEND((id)pool, "drain"); } + +// 10.6 has no zeroing weak references for non-GC code. These hold the object unretained +// and never zero: sound only while the referent outlives the reference, as winit's view +// and its window do. +id objc_initWeak(id *location, id value) { return *location = value; } +id objc_storeWeak(id *location, id value) { return *location = value; } +id objc_loadWeakRetained(id *location) { return objc_retain(*location); } +id objc_loadWeak(id *location) { return objc_autorelease(objc_loadWeakRetained(location)); } +void objc_destroyWeak(id *location) { *location = nil; } +void objc_copyWeak(id *to, id *from) { *to = *from; } +void objc_moveWeak(id *to, id *from) { *to = *from; *from = nil; } diff --git a/tools/canvas/build_macos.py b/tools/canvas/build_macos.py index 9ba61a8f81a32095272653996f67b519c5ce62a6..73676fe5d66e23aa63f435e36f9e662348c8b0e8 100644 --- a/tools/canvas/build_macos.py +++ b/tools/canvas/build_macos.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Build the local macOS canvas bundle.""" +"""Build the local macOS canvas bundle, or with --snow-leopard the Mac OS X 10.6 one.""" import argparse import json from pathlib import Path @@ -7,32 +7,53 @@ import platform import plistlib import shutil import subprocess +import tempfile parser = argparse.ArgumentParser(description=__doc__) parser.add_argument('--release', action='store_true') parser.add_argument('--output', type=Path, help='Create a separate bundle at a new .app path') parser.add_argument('--bundle-id', help='Bundle identifier for the separate app') +parser.add_argument('--snow-leopard', action='store_true', + help='Build with platform/snow-leopard/cargo.sh into target/snow-leopard') args = parser.parse_args() if bool(args.output) != bool(args.bundle_id): parser.error('Use --output and --bundle-id together.') if args.output and (args.output.suffix != '.app' or args.output.exists()): parser.error('Choose a new output path ending in .app.') root = Path(__file__).resolve().parents[2] -subprocess.run(['cargo', 'build', '-p', 'snowbound'] + (['--release'] if args.release else []), cwd=root, check=True) -metadata = json.loads(subprocess.check_output(['cargo', 'metadata', '--format-version=1', '--no-deps'], cwd=root)) -target = Path(metadata['target_directory']) +profile = ['--release'] if args.release else [] +if args.snow_leopard: + subprocess.run([root / 'platform/snow-leopard/cargo.sh', 'build', '-p', 'snowbound', '--no-default-features'] + profile, + cwd=root, check=True) + target = root / 'target/snow-leopard' + built = target / 'x86_64-apple-macosx10.6' +else: + subprocess.run(['cargo', 'build', '-p', 'snowbound'] + profile, cwd=root, check=True) + metadata = json.loads(subprocess.check_output(['cargo', 'metadata', '--format-version=1', '--no-deps'], cwd=root)) + target = built = Path(metadata['target_directory']) bundle = args.output.resolve() if args.output else target / 'Snowbound.app' if args.output: bundle.mkdir(parents=True, exist_ok=False) binary = bundle / 'Contents/MacOS/Snowbound' binary.parent.mkdir(parents=True, exist_ok=True) pending = binary.with_suffix('.next') -shutil.copy2(target / ('release' if args.release else 'debug') / 'snowbound', pending) +shutil.copy2(built / ('release' if args.release else 'debug') / 'snowbound', pending) pending.replace(binary) -icon = 'Snowbound-Tahoe' if int(platform.mac_ver()[0].split('.')[0] or 0) >= 26 else 'Snowbound-Sequoia' +icons = root / 'crates/snowbound/assets/icon' resources = bundle / 'Contents/Resources' resources.mkdir(exist_ok=True) -shutil.copy2(root / 'crates/snowbound/assets/icon' / f'{icon}.icns', resources / 'Snowbound.icns') +if args.snow_leopard: + # 10.6 reads 256 and 512 pixel entries (ic08, ic09), which the checked-in icns lacks. + with tempfile.TemporaryDirectory() as scratch: + iconset = Path(scratch) / 'Snowbound.iconset' + iconset.mkdir() + for side in [16, 32, 128, 256, 512]: + subprocess.run(['sips', '-z', str(side), str(side), icons / 'Snowbound-SnowLeopard.png', + '--out', iconset / f'icon_{side}x{side}.png'], check=True, capture_output=True) + subprocess.run(['iconutil', '-c', 'icns', iconset, '-o', resources / 'Snowbound.icns'], check=True) +else: + icon = 'Snowbound-Tahoe' if int(platform.mac_ver()[0].split('.')[0] or 0) >= 26 else 'Snowbound-Sequoia' + shutil.copy2(icons / f'{icon}.icns', resources / 'Snowbound.icns') (bundle / 'Contents/Info.plist').write_bytes(plistlib.dumps({ 'CFBundleExecutable': binary.name, 'CFBundleIdentifier': args.bundle_id or 'dev.snowbound.app', @@ -43,7 +64,9 @@ shutil.copy2(root / 'crates/snowbound/assets/icon' / f'{icon}.icns', resources / 'NSHighResolutionCapable': True, 'NSPrincipalClass': 'NSApplication', 'CFBundleIconFile': 'Snowbound', -})) -subprocess.run(['codesign', '--force', '--sign', '-', str(bundle)], check=True) -subprocess.run(['codesign', '--verify', '--strict', str(bundle)], check=True) +} | ({'LSMinimumSystemVersion': '10.6'} if args.snow_leopard else {}))) +# 10.6 runs the bundle unsigned. +if not args.snow_leopard: + subprocess.run(['codesign', '--force', '--sign', '-', str(bundle)], check=True) + subprocess.run(['codesign', '--verify', '--strict', str(bundle)], check=True) print(bundle) -- 2.54.0