Compare commits
30 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 24f6c797f2 | |||
| a2885a3283 | |||
| 0143f1e465 | |||
| dc57f9af20 | |||
| 984ede7d55 | |||
| fc398bd1f9 | |||
| 75faf3efaa | |||
| 4f5dd1f399 | |||
| 033ae23b7c | |||
| a0be70d5a6 | |||
| ef29427877 | |||
| 2af0155e6a | |||
| 8130c5cc68 | |||
| 67d23a229f | |||
| 6c9515c385 | |||
| d4f12f6773 | |||
| 65a70996c4 | |||
| 2b8443a1b1 | |||
| c169058915 | |||
| 4c2003d05e | |||
| 42bb758383 | |||
| 26dab9b26b | |||
| d1a4129323 | |||
| a283ddf457 | |||
| b172762288 | |||
| b3da7e84d8 | |||
| 96989be616 | |||
| 9514f15073 | |||
| 1a57eccd38 | |||
| fdfd82207d |
@@ -0,0 +1,2 @@
|
||||
[ 3482ms] [INFO] %cDownload the React DevTools for a better development experience: https://react.dev/link/react-devtools font-weight:bold @ http://localhost:3000/_next/static/chunks/1bba_next_dist_041c6vio7na_y._.js:719
|
||||
[ 3531ms] [LOG] [HMR] connected @ http://localhost:3000/_next/static/chunks/1bba_next_dist_041c6vio7na_y._.js:719
|
||||
@@ -0,0 +1,9 @@
|
||||
[ 210ms] [LOG] [Fast Refresh] rebuilding @ http://localhost:3000/_next/static/chunks/1bba_next_dist_041c6vio7na_y._.js:719
|
||||
[ 1039ms] [LOG] [Fast Refresh] done in 930ms @ http://localhost:3000/_next/static/chunks/1bba_next_dist_041c6vio7na_y._.js:719
|
||||
[ 1076ms] [INFO] %cDownload the React DevTools for a better development experience: https://react.dev/link/react-devtools font-weight:bold @ http://localhost:3000/_next/static/chunks/1bba_next_dist_041c6vio7na_y._.js:719
|
||||
[ 1086ms] [LOG] [HMR] connected @ http://localhost:3000/_next/static/chunks/1bba_next_dist_041c6vio7na_y._.js:719
|
||||
[ 37298ms] [ERROR] Pattern attribute value [0-9() -]{10,16} is not a valid regular expression: Uncaught SyntaxError: Invalid regular expression: /[0-9() -]{10,16}/v: Invalid character in character class @ http://localhost:3000/motorista/entrar:0
|
||||
[ 60872ms] [LOG] [Fast Refresh] rebuilding @ http://localhost:3000/_next/static/chunks/1bba_next_dist_041c6vio7na_y._.js:719
|
||||
[ 60976ms] [LOG] [Fast Refresh] done in 99ms @ http://localhost:3000/_next/static/chunks/1bba_next_dist_041c6vio7na_y._.js:719
|
||||
[ 61021ms] [LOG] [Fast Refresh] done in 99ms @ http://localhost:3000/_next/static/chunks/1bba_next_dist_041c6vio7na_y._.js:719
|
||||
[ 66490ms] [LOG] [Fast Refresh] done in 99ms @ http://localhost:3000/_next/static/chunks/1bba_next_dist_041c6vio7na_y._.js:719
|
||||
@@ -0,0 +1,13 @@
|
||||
[ 93ms] [LOG] [Fast Refresh] done in 99ms @ http://localhost:3000/_next/static/chunks/1bba_next_dist_041c6vio7na_y._.js:719
|
||||
[ 953ms] [INFO] %cDownload the React DevTools for a better development experience: https://react.dev/link/react-devtools font-weight:bold @ http://localhost:3000/_next/static/chunks/1bba_next_dist_041c6vio7na_y._.js:719
|
||||
[ 963ms] [LOG] [HMR] connected @ http://localhost:3000/_next/static/chunks/1bba_next_dist_041c6vio7na_y._.js:719
|
||||
[ 4345ms] [LOG] [Fast Refresh] rebuilding @ http://localhost:3000/_next/static/chunks/1bba_next_dist_041c6vio7na_y._.js:719
|
||||
[ 4433ms] [LOG] [Fast Refresh] done in 189ms @ http://localhost:3000/_next/static/chunks/1bba_next_dist_041c6vio7na_y._.js:719
|
||||
[ 4548ms] [VERBOSE] [DOM] Password forms should have (optionally hidden) username fields for accessibility: (More info: https://goo.gl/9p2vKq) %o @ http://localhost:3000/entrar/senha:0
|
||||
[ 9731ms] [LOG] [Fast Refresh] done in 5486ms @ http://localhost:3000/_next/static/chunks/1bba_next_dist_041c6vio7na_y._.js:719
|
||||
[ 9777ms] [LOG] [Fast Refresh] done in 5486ms @ http://localhost:3000/_next/static/chunks/1bba_next_dist_041c6vio7na_y._.js:719
|
||||
[ 11070ms] [LOG] [Fast Refresh] done in 5486ms @ http://localhost:3000/_next/static/chunks/1bba_next_dist_041c6vio7na_y._.js:719
|
||||
[ 30758ms] [ERROR] WebSocket connection to 'ws://localhost:3000/_next/hmr?id=fBVPvwqt_6CU4TU3WWYmu' failed: @ http://localhost:3000/_next/static/chunks/1bba_next_dist_client_0ru2jkh0js1s8._.js:16225
|
||||
[ 31764ms] [ERROR] WebSocket connection to 'ws://localhost:3000/_next/hmr?id=fBVPvwqt_6CU4TU3WWYmu' failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/_next/static/chunks/1bba_next_dist_client_0ru2jkh0js1s8._.js:16225
|
||||
[ 32766ms] [ERROR] WebSocket connection to 'ws://localhost:3000/_next/hmr?id=fBVPvwqt_6CU4TU3WWYmu' failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/_next/static/chunks/1bba_next_dist_client_0ru2jkh0js1s8._.js:16225
|
||||
[ 33776ms] [LOG] [HMR] connected @ http://localhost:3000/_next/static/chunks/1bba_next_dist_041c6vio7na_y._.js:719
|
||||
@@ -0,0 +1,79 @@
|
||||
[ 805ms] [INFO] %cDownload the React DevTools for a better development experience: https://react.dev/link/react-devtools font-weight:bold @ http://localhost:3000/_next/static/chunks/1bba_next_dist_041c6vio7na_y._.js:719
|
||||
[ 813ms] [LOG] [HMR] connected @ http://localhost:3000/_next/static/chunks/1bba_next_dist_041c6vio7na_y._.js:719
|
||||
[ 1121ms] [VERBOSE] [DOM] Password forms should have (optionally hidden) username fields for accessibility: (More info: https://goo.gl/9p2vKq) %o @ http://localhost:3000/entrar/senha:0
|
||||
[ 2267ms] [LOG] [Fast Refresh] rebuilding @ http://localhost:3000/_next/static/chunks/1bba_next_dist_041c6vio7na_y._.js:719
|
||||
[ 2310ms] [LOG] [Fast Refresh] done in 144ms @ http://localhost:3000/_next/static/chunks/1bba_next_dist_041c6vio7na_y._.js:719
|
||||
[ 9621ms] [LOG] [Fast Refresh] rebuilding @ http://localhost:3000/_next/static/chunks/1bba_next_dist_041c6vio7na_y._.js:719
|
||||
[ 9763ms] [LOG] [Fast Refresh] done in 243ms @ http://localhost:3000/_next/static/chunks/1bba_next_dist_041c6vio7na_y._.js:719
|
||||
[ 48693ms] [LOG] [Fast Refresh] done in 39171ms @ http://localhost:3000/_next/static/chunks/1bba_next_dist_041c6vio7na_y._.js:719
|
||||
[ 48749ms] [LOG] [Fast Refresh] done in 39171ms @ http://localhost:3000/_next/static/chunks/1bba_next_dist_041c6vio7na_y._.js:719
|
||||
[ 48826ms] [LOG] [Fast Refresh] done in 39171ms @ http://localhost:3000/_next/static/chunks/1bba_next_dist_041c6vio7na_y._.js:719
|
||||
[ 51025ms] [LOG] [Fast Refresh] done in 39171ms @ http://localhost:3000/_next/static/chunks/1bba_next_dist_041c6vio7na_y._.js:719
|
||||
[ 51539ms] [LOG] [Fast Refresh] done in 39171ms @ http://localhost:3000/_next/static/chunks/1bba_next_dist_041c6vio7na_y._.js:719
|
||||
[ 51907ms] [LOG] [Fast Refresh] done in 39171ms @ http://localhost:3000/_next/static/chunks/1bba_next_dist_041c6vio7na_y._.js:719
|
||||
[ 107213ms] [LOG] [Fast Refresh] done in 39171ms @ http://localhost:3000/_next/static/chunks/1bba_next_dist_041c6vio7na_y._.js:719
|
||||
[ 118297ms] [LOG] [Fast Refresh] done in 39171ms @ http://localhost:3000/_next/static/chunks/1bba_next_dist_041c6vio7na_y._.js:719
|
||||
[ 123464ms] [LOG] [Fast Refresh] done in 39171ms @ http://localhost:3000/_next/static/chunks/1bba_next_dist_041c6vio7na_y._.js:719
|
||||
[ 125669ms] [LOG] [Fast Refresh] done in 39171ms @ http://localhost:3000/_next/static/chunks/1bba_next_dist_041c6vio7na_y._.js:719
|
||||
[ 125771ms] [LOG] [Fast Refresh] done in 39171ms @ http://localhost:3000/_next/static/chunks/1bba_next_dist_041c6vio7na_y._.js:719
|
||||
[ 126799ms] [LOG] [Fast Refresh] done in 39171ms @ http://localhost:3000/_next/static/chunks/1bba_next_dist_041c6vio7na_y._.js:719
|
||||
[ 126941ms] [LOG] [Fast Refresh] done in 39171ms @ http://localhost:3000/_next/static/chunks/1bba_next_dist_041c6vio7na_y._.js:719
|
||||
[ 127929ms] [LOG] [Fast Refresh] done in 39171ms @ http://localhost:3000/_next/static/chunks/1bba_next_dist_041c6vio7na_y._.js:719
|
||||
[ 127981ms] [LOG] [Fast Refresh] done in 39171ms @ http://localhost:3000/_next/static/chunks/1bba_next_dist_041c6vio7na_y._.js:719
|
||||
[ 154930ms] [LOG] [Fast Refresh] done in 39171ms @ http://localhost:3000/_next/static/chunks/1bba_next_dist_041c6vio7na_y._.js:719
|
||||
[ 156620ms] [LOG] [Fast Refresh] done in 39171ms @ http://localhost:3000/_next/static/chunks/1bba_next_dist_041c6vio7na_y._.js:719
|
||||
[ 157845ms] [LOG] [Fast Refresh] done in 39171ms @ http://localhost:3000/_next/static/chunks/1bba_next_dist_041c6vio7na_y._.js:719
|
||||
[ 158106ms] [LOG] [Fast Refresh] done in 39171ms @ http://localhost:3000/_next/static/chunks/1bba_next_dist_041c6vio7na_y._.js:719
|
||||
[ 158454ms] [LOG] [Fast Refresh] done in 39171ms @ http://localhost:3000/_next/static/chunks/1bba_next_dist_041c6vio7na_y._.js:719
|
||||
[ 158504ms] [LOG] [Fast Refresh] done in 39171ms @ http://localhost:3000/_next/static/chunks/1bba_next_dist_041c6vio7na_y._.js:719
|
||||
[ 158748ms] [LOG] [Fast Refresh] done in 39171ms @ http://localhost:3000/_next/static/chunks/1bba_next_dist_041c6vio7na_y._.js:719
|
||||
[ 158848ms] [LOG] [Fast Refresh] done in 39171ms @ http://localhost:3000/_next/static/chunks/1bba_next_dist_041c6vio7na_y._.js:719
|
||||
[ 158955ms] [LOG] [Fast Refresh] done in 39171ms @ http://localhost:3000/_next/static/chunks/1bba_next_dist_041c6vio7na_y._.js:719
|
||||
[ 159307ms] [LOG] [Fast Refresh] done in 39171ms @ http://localhost:3000/_next/static/chunks/1bba_next_dist_041c6vio7na_y._.js:719
|
||||
[ 159399ms] [LOG] [Fast Refresh] done in 39171ms @ http://localhost:3000/_next/static/chunks/1bba_next_dist_041c6vio7na_y._.js:719
|
||||
[ 159488ms] [LOG] [Fast Refresh] done in 39171ms @ http://localhost:3000/_next/static/chunks/1bba_next_dist_041c6vio7na_y._.js:719
|
||||
[ 159901ms] [LOG] [Fast Refresh] done in 39171ms @ http://localhost:3000/_next/static/chunks/1bba_next_dist_041c6vio7na_y._.js:719
|
||||
[ 159993ms] [LOG] [Fast Refresh] done in 39171ms @ http://localhost:3000/_next/static/chunks/1bba_next_dist_041c6vio7na_y._.js:719
|
||||
[ 187894ms] [LOG] [Fast Refresh] done in 39171ms @ http://localhost:3000/_next/static/chunks/1bba_next_dist_041c6vio7na_y._.js:719
|
||||
[ 188000ms] [LOG] [Fast Refresh] done in 39171ms @ http://localhost:3000/_next/static/chunks/1bba_next_dist_041c6vio7na_y._.js:719
|
||||
[ 205603ms] [LOG] [Fast Refresh] done in 39171ms @ http://localhost:3000/_next/static/chunks/1bba_next_dist_041c6vio7na_y._.js:719
|
||||
[ 207208ms] [LOG] [Fast Refresh] done in 39171ms @ http://localhost:3000/_next/static/chunks/1bba_next_dist_041c6vio7na_y._.js:719
|
||||
[ 207296ms] [LOG] [Fast Refresh] done in 39171ms @ http://localhost:3000/_next/static/chunks/1bba_next_dist_041c6vio7na_y._.js:719
|
||||
[ 207951ms] [LOG] [Fast Refresh] done in 39171ms @ http://localhost:3000/_next/static/chunks/1bba_next_dist_041c6vio7na_y._.js:719
|
||||
[ 208126ms] [LOG] [Fast Refresh] done in 39171ms @ http://localhost:3000/_next/static/chunks/1bba_next_dist_041c6vio7na_y._.js:719
|
||||
[ 208244ms] [LOG] [Fast Refresh] done in 39171ms @ http://localhost:3000/_next/static/chunks/1bba_next_dist_041c6vio7na_y._.js:719
|
||||
[ 208353ms] [LOG] [Fast Refresh] done in 39171ms @ http://localhost:3000/_next/static/chunks/1bba_next_dist_041c6vio7na_y._.js:719
|
||||
[ 208415ms] [LOG] [Fast Refresh] done in 39171ms @ http://localhost:3000/_next/static/chunks/1bba_next_dist_041c6vio7na_y._.js:719
|
||||
[ 208590ms] [LOG] [Fast Refresh] done in 39171ms @ http://localhost:3000/_next/static/chunks/1bba_next_dist_041c6vio7na_y._.js:719
|
||||
[ 208695ms] [LOG] [Fast Refresh] done in 39171ms @ http://localhost:3000/_next/static/chunks/1bba_next_dist_041c6vio7na_y._.js:719
|
||||
[ 208794ms] [LOG] [Fast Refresh] done in 39171ms @ http://localhost:3000/_next/static/chunks/1bba_next_dist_041c6vio7na_y._.js:719
|
||||
[ 208878ms] [LOG] [Fast Refresh] done in 39171ms @ http://localhost:3000/_next/static/chunks/1bba_next_dist_041c6vio7na_y._.js:719
|
||||
[ 208986ms] [LOG] [Fast Refresh] done in 39171ms @ http://localhost:3000/_next/static/chunks/1bba_next_dist_041c6vio7na_y._.js:719
|
||||
[ 209132ms] [LOG] [Fast Refresh] done in 39171ms @ http://localhost:3000/_next/static/chunks/1bba_next_dist_041c6vio7na_y._.js:719
|
||||
[ 209234ms] [LOG] [Fast Refresh] done in 39171ms @ http://localhost:3000/_next/static/chunks/1bba_next_dist_041c6vio7na_y._.js:719
|
||||
[ 228278ms] [LOG] [Fast Refresh] rebuilding @ http://localhost:3000/_next/static/chunks/1bba_next_dist_041c6vio7na_y._.js:719
|
||||
[ 228308ms] [LOG] [Fast Refresh] done in 3ms @ http://localhost:3000/_next/static/chunks/1bba_next_dist_041c6vio7na_y._.js:719
|
||||
[ 238991ms] [LOG] [Fast Refresh] done in 10713ms @ http://localhost:3000/_next/static/chunks/1bba_next_dist_041c6vio7na_y._.js:719
|
||||
[ 239037ms] [LOG] [Fast Refresh] done in 10713ms @ http://localhost:3000/_next/static/chunks/1bba_next_dist_041c6vio7na_y._.js:719
|
||||
[ 239175ms] [LOG] [Fast Refresh] rebuilding @ http://localhost:3000/_next/static/chunks/1bba_next_dist_041c6vio7na_y._.js:719
|
||||
[ 239206ms] [LOG] [Fast Refresh] done in 3ms @ http://localhost:3000/_next/static/chunks/1bba_next_dist_041c6vio7na_y._.js:719
|
||||
[ 239602ms] [LOG] [Fast Refresh] done in 3ms @ http://localhost:3000/_next/static/chunks/1bba_next_dist_041c6vio7na_y._.js:719
|
||||
[ 239697ms] [LOG] [Fast Refresh] done in 3ms @ http://localhost:3000/_next/static/chunks/1bba_next_dist_041c6vio7na_y._.js:719
|
||||
[ 264111ms] [LOG] [Fast Refresh] done in 3ms @ http://localhost:3000/_next/static/chunks/1bba_next_dist_041c6vio7na_y._.js:719
|
||||
[ 265916ms] [LOG] [Fast Refresh] done in 3ms @ http://localhost:3000/_next/static/chunks/1bba_next_dist_041c6vio7na_y._.js:719
|
||||
[ 321743ms] [LOG] [Fast Refresh] done in 3ms @ http://localhost:3000/_next/static/chunks/1bba_next_dist_041c6vio7na_y._.js:719
|
||||
[ 333352ms] [LOG] [Fast Refresh] done in 3ms @ http://localhost:3000/_next/static/chunks/1bba_next_dist_041c6vio7na_y._.js:719
|
||||
[ 369410ms] [LOG] [Fast Refresh] done in 3ms @ http://localhost:3000/_next/static/chunks/1bba_next_dist_041c6vio7na_y._.js:719
|
||||
[ 413886ms] [ERROR] WebSocket connection to 'ws://localhost:3000/_next/hmr?id=ruCdDO_vey1tElDs8WYR0' failed: @ http://localhost:3000/_next/static/chunks/1bba_next_dist_client_0ru2jkh0js1s8._.js:16225
|
||||
[ 414902ms] [ERROR] WebSocket connection to 'ws://localhost:3000/_next/hmr?id=ruCdDO_vey1tElDs8WYR0' failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/_next/static/chunks/1bba_next_dist_client_0ru2jkh0js1s8._.js:16225
|
||||
[ 415904ms] [ERROR] WebSocket connection to 'ws://localhost:3000/_next/hmr?id=ruCdDO_vey1tElDs8WYR0' failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/_next/static/chunks/1bba_next_dist_client_0ru2jkh0js1s8._.js:16225
|
||||
[ 416906ms] [ERROR] WebSocket connection to 'ws://localhost:3000/_next/hmr?id=ruCdDO_vey1tElDs8WYR0' failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/_next/static/chunks/1bba_next_dist_client_0ru2jkh0js1s8._.js:16225
|
||||
[ 417908ms] [ERROR] WebSocket connection to 'ws://localhost:3000/_next/hmr?id=ruCdDO_vey1tElDs8WYR0' failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/_next/static/chunks/1bba_next_dist_client_0ru2jkh0js1s8._.js:16225
|
||||
[ 418911ms] [ERROR] WebSocket connection to 'ws://localhost:3000/_next/hmr?id=ruCdDO_vey1tElDs8WYR0' failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/_next/static/chunks/1bba_next_dist_client_0ru2jkh0js1s8._.js:16225
|
||||
[ 423918ms] [ERROR] WebSocket connection to 'ws://localhost:3000/_next/hmr?id=ruCdDO_vey1tElDs8WYR0' failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/_next/static/chunks/1bba_next_dist_client_0ru2jkh0js1s8._.js:16225
|
||||
[ 428928ms] [ERROR] WebSocket connection to 'ws://localhost:3000/_next/hmr?id=ruCdDO_vey1tElDs8WYR0' failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/_next/static/chunks/1bba_next_dist_client_0ru2jkh0js1s8._.js:16225
|
||||
[ 433950ms] [ERROR] WebSocket connection to 'ws://localhost:3000/_next/hmr?id=ruCdDO_vey1tElDs8WYR0' failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/_next/static/chunks/1bba_next_dist_client_0ru2jkh0js1s8._.js:16225
|
||||
[ 438984ms] [ERROR] WebSocket connection to 'ws://localhost:3000/_next/hmr?id=ruCdDO_vey1tElDs8WYR0' failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/_next/static/chunks/1bba_next_dist_client_0ru2jkh0js1s8._.js:16225
|
||||
[ 444022ms] [ERROR] WebSocket connection to 'ws://localhost:3000/_next/hmr?id=ruCdDO_vey1tElDs8WYR0' failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/_next/static/chunks/1bba_next_dist_client_0ru2jkh0js1s8._.js:16225
|
||||
[ 449184ms] [ERROR] WebSocket connection to 'ws://localhost:3000/_next/hmr?id=ruCdDO_vey1tElDs8WYR0' failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/_next/static/chunks/1bba_next_dist_client_0ru2jkh0js1s8._.js:16225
|
||||
[ 454479ms] [ERROR] WebSocket connection to 'ws://localhost:3000/_next/hmr?id=ruCdDO_vey1tElDs8WYR0' failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/_next/static/chunks/1bba_next_dist_client_0ru2jkh0js1s8._.js:16225
|
||||
@@ -0,0 +1,28 @@
|
||||
- generic [ref=e2]:
|
||||
- complementary [ref=e3]:
|
||||
- generic [ref=e8]:
|
||||
- generic [ref=e9]: Rota Fresca
|
||||
- generic [ref=e10]: Distribuição de alimentos
|
||||
- generic [ref=e11]:
|
||||
- heading "Do depósito à prateleira, na hora certa." [level=2] [ref=e12]
|
||||
- paragraph [ref=e13]: Pedidos, entregas e notas da sua rede no mesmo lugar.
|
||||
- main [ref=e14]:
|
||||
- link "Precisa de ajuda?" [ref=e16] [cursor=pointer]:
|
||||
- /url: mailto:suporte@rotafresca.com.br
|
||||
- generic [ref=e17]:
|
||||
- generic [ref=e18]:
|
||||
- heading "Entrar" [level=1] [ref=e19]
|
||||
- paragraph [ref=e20]: Use o e-mail da sua conta. Conforme o tipo de conta, pedimos a senha ou mandamos um link de acesso.
|
||||
- generic [ref=e21]:
|
||||
- generic [ref=e22]:
|
||||
- generic [ref=e23]: E-mail
|
||||
- textbox "E-mail" [ref=e24]:
|
||||
- /placeholder: nome@empresa.com.br
|
||||
- button "Continuar" [ref=e25]
|
||||
- generic [ref=e26]: ou
|
||||
- generic [ref=e33]:
|
||||
- paragraph [ref=e34]: É motorista?
|
||||
- paragraph [ref=e35]: Entre pelo app Rota Fresca no celular, com o seu número. Não precisa de e-mail.
|
||||
- generic [ref=e36]:
|
||||
- paragraph [ref=e37]: Recebeu um convite? Abra o link do e-mail para criar seu acesso.
|
||||
- paragraph [ref=e38]: Termos de uso · Privacidade
|
||||
@@ -0,0 +1,20 @@
|
||||
- generic [active] [ref=f1e1]:
|
||||
- generic [ref=f1e2]:
|
||||
- generic [ref=f1e3]:
|
||||
- generic [ref=f1e8]:
|
||||
- generic [ref=f1e9]: Rota Fresca
|
||||
- generic [ref=f1e10]: App do motorista
|
||||
- banner [ref=f1e11]:
|
||||
- heading "Entrar" [level=1] [ref=f1e12]
|
||||
- paragraph [ref=f1e13]: Use o número de celular que a central cadastrou. Mandamos um código, sem senha.
|
||||
- generic [ref=f1e14]:
|
||||
- generic [ref=f1e15]: Celular
|
||||
- generic [ref=f1e16]:
|
||||
- generic [ref=f1e17]: "+55"
|
||||
- textbox "(21) 98877-4410" [ref=f1e18]
|
||||
- generic [ref=f1e19]:
|
||||
- button "Receber código no WhatsApp" [ref=f1e20]
|
||||
- button "Receber por SMS" [ref=f1e21]
|
||||
- paragraph [ref=f1e22]: Trocou de número? Fale com a central.
|
||||
- button "Open Next.js Dev Tools" [ref=f1e28] [cursor=pointer]
|
||||
- alert [ref=f1e32]
|
||||
@@ -0,0 +1,32 @@
|
||||
- generic [active] [ref=f2e1]:
|
||||
- generic [ref=f2e2]:
|
||||
- complementary [ref=f2e3]:
|
||||
- generic [ref=f2e9]:
|
||||
- generic [ref=f2e10]: Rota Fresca
|
||||
- generic [ref=f2e11]: Distribuição de alimentos
|
||||
- generic [ref=f2e12]:
|
||||
- heading "Do depósito à prateleira, na hora certa." [level=2] [ref=f2e13]
|
||||
- paragraph [ref=f2e14]: Pedidos, entregas e notas da sua rede no mesmo lugar.
|
||||
- main [ref=f2e15]:
|
||||
- link "Precisa de ajuda?" [ref=f2e17] [cursor=pointer]:
|
||||
- /url: mailto:suporte@rotafresca.com.br
|
||||
- generic [ref=f2e18]:
|
||||
- generic [ref=f2e19]:
|
||||
- heading "Entrar" [level=1] [ref=f2e20]
|
||||
- paragraph [ref=f2e21]: Use o e-mail da sua conta. Conforme o tipo de conta, pedimos a senha ou mandamos um link de acesso.
|
||||
- generic [ref=f2e22]:
|
||||
- generic [ref=f2e23]:
|
||||
- generic [ref=f2e24]: E-mail
|
||||
- textbox "E-mail" [ref=f2e25]:
|
||||
- /placeholder: nome@empresa.com.br
|
||||
- button "Continuar" [ref=f2e26]
|
||||
- generic [ref=f2e27]: ou
|
||||
- button "Entrar com chave de acesso (passkey)" [ref=f2e31]
|
||||
- generic [ref=f2e35]:
|
||||
- paragraph [ref=f2e36]: É motorista?
|
||||
- paragraph [ref=f2e37]: Entre pelo app Rota Fresca no celular, com o seu número. Não precisa de e-mail.
|
||||
- generic [ref=f2e38]:
|
||||
- paragraph [ref=f2e39]: Recebeu um convite? Abra o link do e-mail para criar seu acesso.
|
||||
- paragraph [ref=f2e40]: Termos de uso · Privacidade
|
||||
- button "Open Next.js Dev Tools" [ref=f2e46] [cursor=pointer]
|
||||
- alert [ref=f2e50]
|
||||
@@ -0,0 +1,38 @@
|
||||
- generic [active] [ref=f2e1]:
|
||||
- button "Open Next.js Dev Tools" [ref=f2e46] [cursor=pointer]
|
||||
- alert [ref=f2e50]: Digite sua senha
|
||||
- generic [ref=f2e51]:
|
||||
- complementary [ref=f2e52]:
|
||||
- generic [ref=f2e58]:
|
||||
- generic [ref=f2e59]: Rota Fresca
|
||||
- generic [ref=f2e60]: Distribuição de alimentos
|
||||
- generic [ref=f2e61]:
|
||||
- heading "Do depósito à prateleira, na hora certa." [level=2] [ref=f2e62]
|
||||
- paragraph [ref=f2e63]: Pedidos, entregas e notas da sua rede no mesmo lugar.
|
||||
- main [ref=f2e64]:
|
||||
- link "Precisa de ajuda?" [ref=f2e66] [cursor=pointer]:
|
||||
- /url: mailto:suporte@rotafresca.com.br
|
||||
- generic [ref=f2e67]:
|
||||
- generic [ref=f2e68]:
|
||||
- generic [ref=f2e69]: ana.souza@rotafresca.com.br
|
||||
- button "Trocar" [ref=f2e74]
|
||||
- generic [ref=f2e75]:
|
||||
- heading "Digite sua senha" [level=1] [ref=f2e76]
|
||||
- paragraph [ref=f2e77]: Conta de consultor. Depois da senha pedimos o código do autenticador.
|
||||
- generic [ref=f2e78]:
|
||||
- generic [ref=f2e79]:
|
||||
- generic [ref=f2e80]:
|
||||
- generic [ref=f2e81]: Senha
|
||||
- textbox "Senha" [ref=f2e82]
|
||||
- generic [ref=f2e83]:
|
||||
- generic [ref=f2e84]:
|
||||
- checkbox "Mostrar senha" [ref=f2e85]
|
||||
- checkbox [aria-hidden]
|
||||
- text: Mostrar senha
|
||||
- link "Esqueci minha senha" [ref=f2e86] [cursor=pointer]:
|
||||
- /url: /entrar/redefinir
|
||||
- button "Entrar" [ref=f2e87]
|
||||
- paragraph [ref=f2e88]: Pode colar a senha ou usar o gerenciador de senhas do navegador.
|
||||
- generic [ref=f2e89]:
|
||||
- paragraph [ref=f2e90]: Recebeu um convite? Abra o link do e-mail para criar seu acesso.
|
||||
- paragraph [ref=f2e91]: Termos de uso · Privacidade
|
||||
@@ -0,0 +1,40 @@
|
||||
- generic [ref=f2e1]:
|
||||
- button "Open Next.js Dev Tools" [ref=f2e46] [cursor=pointer]
|
||||
- alert [ref=f2e50]: Rota Fresca
|
||||
- generic [ref=f2e51]:
|
||||
- complementary [ref=f2e52]:
|
||||
- generic [ref=f2e58]:
|
||||
- generic [ref=f2e59]: Rota Fresca
|
||||
- generic [ref=f2e60]: Distribuição de alimentos
|
||||
- generic [ref=f2e61]:
|
||||
- heading "Do depósito à prateleira, na hora certa." [level=2] [ref=f2e62]
|
||||
- paragraph [ref=f2e63]: Pedidos, entregas e notas da sua rede no mesmo lugar.
|
||||
- main [ref=f2e64]:
|
||||
- link "Precisa de ajuda?" [ref=f2e66] [cursor=pointer]:
|
||||
- /url: mailto:suporte@rotafresca.com.br
|
||||
- generic [ref=f2e67]:
|
||||
- generic [ref=f2e68]:
|
||||
- generic [ref=f2e69]: ana.souza@rotafresca.com.br
|
||||
- button "Trocar" [ref=f2e74]
|
||||
- generic [ref=f2e75]:
|
||||
- heading "Digite sua senha" [level=1] [ref=f2e76]
|
||||
- paragraph [ref=f2e77]: Conta de consultor. Depois da senha pedimos o código do autenticador.
|
||||
- generic [ref=f2e78]:
|
||||
- generic [ref=f2e79]:
|
||||
- generic [ref=f2e80]:
|
||||
- generic [ref=f2e81]: Senha
|
||||
- textbox "Senha" [active] [invalid] [ref=f2e82]
|
||||
- generic [ref=f2e83]:
|
||||
- generic [ref=f2e84]:
|
||||
- checkbox "Mostrar senha" [ref=f2e85]
|
||||
- checkbox [aria-hidden]
|
||||
- text: Mostrar senha
|
||||
- link "Esqueci minha senha" [ref=f2e86] [cursor=pointer]:
|
||||
- /url: /entrar/redefinir
|
||||
- alert [ref=f2e92]:
|
||||
- generic [ref=f2e93]: Senha incorreta. Confira e tente de novo.
|
||||
- button "Entrar" [ref=f2e87]
|
||||
- paragraph [ref=f2e88]: Pode colar a senha ou usar o gerenciador de senhas do navegador.
|
||||
- generic [ref=f2e89]:
|
||||
- paragraph [ref=f2e90]: Recebeu um convite? Abra o link do e-mail para criar seu acesso.
|
||||
- paragraph [ref=f2e91]: Termos de uso · Privacidade
|
||||
@@ -0,0 +1,32 @@
|
||||
- generic [active] [ref=f3e1]:
|
||||
- generic [ref=f3e2]:
|
||||
- complementary [ref=f3e3]:
|
||||
- generic [ref=f3e9]:
|
||||
- generic [ref=f3e10]: Rota Fresca
|
||||
- generic [ref=f3e11]: Distribuição de alimentos
|
||||
- generic [ref=f3e12]:
|
||||
- heading "Do depósito à prateleira, na hora certa." [level=2] [ref=f3e13]
|
||||
- paragraph [ref=f3e14]: Pedidos, entregas e notas da sua rede no mesmo lugar.
|
||||
- main [ref=f3e15]:
|
||||
- link "Precisa de ajuda?" [ref=f3e17] [cursor=pointer]:
|
||||
- /url: mailto:suporte@rotafresca.com.br
|
||||
- generic [ref=f3e18]:
|
||||
- generic [ref=f3e19]:
|
||||
- heading "Entrar" [level=1] [ref=f3e20]
|
||||
- paragraph [ref=f3e21]: Use o e-mail da sua conta. Conforme o tipo de conta, pedimos a senha ou mandamos um link de acesso.
|
||||
- generic [ref=f3e22]:
|
||||
- generic [ref=f3e23]:
|
||||
- generic [ref=f3e24]: E-mail
|
||||
- textbox "E-mail" [ref=f3e25]:
|
||||
- /placeholder: nome@empresa.com.br
|
||||
- button "Continuar" [ref=f3e26]
|
||||
- generic [ref=f3e27]: ou
|
||||
- button "Entrar com chave de acesso (passkey)" [ref=f3e31]
|
||||
- generic [ref=f3e35]:
|
||||
- paragraph [ref=f3e36]: É motorista?
|
||||
- paragraph [ref=f3e37]: Entre pelo app Rota Fresca no celular, com o seu número. Não precisa de e-mail.
|
||||
- generic [ref=f3e38]:
|
||||
- paragraph [ref=f3e39]: Recebeu um convite? Abra o link do e-mail para criar seu acesso.
|
||||
- paragraph [ref=f3e40]: Termos de uso · Privacidade
|
||||
- button "Open Next.js Dev Tools" [ref=f3e46] [cursor=pointer]
|
||||
- alert [ref=f3e50]
|
||||
@@ -0,0 +1,38 @@
|
||||
- generic [active] [ref=f3e1]:
|
||||
- button "Open Next.js Dev Tools" [ref=f3e46] [cursor=pointer]
|
||||
- alert [ref=f3e50]
|
||||
- generic [ref=f3e51]:
|
||||
- complementary [ref=f3e52]:
|
||||
- generic [ref=f3e58]:
|
||||
- generic [ref=f3e59]: Rota Fresca
|
||||
- generic [ref=f3e60]: Distribuição de alimentos
|
||||
- generic [ref=f3e61]:
|
||||
- heading "Do depósito à prateleira, na hora certa." [level=2] [ref=f3e62]
|
||||
- paragraph [ref=f3e63]: Pedidos, entregas e notas da sua rede no mesmo lugar.
|
||||
- main [ref=f3e64]:
|
||||
- link "Precisa de ajuda?" [ref=f3e66] [cursor=pointer]:
|
||||
- /url: mailto:suporte@rotafresca.com.br
|
||||
- generic [ref=f3e67]:
|
||||
- generic [ref=f3e68]:
|
||||
- generic [ref=f3e69]: ana.souza@rotafresca.com.br
|
||||
- button "Trocar" [ref=f3e74]
|
||||
- generic [ref=f3e75]:
|
||||
- heading "Digite sua senha" [level=1] [ref=f3e76]
|
||||
- paragraph [ref=f3e77]: Conta de consultor. Depois da senha pedimos o código do autenticador.
|
||||
- generic [ref=f3e78]:
|
||||
- generic [ref=f3e79]:
|
||||
- generic [ref=f3e80]:
|
||||
- generic [ref=f3e81]: Senha
|
||||
- textbox "Senha" [ref=f3e82]
|
||||
- generic [ref=f3e83]:
|
||||
- generic [ref=f3e84]:
|
||||
- checkbox "Mostrar senha" [ref=f3e85]
|
||||
- checkbox [aria-hidden]
|
||||
- text: Mostrar senha
|
||||
- link "Esqueci minha senha" [ref=f3e86] [cursor=pointer]:
|
||||
- /url: /entrar/redefinir
|
||||
- button "Entrar" [ref=f3e87]
|
||||
- paragraph [ref=f3e88]: Pode colar a senha ou usar o gerenciador de senhas do navegador.
|
||||
- generic [ref=f3e89]:
|
||||
- paragraph [ref=f3e90]: Recebeu um convite? Abra o link do e-mail para criar seu acesso.
|
||||
- paragraph [ref=f3e91]: Termos de uso · Privacidade
|
||||
@@ -0,0 +1,33 @@
|
||||
- generic [active] [ref=f3e1]:
|
||||
- button "Open Next.js Dev Tools" [ref=f3e46] [cursor=pointer]
|
||||
- alert [ref=f3e50]
|
||||
- generic [ref=f3e92]:
|
||||
- complementary [ref=f3e93]:
|
||||
- generic [ref=f3e99]:
|
||||
- generic [ref=f3e100]: Rota Fresca
|
||||
- generic [ref=f3e101]: Distribuição de alimentos
|
||||
- generic [ref=f3e102]:
|
||||
- heading "Do depósito à prateleira, na hora certa." [level=2] [ref=f3e103]
|
||||
- paragraph [ref=f3e104]: Pedidos, entregas e notas da sua rede no mesmo lugar.
|
||||
- main [ref=f3e105]:
|
||||
- link "Precisa de ajuda?" [ref=f3e107] [cursor=pointer]:
|
||||
- /url: mailto:suporte@rotafresca.com.br
|
||||
- generic [ref=f3e108]:
|
||||
- heading "Confirme que é você" [level=1] [ref=f3e113]
|
||||
- generic [ref=f3e114]:
|
||||
- generic [ref=f3e115]:
|
||||
- generic [ref=f3e116]:
|
||||
- generic [ref=f3e117]: Código
|
||||
- textbox "Código" [ref=f3e118]
|
||||
- generic [ref=f3e119]:
|
||||
- checkbox "Não pedir de novo neste computador por 30 dias" [checked] [ref=f3e120]
|
||||
- checkbox [checked] [aria-hidden]
|
||||
- text: Não pedir de novo neste computador por 30 dias
|
||||
- button "Confirmar" [ref=f3e121]
|
||||
- generic [ref=f3e122]:
|
||||
- link "Usar um código de recuperação" [ref=f3e123] [cursor=pointer]:
|
||||
- /url: /entrar/codigo?recuperacao=1
|
||||
- paragraph [ref=f3e124]: Perdeu o celular? Peça ao administrador da conta para refazer o autenticador.
|
||||
- generic [ref=f3e125]:
|
||||
- paragraph [ref=f3e126]: Recebeu um convite? Abra o link do e-mail para criar seu acesso.
|
||||
- paragraph [ref=f3e127]: Termos de uso · Privacidade
|
||||
@@ -0,0 +1,47 @@
|
||||
- generic [active] [ref=f3e1]:
|
||||
- button "Open Next.js Dev Tools" [ref=f3e46] [cursor=pointer]
|
||||
- alert [ref=f3e50]: Bom dia, Ana
|
||||
- generic [ref=f3e128]:
|
||||
- complementary [ref=f3e129]:
|
||||
- generic [ref=f3e130]:
|
||||
- generic [ref=f3e136]:
|
||||
- generic [ref=f3e137]: Rota Fresca
|
||||
- generic [ref=f3e138]: Distribuição de alimentos
|
||||
- navigation [ref=f3e139]:
|
||||
- link "Painel" [ref=f3e140] [cursor=pointer]:
|
||||
- /url: /painel
|
||||
- link "Roteiro do dia" [ref=f3e143] [cursor=pointer]:
|
||||
- /url: /roteiro
|
||||
- link "Lojas (PDVs)" [ref=f3e146] [cursor=pointer]:
|
||||
- /url: /lojas
|
||||
- link "Pedidos" [ref=f3e149] [cursor=pointer]:
|
||||
- /url: /pedidos
|
||||
- link "Entregas" [ref=f3e152] [cursor=pointer]:
|
||||
- /url: /entregas
|
||||
- link "Validade e lotes" [ref=f3e155] [cursor=pointer]:
|
||||
- /url: /validade
|
||||
- link "Relatórios" [ref=f3e158] [cursor=pointer]:
|
||||
- /url: /relatorios
|
||||
- link "Integrações e notas" [ref=f3e161] [cursor=pointer]:
|
||||
- /url: /integracoes
|
||||
- link "Leitura de documentos" [ref=f3e164] [cursor=pointer]:
|
||||
- /url: /documentos
|
||||
- link "Contatos e mensagens" [ref=f3e167] [cursor=pointer]:
|
||||
- /url: /contatos
|
||||
- link "Configurações" [ref=f3e170] [cursor=pointer]:
|
||||
- /url: /configuracoes
|
||||
- generic [ref=f3e173]:
|
||||
- generic [aria-hidden] [ref=f3e174]: A
|
||||
- generic [ref=f3e175]:
|
||||
- generic [ref=f3e176]: Ana Souza
|
||||
- generic [ref=f3e177]: Consultora · Zona Oeste
|
||||
- main [ref=f3e178]:
|
||||
- generic [ref=f3e179]:
|
||||
- generic [ref=f3e180]:
|
||||
- heading "Bom dia, Ana" [level=1] [ref=f3e181]
|
||||
- paragraph [ref=f3e182]: domingo, 25 de outubro
|
||||
- generic [ref=f3e183]:
|
||||
- textbox "Buscar loja, pedido ou produto" [ref=f3e187]
|
||||
- button "Notificações" [ref=f3e188]
|
||||
- button "Importar / Exportar" [ref=f3e189]
|
||||
- button "Novo pedido" [ref=f3e190]
|
||||
|
After Width: | Height: | Size: 344 KiB |
|
After Width: | Height: | Size: 70 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 56 KiB |
|
After Width: | Height: | Size: 344 KiB |
@@ -784,7 +784,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "craft-ui"
|
||||
version = "0.1.0"
|
||||
source = "git+https://git.marcospaulo.dev.br/masi/craft-libs?rev=22544b6d619d4f16779b8bdfa3f0b642a86fc065#22544b6d619d4f16779b8bdfa3f0b642a86fc065"
|
||||
source = "git+https://git.marcospaulo.dev.br/masi/craft-libs?rev=9c470aa935c4ce48f2ba4f0a17f9410c0c8660fb#9c470aa935c4ce48f2ba4f0a17f9410c0c8660fb"
|
||||
dependencies = [
|
||||
"egui",
|
||||
]
|
||||
@@ -3128,7 +3128,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "papercraft"
|
||||
version = "0.7.3"
|
||||
version = "0.8.1"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"axum",
|
||||
@@ -3161,6 +3161,7 @@ dependencies = [
|
||||
"resvg",
|
||||
"rfd",
|
||||
"rmcp",
|
||||
"ruzstd",
|
||||
"semver",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -3174,8 +3175,10 @@ dependencies = [
|
||||
"web-sys",
|
||||
"web-time",
|
||||
"wgpu",
|
||||
"windows-sys 0.61.2",
|
||||
"winit",
|
||||
"winresource",
|
||||
"zstd",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3853,6 +3856,15 @@ version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
|
||||
|
||||
[[package]]
|
||||
name = "ruzstd"
|
||||
version = "0.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a7c1c839d570d835527c9a5e4db7cb2198683a988cb9d7293fc8674e6bd58fc8"
|
||||
dependencies = [
|
||||
"twox-hash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ryu"
|
||||
version = "1.0.23"
|
||||
@@ -4754,6 +4766,12 @@ dependencies = [
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "twox-hash"
|
||||
version = "2.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "86a801b3cea342a06d468c8710662aa29e5e05e4f5c0d62f00bbb7f2ad7941c2"
|
||||
|
||||
[[package]]
|
||||
name = "type-map"
|
||||
version = "0.5.1"
|
||||
@@ -4777,7 +4795,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e"
|
||||
dependencies = [
|
||||
"memoffset",
|
||||
"tempfile",
|
||||
"windows-sys 0.60.2",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6018,6 +6036,34 @@ version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
|
||||
|
||||
[[package]]
|
||||
name = "zstd"
|
||||
version = "0.13.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a"
|
||||
dependencies = [
|
||||
"zstd-safe",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zstd-safe"
|
||||
version = "7.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "64d80649ab6db9d9f6f9c80a40becd948eda4714a0a5ac8c4d157a32231c7882"
|
||||
dependencies = [
|
||||
"zstd-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zstd-sys"
|
||||
version = "2.1.2+zstd.1.5.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "481ada9c543b2323cbb7bbdbb5249177fbf0188ed959ed2c15e4b22421c84ed3"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"pkg-config",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zune-core"
|
||||
version = "0.5.3"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "papercraft"
|
||||
version = "0.7.3"
|
||||
version = "0.8.1"
|
||||
edition = "2024"
|
||||
rust-version = "1.95"
|
||||
description = "A native infinite-canvas app for planning, working and sketching"
|
||||
@@ -44,7 +44,7 @@ env_logger = "0.11"
|
||||
rfd = "0.17"
|
||||
fastrand = "2"
|
||||
dirs = "6"
|
||||
craft-ui = { version = "0.1.0", git = "https://git.marcospaulo.dev.br/masi/craft-libs", rev = "22544b6d619d4f16779b8bdfa3f0b642a86fc065" }
|
||||
craft-ui = { version = "0.1.0", git = "https://git.marcospaulo.dev.br/masi/craft-libs", rev = "9c470aa935c4ce48f2ba4f0a17f9410c0c8660fb" }
|
||||
i_overlay = "9.0.1"
|
||||
miniz_oxide = "0.9.1"
|
||||
usvg = "0.48"
|
||||
@@ -97,8 +97,14 @@ reqwest = { version = "0.13", default-features = false, features = ["native-tls"
|
||||
# libc: SIGINT to the spawned chat CLIs.
|
||||
keyring = { version = "3", default-features = false, features = ["apple-native", "windows-native", "sync-secret-service", "crypto-rust"] }
|
||||
libc = "0.2"
|
||||
# Pages and versions on disk are zstd-compressed JSON (`project::packed`).
|
||||
zstd = "0.13"
|
||||
|
||||
# Browser build (`src/web.rs`): canvas + WebGPU/WebGL surface, DOM events instead of winit.
|
||||
# Process RAM and CPU readings for the performance overlay (`ui::perf`).
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_System_ProcessStatus", "Win32_System_Threading"] }
|
||||
|
||||
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
||||
wgpu = { version = "30", features = ["webgl"] }
|
||||
wasm-bindgen = "0.2"
|
||||
@@ -106,4 +112,6 @@ wasm-bindgen-futures = "0.4"
|
||||
js-sys = "0.3"
|
||||
console_error_panic_hook = "0.1"
|
||||
console_log = "1"
|
||||
# zstd without C, for the browser: reads the packed files native builds write.
|
||||
ruzstd = "0.8"
|
||||
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "KeyboardEvent", "PointerEvent", "WheelEvent", "ClipboardEvent", "DataTransfer", "MediaQueryList", "Location", "Performance", "Navigator", "Clipboard", "CssStyleDeclaration", "console", "WebSocket", "MessageEvent", "CloseEvent", "ErrorEvent", "Storage", "Crypto", "DragEvent", "FileList", "File", "Blob", "Request", "RequestInit", "Response"] }
|
||||
|
||||
@@ -76,6 +76,80 @@ are credited in `THIRD_PARTY.md`.
|
||||
* **Not carried over**: comments, design tokens, AI image generation, video export, teams;
|
||||
moving layers between pages; remote image URLs (see README Known gaps).
|
||||
|
||||
### 2026-10-28: 0.8.1 (Performance Monitor on Windows, Design tab speed)
|
||||
|
||||
* Performance Monitor: RAM (working set) and CPU now show on Windows; before they were `n/a` there. The overlay and slow-frame badge judge frames against a refresh rate you pick in Settings > Performance: 30 to 360 Hz (144, 165 and 240 included), 60 by default.
|
||||
* Design tab: the lint and the open-feedback scan are kept until the document changes instead of walking every node each frame; a 12.6k-node page no longer drops to a few FPS while the tab is open.
|
||||
* Windows and Linux: window buttons in the toolbar; controls and acrylic come from craft-ui.
|
||||
|
||||
### 2026-10-27: 0.8.0 (screens, packed pages, canvas performance)
|
||||
|
||||
* Screens (`specs/screens`): a frame can be declared a screen or an overlay (drawer, modal, sheet) with `set_screens`, inspector toggles, or Declare N screens on the page. Agent edits to layers hidden behind an overlay or opaque layer in their screen are refused unless `allowHidden`; partly covered ones are reported with their visible share. `get_basic_info` lists screens, `get_node_info` tells a layer's visibility, `lint_design` skips hidden layers, On click lists screens, nested screens get canvas labels.
|
||||
* Pages and saved versions are stored as zstd-compressed compact JSON (`.json.zst`, project format 3): the 68 MB Interview project is 1.3 MB on disk and loads faster. A legacy `.json` still reads and is replaced on the next save; older builds refuse format 3.
|
||||
* Canvas performance: the editor logs which layers each change touched, so the scene cache re-tessellates only those and keeps no copy of the document (443 → 346 MB on Interview). Zoom holds its geometry until it settles, tiny layers are skipped, vertices are 24 bytes, redraws follow vsync, the idle pointer no longer redraws, freed memory goes back to the system when idle, off-screen frame labels are skipped, and the layers panel keeps its row heights between frames (panning a large page: ~9 → ~0.7 ms a frame).
|
||||
* Frame labels: none on frames too narrow on screen, overlapping labels are dropped (selected and larger frames first), long names end in an ellipsis.
|
||||
* Dropdowns size to their longest option (capped), keep the name of `A / B / Name` options, scroll when long and show the full name on hover.
|
||||
* Settings: a Performance page (FPS, frame time, RAM, CPU, GPU, optional overlay and slow-frame badge), agent logos in the agent rows; the Chat page no longer collapses.
|
||||
|
||||
### 2026-10-25: 0.8.0 (properties modal, row merges)
|
||||
|
||||
* Properties modal (inspector, on a main: Edit properties…): a tab per property (name, kind, layer, default, delete); an options property as a Table (a row per option, a column per layer · style; colours picked from the project's variables with a swatch, visibility as a toggle, icon, image and text fields; add or remove options and columns) or as Source (each option's changes as JSON with syntax highlighting); templates from the + Property menu; Save replaces them all. The inline list is name, kind and a remove button. The `status` template writes colours the project has as variables as those variables.
|
||||
* Canvas overlays (agent glow, agent outlines, review outlines, the agent-elsewhere chip) sit below modals instead of showing through them. Two segmented controls in one row no longer share widget ids (the second did not respond).
|
||||
* `merge_variants` keeps what each moved instance shows (texts that came from its old variant), carries nested instances' property values (a badge's Tone) in the options, and takes `textLayers` for state texts only.
|
||||
|
||||
### 2026-10-25: 0.8.0 (prototype links tool, merge icon colours)
|
||||
|
||||
* `set_prototype_links`: agents can link a layer to a screen for Present mode (back arrows, rows, tabs); before, only the inspector could.
|
||||
* `merge_variants` compares an icon's colour (keeping its variable), now that icons are compared as one unit.
|
||||
|
||||
### 2026-10-25: 0.8.0 (data safety fixes)
|
||||
|
||||
* Sync no longer throws away an instance written over a nested one the way older versions stored it (its own root, no swap): it becomes a swap of the main's child at that place. Projects built before #11 lost such layers (a checked checkbox, a status badge) on the next edit of the outer main.
|
||||
* Icons: the HTML parser keeps attribute names in lowercase, so the icon and plugin code never removed the SVG's original `viewBox`; since #14 that made `<x-paper-icon>` (and icon swaps) scale twice and come out tiny. Unreleased builds only; 0.7.3 is not affected.
|
||||
* `merge_variants` refuses merges that would leave one option and changes nothing then.
|
||||
|
||||
### 2026-10-25: 0.8.0 (merge variants, pinned bars)
|
||||
|
||||
* `merge_variants`: per-state components (`Tab bar / Hoje`, `Status / Sucesso`...) become one component with an enum property. Each variant's differences from the base, matched by layer path (fills, colours, borders, weight, radius, visibility, icons; texts on request), become its option; every instance on every page moves to the base with its value; the base takes the family name; the emptied variants are deleted. A variant with layers the base lacks is left out and reported.
|
||||
* Absolute children of a scrolling screen stay pinned while its content scrolls (a tab bar, a floating button), and do not add to the scroll range.
|
||||
|
||||
### 2026-10-25: 0.8.0 (fix #22)
|
||||
|
||||
* #22: a hug column whose width comes from its parent (stretched or filled) re-measures its height once its text wraps at that width, and the parent measures again, instead of keeping a height measured while the text was narrow.
|
||||
|
||||
### 2026-10-25: 0.8.0 (component properties)
|
||||
|
||||
* Main components carry properties (`Node.props`): `text`, `bool`, `icon` and `enum`, whose options are changes in the overrides format (a layer name, `$root` or a path `Item > Label`, to a text or styles that may hold `text`, `icon` and `visibility`), so they restyle, retext, show, hide or re-icon any layer. Instances keep their values (`prop_values`) as overrides, so main edits keep them; changing the definitions re-applies every instance on every page.
|
||||
* Templates: `text` (a property per text layer), `active` (which item of a tab bar, nav or segmented row is active) and `status` (options with a colour tint the root and colour the label and icon).
|
||||
* `set_component_props`, `set_instance_props`, `props='{...}'` on `<x-paper-instance>`, properties in `get_components`, `{Prop}` in code link templates, and an inspector section (a control per property on an instance; Texts and Active item templates on a main). Icon swaps keep the layer's name and link (a `children` override); recoloured icon shapes inside instances now keep their colour after a sync.
|
||||
* Spec: `specs/component-props/spec.md`.
|
||||
|
||||
### 2026-10-25: 0.8.0 (scrolling screens)
|
||||
|
||||
* Screens keep their size and scroll what overflows: a frame's `scroll` flag (CSS `overflow: auto|scroll`, `overflow-y-auto` in the Tailwind export) makes it fixed height and clipped, and the wheel over it scrolls its content on the canvas and in Present, then pans once the content ends. The offset is view state, not saved; hit-testing and overlays follow it.
|
||||
* Toggles: a scroll button beside each screen's name (shown when it scrolls, overflows, or is hovered), Scroll overflow on a selected frame, and Screens scroll on the page for every screen at once. A fit-content screen that grew goes back to its min height.
|
||||
* Agents are told to keep screens at their size and use overflow:auto for long content.
|
||||
|
||||
### 2026-10-25: 0.8.0 (fixes #19, #20, #21)
|
||||
|
||||
* #19: a clipping frame draws its border after its children, so filled rows no longer hide it. Clipping stays rectangular: children's square corners still show past a rounded border.
|
||||
* #20: a border written with a colour variable (`1px solid var(--border)`) binds the stroke, a literal one unbinds it, and reads show `var(--name)` for bound strokes. `find_nodes` says when it stopped at 500 matches.
|
||||
* #21: hug (fit-content) frames grow around growing children (CSS flex-basis auto) instead of staying at their min height; with short content the min height stays and the growing child fills it, so a pinned footer works.
|
||||
|
||||
### 2026-10-25: 0.8.0 (follow agents, skeletons)
|
||||
|
||||
* Follow agents (Settings, Chat; on by default): in Design and Chat mode alike, the canvas pans, and switches page, to the layer any agent changes (the chat, Claude Code over MCP, a helper). Moving the canvas yourself pauses it for a moment; off, a chip offers the agent's page.
|
||||
* Skeletons: the moment the model starts writing a tool call (`ToolDraft` and `ToolInput` events from Claude Code's stream and the API), the canvas shows a shimmering skeleton over its target, or where a new artboard goes, labelled with what it is doing, until the call has run. Fresh agent content comes in through a skeleton that fades, so an outline never frames empty space.
|
||||
* Top bar: the title and the agent pill leave room measured from the buttons on the right, so nothing overlaps at narrow widths.
|
||||
|
||||
### 2026-10-25: 0.8.0 (fixes #14 to #18)
|
||||
|
||||
* #14: `write_html` fits an `<svg>` viewBox into its width and height like a browser (centred, aspect kept).
|
||||
* #15: a note on something placed straight in its artboard treats the artboard as the screen: tidy keeps cards off it instead of in its free space.
|
||||
* #16: `not-an-instance` no longer matches look-alikes with a different icon; its fix names the `<x-paper-instance>` to write (swap_instance refuses plain frames).
|
||||
* #17: instances with their own text re-measure it when the main's text style changes, so hug buttons keep fitting their labels (also covers the size reset after a swap).
|
||||
* #18: `lint_design` reports borders whose hex equals a color variable, and `fix` binds them (`stroke`), so a mode switch reaches them.
|
||||
|
||||
### 2026-10-25: 0.7.3 (QA sub-agent, helper agents, auto mode, mid-turn messages, isolated dev settings)
|
||||
|
||||
* QA sub-agent: Claude Code chats always get a Sonnet `qa` agent that lints and screenshots built frames and returns only findings, so screenshots stay out of the main context.
|
||||
|
||||
@@ -62,3 +62,15 @@ SOFTWARE.
|
||||
|
||||
Inter 4.1 by Rasmus Andersson — SIL Open Font License 1.1 (`assets/fonts/OFL.txt`). Regular,
|
||||
Medium, SemiBold, Bold, Italic and Bold Italic are bundled unmodified.
|
||||
|
||||
Interface fonts, SIL Open Font License 1.1, Latin subsets as served by Google Fonts, unmodified
|
||||
(Regular, Medium, SemiBold): Geist (Vercel), Figtree (Erik Kennedy), DM Sans (Colophon Foundry,
|
||||
Jonny Pinhorn), Nunito (Vernon Adams, Jacques Le Bailly), Quicksand (Andrew Paglinawan),
|
||||
Fredoka (Milena Brandão, Hafontia), and Varela Round (Joe Prince; Regular only).
|
||||
|
||||
## Agent logos
|
||||
|
||||
`assets/agents/*.svg` are from LobeHub Icons (`@lobehub/icons-static-svg` 1.80.0,
|
||||
https://github.com/lobehub/lobe-icons), MIT License, Copyright (c) LobeHub. They are used
|
||||
unmodified to identify each coding agent in the agent picker. The marks are trademarks of
|
||||
their owners.
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Claude</title><path d="M4.709 15.955l4.72-2.647.08-.23-.08-.128H9.2l-.79-.048-2.698-.073-2.339-.097-2.266-.122-.571-.121L0 11.784l.055-.352.48-.321.686.06 1.52.103 2.278.158 1.652.097 2.449.255h.389l.055-.157-.134-.098-.103-.097-2.358-1.596-2.552-1.688-1.336-.972-.724-.491-.364-.462-.158-1.008.656-.722.881.06.225.061.893.686 1.908 1.476 2.491 1.833.365.304.145-.103.019-.073-.164-.274-1.355-2.446-1.446-2.49-.644-1.032-.17-.619a2.97 2.97 0 01-.104-.729L6.283.134 6.696 0l.996.134.42.364.62 1.414 1.002 2.229 1.555 3.03.456.898.243.832.091.255h.158V9.01l.128-1.706.237-2.095.23-2.695.08-.76.376-.91.747-.492.584.28.48.685-.067.444-.286 1.851-.559 2.903-.364 1.942h.212l.243-.242.985-1.306 1.652-2.064.73-.82.85-.904.547-.431h1.033l.76 1.129-.34 1.166-1.064 1.347-.881 1.142-1.264 1.7-.79 1.36.073.11.188-.02 2.856-.606 1.543-.28 1.841-.315.833.388.091.395-.328.807-1.969.486-2.309.462-3.439.813-.042.03.049.061 1.549.146.662.036h1.622l3.02.225.79.522.474.638-.079.485-1.215.62-1.64-.389-3.829-.91-1.312-.329h-.182v.11l1.093 1.068 2.006 1.81 2.509 2.33.127.578-.322.455-.34-.049-2.205-1.657-.851-.747-1.926-1.62h-.128v.17l.444.649 2.345 3.521.122 1.08-.17.353-.608.213-.668-.122-1.374-1.925-1.415-2.167-1.143-1.943-.14.08-.674 7.254-.316.37-.729.28-.607-.461-.322-.747.322-1.476.389-1.924.315-1.53.286-1.9.17-.632-.012-.042-.14.018-1.434 1.967-2.18 2.945-1.726 1.845-.414.164-.717-.37.067-.662.401-.589 2.388-3.036 1.44-1.882.93-1.086-.006-.158h-.055L4.132 18.56l-1.13.146-.487-.456.061-.746.231-.243 1.908-1.312-.006.006z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.7 KiB |
@@ -0,0 +1 @@
|
||||
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Cline</title><path d="M17.035 3.991c2.75 0 4.98 2.24 4.98 5.003v1.667l1.45 2.896a1.01 1.01 0 01-.002.909l-1.448 2.864v1.668c0 2.762-2.23 5.002-4.98 5.002H7.074c-2.751 0-4.98-2.24-4.98-5.002V17.33l-1.48-2.855a1.01 1.01 0 01-.003-.927l1.482-2.887V8.994c0-2.763 2.23-5.003 4.98-5.003h9.962zM8.265 9.6a2.274 2.274 0 00-2.274 2.274v4.042a2.274 2.274 0 004.547 0v-4.042A2.274 2.274 0 008.265 9.6zm7.326 0a2.274 2.274 0 00-2.274 2.274v4.042a2.274 2.274 0 104.548 0v-4.042A2.274 2.274 0 0015.59 9.6z"></path><path d="M12.054 5.558a2.779 2.779 0 100-5.558 2.779 2.779 0 000 5.558z"></path></svg>
|
||||
|
After Width: | Height: | Size: 750 B |
@@ -0,0 +1 @@
|
||||
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Cursor</title><path d="M22.106 5.68L12.5.135a.998.998 0 00-.998 0L1.893 5.68a.84.84 0 00-.419.726v11.186c0 .3.16.577.42.727l9.607 5.547a.999.999 0 00.998 0l9.608-5.547a.84.84 0 00.42-.727V6.407a.84.84 0 00-.42-.726zm-.603 1.176L12.228 22.92c-.063.108-.228.064-.228-.061V12.34a.59.59 0 00-.295-.51l-9.11-5.26c-.107-.062-.063-.228.062-.228h18.55c.264 0 .428.286.296.514z"></path></svg>
|
||||
|
After Width: | Height: | Size: 547 B |
@@ -0,0 +1 @@
|
||||
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Gemini</title><path d="M20.616 10.835a14.147 14.147 0 01-4.45-3.001 14.111 14.111 0 01-3.678-6.452.503.503 0 00-.975 0 14.134 14.134 0 01-3.679 6.452 14.155 14.155 0 01-4.45 3.001c-.65.28-1.318.505-2.002.678a.502.502 0 000 .975c.684.172 1.35.397 2.002.677a14.147 14.147 0 014.45 3.001 14.112 14.112 0 013.679 6.453.502.502 0 00.975 0c.172-.685.397-1.351.677-2.003a14.145 14.145 0 013.001-4.45 14.113 14.113 0 016.453-3.678.503.503 0 000-.975 13.245 13.245 0 01-2.003-.678z"></path></svg>
|
||||
|
After Width: | Height: | Size: 651 B |
@@ -0,0 +1 @@
|
||||
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>GithubCopilot</title><path d="M19.245 5.364c1.322 1.36 1.877 3.216 2.11 5.817.622 0 1.2.135 1.592.654l.73.964c.21.278.323.61.323.955v2.62c0 .339-.173.669-.453.868C20.239 19.602 16.157 21.5 12 21.5c-4.6 0-9.205-2.583-11.547-4.258-.28-.2-.452-.53-.453-.868v-2.62c0-.345.113-.679.321-.956l.73-.963c.392-.517.974-.654 1.593-.654l.029-.297c.25-2.446.81-4.213 2.082-5.52 2.461-2.54 5.71-2.851 7.146-2.864h.198c1.436.013 4.685.323 7.146 2.864zm-7.244 4.328c-.284 0-.613.016-.962.05-.123.447-.305.85-.57 1.108-1.05 1.023-2.316 1.18-2.994 1.18-.638 0-1.306-.13-1.851-.464-.516.165-1.012.403-1.044.996a65.882 65.882 0 00-.063 2.884l-.002.48c-.002.563-.005 1.126-.013 1.69.002.326.204.63.51.765 2.482 1.102 4.83 1.657 6.99 1.657 2.156 0 4.504-.555 6.985-1.657a.854.854 0 00.51-.766c.03-1.682.006-3.372-.076-5.053-.031-.596-.528-.83-1.046-.996-.546.333-1.212.464-1.85.464-.677 0-1.942-.157-2.993-1.18-.266-.258-.447-.661-.57-1.108-.32-.032-.64-.049-.96-.05zm-2.525 4.013c.539 0 .976.426.976.95v1.753c0 .525-.437.95-.976.95a.964.964 0 01-.976-.95v-1.752c0-.525.437-.951.976-.951zm5 0c.539 0 .976.426.976.95v1.753c0 .525-.437.95-.976.95a.964.964 0 01-.976-.95v-1.752c0-.525.437-.951.976-.951zM7.635 5.087c-1.05.102-1.935.438-2.385.906-.975 1.037-.765 3.668-.21 4.224.405.394 1.17.657 1.995.657h.09c.649-.013 1.785-.176 2.73-1.11.435-.41.705-1.433.675-2.47-.03-.834-.27-1.52-.63-1.813-.39-.336-1.275-.482-2.265-.394zm6.465.394c-.36.292-.6.98-.63 1.813-.03 1.037.24 2.06.675 2.47.968.957 2.136 1.104 2.776 1.11h.044c.825 0 1.59-.263 1.995-.657.555-.556.765-3.187-.21-4.224-.45-.468-1.335-.804-2.385-.906-.99-.088-1.875.058-2.265.394zM12 7.615c-.24 0-.525.015-.84.044.03.16.045.336.06.526l-.001.159a2.94 2.94 0 01-.014.25c.225-.022.425-.027.612-.028h.366c.187 0 .387.006.612.028-.015-.146-.015-.277-.015-.409.015-.19.03-.365.06-.526a9.29 9.29 0 00-.84-.044z"></path></svg>
|
||||
|
After Width: | Height: | Size: 2.0 KiB |
@@ -0,0 +1 @@
|
||||
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Goose</title><path d="M21.595 23.61c1.167-.254 2.405-.944 2.405-.944l-2.167-1.784a12.124 12.124 0 01-2.695-3.131 12.127 12.127 0 00-3.97-4.049l-.794-.462a1.115 1.115 0 01-.488-.815.844.844 0 01.154-.575c.413-.582 2.548-3.115 2.94-3.44.503-.416 1.065-.762 1.586-1.159.074-.056.148-.112.221-.17.003-.002.007-.004.009-.007.167-.131.325-.272.45-.438.453-.524.563-.988.59-1.193-.061-.197-.244-.639-.753-1.148.319.02.705.272 1.056.569.235-.376.481-.773.727-1.171.165-.266-.08-.465-.086-.471h-.001V3.22c-.007-.007-.206-.25-.471-.086-.567.35-1.134.702-1.639 1.021 0 0-.597-.012-1.305.599a2.464 2.464 0 00-.438.45l-.007.009c-.058.072-.114.147-.17.221-.397.521-.743 1.083-1.16 1.587-.323.391-2.857 2.526-3.44 2.94a.842.842 0 01-.574.153 1.115 1.115 0 01-.815-.488l-.462-.794a12.123 12.123 0 00-4.049-3.97 12.133 12.133 0 01-3.13-2.695L1.332 0S.643 1.238.39 2.405c.352.428 1.27 1.49 2.34 2.302C1.58 4.167.73 3.75.06 3.4c-.103.765-.063 1.92.043 2.816.726.317 1.961.806 3.219 1.066-1.006.236-2.11.278-2.961.262.15.554.358 1.119.64 1.688.119.263.25.52.39.77.452.125 2.222.383 3.164.171l-2.51.897a27.776 27.776 0 002.544 2.726c2.031-1.092 2.494-1.241 4.018-2.238-2.467 2.008-3.108 2.828-3.8 3.67l-.483.678c-.25.351-.469.725-.65 1.117-.61 1.31-1.47 4.1-1.47 4.1-.154.486.202.842.674.674 0 0 2.79-.861 4.1-1.47.392-.182.766-.4 1.118-.65l.677-.483c.227-.187.453-.37.701-.586 0 0 1.705 2.02 3.458 3.349l.896-2.511c-.211.942.046 2.712.17 3.163.252.142.509.272.772.392.569.28 1.134.49 1.688.64-.016-.853.026-1.956.261-2.962.26 1.258.75 2.493 1.067 3.219.895.106 2.051.146 2.816.043a73.87 73.87 0 01-1.308-2.67c.811 1.07 1.874 1.988 2.302 2.34h-.001z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.8 KiB |
@@ -0,0 +1 @@
|
||||
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Kimi</title><path d="M21.846 0a1.923 1.923 0 110 3.846H20.15a.226.226 0 01-.227-.226V1.923C19.923.861 20.784 0 21.846 0z"></path><path d="M11.065 11.199l7.257-7.2c.137-.136.06-.41-.116-.41H14.3a.164.164 0 00-.117.051l-7.82 7.756c-.122.12-.302.013-.302-.179V3.82c0-.127-.083-.23-.185-.23H3.186c-.103 0-.186.103-.186.23V19.77c0 .128.083.23.186.23h2.69c.103 0 .186-.102.186-.23v-3.25c0-.069.025-.135.069-.178l2.424-2.406a.158.158 0 01.205-.023l6.484 4.772a7.677 7.677 0 003.453 1.283c.108.012.2-.095.2-.23v-3.06c0-.117-.07-.212-.164-.227a5.028 5.028 0 01-2.027-.807l-5.613-4.064c-.117-.078-.132-.279-.028-.381z"></path></svg>
|
||||
|
After Width: | Height: | Size: 786 B |
@@ -0,0 +1 @@
|
||||
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Mistral</title><path clip-rule="evenodd" d="M3.428 3.4h3.429v3.428h3.429v3.429h-.002 3.431V6.828h3.427V3.4h3.43v13.714H24v3.429H13.714v-3.428h-3.428v-3.429h-3.43v3.428h3.43v3.429H0v-3.429h3.428V3.4zm10.286 13.715h3.428v-3.429h-3.427v3.429z"></path></svg>
|
||||
|
After Width: | Height: | Size: 418 B |
|
After Width: | Height: | Size: 19 KiB |
@@ -0,0 +1 @@
|
||||
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>OpenAI</title><path d="M9.205 8.658v-2.26c0-.19.072-.333.238-.428l4.543-2.616c.619-.357 1.356-.523 2.117-.523 2.854 0 4.662 2.212 4.662 4.566 0 .167 0 .357-.024.547l-4.71-2.759a.797.797 0 00-.856 0l-5.97 3.473zm10.609 8.8V12.06c0-.333-.143-.57-.429-.737l-5.97-3.473 1.95-1.118a.433.433 0 01.476 0l4.543 2.617c1.309.76 2.189 2.378 2.189 3.948 0 1.808-1.07 3.473-2.76 4.163zM7.802 12.703l-1.95-1.142c-.167-.095-.239-.238-.239-.428V5.899c0-2.545 1.95-4.472 4.591-4.472 1 0 1.927.333 2.712.928L8.23 5.067c-.285.166-.428.404-.428.737v6.898zM12 15.128l-2.795-1.57v-3.33L12 8.658l2.795 1.57v3.33L12 15.128zm1.796 7.23c-1 0-1.927-.332-2.712-.927l4.686-2.712c.285-.166.428-.404.428-.737v-6.898l1.974 1.142c.167.095.238.238.238.428v5.233c0 2.545-1.974 4.472-4.614 4.472zm-5.637-5.303l-4.544-2.617c-1.308-.761-2.188-2.378-2.188-3.948A4.482 4.482 0 014.21 6.327v5.423c0 .333.143.571.428.738l5.947 3.449-1.95 1.118a.432.432 0 01-.476 0zm-.262 3.9c-2.688 0-4.662-2.021-4.662-4.519 0-.19.024-.38.047-.57l4.686 2.71c.286.167.571.167.856 0l5.97-3.448v2.26c0 .19-.07.333-.237.428l-4.543 2.616c-.619.357-1.356.523-2.117.523zm5.899 2.83a5.947 5.947 0 005.827-4.756C22.287 18.339 24 15.84 24 13.296c0-1.665-.713-3.282-1.998-4.448.119-.5.19-.999.19-1.498 0-3.401-2.759-5.947-5.946-5.947-.642 0-1.26.095-1.88.31A5.962 5.962 0 0010.205 0a5.947 5.947 0 00-5.827 4.757C1.713 5.447 0 7.945 0 10.49c0 1.666.713 3.283 1.998 4.448-.119.5-.19 1-.19 1.499 0 3.401 2.759 5.946 5.946 5.946.642 0 1.26-.095 1.88-.309a5.96 5.96 0 004.162 1.713z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.6 KiB |
@@ -0,0 +1 @@
|
||||
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Qwen</title><path d="M12.604 1.34c.393.69.784 1.382 1.174 2.075a.18.18 0 00.157.091h5.552c.174 0 .322.11.446.327l1.454 2.57c.19.337.24.478.024.837-.26.43-.513.864-.76 1.3l-.367.658c-.106.196-.223.28-.04.512l2.652 4.637c.172.301.111.494-.043.77-.437.785-.882 1.564-1.335 2.34-.159.272-.352.375-.68.37-.777-.016-1.552-.01-2.327.016a.099.099 0 00-.081.05 575.097 575.097 0 01-2.705 4.74c-.169.293-.38.363-.725.364-.997.003-2.002.004-3.017.002a.537.537 0 01-.465-.271l-1.335-2.323a.09.09 0 00-.083-.049H4.982c-.285.03-.553-.001-.805-.092l-1.603-2.77a.543.543 0 01-.002-.54l1.207-2.12a.198.198 0 000-.197 550.951 550.951 0 01-1.875-3.272l-.79-1.395c-.16-.31-.173-.496.095-.965.465-.813.927-1.625 1.387-2.436.132-.234.304-.334.584-.335a338.3 338.3 0 012.589-.001.124.124 0 00.107-.063l2.806-4.895a.488.488 0 01.422-.246c.524-.001 1.053 0 1.583-.006L11.704 1c.341-.003.724.032.9.34zm-3.432.403a.06.06 0 00-.052.03L6.254 6.788a.157.157 0 01-.135.078H3.253c-.056 0-.07.025-.041.074l5.81 10.156c.025.042.013.062-.034.063l-2.795.015a.218.218 0 00-.2.116l-1.32 2.31c-.044.078-.021.118.068.118l5.716.008c.046 0 .08.02.104.061l1.403 2.454c.046.081.092.082.139 0l5.006-8.76.783-1.382a.055.055 0 01.096 0l1.424 2.53a.122.122 0 00.107.062l2.763-.02a.04.04 0 00.035-.02.041.041 0 000-.04l-2.9-5.086a.108.108 0 010-.113l.293-.507 1.12-1.977c.024-.041.012-.062-.035-.062H9.2c-.059 0-.073-.026-.043-.077l1.434-2.505a.107.107 0 000-.114L9.225 1.774a.06.06 0 00-.053-.031zm6.29 8.02c.046 0 .058.02.034.06l-.832 1.465-2.613 4.585a.056.056 0 01-.05.029.058.058 0 01-.05-.029L8.498 9.841c-.02-.034-.01-.052.028-.054l.216-.012 6.722-.012z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.7 KiB |
@@ -0,0 +1 @@
|
||||
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Tencent</title><path d="M9.976 1L24 9.8l-10.587.015L10.723 23H5.489L8.18 9.8H3.244L1 5.4h8.077L9.976 1z"></path></svg>
|
||||
|
After Width: | Height: | Size: 282 B |
@@ -33,7 +33,7 @@ Allowed spacing values: **4, 8, 12, 16, 24, 32, 48, 64** (and 96 for page sectio
|
||||
## 2. Tokens first (variables)
|
||||
|
||||
* All colors are variables (`var(--name)`), named by role: `bg`, `surface`, `border`, `text`, `text-muted`, `accent`, `on-accent`, `success`, `warning`, `danger`. Two to three surface levels at most (`bg` < `surface` < `surface-2`).
|
||||
* Define at least two modes if the product may be dark or light (`set_variables` with `mode`), and check both: lint contrast in each mode after `set_variable_mode`.
|
||||
* Define at least two modes if the product may be dark or light (`set_variables` with `mode`), and check both: lint contrast in each mode after `set_variable_mode`, or look with `get_screenshot {mode}` without switching. Themes are modes, never copies: one artboard per screen, never a light and a dark duplicate (`lint_design` flags them as `theme-copy`).
|
||||
* Semantic colors carry meaning only: red means error or destructive, green means success. Do not use them decoratively.
|
||||
* If a color you need is not a variable, add the variable, then use it. `lint_design` reports hard-coded colors that equal a variable.
|
||||
* Spacing, radius and type sizes are number variables (`space/md`, `radius/sm`, `font/body`): define them once, in a collection of their own, and bind fields with `bind_variable`. Keep the values on the lists in this skill. Group names with `/` and alias semantic names to base ones (`color/button/bg` to `color/brand/primary`).
|
||||
|
||||
@@ -45,7 +45,7 @@ Lessons from redesigning a 3-page, 130-screen project with up to six agents at o
|
||||
* **Split by section, not by page.** Give each agent named sections (frame ids) and forbid the rest. New sections go below the current lowest one; re-read positions right before creating, since another agent may have just added one.
|
||||
* **Section layout**: a section frame holds a title and rows of screen slots (a route label above a fixed-size screen). Keep 640 px between screens and rows and 1600 px between sections, so notes fit beside screens. Stack sections in one column.
|
||||
* **Recreate, then delete.** Build the new screen next to the old one, finish it, then delete the old one, and record the deleted ids and names in the report. A cleanup agent once deleted buttons and lost icons by editing in place.
|
||||
* **Reuse the design system across pages**: `create_instance` / `swap_instance` / `<x-paper-instance>` take a main from any page and stay linked. Never `import_components` to use a component: that copies mains onto the page, and instances made from the copies stop following the design system. `lint_design` flags hand-made look-alikes as `not-an-instance` (with the component key to use).
|
||||
* **Reuse the design system across pages**: `create_instance` / `swap_instance` / `<x-paper-instance>` take a main from any page and stay linked. Never `import_components` to use a component: that copies mains onto the page, and instances made from the copies stop following the design system. `lint_design` flags hand-made look-alikes as `not-an-instance` (a different icon is a different component); fix with `write_html` mode replace and the `<x-paper-instance>` it names.
|
||||
* **QA pass after a build** (screen by screen at scale 1, one call at a time): text layers with stale fixed widths clip labels ("In prog", "New sess"): set `width: fit-content`. Sidebars and panels fixed at 900 px stop short on taller screens: make them fill the height and let the screen hug with a 900 px minimum. Tags and chips overflowing their box, cards overlapping buttons, footers not pinned, leftover `Text` / `Frame` names, gendered copy about people whose gender is unknown (use their name or neutral wording).
|
||||
* **Report per agent** in `specs/<feature>/<agent>-report.md`: what changed (ids), why (playbook rule ids from `playbook search`), open questions. Unknown product facts are written as questions for the team, never invented.
|
||||
|
||||
@@ -102,7 +102,7 @@ Full flows: [recipes.md](references/recipes.md).
|
||||
| Inspect one node | `get_node_info`, `get_computed_styles`, `get_jsx` |
|
||||
| Find nodes by text or style | `find_nodes` (text contains the phrase, any case, every page) |
|
||||
| Read all copy in a subtree | `get_tree_summary {nodeId, fullText: true}` (depth counts from the node: rows under a "Screens" frame need depth 3) |
|
||||
| See it | `get_screenshot` (`nodeId`, `scale` 1 or 2, `annotations: true` to include notes) |
|
||||
| See it | `get_screenshot` (`nodeId`, `scale` 1 or 2, `annotations: true` to include notes, `mode` to preview a theme without switching) |
|
||||
| Show the user something | `set_viewport` (page switch, fit, zoom) |
|
||||
| Document rules on the design | `annotate`, `get_annotation_template`, `get_annotations`, `update_annotation`, `delete_annotations` |
|
||||
| New screen | `create_artboard` (`width` and `height` required) |
|
||||
@@ -112,6 +112,10 @@ Full flows: [recipes.md](references/recipes.md).
|
||||
| Copy, move, delete | `duplicate_nodes`, `move_nodes`, `delete_nodes` |
|
||||
| Tokens | `get_variables` (`format` css, json or tailwind), `set_variables` (`collection`, `type`, `mode`, `alias`), `create_collection`, `delete_variables`, `set_variable_mode`, `bind_variable` |
|
||||
| Reuse | `create_component`, `create_instance`, `swap_instance` (also a nested badge inside a row instance: a swap that survives main edits), `get_components`, `<x-paper-instance>` |
|
||||
| Component properties | One component with properties instead of one per state: `set_component_props` (`text`, `bool`, `icon`, `enum` options in the overrides format; templates `text`, `active` for tab bars and navs, `status` with [{value, color}]), then `set_instance_props {nodeIds, values}` or `<x-paper-instance component="Badge" props='{"Status":"Rejeitada"}'/>`; `get_components` lists them |
|
||||
| Merge per-state components | `merge_variants {componentIds, name}` turns `Tab bar / Hoje`, `Tab bar / Lojas`... into one `Tab bar` with an enum, moves every instance and deletes the emptied variants |
|
||||
| Prototype | `set_prototype_links {links:[{nodeId, to}]}`: back arrows, rows and tabs go to their screens in Present mode |
|
||||
| Screens | Fixed size (390x844, 1440x900...); content longer than the screen: `overflow: auto` on the screen frame, so it keeps its size and the overflow scrolls on the canvas and in Present (inspector: Scroll overflow; page: Screens scroll) |
|
||||
| Project | `rename_file {name}` (open project stays open), `move_nodes {toPageId}` keeps instances linked and notes attached |
|
||||
| Shared libraries | `publish_library`, `list_libraries`, `enable_library`, `update_library`, `disable_library` |
|
||||
| Icons | `search_icons`, `<x-paper-icon name="set:name">`; CLI: `papercraft-cli icons add\|list\|search` |
|
||||
|
||||
@@ -207,6 +207,7 @@ Render a node (or the whole page when nodeId is omitted or "root") to a PNG and
|
||||
| Argument | Type | Required | Notes |
|
||||
|---|---|---|---|
|
||||
| `annotations` | boolean | | Also draw the note cards, badges and leader lines of notes on the captured layers; the image grows to include the cards. |
|
||||
| `mode` | string | | Render in this variable mode (e.g. "Light") without switching the document: preview every theme of one artboard while other agents keep working in the current mode. |
|
||||
| `nodeId` | string | | The node to capture; omit for the whole page. |
|
||||
| `out` | string | | Optional file path to save the PNG to (also used by papercraft-cli). |
|
||||
| `scale` | number | | Render scale: 1 (default) or 2. |
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
# Component properties
|
||||
|
||||
## Why
|
||||
|
||||
A status badge, a tab bar or a list row is drawn once as a component, but every use differs:
|
||||
the status is Autorizada, Rejeitada or Na SEFAZ; a different tab is active; the label and the
|
||||
icon change. Today each case is its own component (`Badge / Green`, `Tabs / Lojas`, `Tabs /
|
||||
Pedidos`) or hand edits on layers, so a design needs a component per state and an agent edits
|
||||
layer by layer.
|
||||
|
||||
## What
|
||||
|
||||
1. **Properties live on the main component** (`Node.props`), each with a name and a type:
|
||||
* `text`: the text of a layer (`layer`).
|
||||
* `bool`: shows or hides a layer.
|
||||
* `icon`: the icon of an icon layer (`Icon / set:name`), any `set:name`.
|
||||
* `enum`: a list of options; each option is a set of changes in the `overrides` format
|
||||
(`{"Label": "Rejeitada", "$root": {"background": "var(--danger-bg)"}, "Dot": {"icon": "lucide:x"}}`),
|
||||
so an option can restyle, retext, show, hide or re-icon any layer. Fully custom.
|
||||
2. **Instances keep their values** (`Node.prop_values` on the instance root) and apply them as
|
||||
overrides, so edits to the main keep them; editing the definitions re-applies every
|
||||
instance's values on every page.
|
||||
3. **Templates** make the common cases one call: `text` (a text property per text layer),
|
||||
`active` (an enum choosing which item of a row, tab bar or nav is active, with active and
|
||||
inactive styles) and `status` (an enum of statuses, each a colour that tints the root and
|
||||
colours its text and icon).
|
||||
4. **Where to use it**: `set_component_props` (define, or from a template),
|
||||
`set_instance_props` (many instances in one call), `get_components` (lists properties),
|
||||
`<x-paper-instance component="Badge" props='{"Status":"Rejeitada"}'/>` in `write_html`,
|
||||
the inspector (a control per property on an instance; templates on a main), and code
|
||||
links (`{Status}` in a usage template is the instance's value).
|
||||
|
||||
## Not now
|
||||
|
||||
Variant sets made of several mains, nested property exposure (a row exposing its badge's
|
||||
properties), numeric properties.
|
||||
|
||||
## Acceptance
|
||||
|
||||
* A badge with an enum Status (Autorizada, Rejeitada) shows the option's colours and label on
|
||||
each instance; switching the value updates only that instance.
|
||||
* Editing the main (gap) keeps every instance's values; editing an option's colour updates
|
||||
every instance using it.
|
||||
* The `active` template on a five-item tab bar makes one property whose options are the item
|
||||
names; each instance shows its active item highlighted.
|
||||
* An icon property swaps the icon and survives a sync of the main.
|
||||
@@ -0,0 +1,29 @@
|
||||
# Screens — plan
|
||||
|
||||
Built against `spec.md` (decisions resolved 2026-10-27).
|
||||
|
||||
## Model
|
||||
- `Node::screen` and `Node::overlay` (bools, omitted when false). Shipped in project format v3, which is not released yet, so no extra version bump is needed: older builds already refuse v3.
|
||||
|
||||
## Editor (`src/editor/screens.rs`)
|
||||
- `Role`, `refusal`, `declared`, `screen_of`, `candidates`, `device_of`, and `Editor::set_role`, which changes roles as one undo step.
|
||||
- `coverage`: inside the layer's screen, it finds layers painted after it that are visible and either overlays or opaque (fill at 95% opacity or more), clips them to the screen, and returns the union area as a visible share. A layer under 1% visible counts as hidden; under 99% visible, as partly covered.
|
||||
- `Editor::screens()` returns the declared screens, or the top-level frames when none are declared. "Screens scroll" therefore stops toggling sections.
|
||||
- Frame labels: declared screens nested in sections get labels. A frame's own ancestors don't count as covering its label.
|
||||
|
||||
## MCP
|
||||
- `set_screens {nodeIds, role}`.
|
||||
- The guard runs on every tool in `GUARDED`. It reads `nodeId`, `nodeIds`, `parentId` and `targetNodeId` at any depth in the arguments.
|
||||
- It refuses hidden targets unless `allowHidden` is set.
|
||||
- It appends a `partlyCovered` or `editedWhileHidden` note to the result.
|
||||
- `get_basic_info.screens`, `get_node_info.screen`, `lint_design.skippedHidden` (hidden layers are dropped from the findings).
|
||||
|
||||
## UI
|
||||
- The frame inspector has Screen and Overlay toggles, shown only where the role is allowed.
|
||||
- The page inspector has a "Declare N screens" button listing the candidates, as one undo step.
|
||||
- "On click" lists `ed.screens()`.
|
||||
|
||||
## Known ceilings
|
||||
- Covers are clipped to the screen only, not to frames in between.
|
||||
- Lint autofix still edits hidden layers.
|
||||
- Export and design-to-code don't use screens yet (planned for later).
|
||||
@@ -0,0 +1,75 @@
|
||||
# Screens
|
||||
|
||||
## Problem
|
||||
PaperCraft has no notion of a "screen": a screen, a section that groups screens, a slot that labels one, and a component main are all just frames. Big projects like Interview (42k layers) nest screens three or four frames deep (Section > Screens > Row > Slot > Screen), so every frame-based feature guesses wrong:
|
||||
- The canvas shows labels only for top-level frames, so the nested screens get none.
|
||||
- The "On click" link list offers sections and labels instead of screens.
|
||||
- Scrolling picks whichever scroll-enabled frame is deepest.
|
||||
- Agents cannot tell which frame is the device viewport.
|
||||
|
||||
The owner reports a second, related problem. When a drawer or modal is open over a screen, agents still edit, lint and "fix" the content hidden underneath it. The owner can't see that work, it can't be checked in a screenshot, and it often breaks the open-state design. Evidence: owner reports in this session (2026-10-27) about agents working behind open drawers and screen scroll caught by the section frame.
|
||||
|
||||
## Goal
|
||||
A frame can be declared a Screen. Agents and the editor then treat it as one device viewport, and agent edits to layers fully hidden inside it are refused with a clear reason.
|
||||
|
||||
## Non-Goals
|
||||
- Prototyping or interactive preview (flows, transitions, overlays opening on click).
|
||||
- Responsive variants or auto-generating breakpoints from one screen.
|
||||
- Occlusion between separate screens on the canvas, or between sections. Only layers inside one screen count.
|
||||
- Pixel-exact occlusion through transparency, blur or blend modes. Translucent covers (scrims) are judged by a rule, not by rendered pixels.
|
||||
- Changing how frames that are not screens behave.
|
||||
- Migrating existing projects automatically without the owner choosing to.
|
||||
|
||||
## User Stories
|
||||
- As a designer, I want to mark a frame as a Screen (with a device size), so that PaperCraft and agents know it is a page of the product, not a container.
|
||||
- As a designer, I want screens to show their name and device on the canvas even when nested in sections, so that I can read a large page at a glance.
|
||||
- As a designer, I want "On click" and other screen pickers to list only screens, so that I can find the target among hundreds of frames.
|
||||
- As a designer working with an agent, I want the agent stopped from editing layers that are fully hidden behind an open drawer or modal, so that it can't change what I can't see.
|
||||
- As an agent, I want each layer's visibility in its screen (visible, partly covered, hidden) and what covers it, so that I can choose the right target or open the right state first.
|
||||
- As a designer, I want scrolling over a screen to scroll that screen's scroll area, not its section, so that I can review long screens in place.
|
||||
- As a designer, I want to convert existing screen-like frames into Screens in one step, so that big projects get the benefit without hand-marking each one.
|
||||
|
||||
## Acceptance Criteria
|
||||
Declaring screens:
|
||||
- [ ] Given a frame, when the owner or an agent declares it a Screen, then it is saved as a Screen with a device size, survives save and reload, and the change is one undo step.
|
||||
- [ ] Given a frame that is a component main, an instance, or inside another Screen, when someone tries to declare it a Screen, then the request is refused with a message that names the reason.
|
||||
- [ ] Given a Screen, when it is undeclared, then it goes back to an ordinary frame and its layers and layout do not change.
|
||||
- [ ] Given a project file written before this feature, when it opens, then it loads with no Screens and nothing else changes.
|
||||
- [ ] Given a project that has Screens, when an older PaperCraft build opens it, then the behaviour matches the project's existing version-compatibility rules (refused or loaded without screen data), never silent data loss.
|
||||
|
||||
Agent tools:
|
||||
- [ ] Given a page with Screens, when an agent asks for the page summary, then Screens are listed as screens (name, device, size, section they sit in), separate from other frames.
|
||||
- [ ] Given a Screen where an opaque drawer covers a layer entirely, when an agent edits that layer (styles, text, delete, move, write content into it), then the edit is refused. The reason names the covering layer and is returned as an error, not a silent no-op.
|
||||
- [ ] Given the same layer only partly covered, when an agent edits it, then the edit succeeds and the result says the layer is partly covered, by which layer, and the visible fraction.
|
||||
- [ ] Given the owner, not an agent, edits a fully hidden layer in the app, then the edit is allowed (the guard applies to agent tools only).
|
||||
- [ ] Given an agent that passes an explicit override on a refused edit, then the edit succeeds and the result records that it was hidden. (Pending Open Question 3.)
|
||||
- [ ] Given a layer that is hidden only because its parent clips it or it scrolled out of view, then it is not treated as covered. Scroll and clipping are reported separately from covering.
|
||||
- [ ] Given a Screen with a hidden layer, when an agent lints that Screen, then hidden layers are skipped (or reported once as "hidden") instead of producing fix-me findings.
|
||||
- [ ] Given an agent reads a layer's info, then the response includes its visibility state in its Screen: visible, partly covered, hidden, or not in a screen.
|
||||
|
||||
Editor:
|
||||
- [ ] Given Screens nested in sections, when the canvas is zoomed out, then each Screen shows a name label (subject to the existing label overlap rules), and sections keep theirs.
|
||||
- [ ] Given the "On click" picker, when a project has Screens, then it lists Screens (grouped by section) and no other frames.
|
||||
- [ ] Given the pointer over a Screen whose own scroll area can still move, when the owner scrolls, then that area scrolls and the section around the Screen does not.
|
||||
- [ ] Given a page with screen-like frames (same size as a known device, or named "Screen / ..."), when the owner runs "Convert to Screens", then the owner sees the candidates and confirms, and the declaration is one undo step.
|
||||
|
||||
Performance:
|
||||
- [ ] Given the Interview Admin page (19k layers), when an agent edits a layer inside a Screen, then the hidden check adds no more than 5 ms to the call.
|
||||
|
||||
## Constraints
|
||||
- Stack: Rust, egui 0.36, wgpu; existing MCP tools and `papercraft-cli` (online and offline) must behave the same.
|
||||
- Performance: no extra cost per canvas frame; the hidden check runs only when an agent tool targets layers.
|
||||
- Security: none new. The refusal is a safety rail for agents, not a permission system.
|
||||
- Compat: the project format version bump follows the existing format migration rules. Web (wasm) build keeps parity.
|
||||
|
||||
## Open Questions
|
||||
None open. Resolved 2026-10-27 (owner: "go with your recommendations"):
|
||||
1. Cover: a layer later in paint order inside the same Screen that is opaque (solid or image fill at 95%+ effective opacity), or any layer marked as an overlay (drawer, modal, sheet), even if translucent.
|
||||
2. Partly covered: an edit passes when at least 1% of the layer's area is visible; the result reports the visible share.
|
||||
3. Override: yes, agents can pass an explicit flag; the result records that the layer was hidden.
|
||||
4. Overlays: a direct child of a Screen can be marked as an overlay. To work underneath it, agents hide it (an ordinary undo step); no separate toggle.
|
||||
5. Device: presets (mobile 390×844, tablet 768×1024, desktop 1440×900, large 2560×1440) or free size. The Screen's height is the viewport; taller content scrolls.
|
||||
6. Labels: top-level frames keep labels, and nested Screens gain them. Other nested frames get none.
|
||||
7. Convert to Screens: candidates are frames named "Screen / …", frames that are the screen inside a "Slot / …" frame, or top-level frames at a preset size. The owner confirms in the app; agents declare by id only.
|
||||
8. Never inside a component main or another Screen.
|
||||
9. First release: On click, page summary, lint scope, layer info. Export and design-to-code follow later.
|
||||
@@ -31,8 +31,38 @@ struct Gfx {
|
||||
adapter_info: wgpu::AdapterInfo,
|
||||
}
|
||||
|
||||
/// Repaint delays this short are drawn on the next vsync instead of a timer: about one frame
|
||||
/// at 60 Hz, so animations follow the display's refresh rate.
|
||||
const FRAME_SLACK: Duration = Duration::from_millis(16);
|
||||
/// Layers re-tessellated in one frame past which the freed memory is handed back when idle.
|
||||
const RELIEF_MISSES: usize = 1000;
|
||||
|
||||
/// Hands memory freed by a large rebuild back to the system: the allocator otherwise keeps the
|
||||
/// freed pages in the process (counted in its footprint) for reuse.
|
||||
fn release_freed_memory() {
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
unsafe extern "C" {
|
||||
fn malloc_zone_pressure_relief(zone: *mut std::ffi::c_void, goal: usize) -> usize;
|
||||
}
|
||||
// SAFETY: a null zone means every zone, and a goal of 0 as much as can be released.
|
||||
unsafe { malloc_zone_pressure_relief(std::ptr::null_mut(), 0) };
|
||||
}
|
||||
#[cfg(all(target_os = "linux", target_env = "gnu"))]
|
||||
// SAFETY: malloc_trim has no preconditions.
|
||||
unsafe {
|
||||
libc::malloc_trim(0);
|
||||
}
|
||||
}
|
||||
|
||||
/// Shortest gap between frames drawn for an agent's edits alone (20 FPS).
|
||||
const AGENT_FRAME: Duration = Duration::from_millis(50);
|
||||
|
||||
pub struct App {
|
||||
gfx: Option<Gfx>,
|
||||
/// The undecorated window edge under the pointer (Linux), which a press resizes.
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
resize_edge: Option<winit::window::ResizeDirection>,
|
||||
pub ed: Editor,
|
||||
session: Session,
|
||||
st: UiState,
|
||||
@@ -45,6 +75,10 @@ pub struct App {
|
||||
title: String,
|
||||
/// When egui asked for a delayed repaint (tooltips, caret blink, animations).
|
||||
next_repaint: Option<Instant>,
|
||||
/// When the last frame was drawn (paces redraws for agent edits).
|
||||
last_redraw: Option<Instant>,
|
||||
/// A large rebuild freed memory: hand it back to the system once the app is idle.
|
||||
relief_due: bool,
|
||||
#[cfg(target_os = "macos")]
|
||||
menu: Option<crate::macos::NativeMenu>,
|
||||
/// Tool calls from the MCP server, and the server itself (stops when dropped).
|
||||
@@ -79,11 +113,17 @@ pub enum Start {
|
||||
impl App {
|
||||
pub fn new(start: Start, store: Store) -> App {
|
||||
let ctx = egui::Context::default();
|
||||
theme::set_material(theme::Material::load());
|
||||
theme::set_roundness(theme::Roundness::load());
|
||||
theme::set_ui_font(theme::load_ui_font());
|
||||
theme::set_accent_choice(theme::load_accent_choice());
|
||||
theme::apply_choice(&ctx, theme::Choice::load());
|
||||
theme::apply_ui_scale(&ctx, theme::load_ui_scale(), false);
|
||||
let library = crate::library::Library::load(&crate::library::Library::path_in(&store.root));
|
||||
let mut app = App {
|
||||
gfx: None,
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
resize_edge: None,
|
||||
ed: Editor::default(),
|
||||
session: Session::new(store),
|
||||
st: UiState::default(),
|
||||
@@ -94,12 +134,15 @@ impl App {
|
||||
close_requested: false,
|
||||
title: String::new(),
|
||||
next_repaint: None,
|
||||
last_redraw: None,
|
||||
relief_due: false,
|
||||
#[cfg(target_os = "macos")]
|
||||
menu: None,
|
||||
mcp: None,
|
||||
runs: Default::default(),
|
||||
};
|
||||
app.st.library.library = library;
|
||||
app.st.perf.prefs = crate::ui::perf::PerfPrefs::load();
|
||||
app.ed.clipboard_io = crate::io::clipboard::System::new()
|
||||
.map(|c| Box::new(c) as Box<dyn crate::io::clipboard::Provider>);
|
||||
match start {
|
||||
@@ -257,6 +300,7 @@ impl App {
|
||||
self.save_project(true);
|
||||
}
|
||||
let counts = self.runs.counts(&self.ed);
|
||||
self.relief_due = true;
|
||||
match self.session.open(&mut self.ed, dir) {
|
||||
Ok(()) => {
|
||||
self.runs.end_counted(crate::project::format::now_ms(), counts, None);
|
||||
@@ -726,12 +770,14 @@ impl App {
|
||||
// A chat turn that edited is over: close its run now, not after the 30 s idle timeout.
|
||||
self.runs.finish(crate::project::format::now_ms(), &self.ed);
|
||||
}
|
||||
self.st.sync_runs(&mut self.runs, &self.ed);
|
||||
self.st.sync_runs(&self.ctx, &mut self.runs, &self.ed);
|
||||
self.st.chat.tools = self.tool_dispatch().map(crate::chat::events::ToolDispatch);
|
||||
if self.st.chat.gpu.is_none() {
|
||||
self.st.chat.gpu = self.offscreen();
|
||||
}
|
||||
let Some(g) = self.gfx.as_mut() else { return };
|
||||
let work = Instant::now();
|
||||
self.last_redraw = Some(work);
|
||||
let input = g.egui.take_egui_input(&g.window);
|
||||
let mut l = frame::layout(&self.ctx, input, &mut self.ed, &mut self.st);
|
||||
self.canvas = l.canvas;
|
||||
@@ -743,6 +789,8 @@ impl App {
|
||||
let platform = std::mem::take(&mut l.output.platform_output);
|
||||
g.egui.handle_platform_output(&g.window, platform);
|
||||
|
||||
// The wait for a drawable (vsync back-pressure) is not work: leave it out of the timing.
|
||||
let layout_ms = work.elapsed().as_secs_f32() * 1000.0;
|
||||
let frame = match g.surface.get_current_texture() {
|
||||
wgpu::CurrentSurfaceTexture::Success(f)
|
||||
| wgpu::CurrentSurfaceTexture::Suboptimal(f) => f,
|
||||
@@ -770,6 +818,7 @@ impl App {
|
||||
{
|
||||
crate::macos::hide_traffic_lights(&g.window);
|
||||
}
|
||||
let work = Instant::now();
|
||||
let view = frame.texture.create_view(&Default::default());
|
||||
let size = [g.config.width, g.config.height];
|
||||
g.painter.paint(
|
||||
@@ -782,6 +831,8 @@ impl App {
|
||||
&self.ed,
|
||||
!self.st.show_grid_off,
|
||||
);
|
||||
self.st.perf.record(self.start.elapsed().as_secs_f64(), layout_ms + work.elapsed().as_secs_f32() * 1000.0);
|
||||
self.relief_due |= g.painter.cache.last_misses > RELIEF_MISSES;
|
||||
g.window.pre_present_notify();
|
||||
g.queue.present(frame);
|
||||
|
||||
@@ -834,13 +885,16 @@ impl App {
|
||||
} else {
|
||||
repaint_delay
|
||||
};
|
||||
if repaint_delay.is_zero() || pending || animating || !self.ed.requests.is_empty() {
|
||||
// A repaint due within a frame is drawn now and paced by vsync: a timer that short wakes
|
||||
// late and off the display's beat (84 FPS on a 120 Hz screen).
|
||||
if repaint_delay <= FRAME_SLACK || pending || animating || !self.ed.requests.is_empty() {
|
||||
g.window.request_redraw();
|
||||
self.next_repaint = None;
|
||||
} else if repaint_delay < Duration::from_secs(3600) {
|
||||
self.next_repaint = Some(Instant::now() + repaint_delay);
|
||||
} else {
|
||||
self.next_repaint = None;
|
||||
let timer = (repaint_delay < Duration::from_secs(3600)).then(|| Instant::now() + repaint_delay);
|
||||
// The zoom stopped between buckets: one more frame re-tessellates for it.
|
||||
let refine = g.painter.cache.refine_at();
|
||||
self.next_repaint = timer.into_iter().chain(refine).min();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -989,8 +1043,12 @@ impl ApplicationHandler<UserEvent> for App {
|
||||
.with_corner_preference(CornerPreference::Round)
|
||||
.with_taskbar_icon(icon)
|
||||
};
|
||||
// Linux: no native frame either; the toolbar carries the window buttons and the window
|
||||
// edges resize (`edge_resize`).
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
let attrs = attrs.with_decorations(false);
|
||||
let window = Arc::new(event_loop.create_window(attrs).expect("create window"));
|
||||
self.st.custom_titlebar = cfg!(windows);
|
||||
self.st.custom_titlebar = cfg!(any(windows, all(unix, not(target_os = "macos"))));
|
||||
window.set_ime_allowed(true);
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
@@ -1050,6 +1108,7 @@ impl ApplicationHandler<UserEvent> for App {
|
||||
Some(device.limits().max_texture_dimension_2d as usize),
|
||||
);
|
||||
window.request_redraw();
|
||||
self.st.perf.gpu = format!("{} ({:?})", adapter_info.name, adapter_info.backend);
|
||||
self.gfx = Some(Gfx {
|
||||
window,
|
||||
surface,
|
||||
@@ -1069,6 +1128,10 @@ impl ApplicationHandler<UserEvent> for App {
|
||||
log::trace!("event {event:?} -> egui repaint {}", resp.repaint);
|
||||
}
|
||||
let scale = g.window.scale_factor();
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
if self.st.custom_titlebar && edge_resize(&g.window, &event, &mut self.resize_edge) {
|
||||
return;
|
||||
}
|
||||
let mut redraw = resp.repaint;
|
||||
let now = self.now();
|
||||
match event {
|
||||
@@ -1120,12 +1183,19 @@ impl ApplicationHandler<UserEvent> for App {
|
||||
WindowEvent::CursorMoved { position, .. } => {
|
||||
let p = v2(position.x / scale, position.y / scale);
|
||||
if self.captured || self.over_canvas(p) {
|
||||
let before = (self.ed.hover, self.ed.measures.len());
|
||||
self.ed.handle(InputEvent::PointerMove {
|
||||
pos: self.to_canvas(p),
|
||||
pressure: None,
|
||||
time_ms: now,
|
||||
});
|
||||
redraw = true;
|
||||
// Hovering with the select or hand tool changes the screen only when the
|
||||
// layer under the pointer (or the Alt measures) changes: skip the frame
|
||||
// otherwise. egui keeps the move and gets it with the next frame.
|
||||
let idle = !self.captured
|
||||
&& [crate::editor::Tool::Select, crate::editor::Tool::Hand].contains(&self.ed.tool)
|
||||
&& !self.ed.is_busy();
|
||||
redraw = !idle || before != (self.ed.hover, self.ed.measures.len());
|
||||
} else if self.ed.hover.take().is_some() {
|
||||
redraw = true;
|
||||
}
|
||||
@@ -1389,7 +1459,12 @@ impl ApplicationHandler<UserEvent> for App {
|
||||
event_loop.set_control_flow(ControlFlow::Wait);
|
||||
}
|
||||
Some(t) => event_loop.set_control_flow(ControlFlow::WaitUntil(t)),
|
||||
None => event_loop.set_control_flow(ControlFlow::Wait),
|
||||
None => {
|
||||
if std::mem::take(&mut self.relief_due) {
|
||||
release_freed_memory();
|
||||
}
|
||||
event_loop.set_control_flow(ControlFlow::Wait)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1520,8 +1595,16 @@ impl App {
|
||||
self.st.project_name = self.session.project_name();
|
||||
self.st.project_dir = self.session.project.as_ref().map(|p| p.dir.clone());
|
||||
self.ed.revision += 1;
|
||||
if let Some(g) = &self.gfx {
|
||||
g.window.request_redraw();
|
||||
// An agent's edits are drawn at most every AGENT_FRAME: its calls can come faster than
|
||||
// the eye follows, and each redraw of a large page costs milliseconds.
|
||||
let due = self.last_redraw.map(|t| t + AGENT_FRAME).filter(|t| *t > Instant::now());
|
||||
match due {
|
||||
Some(t) => self.next_repaint = Some(self.next_repaint.map_or(t, |r| r.min(t))),
|
||||
None => {
|
||||
if let Some(g) = &self.gfx {
|
||||
g.window.request_redraw();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1673,3 +1756,39 @@ mod tests {
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
/// An undecorated window has no resize border (Linux): the pointer within a few pixels of an
|
||||
/// edge shows a resize cursor and a press resizes. True when the event was consumed.
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
fn edge_resize(window: &Window, event: &WindowEvent, edge: &mut Option<winit::window::ResizeDirection>) -> bool {
|
||||
use winit::window::{CursorIcon, ResizeDirection as D};
|
||||
const BORDER: f64 = 6.0;
|
||||
match event {
|
||||
WindowEvent::CursorMoved { position, .. } => {
|
||||
let size = window.inner_size();
|
||||
let b = BORDER * window.scale_factor();
|
||||
let (l, r) = (position.x < b, position.x > f64::from(size.width) - b);
|
||||
let (t, bo) = (position.y < b, position.y > f64::from(size.height) - b);
|
||||
let next = match (l, r, t, bo) {
|
||||
(true, _, true, _) => Some(D::NorthWest),
|
||||
(_, true, true, _) => Some(D::NorthEast),
|
||||
(true, _, _, true) => Some(D::SouthWest),
|
||||
(_, true, _, true) => Some(D::SouthEast),
|
||||
(true, ..) => Some(D::West),
|
||||
(_, true, ..) => Some(D::East),
|
||||
(_, _, true, _) => Some(D::North),
|
||||
(.., true) => Some(D::South),
|
||||
_ => None,
|
||||
};
|
||||
if next != *edge {
|
||||
window.set_cursor(next.map_or(CursorIcon::Default, CursorIcon::from));
|
||||
*edge = next;
|
||||
}
|
||||
false
|
||||
}
|
||||
WindowEvent::MouseInput { state: ElementState::Pressed, button: MouseButton::Left, .. } => {
|
||||
edge.is_some_and(|d| window.drag_resize_window(d).is_ok())
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -357,7 +357,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn path_override_wins_and_hung_program_times_out() {
|
||||
let (dir, path) = fake_dir(&[("opencode", "echo opencode"), ("hang", "sleep 30")]);
|
||||
let (dir, path) = fake_dir(&[("opencode", "echo opencode"), ("hang", "/bin/sleep 30")]);
|
||||
let mut prefs = ChatPrefs::default();
|
||||
prefs.agents.insert("opencode".into(), AgentPrefs { path: Some("/nonexistent/opencode".into()), ..Default::default() });
|
||||
assert_eq!(state(&detect_all_in(&prefs, &path), "opencode"), AgentState::NotFound);
|
||||
|
||||
@@ -71,6 +71,9 @@ impl SseParser {
|
||||
if !raw.is_object() {
|
||||
return out.push(Event::Error(format!("unexpected content_block at index {idx}: {raw}")));
|
||||
}
|
||||
if raw["type"] == "tool_use" {
|
||||
out.push(Event::ToolDraft { id: str_of(&raw["id"]), name: str_of(&raw["name"]) });
|
||||
}
|
||||
self.blocks.insert(idx, Block { raw, json: String::new() });
|
||||
}
|
||||
Some("content_block_delta") => self.delta(idx, &v["delta"], out),
|
||||
@@ -126,7 +129,13 @@ impl SseParser {
|
||||
out.push(Event::Thinking(s));
|
||||
}
|
||||
Some("signature_delta") => append(&mut b.raw, "signature", d["signature"].as_str().unwrap_or("")),
|
||||
Some("input_json_delta") => b.json.push_str(d["partial_json"].as_str().unwrap_or("")),
|
||||
Some("input_json_delta") => {
|
||||
let s = d["partial_json"].as_str().unwrap_or("");
|
||||
b.json.push_str(s);
|
||||
if b.raw["type"] == "tool_use" && !s.is_empty() {
|
||||
out.push(Event::ToolInput(s.into()));
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -841,7 +850,7 @@ mod api {
|
||||
// One Context event per request: the latest call's input and cache tokens (120, then 160).
|
||||
let contexts: Vec<u64> = ev.iter().filter_map(|e| if let Event::Context(n) = e { Some(*n) } else { None }).collect();
|
||||
assert_eq!(contexts, [120, 160], "{ev:?}");
|
||||
let ev: Vec<Event> = ev.into_iter().filter(|e| !matches!(e, Event::Context(_))).collect();
|
||||
let ev: Vec<Event> = ev.into_iter().filter(|e| !matches!(e, Event::Context(_) | Event::ToolDraft { .. } | Event::ToolInput(_))).collect();
|
||||
let kinds: Vec<&str> = ev
|
||||
.iter()
|
||||
.map(|e| match e {
|
||||
@@ -1014,8 +1023,10 @@ mod tests {
|
||||
/// Feed in awkward 7-byte chunks (fixtures are ASCII) to prove line buffering.
|
||||
fn run(src: &str) -> (SseParser, Vec<Event>) {
|
||||
let mut p = SseParser::default();
|
||||
let ev = src.as_bytes().chunks(7).flat_map(|c| p.push(std::str::from_utf8(c).unwrap())).collect();
|
||||
(p, ev)
|
||||
let ev: Vec<Event> = src.as_bytes().chunks(7).flat_map(|c| p.push(std::str::from_utf8(c).unwrap())).collect();
|
||||
// Tool calls are also announced while they stream; the rest of each test reads past that.
|
||||
assert!(!src.contains("\"tool_use\"") || ev.iter().any(|e| matches!(e, Event::ToolDraft { .. })), "{ev:?}");
|
||||
(p, ev.into_iter().filter(|e| !matches!(e, Event::ToolDraft { .. } | Event::ToolInput(_))).collect())
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -84,6 +84,15 @@ fn push_text(out: &mut Vec<Event>, s: Option<&str>, thinking: bool) {
|
||||
|
||||
fn delta(e: &Value) -> Vec<Event> {
|
||||
let mut out = vec![];
|
||||
// A tool call starts streaming long before its `assistant` line: say so at once.
|
||||
if e["type"] == "content_block_start" && e["content_block"]["type"] == "tool_use" {
|
||||
let name = str_of(&e["content_block"]["name"]);
|
||||
out.push(Event::ToolDraft {
|
||||
id: str_of(&e["content_block"]["id"]),
|
||||
name: name.strip_prefix(MCP_PREFIX).map_or(name.clone(), str::to_string),
|
||||
});
|
||||
return out;
|
||||
}
|
||||
if e["type"] != "content_block_delta" {
|
||||
return out;
|
||||
}
|
||||
@@ -91,6 +100,11 @@ fn delta(e: &Value) -> Vec<Event> {
|
||||
match d["type"].as_str() {
|
||||
Some("text_delta") => push_text(&mut out, d["text"].as_str(), false),
|
||||
Some("thinking_delta") => push_text(&mut out, d["thinking"].as_str(), true),
|
||||
Some("input_json_delta") => {
|
||||
if let Some(j) = d["partial_json"].as_str().filter(|j| !j.is_empty()) {
|
||||
out.push(Event::ToolInput(j.into()));
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
out
|
||||
@@ -400,6 +414,14 @@ mod process {
|
||||
use std::sync::mpsc::channel;
|
||||
|
||||
#[test]
|
||||
fn a_tool_call_is_announced_as_it_starts_streaming() {
|
||||
let start = r#"{"type":"stream_event","event":{"type":"content_block_start","index":1,"content_block":{"type":"tool_use","id":"t9","name":"mcp__papercraft__write_html","input":{}}}}"#;
|
||||
assert_eq!(parse_line_partial(start), vec![Event::ToolDraft { id: "t9".into(), name: "write_html".into() }]);
|
||||
let piece = r#"{"type":"stream_event","event":{"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":"{\"targetNodeId\": \"8f98b5:1\""}}}"#;
|
||||
assert_eq!(parse_line_partial(piece), vec![Event::ToolInput("{\"targetNodeId\": \"8f98b5:1\"".into())]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_sub_agent_shows_its_tool_calls_not_its_words() {
|
||||
let text = r#"{"type":"stream_event","parent_tool_use_id":"t1","event":{"type":"content_block_delta","delta":{"type":"text_delta","text":"thinking aloud"}}}"#;
|
||||
assert!(parse_line_partial(text).is_empty());
|
||||
|
||||
@@ -8,6 +8,11 @@ pub enum Event {
|
||||
Text(String),
|
||||
Thinking(String),
|
||||
ToolStart { id: String, name: String, args: Value },
|
||||
/// The model began writing a tool call: its input streams in as [`Event::ToolInput`], and
|
||||
/// the canvas shows a skeleton where it will land until the call ends.
|
||||
ToolDraft { id: String, name: String },
|
||||
/// A piece of the input of the call being drafted (raw JSON text).
|
||||
ToolInput(String),
|
||||
/// `result` on success, `error` on failure; exactly one is set.
|
||||
ToolEnd { id: String, result: Option<Value>, error: Option<String> },
|
||||
TurnEnd { usage: Option<Usage>, session: Option<String>, stop_reason: Option<String> },
|
||||
|
||||
@@ -186,4 +186,16 @@ pub struct Chat {
|
||||
pub backend: Backend,
|
||||
#[serde(default)]
|
||||
pub messages: Vec<Message>,
|
||||
/// What became of the issues drafted in this chat, by the drafting tool call's id.
|
||||
#[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
|
||||
pub issues: std::collections::BTreeMap<String, IssueOutcome>,
|
||||
}
|
||||
|
||||
/// A drafted issue the user sent or dropped.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(tag = "state", rename_all = "lowercase")]
|
||||
pub enum IssueOutcome {
|
||||
/// Filed as #number; `existing` when an issue with a matching title was there already.
|
||||
Filed { number: u64, url: String, existing: bool },
|
||||
Discarded,
|
||||
}
|
||||
|
||||
@@ -120,6 +120,7 @@ mod tests {
|
||||
}),
|
||||
at: 5,
|
||||
}],
|
||||
issues: [("t1".to_owned(), crate::chat::IssueOutcome::Filed { number: 7, url: "u".into(), existing: false })].into(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -166,7 +166,7 @@ impl Editor {
|
||||
if geometry {
|
||||
self.fixup();
|
||||
}
|
||||
match self.rec.take() {
|
||||
match self.take_rec() {
|
||||
Some((rec, key)) if keep => {
|
||||
if self.log_touched(&rec) && self.history.commit(&self.doc, rec, key) {
|
||||
self.revision += 1;
|
||||
@@ -178,6 +178,24 @@ impl Editor {
|
||||
n
|
||||
}
|
||||
|
||||
/// A copy of page `page` with its variables resolved in mode `mode` (by name), for a preview
|
||||
/// that leaves the document, its history and the canvas as they are.
|
||||
// ponytail: colours and bindings only; a mode that changes bound sizes previews them without
|
||||
// re-running layout (fit_text/fixup need the editor).
|
||||
pub fn page_doc_in_mode(&mut self, page: usize, mode: &str) -> Result<crate::model::Document, String> {
|
||||
let (cid, mid) = find_mode(&self.design, mode).ok_or(format!("no mode `{mode}`"))?;
|
||||
self.ensure_loaded(page);
|
||||
let mut modes = self.pages[page].modes.clone();
|
||||
modes.insert(cid, mid);
|
||||
let cache = self.design.cache_for(&modes);
|
||||
let mut doc = self.page_doc(page).clone();
|
||||
let mut rec = crate::history::Recording::new("Preview mode", &doc);
|
||||
let mut tx = crate::history::Tx::new(&mut doc, &mut rec);
|
||||
tx.set_cache(&cache, &BTreeMap::new());
|
||||
apply_bindings(&mut tx, &cache, &BTreeMap::new());
|
||||
Ok(doc)
|
||||
}
|
||||
|
||||
/// Switch the active page to `mode_id` of `collection_id`: that page's modes, cache and bound
|
||||
/// fills only, one undo step. Returns the fills that changed.
|
||||
pub fn set_page_mode(&mut self, collection_id: &str, mode_id: &str) -> Result<usize, Error> {
|
||||
|
||||
@@ -67,13 +67,32 @@ pub fn node_contains(doc: &Document, id: NodeId, p: V2, zoom: f64) -> bool {
|
||||
}
|
||||
|
||||
/// Topmost visible, unlocked node under `p`, deepest first.
|
||||
///
|
||||
/// Reverse paint order (children before their parent, later siblings first), descending only
|
||||
/// into subtrees that can hold the hit: hidden and locked subtrees are skipped whole, and so
|
||||
/// are clipping frames that do not contain `p`.
|
||||
pub fn hit_test(doc: &Document, p: V2, zoom: f64) -> Option<NodeId> {
|
||||
doc.paint_order().into_iter().rev().find(|id| {
|
||||
doc.is_effectively_visible(*id)
|
||||
&& !doc.is_effectively_locked(*id)
|
||||
&& node_contains(doc, *id, p, zoom)
|
||||
&& inside_ancestor_clips(doc, *id, p)
|
||||
})
|
||||
fn walk(doc: &Document, ids: &[NodeId], p: V2, zoom: f64) -> Option<NodeId> {
|
||||
for &id in ids.iter().rev() {
|
||||
let Some(n) = doc.get(id) else { continue };
|
||||
if !n.visible || n.locked {
|
||||
continue;
|
||||
}
|
||||
if matches!(n.kind, NodeKind::Frame { clip: true })
|
||||
&& !doc.world_rect(id).is_some_and(|r| r.contains(p))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if let Some(hit) = walk(doc, &n.children, p, zoom) {
|
||||
return Some(hit);
|
||||
}
|
||||
if node_contains(doc, id, p, zoom) {
|
||||
return Some(id);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
walk(doc, &doc.root, p, zoom)
|
||||
}
|
||||
|
||||
/// Screen-space rectangle of a top-level frame's name label.
|
||||
|
||||
@@ -8,6 +8,7 @@ pub mod design;
|
||||
pub mod hit;
|
||||
pub mod input;
|
||||
pub mod pages;
|
||||
pub mod screens;
|
||||
pub mod shortcuts;
|
||||
pub mod snap;
|
||||
pub mod structure;
|
||||
@@ -24,6 +25,7 @@ pub use input::{Button, InputEvent, Key, Modifiers};
|
||||
pub use pages::{PageSlot, PageStash};
|
||||
use snap::Guide;
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::Arc;
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
@@ -252,6 +254,11 @@ pub struct Editor {
|
||||
/// Process-unique stamp of the document content: changes whenever the document may
|
||||
/// have changed (edits, undo, page switch, load). Renderers reuse work while it holds.
|
||||
pub doc_stamp: u64,
|
||||
/// What changed in `doc` at each recent `doc_stamp`, oldest first, so renderers redo only
|
||||
/// those layers (`changes_since`).
|
||||
change_log: std::collections::VecDeque<(u64, Changed)>,
|
||||
/// Stamps before this one are no longer covered by `change_log`.
|
||||
change_base: u64,
|
||||
pub project_stamp: u64,
|
||||
/// Fit the camera to the content once the real viewport size is known.
|
||||
pub pending_fit: bool,
|
||||
@@ -355,6 +362,8 @@ impl Editor {
|
||||
last_click: None,
|
||||
revision: 0,
|
||||
doc_stamp: next_stamp(),
|
||||
change_log: Default::default(),
|
||||
change_base: 0,
|
||||
project_stamp: next_stamp(),
|
||||
pending_fit: false,
|
||||
headless: false,
|
||||
@@ -407,7 +416,7 @@ impl Editor {
|
||||
self.rec = Some((Recording::new("Edit", &self.doc), None));
|
||||
}
|
||||
self.revision += 1;
|
||||
self.doc_stamp = next_stamp();
|
||||
self.mark(Changed::Open);
|
||||
let (rec, _) = self.rec.as_mut().unwrap();
|
||||
Tx::new(&mut self.doc, rec)
|
||||
}
|
||||
@@ -415,7 +424,7 @@ impl Editor {
|
||||
pub fn commit(&mut self) {
|
||||
self.fixup();
|
||||
let mains = std::mem::take(&mut self.touched_mains);
|
||||
if let Some((rec, key)) = self.rec.take()
|
||||
if let Some((rec, key)) = self.take_rec()
|
||||
&& self.log_touched(&rec)
|
||||
&& self.history.commit(&self.doc, rec, key)
|
||||
{
|
||||
@@ -457,7 +466,7 @@ impl Editor {
|
||||
pub fn edit_variables<R>(&mut self, label: &str, f: impl FnOnce(&mut Tx) -> R) -> R {
|
||||
self.begin(label);
|
||||
let result = f(&mut self.tx());
|
||||
if let Some((rec, key)) = self.rec.take()
|
||||
if let Some((rec, key)) = self.take_rec()
|
||||
&& self.log_touched(&rec)
|
||||
&& self.history.commit(&self.doc, rec, key)
|
||||
{
|
||||
@@ -482,27 +491,31 @@ impl Editor {
|
||||
self.cancel_gesture();
|
||||
self.end_text_edit();
|
||||
self.commit();
|
||||
if let Some(u) = self.history.undo(&mut self.doc) {
|
||||
let mut changed = Changed::Nodes(Arc::new([]));
|
||||
if let Some(mut u) = self.history.undo(&mut self.doc) {
|
||||
self.status = format!("Undo {}", u.label);
|
||||
changed = Changed::Nodes(std::mem::take(&mut u.nodes).into());
|
||||
self.restore_project_state(u);
|
||||
}
|
||||
self.after_history();
|
||||
self.after_history(changed);
|
||||
}
|
||||
|
||||
pub fn redo(&mut self) {
|
||||
self.cancel_gesture();
|
||||
self.end_text_edit();
|
||||
self.commit();
|
||||
if let Some(u) = self.history.redo(&mut self.doc) {
|
||||
let mut changed = Changed::Nodes(Arc::new([]));
|
||||
if let Some(mut u) = self.history.redo(&mut self.doc) {
|
||||
self.status = format!("Redo {}", u.label);
|
||||
changed = Changed::Nodes(std::mem::take(&mut u.nodes).into());
|
||||
self.restore_project_state(u);
|
||||
}
|
||||
self.after_history();
|
||||
self.after_history(changed);
|
||||
}
|
||||
|
||||
fn after_history(&mut self) {
|
||||
fn after_history(&mut self, changed: Changed) {
|
||||
self.revision += 1;
|
||||
self.doc_stamp = next_stamp();
|
||||
self.mark(changed);
|
||||
self.selection.retain(|id| self.doc.nodes.contains_key(id));
|
||||
}
|
||||
|
||||
@@ -560,6 +573,58 @@ impl Editor {
|
||||
|
||||
// ------------------------------------------------------------------ queries
|
||||
|
||||
/// Make frames scrolling screens (fixed height, clipped, overflow scrolls) or plain again.
|
||||
/// A screen that grew to fit its content (fit-content) goes back to its min height.
|
||||
pub fn set_scroll(&mut self, ids: &[NodeId], on: bool) {
|
||||
self.edit(if on { "Scroll screen" } else { "Stop scrolling" }, |tx| {
|
||||
for id in ids {
|
||||
let Some(n) = tx.node_mut(*id).filter(|n| n.is_frame()) else { continue };
|
||||
n.scroll = on;
|
||||
n.scroll_y = 0.0;
|
||||
if on {
|
||||
n.kind = NodeKind::Frame { clip: true };
|
||||
if n.layout_child.sizing[1] != crate::model::Sizing::Fixed {
|
||||
n.layout_child.sizing[1] = crate::model::Sizing::Fixed;
|
||||
if let Some(h) = n.layout_child.min_size[1] {
|
||||
n.rect.h = h;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// The page's screens: the declared ones, or else its top-level frames.
|
||||
pub fn screens(&self) -> Vec<NodeId> {
|
||||
let declared = screens::declared(&self.doc);
|
||||
if !declared.is_empty() {
|
||||
return declared;
|
||||
}
|
||||
self.doc.root.iter().copied().filter(|id| self.doc.get(*id).is_some_and(|n| n.is_frame())).collect()
|
||||
}
|
||||
|
||||
/// Scroll the innermost scrollable frame under `pos` (screen) by `dy` world units, when its
|
||||
/// content overflows and it can still move that way. False: the canvas pans instead.
|
||||
pub fn scroll_frame(&mut self, pos: V2, dy: f64) -> bool {
|
||||
let world = self.screen_to_world(pos);
|
||||
let hit = self
|
||||
.doc
|
||||
.nodes
|
||||
.values()
|
||||
.filter(|n| n.scroll && n.visible && dy != 0.0)
|
||||
.filter(|n| self.doc.world_rect(n.id).is_some_and(|r| r.contains(world)))
|
||||
.filter_map(|n| {
|
||||
let max = scroll_range(&self.doc, n.id);
|
||||
let next = (n.scroll_y + dy).clamp(0.0, max);
|
||||
((next - n.scroll_y).abs() > 1e-9).then(|| (self.doc.ancestors(n.id).len(), n.id, next))
|
||||
})
|
||||
.max_by_key(|(depth, ..)| *depth);
|
||||
let Some((_, id, next)) = hit else { return false };
|
||||
self.doc.nodes.get_mut(&id).unwrap().scroll_y = next;
|
||||
self.mark(Changed::Nodes(Arc::new([id])));
|
||||
true
|
||||
}
|
||||
|
||||
pub fn screen_to_world(&self, p: V2) -> V2 {
|
||||
self.camera.screen_to_world(p)
|
||||
}
|
||||
@@ -661,21 +726,65 @@ impl Editor {
|
||||
})
|
||||
}
|
||||
|
||||
/// Screen-space frame label rectangles for top-level frames (also used by the renderer).
|
||||
/// Screen-space frame label rectangles for top-level frames and declared screens (also
|
||||
/// used by the renderer).
|
||||
/// A frame too narrow on screen for a readable name gets none, and a label that would
|
||||
/// overlap one already placed is dropped (selected frames first, then larger ones), so a
|
||||
/// zoomed-out page shows a few clean names instead of a pile.
|
||||
pub fn frame_labels(&self) -> Vec<(NodeId, Rect)> {
|
||||
self.doc
|
||||
let screen = Rect::new(0.0, 0.0, self.viewport.x, self.viewport.y);
|
||||
// Top-level frames, then declared screens nested in sections.
|
||||
let nested = screens::declared(&self.doc).into_iter().filter(|id| self.doc.get(*id).is_some_and(|n| n.parent.is_some()));
|
||||
let bodies: Vec<(usize, NodeId, Rect)> = self
|
||||
.doc
|
||||
.root
|
||||
.iter()
|
||||
.filter_map(|id| {
|
||||
let n = self.doc.get(*id)?;
|
||||
if !n.is_frame() || !n.visible {
|
||||
.copied()
|
||||
.chain(nested)
|
||||
.enumerate()
|
||||
.filter_map(|(i, id)| {
|
||||
let n = self.doc.get(id)?;
|
||||
if !n.is_frame() || !n.visible || (n.parent.is_some() && !self.doc.ancestors(id).iter().all(|a| self.doc.get(*a).is_some_and(|a| a.visible))) {
|
||||
return None;
|
||||
}
|
||||
let sr = self.camera.world_rect_to_screen(&self.doc.world_rect(*id)?);
|
||||
let w = text::layout(&n.name, 12.0, None, TextAlign::Left).width;
|
||||
Some((*id, hit::frame_label_rect(&sr, w.min(sr.w.max(24.0)))))
|
||||
Some((i, id, self.camera.world_rect_to_screen(&self.doc.world_rect(id)?)))
|
||||
})
|
||||
.collect()
|
||||
.collect();
|
||||
let mut candidates: Vec<(usize, NodeId, Rect)> = bodies
|
||||
.iter()
|
||||
.copied()
|
||||
.filter_map(|(i, id, sr)| {
|
||||
let id = &id;
|
||||
let selected = self.selection.contains(id);
|
||||
if sr.w < MIN_LABELED_FRAME_PX && !selected {
|
||||
return None;
|
||||
}
|
||||
// Off-screen labels are neither drawn nor clickable: skip their text layout.
|
||||
if !hit::frame_label_rect(&sr, sr.w).intersects(&screen) {
|
||||
return None;
|
||||
}
|
||||
Some((i, *id, sr))
|
||||
})
|
||||
.collect();
|
||||
let selected = |id: &NodeId| self.selection.contains(id);
|
||||
candidates.sort_by(|a, b| {
|
||||
let key = |(_, id, r): &(usize, NodeId, Rect)| (!self.selection.contains(id), -(r.w * r.h));
|
||||
key(a).partial_cmp(&key(b)).unwrap_or(std::cmp::Ordering::Equal)
|
||||
});
|
||||
// ponytail: pairwise overlap check, fine for the few hundred frames a page shows.
|
||||
let mut placed: Vec<(usize, NodeId, Rect)> = Vec::new();
|
||||
for (i, id, sr) in candidates {
|
||||
let w = text::layout(&self.doc.get(id).unwrap().name, 12.0, None, TextAlign::Left).width;
|
||||
let r = hit::frame_label_rect(&sr, w.min(sr.w));
|
||||
// A label over another frame's content (or another label) would hide both.
|
||||
let covers_frame = !selected(&id)
|
||||
&& bodies.iter().any(|(_, b, br)| *b != id && !self.doc.is_ancestor(*b, id) && br.intersects(&r));
|
||||
if !covers_frame && !placed.iter().any(|(_, _, p)| p.intersects(&r)) {
|
||||
placed.push((i, id, r));
|
||||
}
|
||||
}
|
||||
placed.sort_by_key(|(i, ..)| *i);
|
||||
placed.into_iter().map(|(_, id, r)| (id, r)).collect()
|
||||
}
|
||||
|
||||
fn label_at(&self, p: V2) -> Option<NodeId> {
|
||||
@@ -731,7 +840,7 @@ impl Editor {
|
||||
if self.mods.ctrl || self.mods.logo {
|
||||
let factor = (-delta.y * ZOOM_PER_SCROLL_PX).exp();
|
||||
self.camera.zoom_about(pos, self.camera.zoom * factor);
|
||||
} else {
|
||||
} else if !self.scroll_frame(pos, delta.y / self.camera.zoom) {
|
||||
self.camera.pan_by_screen(-delta);
|
||||
}
|
||||
self.revision += 1;
|
||||
@@ -2112,12 +2221,15 @@ impl Editor {
|
||||
| Gesture::ConnectorMid { .. }
|
||||
| Gesture::Erase => {
|
||||
// Roll back the open transaction.
|
||||
if let Some((rec, key)) = self.rec.take() {
|
||||
if let Some((rec, key)) = self.take_rec() {
|
||||
let mut h = History::default();
|
||||
if h.commit(&self.doc, rec, key) {
|
||||
h.undo(&mut self.doc);
|
||||
let mut changed = Changed::Nodes(Arc::new([]));
|
||||
if h.commit(&self.doc, rec, key)
|
||||
&& let Some(u) = h.undo(&mut self.doc)
|
||||
{
|
||||
changed = Changed::Nodes(u.nodes.into());
|
||||
}
|
||||
self.after_history();
|
||||
self.after_history(changed);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
@@ -2513,6 +2625,67 @@ pub fn insert_subtrees_tx(
|
||||
}
|
||||
|
||||
/// A fresh process-wide document stamp.
|
||||
/// What a `doc_stamp` change did to the document.
|
||||
#[derive(Clone)]
|
||||
pub(crate) enum Changed {
|
||||
/// Whatever the open transaction touches (resolved when it closes).
|
||||
Open,
|
||||
Nodes(Arc<[NodeId]>),
|
||||
All,
|
||||
}
|
||||
|
||||
/// Frames narrower than this on screen (px) show no name label.
|
||||
const MIN_LABELED_FRAME_PX: f64 = 48.0;
|
||||
|
||||
/// Changes kept for `Editor::changes_since`; a renderer further behind redoes everything.
|
||||
const CHANGE_LOG_LEN: usize = 64;
|
||||
|
||||
impl Editor {
|
||||
/// Bumps `doc_stamp` and logs what changed with it.
|
||||
pub(crate) fn mark(&mut self, changed: Changed) {
|
||||
self.doc_stamp = next_stamp();
|
||||
if let (Changed::Open, Some((s, Changed::Open))) = (&changed, self.change_log.back_mut()) {
|
||||
*s = self.doc_stamp;
|
||||
return;
|
||||
}
|
||||
if self.change_log.len() == CHANGE_LOG_LEN
|
||||
&& let Some((s, _)) = self.change_log.pop_front()
|
||||
{
|
||||
self.change_base = s;
|
||||
}
|
||||
self.change_log.push_back((self.doc_stamp, changed));
|
||||
}
|
||||
|
||||
/// Closes the open transaction, logging the layers it touched.
|
||||
pub(crate) fn take_rec(&mut self) -> Option<(Recording, Option<String>)> {
|
||||
let rec = self.rec.take();
|
||||
let touched = rec.as_ref().map(|(r, _)| Changed::Nodes(r.touched().into()));
|
||||
for (_, c) in &mut self.change_log {
|
||||
if matches!(c, Changed::Open) {
|
||||
*c = touched.clone().unwrap_or(Changed::All);
|
||||
}
|
||||
}
|
||||
rec
|
||||
}
|
||||
|
||||
/// The layers whose content changed after `doc_stamp` was `stamp`, or None when that is
|
||||
/// unknown (too long ago, another editor, a page switch): then everything may have.
|
||||
pub fn changes_since(&self, stamp: u64) -> Option<std::collections::HashSet<NodeId>> {
|
||||
if stamp < self.change_base || stamp > self.doc_stamp {
|
||||
return None;
|
||||
}
|
||||
let mut out = std::collections::HashSet::new();
|
||||
for (_, c) in self.change_log.iter().rev().take_while(|(s, _)| *s > stamp) {
|
||||
match c {
|
||||
Changed::Open => out.extend(self.rec.as_ref()?.0.touched()),
|
||||
Changed::Nodes(ids) => out.extend(ids.iter()),
|
||||
Changed::All => return None,
|
||||
}
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn next_stamp() -> u64 {
|
||||
static N: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
|
||||
N.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
|
||||
@@ -2537,3 +2710,12 @@ fn dedup(mut v: Vec<NodeId>) -> Vec<NodeId> {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
/// How far a scrollable frame's content reaches past its bottom edge (0 when it fits).
|
||||
pub fn scroll_range(doc: &crate::model::Document, id: NodeId) -> f64 {
|
||||
let Some(n) = doc.get(id) else { return 0.0 };
|
||||
let pad = n.layout.map_or(0.0, |l| l.padding[2]);
|
||||
// Pinned (absolute) children do not scroll, so they do not add to the range.
|
||||
let bottom = n.children.iter().filter_map(|c| doc.get(*c)).filter(|c| c.visible && !c.layout_child.absolute).map(|c| c.rect.bottom()).fold(0.0, f64::max);
|
||||
(bottom + pad - n.rect.h).max(0.0)
|
||||
}
|
||||
|
||||
@@ -146,7 +146,7 @@ impl Editor {
|
||||
}
|
||||
self.meta_dirty = true; // the active page is remembered in the manifest
|
||||
self.revision += 1;
|
||||
self.doc_stamp = super::next_stamp();
|
||||
self.mark(super::Changed::All);
|
||||
}
|
||||
|
||||
fn unique_page_name(&self, base: &str) -> String {
|
||||
@@ -296,7 +296,7 @@ impl Editor {
|
||||
self.pages[i].name = name.to_string();
|
||||
if i == self.active_page {
|
||||
self.doc.name = name.to_string();
|
||||
self.doc_stamp = super::next_stamp();
|
||||
self.mark(super::Changed::Nodes(std::sync::Arc::new([])));
|
||||
} else if let Some(s) = &mut self.pages[i].stash {
|
||||
s.doc.name = name.to_string();
|
||||
}
|
||||
@@ -477,7 +477,8 @@ impl Editor {
|
||||
self.saved_marker = saved_marker;
|
||||
self.pages[i].stash = Some(s);
|
||||
self.revision += 1;
|
||||
self.doc_stamp = super::next_stamp();
|
||||
// The active page's document is the one it was before `f`.
|
||||
self.mark(super::Changed::Nodes(std::sync::Arc::new([])));
|
||||
self.flush_cross_pages();
|
||||
r
|
||||
}
|
||||
@@ -602,7 +603,7 @@ impl Editor {
|
||||
id: self.pages[i].id.clone(),
|
||||
name: self.pages[i].name.clone(),
|
||||
};
|
||||
let path = dir.join(crate::project::PAGES_DIR).join(format!("{}.json", r.id));
|
||||
let path = crate::project::page_file(&dir, &r.id);
|
||||
let read = crate::project::read_page_file(&path, &r);
|
||||
self.install_page(&r.id, read);
|
||||
}
|
||||
@@ -624,7 +625,8 @@ impl Editor {
|
||||
return;
|
||||
}
|
||||
};
|
||||
let changed = crate::project::finalize_page(&self.design, &mut p, &mut modes);
|
||||
let legacy = self.loader.as_ref().is_some_and(|l| crate::project::is_legacy_page(&l.dir, id));
|
||||
let changed = crate::project::finalize_page(&self.design, &mut p, &mut modes) || legacy;
|
||||
let slot = &mut self.pages[i];
|
||||
let Some(stash) = slot.stash.as_mut() else { return };
|
||||
// The manifest owns the name, and it may have been renamed meanwhile.
|
||||
|
||||
@@ -0,0 +1,278 @@
|
||||
//! Screens: frames declared as one device viewport of the product (not a section, slot or
|
||||
//! component), and what inside one is hidden behind a drawer or modal ([`coverage`]), so
|
||||
//! agents don't edit what nobody can see. Spec: `specs/screens/spec.md`.
|
||||
|
||||
use super::Editor;
|
||||
use crate::geom::Rect;
|
||||
use crate::model::{Document, Node, NodeId, NodeKind, Paint};
|
||||
|
||||
/// Device sizes a screen is usually declared at.
|
||||
pub const DEVICES: [(&str, f64, f64); 4] = [
|
||||
("Mobile", 390.0, 844.0),
|
||||
("Tablet", 768.0, 1024.0),
|
||||
("Desktop", 1440.0, 900.0),
|
||||
("Large desktop", 2560.0, 1440.0),
|
||||
];
|
||||
|
||||
/// A layer with less than this share of its area in view counts as hidden.
|
||||
pub const HIDDEN_BELOW: f64 = 0.01;
|
||||
/// A layer with at least this share in view counts as fully visible.
|
||||
const VISIBLE_FROM: f64 = 0.99;
|
||||
/// Fill alpha and opacity from which a layer hides what is under it.
|
||||
const OPAQUE: f64 = 0.95;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Role {
|
||||
Screen,
|
||||
Overlay,
|
||||
None,
|
||||
}
|
||||
|
||||
/// The device whose width matches `w` (heights vary: screens scroll).
|
||||
pub fn device_of(w: f64) -> Option<&'static str> {
|
||||
DEVICES.iter().find(|d| (d.1 - w).abs() < 0.5).map(|d| d.0)
|
||||
}
|
||||
|
||||
/// Declared screens of the page, in paint order.
|
||||
pub fn declared(doc: &Document) -> Vec<NodeId> {
|
||||
doc.paint_order().into_iter().filter(|id| doc.get(*id).is_some_and(|n| n.screen)).collect()
|
||||
}
|
||||
|
||||
/// The declared screen `id` is inside (not `id` itself).
|
||||
pub fn screen_of(doc: &Document, id: NodeId) -> Option<NodeId> {
|
||||
doc.ancestors(id).into_iter().find(|a| doc.get(*a).is_some_and(|n| n.screen))
|
||||
}
|
||||
|
||||
/// Why `id` can't take `role`, if it can't.
|
||||
pub fn refusal(doc: &Document, id: NodeId, role: Role) -> Option<&'static str> {
|
||||
let n = doc.get(id)?;
|
||||
let in_component = || n.component || n.instance.is_some() || doc.ancestors(id).iter().any(|a| doc.get(*a).is_some_and(|p| p.component));
|
||||
match role {
|
||||
Role::None => None,
|
||||
Role::Screen if !n.is_frame() => Some("only a frame can be a screen"),
|
||||
Role::Screen if in_component() => Some("a component main, an instance or a layer inside a main can't be a screen"),
|
||||
Role::Screen if screen_of(doc, id).is_some() => Some("it is already inside a screen"),
|
||||
Role::Screen if doc.subtree(id).iter().skip(1).any(|d| doc.get(*d).is_some_and(|c| c.screen)) => {
|
||||
Some("it holds screens (a section): declare the screens inside it")
|
||||
}
|
||||
Role::Screen => None,
|
||||
Role::Overlay if screen_of(doc, id).is_none() => Some("an overlay (drawer, modal, sheet) must be inside a screen"),
|
||||
Role::Overlay => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Frames that look like screens: named `Screen / ...`, the frame shown in a `Slot / ...`
|
||||
/// frame, or a top-level frame at exactly a device size (a tall section at a device width
|
||||
/// is not one). The outermost of nested candidates wins,
|
||||
/// and frames that can't be screens are left out.
|
||||
pub fn candidates(doc: &Document) -> Vec<NodeId> {
|
||||
let looks = |n: &Node| {
|
||||
n.is_frame()
|
||||
&& !n.screen
|
||||
&& (n.name.starts_with("Screen /")
|
||||
|| n.parent.and_then(|p| doc.get(p)).is_some_and(|p| p.name.starts_with("Slot /"))
|
||||
|| (n.parent.is_none() && DEVICES.iter().any(|d| (d.1 - n.rect.w).abs() < 0.5 && (d.2 - n.rect.h).abs() < 0.5)))
|
||||
};
|
||||
let found: Vec<NodeId> = doc
|
||||
.paint_order()
|
||||
.into_iter()
|
||||
.filter(|id| doc.get(*id).is_some_and(looks) && refusal(doc, *id, Role::Screen).is_none())
|
||||
.collect();
|
||||
found.iter().copied().filter(|id| !doc.ancestors(*id).iter().any(|a| found.contains(a))).collect()
|
||||
}
|
||||
|
||||
/// How much of a layer inside a screen is in view, and what covers the rest.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct Coverage {
|
||||
pub screen: NodeId,
|
||||
/// Share of the layer's area not covered, 0..=1.
|
||||
pub visible: f64,
|
||||
/// Layers painted over it inside the screen: overlays and opaque layers.
|
||||
pub covers: Vec<NodeId>,
|
||||
}
|
||||
|
||||
impl Coverage {
|
||||
pub fn is_hidden(&self) -> bool {
|
||||
self.visible < HIDDEN_BELOW
|
||||
}
|
||||
pub fn is_partial(&self) -> bool {
|
||||
!self.is_hidden() && self.visible < VISIBLE_FROM
|
||||
}
|
||||
pub fn state(&self) -> &'static str {
|
||||
if self.is_hidden() {
|
||||
"hidden"
|
||||
} else if self.is_partial() {
|
||||
"partly covered"
|
||||
} else {
|
||||
"visible"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Coverage of `id` by layers painted after it in its screen. None outside a screen. Clipping
|
||||
/// and scrolling don't count as covering: only layers on top do.
|
||||
pub fn coverage(doc: &Document, id: NodeId) -> Option<Coverage> {
|
||||
let screen = screen_of(doc, id)?;
|
||||
let target = doc.world_rect(id)?;
|
||||
let screen_rect = doc.world_rect(screen)?;
|
||||
let mut order = Vec::new();
|
||||
paint_walk(doc, screen, &mut order);
|
||||
let at = order.iter().position(|x| *x == id)?;
|
||||
let mut covers = Vec::new();
|
||||
let mut rects = Vec::new();
|
||||
for &c in &order[at + 1..] {
|
||||
if doc.is_ancestor(id, c) || !shown(doc, c, screen) || !hides_below(doc, c, screen) {
|
||||
continue;
|
||||
}
|
||||
// ponytail: covers are clipped to the screen only, not to frames between; refine if
|
||||
// a clipped cover ever hides more than it shows.
|
||||
let Some(r) = doc.world_rect(c).and_then(|r| r.intersection(&screen_rect)).and_then(|r| r.intersection(&target)) else {
|
||||
continue;
|
||||
};
|
||||
covers.push(c);
|
||||
rects.push(r);
|
||||
}
|
||||
let area = target.w * target.h;
|
||||
let visible = if area <= 0.0 { 1.0 } else { (1.0 - union_area(&rects) / area).clamp(0.0, 1.0) };
|
||||
Some(Coverage { screen, visible, covers })
|
||||
}
|
||||
|
||||
/// Parents before children, children bottom to top: the order layers are painted in.
|
||||
fn paint_walk(doc: &Document, id: NodeId, out: &mut Vec<NodeId>) {
|
||||
out.push(id);
|
||||
for c in doc.get(id).map(|n| n.children.as_slice()).unwrap_or_default() {
|
||||
paint_walk(doc, *c, out);
|
||||
}
|
||||
}
|
||||
|
||||
/// `id` and every ancestor up to `screen` are visible.
|
||||
fn shown(doc: &Document, id: NodeId, screen: NodeId) -> bool {
|
||||
std::iter::once(id).chain(doc.ancestors(id).into_iter().take_while(|a| *a != screen)).all(|a| doc.get(a).is_some_and(|n| n.visible))
|
||||
}
|
||||
|
||||
/// `id` hides what is under it: an overlay or inside one, or an opaque frame or rectangle.
|
||||
fn hides_below(doc: &Document, id: NodeId, screen: NodeId) -> bool {
|
||||
let chain: Vec<NodeId> = std::iter::once(id).chain(doc.ancestors(id).into_iter().take_while(|a| *a != screen)).collect();
|
||||
if chain.iter().any(|a| doc.get(*a).is_some_and(|n| n.overlay)) {
|
||||
return true;
|
||||
}
|
||||
let Some(n) = doc.get(id) else { return false };
|
||||
let opacity: f64 = chain.iter().filter_map(|a| doc.get(*a)).map(|n| n.style.opacity).product();
|
||||
matches!(n.kind, NodeKind::Frame { .. } | NodeKind::Rect)
|
||||
&& opacity >= OPAQUE
|
||||
&& n.style.fills.iter().any(|f| match f {
|
||||
Paint::Solid { color, .. } => f64::from(color.a) / 255.0 >= OPAQUE,
|
||||
Paint::Image { opacity, .. } => *opacity >= OPAQUE,
|
||||
_ => false,
|
||||
})
|
||||
}
|
||||
|
||||
/// Area covered by the union of `rects` (coordinate compression; covers are few).
|
||||
fn union_area(rects: &[Rect]) -> f64 {
|
||||
let mut xs: Vec<f64> = rects.iter().flat_map(|r| [r.x, r.right()]).collect();
|
||||
let mut ys: Vec<f64> = rects.iter().flat_map(|r| [r.y, r.bottom()]).collect();
|
||||
xs.sort_by(f64::total_cmp);
|
||||
ys.sort_by(f64::total_cmp);
|
||||
let mut area = 0.0;
|
||||
for xw in xs.windows(2) {
|
||||
for yw in ys.windows(2) {
|
||||
let (cx, cy) = ((xw[0] + xw[1]) / 2.0, (yw[0] + yw[1]) / 2.0);
|
||||
if rects.iter().any(|r| r.x <= cx && cx <= r.right() && r.y <= cy && cy <= r.bottom()) {
|
||||
area += (xw[1] - xw[0]) * (yw[1] - yw[0]);
|
||||
}
|
||||
}
|
||||
}
|
||||
area
|
||||
}
|
||||
|
||||
impl Editor {
|
||||
/// Declares `ids` as screens or overlays, or back to plain frames: one undo step. Errors
|
||||
/// name the first layer that can't take the role, and nothing changes then.
|
||||
pub fn set_role(&mut self, ids: &[NodeId], role: Role) -> Result<(), String> {
|
||||
if let Some((id, why)) = ids.iter().find_map(|id| refusal(&self.doc, *id, role).map(|w| (*id, w))) {
|
||||
let name = self.doc.get(id).map_or("?", |n| n.name.as_str());
|
||||
return Err(format!("`{name}` can't be a {}: {why}", if role == Role::Overlay { "overlay" } else { "screen" }));
|
||||
}
|
||||
let label = match role {
|
||||
Role::Screen => "Declare screens",
|
||||
Role::Overlay => "Mark overlays",
|
||||
Role::None => "Clear screen roles",
|
||||
};
|
||||
self.edit(label, |tx| {
|
||||
for id in ids {
|
||||
if let Some(n) = tx.node_mut(*id) {
|
||||
n.screen = role == Role::Screen;
|
||||
n.overlay = role == Role::Overlay;
|
||||
}
|
||||
}
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::model::Color;
|
||||
|
||||
fn frame(ed: &mut Editor, parent: Option<NodeId>, r: Rect, fill: Option<Color>) -> NodeId {
|
||||
ed.edit("Add", |tx| {
|
||||
let id = tx.alloc_id();
|
||||
let mut n = Node::new(id, NodeKind::Frame { clip: true }, r);
|
||||
n.style.fills = fill.map(Paint::solid).into_iter().collect();
|
||||
tx.insert(n, parent, None);
|
||||
id
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drawer_hides_what_is_under_it_and_partial_passes() {
|
||||
let mut ed = Editor::default();
|
||||
let screen = frame(&mut ed, None, Rect::new(0.0, 0.0, 400.0, 800.0), None);
|
||||
let under = frame(&mut ed, Some(screen), Rect::new(0.0, 0.0, 200.0, 100.0), None);
|
||||
let half = frame(&mut ed, Some(screen), Rect::new(100.0, 200.0, 200.0, 100.0), None);
|
||||
let drawer = frame(&mut ed, Some(screen), Rect::new(0.0, 0.0, 200.0, 800.0), Some(Color { r: 255, g: 255, b: 255, a: 255 }));
|
||||
assert!(coverage(&ed.doc, under).is_none(), "not a screen yet");
|
||||
ed.set_role(&[screen], Role::Screen).unwrap();
|
||||
let c = coverage(&ed.doc, under).unwrap();
|
||||
assert!(c.is_hidden() && c.covers == vec![drawer], "{c:?}");
|
||||
let c = coverage(&ed.doc, half).unwrap();
|
||||
assert!(c.is_partial() && (c.visible - 0.5).abs() < 1e-9, "{c:?}");
|
||||
assert_eq!(coverage(&ed.doc, drawer).unwrap().state(), "visible");
|
||||
// A translucent drawer hides only once it is marked an overlay.
|
||||
ed.edit("Fade", |tx| tx.node_mut(drawer).unwrap().style.opacity = 0.5);
|
||||
assert_eq!(coverage(&ed.doc, under).unwrap().state(), "visible");
|
||||
ed.set_role(&[drawer], Role::Overlay).unwrap();
|
||||
assert!(coverage(&ed.doc, under).unwrap().is_hidden());
|
||||
// Hidden drawer: nothing covered.
|
||||
ed.edit("Hide", |tx| tx.node_mut(drawer).unwrap().visible = false);
|
||||
assert_eq!(coverage(&ed.doc, under).unwrap().state(), "visible");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn roles_are_refused_where_they_make_no_sense() {
|
||||
let mut ed = Editor::default();
|
||||
let section = frame(&mut ed, None, Rect::new(0.0, 0.0, 3000.0, 3000.0), None);
|
||||
let slot = frame(&mut ed, Some(section), Rect::new(0.0, 0.0, 1440.0, 924.0), None);
|
||||
let screen = frame(&mut ed, Some(slot), Rect::new(0.0, 24.0, 1440.0, 900.0), None);
|
||||
let inner = frame(&mut ed, Some(screen), Rect::new(0.0, 0.0, 10.0, 10.0), None);
|
||||
ed.edit("Name", |tx| tx.node_mut(slot).unwrap().name = "Slot / Home".into());
|
||||
assert_eq!(candidates(&ed.doc), vec![screen]);
|
||||
assert!(ed.set_role(&[inner], Role::Overlay).is_err(), "overlay outside a screen");
|
||||
ed.set_role(&[screen], Role::Screen).unwrap();
|
||||
assert!(ed.set_role(&[inner], Role::Screen).is_err(), "screen in a screen");
|
||||
assert!(ed.set_role(&[section], Role::Screen).is_err(), "a section holding screens");
|
||||
ed.set_role(&[inner], Role::Overlay).unwrap();
|
||||
assert_eq!(ed.screens(), vec![screen]);
|
||||
ed.undo();
|
||||
ed.undo();
|
||||
assert!(ed.doc.get(screen).is_some_and(|n| !n.screen), "one undo step each");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn union_area_counts_overlaps_once() {
|
||||
let a = Rect::new(0.0, 0.0, 10.0, 10.0);
|
||||
let b = Rect::new(5.0, 5.0, 10.0, 10.0);
|
||||
assert!((union_area(&[a, b]) - 175.0).abs() < 1e-9);
|
||||
}
|
||||
}
|
||||
@@ -109,6 +109,15 @@ fn copy_props(main: &Node, inst: &mut Node, overrides: &[String], is_root: bool)
|
||||
if !ov("size") {
|
||||
inst.rect.w = main.rect.w;
|
||||
inst.rect.h = main.rect.h;
|
||||
// Its own text gets its own box: the main's was measured for the main's words.
|
||||
if ov("text")
|
||||
&& let Some(l) = crate::text::layout_node(inst)
|
||||
{
|
||||
if matches!(inst.kind, NodeKind::Text { auto_width: true, .. }) {
|
||||
inst.rect.w = l.width.max(1.0);
|
||||
}
|
||||
inst.rect.h = l.height;
|
||||
}
|
||||
}
|
||||
// Bindings follow the main, field by field, except in overridden groups.
|
||||
let fields: BTreeSet<String> = main.bindings.keys().chain(inst.bindings.keys()).cloned().collect();
|
||||
@@ -157,6 +166,7 @@ pub fn instantiate_from(
|
||||
n.children = Vec::new();
|
||||
n.component = false;
|
||||
n.component_key = None;
|
||||
n.props.clear();
|
||||
n.comments.clear();
|
||||
n.instance = Some(InstanceLink {
|
||||
main,
|
||||
@@ -228,6 +238,10 @@ pub(super) fn sync_node(tx: &mut Tx, inst: NodeId, is_root: bool, ext: Option<&D
|
||||
if updated != *n {
|
||||
*tx.node_mut(inst).unwrap() = updated;
|
||||
}
|
||||
// A layer whose content was replaced (an icon swapped by a property) keeps its children.
|
||||
if link.overrides.iter().any(|o| o == "children") {
|
||||
return;
|
||||
}
|
||||
// Children: match by link; create missing, remove stale, follow main's order.
|
||||
let kids: Vec<NodeId> = tx.doc().get(inst).unwrap().children.clone();
|
||||
let mut by_main: Vec<(NodeId, Option<Source>, NodeId)> = kids
|
||||
@@ -239,16 +253,30 @@ pub(super) fn sync_node(tx: &mut Tx, inst: NodeId, is_root: bool, ext: Option<&D
|
||||
.map(|l| (l.main, l.source.clone(), *k))
|
||||
})
|
||||
.collect();
|
||||
for k in &kids {
|
||||
let stale = tx
|
||||
.doc()
|
||||
.get(*k)
|
||||
.and_then(|c| c.instance.as_ref())
|
||||
.is_none_or(|l| !main.children.contains(&l.main) || l.source != source);
|
||||
if stale {
|
||||
tx.remove(*k);
|
||||
by_main.retain(|(_, _, x)| x != k);
|
||||
for (at, k) in kids.iter().enumerate() {
|
||||
let link = tx.doc().get(*k).and_then(|c| c.instance.clone());
|
||||
let stale = link.as_ref().is_none_or(|l| !main.children.contains(&l.main) || l.source != source);
|
||||
if !stale {
|
||||
continue;
|
||||
}
|
||||
// An instance written over a nested one (before swaps existed) takes the place of the
|
||||
// main's child at its index, as a swap, instead of being thrown away.
|
||||
let free = main.children.get(at).filter(|mc| !by_main.iter().any(|(m, s, _)| m == *mc && *s == source));
|
||||
let of_a_component = |l: &InstanceLink| match &l.source {
|
||||
None => tx.doc().get(l.main).is_some_and(|m| m.component),
|
||||
s if *s == source => ext.and_then(|d| d.get(l.main)).is_some_and(|m| m.component),
|
||||
_ => false,
|
||||
};
|
||||
if let (Some(l), Some(mc)) = (link.filter(|l| l.swap.is_none() && of_a_component(l)), free) {
|
||||
let mc = *mc;
|
||||
let n = tx.node_mut(*k).unwrap();
|
||||
n.instance = Some(InstanceLink { main: mc, overrides: l.overrides, source: source.clone(), swap: Some(Swap { main: l.main, source: l.source }) });
|
||||
by_main.retain(|(_, _, x)| x != k);
|
||||
by_main.push((mc, source.clone(), *k));
|
||||
continue;
|
||||
}
|
||||
tx.remove(*k);
|
||||
by_main.retain(|(_, _, x)| x != k);
|
||||
}
|
||||
for (i, mc) in main.children.iter().enumerate() {
|
||||
match by_main.iter().find(|(m, s, _)| m == mc && *s == source) {
|
||||
|
||||
@@ -44,12 +44,15 @@ pub struct Undone {
|
||||
pub design: Option<DesignSystem>,
|
||||
pub design_from: Option<DesignSystem>,
|
||||
pub page_modes: Option<PageModes>,
|
||||
/// The layers the step changed.
|
||||
pub nodes: Vec<NodeId>,
|
||||
}
|
||||
|
||||
impl Change {
|
||||
fn undone(&self, forward: bool) -> Undone {
|
||||
Undone {
|
||||
label: self.label.clone(),
|
||||
nodes: self.entries.iter().map(|e| e.id).collect(),
|
||||
design: self
|
||||
.design
|
||||
.as_ref()
|
||||
|
||||
@@ -71,7 +71,10 @@ fn hug_size(doc: &Document, id: NodeId, axis: usize) -> f64 {
|
||||
let kn = doc.get(*k).unwrap();
|
||||
match kn.layout_child.sizing[axis] {
|
||||
Sizing::Hug => clamp_size(&kn.layout_child, axis, hug_size(doc, *k, axis)),
|
||||
Sizing::Fill => 0.0,
|
||||
// A growing child still brings its content (CSS flex-basis auto); its current
|
||||
// size is what it grew to, so it is not used.
|
||||
Sizing::Fill if kn.layout.is_some() => clamp_size(&kn.layout_child, axis, hug_size(doc, *k, axis)),
|
||||
Sizing::Fill => clamp_size(&kn.layout_child, axis, 0.0),
|
||||
Sizing::Fixed => clamp_size(&kn.layout_child, axis, get(&kn.rect, axis).1),
|
||||
}
|
||||
})
|
||||
@@ -164,31 +167,44 @@ pub fn layout_frame(tx: &mut Tx, id: NodeId) -> bool {
|
||||
layout_frame(tx, k);
|
||||
}
|
||||
}
|
||||
// 2. This frame's hug size.
|
||||
// 2. This frame's hug size, then 3. its children. A child that hugs its content and got a
|
||||
// new width here (stretched, filled) re-measures: its height depended on that width. If it
|
||||
// changed, this frame measures and arranges once more.
|
||||
let ma = main_axis(&l);
|
||||
let ca = 1 - ma;
|
||||
let n = tx.doc().get(id).unwrap().clone();
|
||||
let sizing = n.layout_child.sizing;
|
||||
let mut rect = n.rect;
|
||||
for axis in [0, 1] {
|
||||
if sizing[axis] == Sizing::Hug {
|
||||
let any_fill = flow_children(tx.doc(), id)
|
||||
.iter()
|
||||
.any(|k| tx.doc().get(*k).unwrap().layout_child.sizing[axis] == Sizing::Fill);
|
||||
// A fill child on the main axis turns hug into fixed (Figma rule).
|
||||
if !(any_fill && axis == ma) {
|
||||
for pass in 0..2 {
|
||||
let n = tx.doc().get(id).unwrap().clone();
|
||||
let sizing = n.layout_child.sizing;
|
||||
let mut rect = n.rect;
|
||||
for axis in [0, 1] {
|
||||
// Hug fits the content, growing children's included (CSS fit-content); a min size
|
||||
// leaves room that the growing children then fill.
|
||||
if sizing[axis] == Sizing::Hug {
|
||||
let s = hug_size(tx.doc(), id, axis);
|
||||
let (p, _) = get(&rect, axis);
|
||||
set(&mut rect, axis, p, s.max(0.0));
|
||||
}
|
||||
let (p, cur_s) = get(&rect, axis);
|
||||
set(&mut rect, axis, p, clamp_size(&n.layout_child, axis, cur_s));
|
||||
}
|
||||
if rect != n.rect {
|
||||
tx.node_mut(id).unwrap().rect = rect;
|
||||
}
|
||||
arrange(tx, id, &l, rect, ma, ca);
|
||||
let stale: Vec<NodeId> = flow_children(tx.doc(), id)
|
||||
.into_iter()
|
||||
.filter(|k| {
|
||||
let kn = tx.doc().get(*k).unwrap();
|
||||
kn.layout.is_some() && (0..2).any(|ax| kn.layout_child.sizing[ax] == Sizing::Hug && (hug_size(tx.doc(), *k, ax) - get(&kn.rect, ax).1).abs() > 0.5)
|
||||
})
|
||||
.collect();
|
||||
if stale.is_empty() || pass == 1 {
|
||||
break;
|
||||
}
|
||||
for k in stale {
|
||||
layout_frame(tx, k);
|
||||
}
|
||||
let (p, cur_s) = get(&rect, axis);
|
||||
set(&mut rect, axis, p, clamp_size(&n.layout_child, axis, cur_s));
|
||||
}
|
||||
if rect != n.rect {
|
||||
tx.node_mut(id).unwrap().rect = rect;
|
||||
}
|
||||
arrange(tx, id, &l, rect, ma, ca);
|
||||
let after: Vec<(NodeId, Rect)> = tx
|
||||
.doc()
|
||||
.subtree(id)
|
||||
|
||||
@@ -312,7 +312,7 @@ fn tw_classes(doc: &Document, n: &Node, is_root: bool) -> String {
|
||||
tw_padding(l.padding, &mut c);
|
||||
}
|
||||
if matches!(n.kind, NodeKind::Frame { clip: true }) {
|
||||
c.push("overflow-hidden".into());
|
||||
c.push(if n.scroll { "overflow-y-auto" } else { "overflow-hidden" }.into());
|
||||
}
|
||||
match &n.kind {
|
||||
NodeKind::Text {
|
||||
@@ -402,16 +402,19 @@ pub(crate) fn code_link<'a>(doc: &'a Document, n: &'a Node) -> Option<&'a crate:
|
||||
n.code.as_ref().or_else(|| n.instance.as_ref().and_then(|l| doc.get(l.target().0)).and_then(|m| m.code.as_ref()))
|
||||
}
|
||||
|
||||
/// A code link template with `{Name}` replaced by the text of the layer named `Name` under `id`
|
||||
/// (the instance's own text, overrides included). An unknown name stays as written.
|
||||
/// A code link template with `{Name}` replaced by the instance's property `Name`, else the text
|
||||
/// of the layer named `Name` under `id` (overrides included). An unknown name stays as written.
|
||||
fn fill_template(doc: &Document, id: NodeId, template: &str, escape: fn(&str) -> String) -> String {
|
||||
let mut out = String::new();
|
||||
let mut rest = template;
|
||||
while let Some(i) = rest.find('{') {
|
||||
let Some(j) = rest[i..].find('}').map(|j| i + j) else { break };
|
||||
let name = &rest[i + 1..j];
|
||||
// A property value of the instance first (`{Status}`), else a layer's text (`{Label}`).
|
||||
let text = (!name.is_empty() && name.chars().all(|c| c.is_alphanumeric() || " _-".contains(c)))
|
||||
.then(|| doc.subtree(id).into_iter().filter_map(|d| doc.get(d)).find(|d| d.name == name).and_then(|d| d.kind.text().map(|t| t.to_string())))
|
||||
.then(|| {
|
||||
doc.get(id).and_then(|n| n.prop_values.get(name).cloned()).or_else(|| doc.subtree(id).into_iter().filter_map(|d| doc.get(d)).find(|d| d.name == name).and_then(|d| d.kind.text().map(|t| t.to_string())))
|
||||
})
|
||||
.flatten();
|
||||
out.push_str(&rest[..i]);
|
||||
match text {
|
||||
@@ -1646,6 +1649,8 @@ fn build_spec_tree(
|
||||
"fillVar": fill_var,
|
||||
"fills": n.style.fills.iter().map(|f| paint_json(doc, f)).collect::<Vec<_>>(),
|
||||
"stroke": stroke.map(|c| c.to_hex()),
|
||||
// A bound stroke (`bind_variable` field `stroke`) is written as its variable, like fills.
|
||||
"strokeVar": stroke.and(n.bindings.get("stroke")).map(|v| format!("--{}", v.trim_start_matches("--"))),
|
||||
"strokeWidth": stroke.map(|_| n.style.stroke_width),
|
||||
"cornerRadius": n.style.corner_radius,
|
||||
"cornerRadiusToken": token_for(doc, n.style.corner_radius, &["radius"]),
|
||||
@@ -1977,6 +1982,222 @@ pub(crate) fn to_ai_spec_markdown_with_categories(
|
||||
Ok(md)
|
||||
}
|
||||
|
||||
/// Format the AI spec as compact text for an implementer: notes, the tokens the subtree uses,
|
||||
/// kit components, component definitions and one line per node. No reference code, no
|
||||
/// per-node enforcement prose (`verify_code` checks those). About 12x fewer tokens than JSON.
|
||||
pub fn to_ai_spec_compact(doc: &Document, root: NodeId, target: &str) -> Result<String, String> {
|
||||
to_ai_spec_compact_with_categories(doc, root, target, &crate::model::annotation::default_categories())
|
||||
}
|
||||
|
||||
pub(crate) fn to_ai_spec_compact_with_categories(
|
||||
doc: &Document, root: NodeId, target: &str,
|
||||
categories: &[crate::model::annotation::AnnotationCategory],
|
||||
) -> Result<String, String> {
|
||||
let spec = to_ai_spec_with_categories(doc, root, target, categories)?;
|
||||
Ok(format_compact(&spec))
|
||||
}
|
||||
|
||||
fn format_compact(spec: &Value) -> String {
|
||||
let mut out = format!("# {}\n\n", spec["name"].as_str().unwrap_or("Frame"));
|
||||
if let Some(notes) = spec["annotations"].as_array().filter(|a| !a.is_empty()) {
|
||||
out.push_str("## Notes\n");
|
||||
for n in notes {
|
||||
out.push_str(n.as_str().unwrap_or("").trim_start_matches('/').trim());
|
||||
out.push('\n');
|
||||
}
|
||||
out.push('\n');
|
||||
}
|
||||
let mut used = std::collections::BTreeSet::new();
|
||||
collect_tokens(&spec["layoutTree"], &mut used);
|
||||
for d in spec["componentDefinitions"].as_array().into_iter().flatten() {
|
||||
collect_tokens(&d["layoutTree"], &mut used);
|
||||
}
|
||||
if !used.is_empty() {
|
||||
out.push_str("## Tokens used\n");
|
||||
for u in &used {
|
||||
let key = u.trim_start_matches("--");
|
||||
let v = spec["tokens"]["colors"][key].as_str().or(spec["tokens"]["dimensions"][key].as_str()).unwrap_or("?");
|
||||
out.push_str(&format!("{u}: {v}\n"));
|
||||
}
|
||||
out.push('\n');
|
||||
}
|
||||
if let Some(lib) = spec["libraryComponents"].as_array().filter(|l| !l.is_empty()) {
|
||||
out.push_str("## Kit components\n");
|
||||
for l in lib {
|
||||
out.push_str(&format!("{} ({}): {}\n", l["name"].as_str().unwrap_or(""), l["kit"].as_str().unwrap_or(""), l["import"].as_str().unwrap_or("")));
|
||||
}
|
||||
out.push('\n');
|
||||
}
|
||||
if let Some(defs) = spec["componentDefinitions"].as_array().filter(|d| !d.is_empty()) {
|
||||
out.push_str("## Component definitions\n");
|
||||
for d in defs {
|
||||
out.push_str(&format!("### {}\n", d["name"].as_str().unwrap_or("")));
|
||||
compact_node(&d["layoutTree"], 0, &mut out);
|
||||
out.push('\n');
|
||||
}
|
||||
}
|
||||
out.push_str("## Tree\nFormat: Kind \"layer\" WxH layout style. hug/fill = sizing. Tokens as --name. Text: \"content\" size/lineHeight weight color.\n\n");
|
||||
compact_node(&spec["layoutTree"], 0, &mut out);
|
||||
out
|
||||
}
|
||||
|
||||
fn collect_tokens(n: &Value, used: &mut std::collections::BTreeSet<String>) {
|
||||
let mut add = |v: &Value| {
|
||||
if let Some(s) = v.as_str() {
|
||||
used.insert(s.to_owned());
|
||||
}
|
||||
};
|
||||
add(&n["autoLayout"]["gapToken"]);
|
||||
for t in n["autoLayout"]["paddingTokens"].as_array().into_iter().flatten() {
|
||||
add(t);
|
||||
}
|
||||
for f in n["style"]["fills"].as_array().into_iter().flatten() {
|
||||
add(&f["var"]);
|
||||
}
|
||||
add(&n["style"]["fillVar"]);
|
||||
add(&n["style"]["strokeVar"]);
|
||||
add(&n["style"]["cornerRadiusToken"]);
|
||||
add(&n["text"]["fontSizeToken"]);
|
||||
add(&n["icon"]["colorVar"]);
|
||||
for c in n["children"].as_array().into_iter().flatten() {
|
||||
collect_tokens(c, used);
|
||||
}
|
||||
}
|
||||
|
||||
/// A token name when bound, else the raw value.
|
||||
fn tok(var: &Value, value: &Value) -> String {
|
||||
if let Some(v) = var.as_str() {
|
||||
return v.to_owned();
|
||||
}
|
||||
match value {
|
||||
Value::Number(n) => px_num(n.as_f64().unwrap_or(0.0)),
|
||||
Value::String(s) => s.clone(),
|
||||
_ => "0".into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn compact_size(n: &Value) -> String {
|
||||
let axis = |mode: &Value, px: &Value| match mode.as_str() {
|
||||
Some("hug") => "hug".to_owned(),
|
||||
Some("fill") => "fill".to_owned(),
|
||||
_ => px_num(px.as_f64().unwrap_or(0.0)),
|
||||
};
|
||||
format!(
|
||||
"{}×{}",
|
||||
axis(&n["sizing"]["horizontal"], &n["bounds"]["width"]),
|
||||
axis(&n["sizing"]["vertical"], &n["bounds"]["height"])
|
||||
)
|
||||
}
|
||||
|
||||
fn compact_layout(al: &Value) -> Vec<String> {
|
||||
if al.is_null() {
|
||||
return Vec::new();
|
||||
}
|
||||
let mut parts = vec![if al["direction"] == "row" { "row" } else { "col" }.to_owned()];
|
||||
if al["wrap"] == true {
|
||||
parts.push("wrap".into());
|
||||
}
|
||||
let gap = tok(&al["gapToken"], &al["gap"]);
|
||||
if gap != "0" {
|
||||
parts.push(format!("gap={gap}"));
|
||||
}
|
||||
let pad: Vec<&Value> = al["padding"].as_array().map(|a| a.iter().collect()).unwrap_or_default();
|
||||
if pad.iter().any(|v| v.as_f64().unwrap_or(0.0) != 0.0) {
|
||||
let toks = al["paddingTokens"].as_array();
|
||||
let parts_pad: Vec<String> = pad.iter().enumerate().map(|(i, v)| tok(toks.and_then(|t| t.get(i)).unwrap_or(&Value::Null), v)).collect();
|
||||
parts.push(format!("pad=[{}]", parts_pad.join(",")));
|
||||
}
|
||||
if let Some(m) = al["alignMain"].as_str().filter(|m| *m != "start") {
|
||||
parts.push(format!("main={m}"));
|
||||
}
|
||||
if let Some(c) = al["alignCross"].as_str().filter(|c| *c != "start") {
|
||||
parts.push(format!("cross={c}"));
|
||||
}
|
||||
parts
|
||||
}
|
||||
|
||||
fn compact_style(st: &Value) -> Vec<String> {
|
||||
let mut parts = Vec::new();
|
||||
for f in st["fills"].as_array().into_iter().flatten() {
|
||||
if f["type"] == "solid" {
|
||||
parts.push(format!("bg={}", tok(&f["var"], &f["color"])));
|
||||
} else if let Some(t) = f["type"].as_str() {
|
||||
parts.push(format!("bg={t}"));
|
||||
}
|
||||
}
|
||||
if !st["stroke"].is_null() {
|
||||
parts.push(format!("border={}/{}", tok(&st["strokeVar"], &st["stroke"]), px_num(st["strokeWidth"].as_f64().unwrap_or(1.0))));
|
||||
}
|
||||
if st["cornerRadius"].as_f64().unwrap_or(0.0) > 0.0 {
|
||||
parts.push(format!("r={}", tok(&st["cornerRadiusToken"], &st["cornerRadius"])));
|
||||
}
|
||||
if let Some(o) = st["opacity"].as_f64().filter(|o| *o != 1.0) {
|
||||
parts.push(format!("op={o}"));
|
||||
}
|
||||
if st["clip"] == true {
|
||||
parts.push("clip".into());
|
||||
}
|
||||
parts
|
||||
}
|
||||
|
||||
fn compact_node(n: &Value, depth: usize, out: &mut String) {
|
||||
let ind = " ".repeat(depth);
|
||||
let name = n["name"].as_str().unwrap_or("");
|
||||
let kind = n["kind"].as_str().unwrap_or("node");
|
||||
let line = match kind {
|
||||
"text" => {
|
||||
let t = &n["text"];
|
||||
let mut bits = vec![
|
||||
format!("Text {name:?}"),
|
||||
format!("{:?}", t["content"].as_str().unwrap_or("")),
|
||||
format!("{}/{}", tok(&t["fontSizeToken"], &t["fontSize"]), px_num(t["lineHeight"].as_f64().unwrap_or(0.0))),
|
||||
t["fontWeight"].as_u64().unwrap_or(400).to_string(),
|
||||
];
|
||||
if let Some(a) = t["align"].as_str().filter(|a| *a != "left") {
|
||||
bits.push(a.to_owned());
|
||||
}
|
||||
bits.push(format!("color={}", tok(&n["style"]["fillVar"], &n["style"]["fill"])));
|
||||
if t["autoWidth"] != true {
|
||||
bits.push(format!("w={}", px_num(n["bounds"]["width"].as_f64().unwrap_or(0.0))));
|
||||
}
|
||||
bits.join(" ")
|
||||
}
|
||||
"icon" => {
|
||||
let i = &n["icon"];
|
||||
format!(
|
||||
"Icon {}:{} {} color={}",
|
||||
i["set"].as_str().unwrap_or(""),
|
||||
i["name"].as_str().unwrap_or(""),
|
||||
px_num(i["size"][0].as_f64().unwrap_or(0.0)),
|
||||
tok(&i["colorVar"], &i["color"])
|
||||
)
|
||||
}
|
||||
"library" => {
|
||||
let l = &n["library"];
|
||||
let usage = l["usage"].as_str().filter(|u| !u.is_empty()).or(l["react"].as_str()).unwrap_or("");
|
||||
format!("Kit {} {}", l["kit"].as_str().unwrap_or(""), usage.lines().next().unwrap_or(""))
|
||||
}
|
||||
_ if !n["instanceOf"].is_null() => {
|
||||
let of = n["instanceOf"]["component"].as_str().unwrap_or("");
|
||||
format!("Instance {name:?} of {of:?} {}", compact_size(n))
|
||||
}
|
||||
"vector" => format!("Vector {name:?} {}", compact_size(n)),
|
||||
_ => {
|
||||
let mut bits = vec![format!("{}{} {name:?}", kind[..1].to_ascii_uppercase(), &kind[1..]), compact_size(n)];
|
||||
bits.extend(compact_layout(&n["autoLayout"]));
|
||||
bits.extend(compact_style(&n["style"]));
|
||||
bits.join(" ")
|
||||
}
|
||||
};
|
||||
out.push_str(&format!("{ind}{line}\n"));
|
||||
if matches!(kind, "icon" | "library") || !n["instanceOf"].is_null() {
|
||||
return;
|
||||
}
|
||||
for c in n["children"].as_array().into_iter().flatten() {
|
||||
compact_node(c, depth + 1, out);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -654,8 +654,30 @@ pub fn apply(n: &mut Node, styles: &[(String, String)], ctx: &Context) -> Applie
|
||||
missing.is_empty().then_some((k, text))
|
||||
})
|
||||
.collect();
|
||||
// A border naming a color variable (`1px solid var(--border)`) binds the stroke to it, so a
|
||||
// mode switch reaches it; a border written with a literal colour drops that binding.
|
||||
let stroke_link: Option<Option<String>> = styles.iter().rev().find(|(k, _)| BORDER_PROPS.contains(&k.as_str())).map(|(_, v)| {
|
||||
let mut rest = v.as_str();
|
||||
while let Some(i) = rest.find("var(--") {
|
||||
let name = rest[i + 6..].split([')', ',']).next().unwrap_or_default().trim();
|
||||
if VARS.with(|m| m.borrow().contains_key(name)) {
|
||||
return Some(name.to_owned());
|
||||
}
|
||||
rest = &rest[i + 6..];
|
||||
}
|
||||
None
|
||||
});
|
||||
let mut a = apply_resolved(n, &resolved, ctx);
|
||||
a.ignored.extend(unknown);
|
||||
match stroke_link {
|
||||
Some(Some(name)) => {
|
||||
n.bindings.insert("stroke".into(), name);
|
||||
}
|
||||
Some(None) => {
|
||||
n.bindings.remove("stroke");
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
for name in linked {
|
||||
if let Some(Paint::Solid { var, .. }) = n.style.fills.last_mut() {
|
||||
*var = Some(name.to_string());
|
||||
@@ -664,6 +686,9 @@ pub fn apply(n: &mut Node, styles: &[(String, String)], ctx: &Context) -> Applie
|
||||
a
|
||||
}
|
||||
|
||||
/// Properties whose colour is the stroke's.
|
||||
const BORDER_PROPS: [&str; 9] = ["border", "outline", "borderColor", "stroke", "borderTop", "borderRight", "borderBottom", "borderLeft", "borderTopColor"];
|
||||
|
||||
const COLOR_PROPS: [&str; 5] = [
|
||||
"backgroundColor",
|
||||
"background",
|
||||
@@ -1200,10 +1225,15 @@ fn apply_resolved(n: &mut Node, styles: &[(String, String)], ctx: &Context) -> A
|
||||
// Accepted without an equivalent; harmless. fontFamily: Inter is the only face.
|
||||
true
|
||||
}
|
||||
"overflow" if n.is_frame() => {
|
||||
"overflow" | "overflowY" if n.is_frame() => {
|
||||
if let NodeKind::Frame { clip } = &mut n.kind {
|
||||
*clip = matches!(lower.as_str(), "hidden" | "clip" | "scroll" | "auto");
|
||||
}
|
||||
// A scrolling screen: fixed size, its overflow scrolls on the canvas.
|
||||
n.scroll = matches!(lower.as_str(), "scroll" | "auto");
|
||||
if !n.scroll {
|
||||
n.scroll_y = 0.0;
|
||||
}
|
||||
true
|
||||
}
|
||||
"backgroundColor" | "background" | "backgroundImage" | "fill" if !is_text => {
|
||||
@@ -1776,7 +1806,7 @@ pub fn computed(n: &Node, parent_layout: Option<&AutoLayout>, is_root: bool) ->
|
||||
}
|
||||
}
|
||||
if let NodeKind::Frame { clip: true } = n.kind {
|
||||
put("overflow", "hidden".into());
|
||||
put("overflow", if n.scroll { "auto" } else { "hidden" }.into());
|
||||
}
|
||||
if !n.visible {
|
||||
put("display", "none".into());
|
||||
@@ -1851,6 +1881,8 @@ pub fn computed(n: &Node, parent_layout: Option<&AutoLayout>, is_root: bool) ->
|
||||
if let Some(s) = n.style.stroke
|
||||
&& n.style.stroke_width > 0.0
|
||||
{
|
||||
// A stroke bound to a variable reads as that variable.
|
||||
let stroke = n.bindings.get("stroke").map_or_else(|| color_css(s), |v| format!("var(--{})", v.trim_start_matches("--")));
|
||||
let kind = if n.style.dash.is_empty() {
|
||||
"solid"
|
||||
} else if n.style.dash[0] <= n.style.stroke_width * 1.5 {
|
||||
@@ -1866,14 +1898,11 @@ pub fn computed(n: &Node, parent_layout: Option<&AutoLayout>, is_root: bool) ->
|
||||
.enumerate()
|
||||
{
|
||||
if sides[i] > 0.0 {
|
||||
put(key, format!("{} {kind} {}", px(sides[i]), color_css(s)));
|
||||
put(key, format!("{} {kind} {stroke}", px(sides[i])));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
put(
|
||||
"border",
|
||||
format!("{} {kind} {}", px(n.style.stroke_width), color_css(s)),
|
||||
);
|
||||
put("border", format!("{} {kind} {stroke}", px(n.style.stroke_width)));
|
||||
}
|
||||
}
|
||||
if let Some(r) = n.style.radii {
|
||||
|
||||
@@ -87,24 +87,11 @@ fn strip_token_declaration_blocks(code: &str) -> String {
|
||||
let mut i = 0;
|
||||
while i < code.len() {
|
||||
let rest = &code[i..];
|
||||
let starts_decl = ["root", "theme", "host", "data-mode", "data-theme"]
|
||||
// Byte-safe prefix test: slicing `rest[..n]` panics when byte n falls inside a
|
||||
// multibyte char (a `ó` in "Rótulo" took the app down on a real screen's code).
|
||||
let starts_decl = [":root", "@theme", ":host", "[data-mode", "[data-theme"]
|
||||
.iter()
|
||||
.any(|kw| {
|
||||
let pfx_len = match *kw {
|
||||
"root" | "host" => 1 + kw.len(), // :root, :host
|
||||
"theme" => 1 + kw.len(), // @theme
|
||||
_ => 1 + kw.len(), // [data-mode, [data-theme
|
||||
};
|
||||
if rest.len() < pfx_len {
|
||||
return false;
|
||||
}
|
||||
let head = &rest[..pfx_len];
|
||||
head.eq_ignore_ascii_case(":root")
|
||||
|| head.eq_ignore_ascii_case("@theme")
|
||||
|| head.eq_ignore_ascii_case(":host")
|
||||
|| head.eq_ignore_ascii_case("[data-mode")
|
||||
|| head.eq_ignore_ascii_case("[data-theme")
|
||||
});
|
||||
.any(|p| rest.get(..p.len()).is_some_and(|head| head.eq_ignore_ascii_case(p)));
|
||||
if starts_decl && let Some(open_rel) = rest.find('{') {
|
||||
let mut depth = 0usize;
|
||||
let mut end_idx = None;
|
||||
@@ -138,7 +125,28 @@ fn web_var_referenced(code: &str, var_name: &str) -> bool {
|
||||
let outside = strip_token_declaration_blocks(code);
|
||||
let css_var = format!("--{var_name}");
|
||||
let tw_color_var = format!("--color-{var_name}");
|
||||
outside.contains(&css_var) || outside.contains(&tw_color_var)
|
||||
outside.contains(&css_var) || outside.contains(&tw_color_var) || tailwind_utility_references(&outside, var_name)
|
||||
}
|
||||
|
||||
/// `bg-primary`, `text-muted-foreground/80`, `hover:border-ring`: Tailwind v4 utilities generated
|
||||
/// from `@theme` colour tokens reference the variable without spelling `--name`. A hit needs a
|
||||
/// utility prefix letter before `-name` and no identifier char after it (`bg-primary-foreground`
|
||||
/// is not a reference to `primary`).
|
||||
fn tailwind_utility_references(code: &str, var_name: &str) -> bool {
|
||||
let needle = format!("-{var_name}");
|
||||
let bytes = code.as_bytes();
|
||||
let mut from = 0;
|
||||
while let Some(pos) = code[from..].find(&needle) {
|
||||
let start = from + pos;
|
||||
let end = start + needle.len();
|
||||
let before_ok = start > 0 && bytes[start - 1].is_ascii_alphabetic();
|
||||
let after_ok = end >= bytes.len() || !(bytes[end].is_ascii_alphanumeric() || bytes[end] == b'-' || bytes[end] == b'_');
|
||||
if before_ok && after_ok {
|
||||
return true;
|
||||
}
|
||||
from = end;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn count_web_absolute(code: &str) -> usize {
|
||||
@@ -758,3 +766,20 @@ mod tests {
|
||||
assert!(rules.contains(&"spacing-drift"), "{rules:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tailwind_reference_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn tailwind_v4_utilities_count_as_variable_references() {
|
||||
assert!(web_var_referenced(r#"<div className="bg-background text-foreground">"#, "background"));
|
||||
assert!(web_var_referenced(r#"className="hover:text-muted-foreground/80""#, "muted-foreground"));
|
||||
assert!(web_var_referenced(r#"className="border-ring""#, "ring"));
|
||||
// `bg-primary-foreground` references primary-foreground, not primary.
|
||||
assert!(!web_var_referenced(r#"className="bg-primary-foreground""#, "primary"));
|
||||
// A word that merely ends with the name is not a utility.
|
||||
assert!(!web_var_referenced("const x = 'anything-ring_'", "ring"));
|
||||
assert!(web_var_referenced("style={{ color: 'var(--primary)' }}", "primary"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,16 +71,34 @@ fn solid(n: &Node) -> Option<(Color, Option<&str>)> {
|
||||
}
|
||||
}
|
||||
|
||||
/// What a node's top fill looks like behind its children: a solid's color, a gradient's stops
|
||||
/// averaged; `Some(None)` for an image, whose color is unknown.
|
||||
fn fill_color(n: &Node) -> Option<Option<Color>> {
|
||||
match n.style.fills.last()? {
|
||||
Paint::Solid { color, .. } => (color.a > 0).then_some(Some(*color)),
|
||||
Paint::Linear { stops, .. } | Paint::Radial { stops, .. } if !stops.is_empty() => {
|
||||
let avg = |f: fn(&Color) -> u8| {
|
||||
(stops.iter().map(|s| f(&s.color) as f64).sum::<f64>() / stops.len() as f64).round() as u8
|
||||
};
|
||||
let c = Color::rgba(avg(|c| c.r), avg(|c| c.g), avg(|c| c.b), avg(|c| c.a));
|
||||
(c.a > 0).then_some(Some(c))
|
||||
}
|
||||
Paint::Image { .. } => Some(None),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The color text sits on: the nearest opaque fill up the tree (translucent ones blend over
|
||||
/// what is behind them); white when nothing is filled.
|
||||
fn backdrop(doc: &Document, id: NodeId) -> Color {
|
||||
/// what is behind them); white when nothing is filled; `None` over an image.
|
||||
fn backdrop(doc: &Document, id: NodeId) -> Option<Color> {
|
||||
let mut layers: Vec<Color> = Vec::new();
|
||||
let mut cur = doc.get(id).and_then(|n| n.parent);
|
||||
while let Some(p) = cur {
|
||||
let Some(n) = doc.get(p) else { break };
|
||||
if n.visible
|
||||
&& let Some((c, _)) = solid(n)
|
||||
&& let Some(c) = fill_color(n)
|
||||
{
|
||||
let c = c?;
|
||||
layers.push(c);
|
||||
if c.a == 255 {
|
||||
break;
|
||||
@@ -88,10 +106,12 @@ fn backdrop(doc: &Document, id: NodeId) -> Color {
|
||||
}
|
||||
cur = n.parent;
|
||||
}
|
||||
layers
|
||||
.into_iter()
|
||||
.rev()
|
||||
.fold(Color::rgb(255, 255, 255), blend)
|
||||
Some(
|
||||
layers
|
||||
.into_iter()
|
||||
.rev()
|
||||
.fold(Color::rgb(255, 255, 255), blend),
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn is_default_name(name: &str) -> bool {
|
||||
@@ -128,6 +148,9 @@ pub fn lint(doc: &Document, roots: &[NodeId], scale: &[f64]) -> Vec<Issue> {
|
||||
ids.extend(doc.subtree(*r));
|
||||
}
|
||||
let vars: BTreeMap<String, Color> = doc.variables.clone();
|
||||
// With two or more modes a raw colour cannot follow a theme switch: an error, not a hint.
|
||||
let themed = doc.modes.len() >= 2;
|
||||
let (hard, literal_sev) = if themed { ("error", "error") } else { ("warning", "info") };
|
||||
// Colors used literally, with the first node using each, for the variables report.
|
||||
let mut literal: BTreeMap<String, (usize, NodeId)> = BTreeMap::new();
|
||||
for id in ids {
|
||||
@@ -146,8 +169,9 @@ pub fn lint(doc: &Document, roots: &[NodeId], scale: &[f64]) -> Vec<Issue> {
|
||||
};
|
||||
// ---- text
|
||||
if let (Some(size), Some(_)) = (n.kind.font_size(), n.kind.text()) {
|
||||
if let Some((fg, _)) = solid(n) {
|
||||
let bg = backdrop(doc, id);
|
||||
if let Some((fg, _)) = solid(n)
|
||||
&& let Some(bg) = backdrop(doc, id)
|
||||
{
|
||||
let fg = blend(bg, fg);
|
||||
let ratio = contrast(fg, bg);
|
||||
let large = size >= 24.0 || (size >= 18.66 && n.text_style.bold);
|
||||
@@ -245,7 +269,7 @@ pub fn lint(doc: &Document, roots: &[NodeId], scale: &[f64]) -> Vec<Issue> {
|
||||
if let Some((name, _)) = vars.iter().find(|(_, v)| **v == c) {
|
||||
add(
|
||||
"hard-coded-color",
|
||||
"warning",
|
||||
hard,
|
||||
format!("{} is the value of --{name}", c.to_hex()),
|
||||
format!("write var(--{name}) so a theme change reaches it"),
|
||||
);
|
||||
@@ -254,16 +278,96 @@ pub fn lint(doc: &Document, roots: &[NodeId], scale: &[f64]) -> Vec<Issue> {
|
||||
e.0 += 1;
|
||||
}
|
||||
}
|
||||
// Borders too: a theme change must reach them like it reaches fills.
|
||||
if let Some(name) = hard_stroke(n, &vars) {
|
||||
add(
|
||||
"hard-coded-color",
|
||||
hard,
|
||||
format!("border {} is the value of --{name}", n.style.stroke.unwrap().to_hex()),
|
||||
format!("bind_variable {{field:\"stroke\", variable:\"{name}\"}} so a theme change reaches it"),
|
||||
);
|
||||
}
|
||||
}
|
||||
for (hex, (count, first)) in literal {
|
||||
out.push(Issue {
|
||||
rule: "hard-coded-color",
|
||||
severity: "info",
|
||||
severity: literal_sev,
|
||||
node: first,
|
||||
message: format!("{hex} is used {count}× and is not a variable"),
|
||||
fix: "add it with set_variables, or use an existing variable".into(),
|
||||
});
|
||||
}
|
||||
out.extend(theme_copies(doc, roots));
|
||||
out
|
||||
}
|
||||
|
||||
/// Words that name a theme in an artboard name (the project's mode names count too).
|
||||
const THEME_WORDS: &[&str] = &[
|
||||
"light", "dark", "claro", "escuro", "day", "night", "dia", "noite", "modo", "mode", "theme",
|
||||
"tema",
|
||||
];
|
||||
|
||||
/// Top-level artboards in `roots` that copy a sibling for another theme: names that differ
|
||||
/// only by a theme word, or the same layers and text in different colours.
|
||||
fn theme_copies(doc: &Document, roots: &[NodeId]) -> Vec<Issue> {
|
||||
let modes: Vec<String> = doc.modes.keys().map(|m| m.to_lowercase()).collect();
|
||||
let base = |name: &str| -> String {
|
||||
name.to_lowercase()
|
||||
.split(|c: char| !c.is_alphanumeric())
|
||||
.filter(|w| !w.is_empty() && !THEME_WORDS.contains(w) && !modes.iter().any(|m| m == w))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
};
|
||||
// Kinds, child counts and text, colours left out.
|
||||
fn shape(doc: &Document, id: NodeId, out: &mut String) {
|
||||
let Some(n) = doc.get(id) else { return };
|
||||
out.push_str(n.kind.type_name());
|
||||
out.push_str(n.kind.text().map_or("", |t| t.as_str()));
|
||||
out.push_str(&n.children.len().to_string());
|
||||
for c in &n.children {
|
||||
shape(doc, *c, out);
|
||||
}
|
||||
}
|
||||
fn colours(doc: &Document, id: NodeId, out: &mut Vec<Option<Color>>) {
|
||||
let Some(n) = doc.get(id) else { return };
|
||||
out.push(n.style.fills.last().map(|p| p.preview_color()));
|
||||
out.push(n.style.stroke);
|
||||
for c in &n.children {
|
||||
colours(doc, *c, out);
|
||||
}
|
||||
}
|
||||
let boards: Vec<&Node> = doc.root.iter().filter_map(|id| doc.get(*id)).filter(|n| n.is_frame()).collect();
|
||||
let mut out = Vec::new();
|
||||
for n in boards.iter().filter(|n| roots.contains(&n.id)) {
|
||||
let other = boards.iter().find(|o| {
|
||||
if o.id == n.id || o.name == n.name {
|
||||
return false;
|
||||
}
|
||||
let (bn, bo) = (base(&n.name), base(&o.name));
|
||||
if !bn.is_empty() && bn == bo {
|
||||
return true;
|
||||
}
|
||||
let (mut sn, mut so) = (String::new(), String::new());
|
||||
shape(doc, n.id, &mut sn);
|
||||
shape(doc, o.id, &mut so);
|
||||
if sn != so || n.children.is_empty() {
|
||||
return false;
|
||||
}
|
||||
let (mut cn, mut co) = (Vec::new(), Vec::new());
|
||||
colours(doc, n.id, &mut cn);
|
||||
colours(doc, o.id, &mut co);
|
||||
cn != co
|
||||
});
|
||||
if let Some(o) = other {
|
||||
out.push(Issue {
|
||||
rule: "theme-copy",
|
||||
severity: "warning",
|
||||
node: n.id,
|
||||
message: format!("`{}` looks like a theme copy of `{}`", n.name, o.name),
|
||||
fix: "use one artboard bound to variables with a value per mode, switch with set_variable_mode, preview with get_screenshot {mode}; show theme-only ornaments with bind_variable {field:\"visible\"} to a boolean".into(),
|
||||
});
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
@@ -283,6 +387,11 @@ fn same_shape(a: &Document, ia: NodeId, b: &Document, ib: NodeId) -> bool {
|
||||
if na.kind.type_name() != nb.kind.type_name() || na.children.len() != nb.children.len() {
|
||||
return false;
|
||||
}
|
||||
// An instance cannot swap its icon, so a different icon is a different component.
|
||||
let icon = |n: &Node| n.name.strip_prefix(ICON_PREFIX).map(str::to_owned);
|
||||
if (icon(na).is_some() || icon(nb).is_some()) && icon(na) != icon(nb) {
|
||||
return false;
|
||||
}
|
||||
if na.is_frame()
|
||||
&& (na.style.fills.first() != nb.style.fills.first()
|
||||
|| (na.style.corner_radius - nb.style.corner_radius).abs() > COPY_RADIUS_TOLERANCE
|
||||
@@ -304,6 +413,18 @@ pub fn component_mains(ed: &crate::editor::Editor) -> Vec<(&Document, NodeId)> {
|
||||
docs.flat_map(|d| d.nodes.values().filter(|n| n.component).map(move |n| (d, n.id))).collect()
|
||||
}
|
||||
|
||||
/// The color variable an unbound, opaque border's colour equals.
|
||||
fn hard_stroke(n: &Node, vars: &BTreeMap<String, Color>) -> Option<String> {
|
||||
let s = n.style.stroke.filter(|s| s.a == 255 && n.style.stroke_width > 0.0)?;
|
||||
if n.bindings.contains_key("stroke") {
|
||||
return None;
|
||||
}
|
||||
vars.iter().find(|(_, v)| **v == s).map(|(name, _)| name.clone())
|
||||
}
|
||||
|
||||
/// Name prefix of a layer placed with `<x-paper-icon>` (`Icon / lucide:check`).
|
||||
const ICON_PREFIX: &str = "Icon / ";
|
||||
|
||||
/// `not-an-instance`: frames under `roots` built by hand to look like one of `mains` (same
|
||||
/// structure, size within 4px) instead of being an instance of it. `lint_design {fix}` leaves
|
||||
/// these alone; swapping is the user's call.
|
||||
@@ -317,13 +438,12 @@ pub fn not_an_instance(doc: &Document, roots: &[NodeId], mains: &[(&Document, No
|
||||
let near = |m: &Node| (m.rect.w - n.rect.w).abs() <= COPY_SIZE_TOLERANCE && (m.rect.h - n.rect.h).abs() <= COPY_SIZE_TOLERANCE;
|
||||
let hit = mains.iter().find(|(d, m)| d.get(*m).is_some_and(near) && same_shape(doc, id, d, *m));
|
||||
let Some(m) = hit.and_then(|(d, m)| d.get(*m)) else { continue };
|
||||
let key = m.component_key.as_deref().unwrap_or(&m.name);
|
||||
out.push(Issue {
|
||||
rule: "not-an-instance",
|
||||
severity: "warning",
|
||||
node: id,
|
||||
message: format!("`{}` looks like component `{}` but is not an instance of it", n.name, m.name),
|
||||
fix: format!("swap_instance with componentKey {key}"),
|
||||
fix: format!("write_html mode replace on it with <x-paper-instance component=\"{}\"/> (plus overrides for its texts)", m.name),
|
||||
});
|
||||
}
|
||||
out
|
||||
@@ -397,6 +517,10 @@ pub fn autofix_node(tx: &mut crate::history::Tx, id: NodeId, scale: &[f64]) -> b
|
||||
}
|
||||
}
|
||||
|
||||
// Hard-coded borders that equal a color variable get bound to it.
|
||||
let stroke_var = hard_stroke(&n, &vars);
|
||||
changed |= stroke_var.is_some();
|
||||
|
||||
// 3. Snap text size to >= 12px and nearest type-scale value.
|
||||
let mut new_kind = n.kind.clone();
|
||||
if let crate::model::NodeKind::Text { font_size, .. } = &mut new_kind {
|
||||
@@ -436,6 +560,9 @@ pub fn autofix_node(tx: &mut crate::history::Tx, id: NodeId, scale: &[f64]) -> b
|
||||
}
|
||||
|
||||
if changed && let Some(m) = tx.node_mut(id) {
|
||||
if let Some(v) = stroke_var {
|
||||
m.bindings.insert("stroke".into(), v);
|
||||
}
|
||||
m.layout = new_layout;
|
||||
m.style.fills = new_fills;
|
||||
m.kind = new_kind;
|
||||
@@ -486,6 +613,31 @@ mod tests {
|
||||
assert_eq!(hard(&doc), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn light_text_on_a_dark_gradient_passes_contrast() {
|
||||
let mut doc = Document::default();
|
||||
let bg = doc.alloc_id();
|
||||
let mut frame = Node::new(bg, crate::model::NodeKind::Frame { clip: false }, crate::geom::Rect::new(0.0, 0.0, 400.0, 200.0));
|
||||
frame.style.fills = vec![Paint::radial(Color::rgb(0x2a, 0x21, 0x40), Color::rgb(0x0d, 0x0d, 0x14))];
|
||||
let id = doc.alloc_id();
|
||||
let mut n = Node::new(
|
||||
id,
|
||||
crate::model::NodeKind::Text { text: "Hello".into(), font_size: 14.0, auto_width: true, align: Default::default() },
|
||||
crate::geom::Rect::new(24.0, 24.0, 40.0, 20.0),
|
||||
);
|
||||
n.style.fills = vec![Paint::solid(Color::rgb(0xcd, 0xd6, 0xf4))];
|
||||
n.parent = Some(bg);
|
||||
frame.children.push(id);
|
||||
doc.nodes.insert(bg, frame);
|
||||
doc.nodes.insert(id, n);
|
||||
doc.root.push(bg);
|
||||
let low = |d: &Document| lint(d, &[bg], &DEFAULT_SCALE).iter().filter(|i| i.rule == "contrast").count();
|
||||
assert_eq!(low(&doc), 0);
|
||||
// Dark text on the same gradient still fails.
|
||||
doc.nodes.get_mut(&id).unwrap().style.fills = vec![Paint::solid(Color::rgb(0x30, 0x30, 0x40))];
|
||||
assert_eq!(low(&doc), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hand_made_copy_of_a_component_is_flagged_and_an_instance_is_not() {
|
||||
use crate::mcp::{Out, exec};
|
||||
@@ -511,11 +663,33 @@ mod tests {
|
||||
let found = hits(&r);
|
||||
assert_eq!(found.len(), 1, "only the hand-made copy: {r}");
|
||||
assert_eq!(found[0]["node"], copy["createdNodeIds"][0]);
|
||||
assert!(found[0]["fix"].as_str().unwrap().starts_with("swap_instance with componentKey "), "{}", found[0]);
|
||||
assert!(found[0]["fix"].as_str().unwrap().contains("<x-paper-instance component=\"Button\"/>"), "{}", found[0]);
|
||||
let r = call("lint_design", json!({"fix": true}));
|
||||
assert_eq!(hits(&r).len(), 1, "fix does not swap: {r}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_different_icon_is_a_different_component() {
|
||||
// Issue #16.
|
||||
let mut doc = Document::default();
|
||||
use crate::geom::Rect;
|
||||
use crate::model::NodeKind;
|
||||
let chip = |doc: &mut Document, icon: &str| {
|
||||
let (root, kid) = (doc.alloc_id(), doc.alloc_id());
|
||||
let mut r = Node::new(root, NodeKind::Frame { clip: false }, Rect::new(0.0, 0.0, 60.0, 20.0));
|
||||
r.children = vec![kid];
|
||||
let mut i = Node::new(kid, NodeKind::Frame { clip: false }, Rect::new(0.0, 0.0, 14.0, 14.0));
|
||||
i.name = format!("Icon / {icon}");
|
||||
i.parent = Some(root);
|
||||
doc.nodes.insert(root, r);
|
||||
doc.nodes.insert(kid, i);
|
||||
root
|
||||
};
|
||||
let (star, heart, star2) = (chip(&mut doc, "phosphor:star"), chip(&mut doc, "phosphor:heart"), chip(&mut doc, "phosphor:star"));
|
||||
assert!(!same_shape(&doc, star, &doc, heart));
|
||||
assert!(same_shape(&doc, star, &doc, star2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn autofix_snaps_grid_and_links_variables() {
|
||||
let mut doc = Document::default();
|
||||
|
||||
@@ -70,6 +70,11 @@ fn tool(name: &str, desc: &str, props: Value, required: &[&str], read_only: bool
|
||||
} else {
|
||||
json!({"destructiveHint": false, "readOnlyHint": false})
|
||||
};
|
||||
if crate::mcp::exec::GUARDED.contains(&name)
|
||||
&& let Some(m) = props.as_object_mut()
|
||||
{
|
||||
m.insert("allowHidden".into(), json!({"type": "boolean", "description": "Edit layers that are hidden behind an overlay or opaque layer in their screen (refused otherwise)."}));
|
||||
}
|
||||
json!({
|
||||
"name": name,
|
||||
"description": desc,
|
||||
@@ -245,6 +250,7 @@ pub fn definitions() -> Vec<Value> {
|
||||
"scale": {"type": "number", "description": "Render scale: 1 (default) or 2."},
|
||||
"out": {"type": "string", "description": "Optional file path to save the PNG to (also used by papercraft-cli)."},
|
||||
"annotations": {"type": "boolean", "description": "Also draw the note cards, badges and leader lines of notes on the captured layers; the image grows to include the cards."},
|
||||
"mode": {"type": "string", "description": "Render in this variable mode (e.g. \"Light\") without switching the document: preview every theme of one artboard while other agents keep working in the current mode."},
|
||||
"pageId": page_id(),
|
||||
}),
|
||||
&[],
|
||||
@@ -264,6 +270,17 @@ pub fn definitions() -> Vec<Value> {
|
||||
&["nodeId"],
|
||||
true,
|
||||
),
|
||||
tool(
|
||||
"get_inventory",
|
||||
"The whole project for planning in one call: each page's screens (top-level frames \
|
||||
with id, size and a size class: mobile, tablet, desktop, email, pdf-a4), its \
|
||||
component, instance and annotation counts and the notes still marked `? (team)`, \
|
||||
plus every component and the token group sizes. Use it before planning an \
|
||||
implementation instead of one get_tree_summary and get_annotations per page.",
|
||||
json!({}),
|
||||
&[],
|
||||
true,
|
||||
),
|
||||
tool(
|
||||
"get_design_spec",
|
||||
"Export a deterministic AI Design-to-Code specification for a frame or subtree. \
|
||||
@@ -275,7 +292,7 @@ pub fn definitions() -> Vec<Value> {
|
||||
json!({
|
||||
"nodeId": node_id("Root frame or node id to export."),
|
||||
"target": {"type": "string", "enum": ["react", "tailwind", "angular", "html", "egui"], "description": "Target UI framework (default \"react\")."},
|
||||
"format": {"type": "string", "enum": ["markdown", "json"], "description": "Output format: \"markdown\" (default) or \"json\"."},
|
||||
"format": {"type": "string", "enum": ["markdown", "json", "compact"], "description": "Output format: \"markdown\" (default), \"json\", or \"compact\" (terse text for an implementer: notes, tokens used, kit components, one line per node; no reference code; about 12x fewer tokens than json)."},
|
||||
"pageId": page_id(),
|
||||
}),
|
||||
&["nodeId"],
|
||||
@@ -865,6 +882,103 @@ pub fn definitions() -> Vec<Value> {
|
||||
&[],
|
||||
false,
|
||||
),
|
||||
tool(
|
||||
"set_component_props",
|
||||
"Give a main component properties that each instance sets: `text` (a layer's text), \
|
||||
`bool` (shows a layer), `icon` (an icon layer's icon, any set:name) and `enum` (one of \
|
||||
options, each a set of changes in the overrides format: layer name, `$root` or a path \
|
||||
`Item > Label`, to a text or a style object that may also hold `text`, `icon` and \
|
||||
`visibility`). Or start from a template: `text` (a property per text layer), `active` \
|
||||
(which item of a tab bar, nav or segmented row is active; activeStyle/inactiveStyle \
|
||||
optional) or `status` (options [{value, color, icon?}] tint the root and colour the \
|
||||
label and icon). Use one component with properties instead of a component per state. \
|
||||
Instances on every page re-apply their values.",
|
||||
json!({
|
||||
"componentId": node_id("The main component."),
|
||||
"props": {"type": "array", "items": {"type": "object", "properties": {
|
||||
"name": {"type": "string"},
|
||||
"kind": {"type": "string", "enum": ["text", "bool", "icon", "enum"]},
|
||||
"layer": {"type": "string", "description": "text, bool, icon: the layer's name."},
|
||||
"options": {"type": "array", "items": {"type": "object", "properties": {"value": {"type": "string"}, "set": {"type": "object"}}, "required": ["value", "set"]}},
|
||||
"default": {"type": "string"},
|
||||
}, "required": ["name", "kind"]}},
|
||||
"template": {"type": "string", "enum": ["text", "active", "status"]},
|
||||
"name": {"type": "string", "description": "Template: the property's name (Active, Status)."},
|
||||
"options": {"type": "array", "description": "status template: [{value, color, icon?}].", "items": {"type": "object"}},
|
||||
"label": {"type": "string", "description": "status template: the label layer (default: the first text)."},
|
||||
"activeStyle": {"type": "object"},
|
||||
"inactiveStyle": {"type": "object"},
|
||||
"remove": {"type": "array", "items": {"type": "string"}, "description": "Property names to drop."},
|
||||
"pageId": page_id(),
|
||||
}),
|
||||
&["componentId"],
|
||||
false,
|
||||
),
|
||||
tool(
|
||||
"set_prototype_links",
|
||||
"Prototype links for Present mode: clicking a layer (a button, a row, a tab) goes to a \
|
||||
screen on the same page. `to: null` removes a link. Link every entry point you design \
|
||||
(back arrows, list rows, tabs) so the flow can be clicked through.",
|
||||
json!({
|
||||
"links": {"type": "array", "items": {"type": "object", "properties": {
|
||||
"nodeId": {"type": "string"},
|
||||
"to": {"type": ["string", "null"], "description": "The screen (a frame on the same page)."},
|
||||
}, "required": ["nodeId"]}},
|
||||
"pageId": page_id(),
|
||||
}),
|
||||
&["links"],
|
||||
false,
|
||||
),
|
||||
tool(
|
||||
"set_screens",
|
||||
"Declare frames as screens (one device viewport of the product, not a section, slot or \
|
||||
component) or as overlays (a drawer, modal or sheet inside a screen), or clear the role \
|
||||
with `none`. One undo step. Inside a screen, edits to layers fully hidden behind an \
|
||||
overlay or opaque layer are refused (pass allowHidden:true to edit them on purpose), \
|
||||
and partly covered ones are reported. get_basic_info lists screens; get_node_info tells \
|
||||
a layer's visibility in its screen.",
|
||||
json!({
|
||||
"nodeIds": ids("Frames to declare."),
|
||||
"role": {"type": "string", "enum": ["screen", "overlay", "none"], "description": "Default screen."},
|
||||
"pageId": page_id(),
|
||||
}),
|
||||
&["nodeIds"],
|
||||
false,
|
||||
),
|
||||
tool(
|
||||
"merge_variants",
|
||||
"Turn per-state components (`Tab bar / Hoje`, `Tab bar / Lojas`, `Status / Sucesso`...) \
|
||||
into one component with an enum property: each variant's differences from the base \
|
||||
(fills, colours, borders, weight, radius, visibility, icons; texts with includeText) \
|
||||
become its option, every instance on every page moves to the base with its value, \
|
||||
the base takes the family name and the emptied variants are deleted. A variant with \
|
||||
layers the base lacks is left out (reported).",
|
||||
json!({
|
||||
"componentIds": {"type": "array", "items": {"type": "string"}, "description": "The variants (same page)."},
|
||||
"name": {"type": "string", "description": "The property's name (Active, Status, State)."},
|
||||
"baseId": {"type": "string", "description": "The variant kept (default: the first)."},
|
||||
"values": {"type": "array", "items": {"type": "string"}, "description": "Option names, in componentIds order (default: each name after its last ' / ')."},
|
||||
"includeText": {"type": "boolean", "description": "Texts that differ are part of the options (default false: instances keep their own texts)."},
|
||||
"textLayers": {"type": "array", "items": {"type": "string"}, "description": "Only these layers' texts are part of the options (a path or its end, e.g. \"Status > Label\"); the other texts stay each instance's."},
|
||||
"deleteMerged": {"type": "boolean", "description": "Delete the emptied variants (default true)."},
|
||||
"pageId": page_id(),
|
||||
}),
|
||||
&["componentIds"],
|
||||
false,
|
||||
),
|
||||
tool(
|
||||
"set_instance_props",
|
||||
"Set property values on instances in one call ({\"Status\": \"Rejeitada\", \"Label\": \"NF 10.371\"}); \
|
||||
get_components lists each component's properties and options. In write_html: \
|
||||
<x-paper-instance component=\"Badge\" props='{\"Status\":\"Rejeitada\"}'/>.",
|
||||
json!({
|
||||
"nodeIds": {"type": "array", "items": {"type": "string"}},
|
||||
"values": {"type": "object", "additionalProperties": {"type": ["string", "boolean"]}},
|
||||
"pageId": page_id(),
|
||||
}),
|
||||
&["nodeIds", "values"],
|
||||
false,
|
||||
),
|
||||
tool(
|
||||
"swap_instance",
|
||||
"Swap an instance to point at a different main component. An instance root keeps \
|
||||
@@ -1095,7 +1209,7 @@ pub fn edits_doc(name: &str, args: &Value) -> bool {
|
||||
/// `None` for a tool with no class, so a tool added without one fails the test below.
|
||||
fn classify(name: &str, args: &Value) -> Option<bool> {
|
||||
Some(match name {
|
||||
"list_resources" | "get_basic_info" | "get_selection" | "get_node_info" | "get_children"
|
||||
"list_resources" | "get_basic_info" | "get_inventory" | "get_selection" | "get_node_info" | "get_children"
|
||||
| "get_tree_summary" | "get_screenshot" | "get_jsx" | "get_design_spec" | "verify_code"
|
||||
| "get_computed_styles" | "get_fill_image" | "zoom_image" | "ask_user" | "fetch_reference_images" | "generate_image" | "report_issue" | "design_rules" | "delegate" | "find_nodes" | "get_font_family_info"
|
||||
| "get_annotations" | "get_annotation_template" | "get_guide" | "get_feedback" | "get_variables" | "get_components" | "search_icons"
|
||||
@@ -1110,6 +1224,7 @@ fn classify(name: &str, args: &Value) -> Option<bool> {
|
||||
| "enable_library" | "update_library" | "disable_library"
|
||||
| "set_variable_mode" | "import_design_system" | "import_components" | "create_component"
|
||||
| "create_instance" | "swap_instance" | "detach_instance" | "delete_nodes" | "apply_ui_kit" | "set_code_link"
|
||||
| "set_component_props" | "set_instance_props" | "merge_variants" | "set_prototype_links" | "set_screens"
|
||||
| "duplicate_nodes" | "move_nodes" | "undo" | "redo" => true,
|
||||
_ => return None,
|
||||
})
|
||||
|
||||
@@ -270,8 +270,11 @@ pub fn place_among(doc: &Document, a: &Annotation, placed: &[Placed]) -> Option<
|
||||
let mut obs = obstacles(doc, Some(target));
|
||||
obs.push(t);
|
||||
// The screen holding the target: a card never sits on its
|
||||
// background, only the line may enter it.
|
||||
let screen = screen_of(doc, target);
|
||||
// background, only the line may enter it. A target placed straight in its artboard has no
|
||||
// inner screen: the artboard is the screen (a phone frame), not a board for notes.
|
||||
let inner = screen_of(doc, target);
|
||||
let screen = inner.or(frame);
|
||||
let frame = frame.filter(|_| inner.is_some());
|
||||
let route_obs = route_obstacles(doc, target);
|
||||
let blocked = |r: &Rect| {
|
||||
let g = r.inflate(MARGIN);
|
||||
|
||||
@@ -103,11 +103,23 @@ impl Document {
|
||||
|
||||
/// World-space position of a node's local coordinate origin's parent space.
|
||||
pub fn parent_origin(&self, parent: Option<NodeId>) -> V2 {
|
||||
self.origin_below(parent, false)
|
||||
}
|
||||
|
||||
/// Where `parent`'s content starts, for a child that is (`pinned`) or is not absolutely
|
||||
/// positioned: a scrolled frame shifts its flow content up, while its absolute children (a
|
||||
/// tab bar, a floating button) stay put, like `position: fixed` inside a scrolling screen.
|
||||
fn origin_below(&self, parent: Option<NodeId>, pinned: bool) -> V2 {
|
||||
let mut o = V2::ZERO;
|
||||
let mut cur = parent;
|
||||
let mut pinned = pinned;
|
||||
while let Some(id) = cur {
|
||||
let Some(n) = self.nodes.get(&id) else { break };
|
||||
o = o + n.rect.min();
|
||||
if !pinned {
|
||||
o = o - V2 { x: 0.0, y: n.scroll_y };
|
||||
}
|
||||
pinned = n.layout_child.absolute;
|
||||
cur = n.parent;
|
||||
}
|
||||
o
|
||||
@@ -115,7 +127,7 @@ impl Document {
|
||||
|
||||
pub fn world_rect(&self, id: NodeId) -> Option<Rect> {
|
||||
let n = self.nodes.get(&id)?;
|
||||
Some(n.rect.translate(self.parent_origin(n.parent)))
|
||||
Some(n.rect.translate(self.origin_below(n.parent, n.layout_child.absolute)))
|
||||
}
|
||||
|
||||
pub fn ancestors(&self, id: NodeId) -> Vec<NodeId> {
|
||||
|
||||
@@ -7,7 +7,7 @@ pub mod rough;
|
||||
|
||||
pub use document::{Document, VarState};
|
||||
pub use node::{
|
||||
Binding, CodeLink, Color, Comment, FillRule, Head, Node, NodeId, NodeKind, PathPoint, Route, Shadow,
|
||||
Binding, CodeLink, PropDef, PropOption, Color, Comment, FillRule, Head, Node, NodeId, NodeKind, PathPoint, Route, Shadow,
|
||||
StrokePoint, Style, SubPath, TextAlign,
|
||||
};
|
||||
pub use paint::*;
|
||||
|
||||
@@ -433,6 +433,49 @@ pub struct Node {
|
||||
/// (`set_code_link`, `apply_ui_kit`).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub code: Option<CodeLink>,
|
||||
/// A frame of fixed size whose overflowing content scrolls (CSS `overflow: auto`): the
|
||||
/// wheel over it on the canvas and in Present scrolls it instead of the canvas.
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
pub scroll: bool,
|
||||
/// A declared screen: one device viewport of the product, not a container (`editor::screens`).
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
pub screen: bool,
|
||||
/// A drawer, modal or sheet inside a screen: what it covers counts as hidden, even where
|
||||
/// it is translucent (a scrim).
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
pub overlay: bool,
|
||||
/// How far a [`Node::scroll`] frame is scrolled, in world units. A view state: not saved.
|
||||
#[serde(skip)]
|
||||
pub scroll_y: f64,
|
||||
/// A main component's properties (status, active tab, label, icon...), set per instance.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub props: Vec<PropDef>,
|
||||
/// An instance root's property values, by property name.
|
||||
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
||||
pub prop_values: BTreeMap<String, String>,
|
||||
}
|
||||
|
||||
/// One property of a component. `kind`: `text` (the text of `layer`), `bool` (shows `layer`),
|
||||
/// `icon` (the icon of `layer`) or `enum` (one of `options`).
|
||||
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
|
||||
pub struct PropDef {
|
||||
pub name: String,
|
||||
pub kind: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub layer: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub options: Vec<PropOption>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub default: Option<String>,
|
||||
}
|
||||
|
||||
/// An enum option: what choosing it changes, in the `overrides` format (layer name, or `$root`,
|
||||
/// to a text or a style object; a style object may hold `icon` and `visibility`).
|
||||
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
|
||||
pub struct PropOption {
|
||||
pub value: String,
|
||||
#[serde(default)]
|
||||
pub set: serde_json::Value,
|
||||
}
|
||||
|
||||
/// What a component is in code: its library, the import, and a usage template per target.
|
||||
@@ -555,6 +598,12 @@ impl Node {
|
||||
bindings: BTreeMap::new(),
|
||||
component_key: None,
|
||||
code: None,
|
||||
scroll: false,
|
||||
screen: false,
|
||||
overlay: false,
|
||||
scroll_y: 0.0,
|
||||
props: Vec::new(),
|
||||
prop_values: BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -21,10 +21,13 @@ pub const LEGACY_FORMAT: &str = "paper-rs";
|
||||
/// v1: pages only. v2: `design.json` is the source of truth for variables and pages carry
|
||||
/// bindings, component keys and sources. An older app would write pages without those and never
|
||||
/// update `design.json`, so it must refuse a v2 project (`TooNew`) instead of losing data.
|
||||
pub const PROJECT_VERSION: u32 = 2;
|
||||
/// v3: pages and versions are zstd-compressed (`pages/<id>.json.zst`); an older app would see
|
||||
/// no page files and save empty pages over them.
|
||||
pub const PROJECT_VERSION: u32 = 3;
|
||||
/// Project migrations, indexed by source version: `PROJECT_STEPS[0]` turns v1 into v2. The
|
||||
/// manifest itself does not change; the design system is built on load from the pages.
|
||||
const PROJECT_STEPS: &[fn(Value) -> Value] = &[|v| v];
|
||||
/// manifest itself does not change; the design system is built on load from the pages, and
|
||||
/// pages are packed as they are saved.
|
||||
const PROJECT_STEPS: &[fn(Value) -> Value] = &[|v| v, |v| v];
|
||||
/// v1: legacy single file. v2: page file inside a project. v3: fill layers (`fills`).
|
||||
pub const PAGE_VERSION: u32 = 3;
|
||||
|
||||
@@ -216,7 +219,7 @@ pub fn page_to_json_modes(p: &PageData, modes: &PageModes) -> String {
|
||||
annotations: p.doc.annotations.clone(),
|
||||
annotation_template: modes.get(PAGE_TEMPLATE_KEY).cloned(),
|
||||
};
|
||||
serde_json::to_string_pretty(&f).expect("page serializes")
|
||||
serde_json::to_string(&f).expect("page serializes")
|
||||
}
|
||||
|
||||
/// Parse a page file (or a legacy `.paper-rs` document) of any supported version.
|
||||
@@ -435,7 +438,7 @@ mod tests {
|
||||
assert_eq!(r.style.fill(), Some(crate::model::Color::rgb(255, 0, 0)));
|
||||
assert_eq!(r.style.corner_radius, 2.0);
|
||||
assert!(p.doc.get(NodeId(2)).unwrap().style.fills.is_empty());
|
||||
assert!(page_to_json(&p).contains("\"version\": 3"));
|
||||
assert!(page_to_json(&p).contains("\"version\":3"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -443,10 +446,10 @@ mod tests {
|
||||
let v1 = r#"{"format":"paper-rs-project","version":1,"id":"a","name":"N","created":1,
|
||||
"modified":2,"pages":[{"id":"p","name":"Page 1"}]}"#;
|
||||
assert_eq!(Manifest::from_json(v1).unwrap().version, PROJECT_VERSION);
|
||||
assert_eq!(PROJECT_VERSION, 2);
|
||||
// What the old 0.4.0 reader (supports 1) does with a v2 file: TooNew.
|
||||
let err = Manifest::from_json(&v1.replace("\"version\":1", "\"version\":3")).unwrap_err();
|
||||
assert!(matches!(err, FormatError::TooNew { version: 3, supported: 2 }));
|
||||
assert_eq!(PROJECT_VERSION, 3);
|
||||
// What an older reader does with a newer file: TooNew.
|
||||
let err = Manifest::from_json(&v1.replace("\"version\":1", "\"version\":4")).unwrap_err();
|
||||
assert!(matches!(err, FormatError::TooNew { version: 4, supported: 3 }));
|
||||
assert!(err.to_string().contains("made by a newer PaperCraft"));
|
||||
}
|
||||
|
||||
|
||||
@@ -39,7 +39,7 @@ impl PageLoader {
|
||||
let Some(r) = queue.lock().ok().and_then(|mut q| q.pop_front()) else {
|
||||
return;
|
||||
};
|
||||
let path = dir.join(super::PAGES_DIR).join(format!("{}.json", r.id));
|
||||
let path = super::page_file(&dir, &r.id);
|
||||
// The editor is gone (closed or another project opened): stop reading.
|
||||
if tx.send((r.id.clone(), super::read_page_file(&path, &r))).is_err() {
|
||||
return;
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
//! <root>/<Project name>/
|
||||
//! project.json manifest: id, name, timestamps, ordered page list
|
||||
//! design.json the project's design system (collections of variables)
|
||||
//! pages/<page-id>.json one infinite canvas per page
|
||||
//! pages/<page-id>.json.zst one infinite canvas per page (zstd JSON, see [`packed`])
|
||||
//! assets/<hash>.<ext> images used by image fills
|
||||
//! thumbnail.png preview for the home screen (written by the app)
|
||||
//! <root>/.trash/<Project name>~<timestamp>/ deleted projects, restorable
|
||||
@@ -16,6 +16,7 @@
|
||||
|
||||
pub mod format;
|
||||
pub mod loader;
|
||||
pub mod packed;
|
||||
|
||||
use crate::io::atomic;
|
||||
pub use format::{FormatError, Manifest, PageData, PageRef, new_id, now_ms};
|
||||
@@ -379,8 +380,19 @@ fn placeholder_page(r: &PageRef) -> PageData {
|
||||
|
||||
/// Read the page file `path` of manifest entry `r`; the manifest owns the name, and a missing
|
||||
/// file is an empty page.
|
||||
/// The file of page `id` in project folder `dir`.
|
||||
pub fn page_file(dir: &Path, id: &str) -> PathBuf {
|
||||
packed::path(&dir.join(PAGES_DIR), id)
|
||||
}
|
||||
|
||||
/// Page `id` is still a plain `.json` from an older build; saving it packs it.
|
||||
pub fn is_legacy_page(dir: &Path, id: &str) -> bool {
|
||||
let f = page_file(dir, id);
|
||||
!crate::vfs::exists(&f) && crate::vfs::exists(&packed::legacy(&f))
|
||||
}
|
||||
|
||||
pub fn read_page_file(path: &Path, r: &PageRef) -> Result<(PageData, PageModes), FormatError> {
|
||||
let (mut p, m) = match crate::vfs::read_to_string(path)
|
||||
let (mut p, m) = match packed::read(path)
|
||||
.map_err(FormatError::from)
|
||||
.and_then(|s| format::page_from_json_modes(&s))
|
||||
{
|
||||
@@ -456,11 +468,11 @@ impl Project {
|
||||
}
|
||||
|
||||
pub fn page_path(&self, id: &str) -> PathBuf {
|
||||
self.dir.join(PAGES_DIR).join(format!("{id}.json"))
|
||||
page_file(&self.dir, id)
|
||||
}
|
||||
|
||||
pub fn load_page(&self, id: &str) -> Result<PageData, FormatError> {
|
||||
let mut p = format::page_from_json(&crate::vfs::read_to_string(self.page_path(id))?)?;
|
||||
let mut p = format::page_from_json(&packed::read(&self.page_path(id))?)?;
|
||||
if let Some(r) = self.manifest.pages.iter().find(|r| r.id == id) {
|
||||
// The manifest owns page names (renames touch only the manifest).
|
||||
p.name = r.name.clone();
|
||||
@@ -583,15 +595,12 @@ impl Project {
|
||||
|
||||
pub fn save_page_modes(&self, page: &PageData, modes: &PageModes) -> Result<(), FormatError> {
|
||||
crate::vfs::create_dir_all(self.dir.join(PAGES_DIR))?;
|
||||
atomic::write(
|
||||
&self.page_path(&page.id),
|
||||
format::page_to_json_modes(page, modes).as_bytes(),
|
||||
)?;
|
||||
packed::write(&self.page_path(&page.id), &format::page_to_json_modes(page, modes))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn delete_page_file(&self, id: &str) {
|
||||
let _ = crate::vfs::remove_file(self.page_path(id));
|
||||
packed::remove(&self.page_path(id));
|
||||
}
|
||||
|
||||
pub fn write_manifest(&self) -> Result<(), FormatError> {
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
//! Pages and saved versions are stored as zstd-compressed JSON (`<name>.json.zst`): a 34 MB
|
||||
//! page becomes about 0.6 MB and reads faster. A plain `<name>.json` from older builds still
|
||||
//! reads, and is removed once the packed file is written.
|
||||
|
||||
use std::io;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// Extension of a packed file, after its stem.
|
||||
pub const EXT: &str = "json.zst";
|
||||
|
||||
/// zstd level on native builds: 3 packs a large page in ~7 ms; higher levels gain little.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
const LEVEL: i32 = 3;
|
||||
|
||||
/// `<dir>/<stem>.json.zst`.
|
||||
pub fn path(dir: &Path, stem: &str) -> PathBuf {
|
||||
dir.join(format!("{stem}.{EXT}"))
|
||||
}
|
||||
|
||||
/// The plain `.json` an older build wrote in place of `packed`.
|
||||
pub fn legacy(packed: &Path) -> PathBuf {
|
||||
packed.with_extension("")
|
||||
}
|
||||
|
||||
/// The stem of a packed or legacy file name (`a.json.zst` or `a.json` give `a`).
|
||||
pub fn stem(file_name: &str) -> Option<&str> {
|
||||
file_name.strip_suffix(".json.zst").or_else(|| file_name.strip_suffix(".json"))
|
||||
}
|
||||
|
||||
pub fn pack(json: &[u8]) -> Vec<u8> {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
zstd::encode_all(json, LEVEL).expect("zstd compresses from memory")
|
||||
}
|
||||
// ponytail: ruzstd's fastest level only (~5x larger than level 3), the browser has no C zstd.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
ruzstd::encoding::compress_to_vec(json, ruzstd::encoding::CompressionLevel::Fastest)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn unpack(bytes: &[u8]) -> io::Result<Vec<u8>> {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
zstd::decode_all(bytes)
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
let mut out = Vec::new();
|
||||
let mut r = ruzstd::decoding::StreamingDecoder::new(bytes).map_err(io::Error::other)?;
|
||||
io::Read::read_to_end(&mut r, &mut out)?;
|
||||
Ok(out)
|
||||
}
|
||||
}
|
||||
|
||||
/// The JSON text of `packed`, or of its legacy `.json` when only that exists.
|
||||
pub fn read(packed: &Path) -> io::Result<String> {
|
||||
let bytes = match crate::vfs::read(packed) {
|
||||
Ok(b) => unpack(&b)?,
|
||||
Err(e) if e.kind() == io::ErrorKind::NotFound => crate::vfs::read(legacy(packed))?,
|
||||
Err(e) => return Err(e),
|
||||
};
|
||||
String::from_utf8(bytes).map_err(io::Error::other)
|
||||
}
|
||||
|
||||
/// Writes `json` packed (atomically), then removes the legacy `.json`, which would otherwise
|
||||
/// be stale beside it.
|
||||
pub fn write(packed: &Path, json: &str) -> io::Result<()> {
|
||||
crate::io::atomic::write(packed, &pack(json.as_bytes()))?;
|
||||
remove_legacy(packed);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn remove_legacy(packed: &Path) {
|
||||
let _ = crate::vfs::remove_file(legacy(packed));
|
||||
}
|
||||
|
||||
/// Removes `packed` and its legacy `.json`.
|
||||
pub fn remove(packed: &Path) {
|
||||
let _ = crate::vfs::remove_file(packed);
|
||||
remove_legacy(packed);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn reads_packed_then_legacy_and_drops_legacy_on_write() {
|
||||
let dir = std::env::temp_dir().join(format!("pc-packed-{}", crate::project::new_id()));
|
||||
crate::vfs::create_dir_all(&dir).unwrap();
|
||||
let p = path(&dir, "a");
|
||||
crate::vfs::write(legacy(&p), "{\"old\":1}").unwrap();
|
||||
assert_eq!(read(&p).unwrap(), "{\"old\":1}");
|
||||
write(&p, "{\"new\":2}").unwrap();
|
||||
assert!(!legacy(&p).exists());
|
||||
assert_eq!(read(&p).unwrap(), "{\"new\":2}");
|
||||
assert_eq!(stem("a.json.zst"), Some("a"));
|
||||
assert_eq!(stem("a.json"), Some("a"));
|
||||
remove(&p);
|
||||
assert!(!p.exists());
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
}
|
||||
@@ -223,10 +223,15 @@ fn sorted(stops: &[Stop]) -> Vec<Stop> {
|
||||
s
|
||||
}
|
||||
|
||||
/// A soft shadow as stacked translucent shapes: each layer is a little larger and the layers add up to
|
||||
/// the shadow color's alpha under the box, fading to nothing across the blur radius.
|
||||
/// A soft shadow as nested translucent shapes, like CSS `box-shadow`: the blur is a Gaussian with
|
||||
/// sigma = blur / 2, so it fades from the color's alpha to nothing across ±blur around the edge.
|
||||
/// Each layer's alpha is chosen so the composited stack follows that curve. The shadow is cut out
|
||||
/// under the box itself, as in CSS and Figma, so translucent fills don't darken.
|
||||
fn shadow_ops(n: &Node, wr: Rect, opacity: f64, ops: &mut Vec<DrawOp>) {
|
||||
const LAYERS: usize = 10;
|
||||
// ponytail: rings every ~3px, capped; wide blurs (>100px) step a little coarser.
|
||||
const MIN_LAYERS: usize = 6;
|
||||
const MAX_LAYERS: usize = 32;
|
||||
const RING_STEP: f64 = 3.0;
|
||||
let Some(s) = n.style.shadow else { return };
|
||||
if s.color.a == 0 || !is_area(n) || wr.w <= 0.0 || wr.h <= 0.0 {
|
||||
return;
|
||||
@@ -238,21 +243,64 @@ fn shadow_ops(n: &Node, wr: Rect, opacity: f64, ops: &mut Vec<DrawOp>) {
|
||||
n.style.corner_radii()
|
||||
};
|
||||
let solid = Color::rgb(s.color.r, s.color.g, s.color.b);
|
||||
let each = opacity * (s.color.a as f64 / 255.0) / LAYERS as f64;
|
||||
for i in 0..LAYERS {
|
||||
let e = s.spread + s.blur * ((i as f64 + 0.5) / LAYERS as f64 - 0.5);
|
||||
let alpha = opacity * (s.color.a as f64 / 255.0);
|
||||
let blur = s.blur.max(0.5);
|
||||
let layers = ((2.0 * blur / RING_STEP).ceil() as usize).clamp(MIN_LAYERS, MAX_LAYERS);
|
||||
let step = 2.0 * blur / layers as f64;
|
||||
// Coverage at distance d outside the edge: alpha * (1 - Φ(d / σ)), Φ by its logistic fit.
|
||||
let coverage = |d: f64| alpha / (1.0 + (1.702 * d / (blur * 0.5)).exp());
|
||||
let hole = (!(s.x == 0.0 && s.y == 0.0 && s.spread >= 0.0))
|
||||
.then(|| contours(&vector::rounded_rect(wr, radii)));
|
||||
// Outermost layer first; `outer` is the coverage the layers outside this one already give.
|
||||
let mut outer = 0.0;
|
||||
for i in (0..layers).rev() {
|
||||
let e = s.spread - blur + step * (i as f64 + 1.0);
|
||||
let target = coverage(e - step * 0.5);
|
||||
let a = 1.0 - (1.0 - target) / (1.0 - outer);
|
||||
outer = target;
|
||||
let r = base.inflate(e);
|
||||
if r.w <= 0.0 || r.h <= 0.0 {
|
||||
if r.w <= 0.0 || r.h <= 0.0 || a <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
let ring = vector::rounded_rect(r, radii.map(|x| (x + e).max(0.0)));
|
||||
let (path, even_odd) = match &hole {
|
||||
// Centered and not shrunk: a layer either lies inside the box (hidden) or contains it,
|
||||
// so even-odd cuts the box out.
|
||||
None if e < 0.0 => continue,
|
||||
None => {
|
||||
let mut p = ring;
|
||||
p.segs.extend(vector::rounded_rect(wr, radii).segs);
|
||||
(p, true)
|
||||
}
|
||||
Some(h) => {
|
||||
use i_overlay::core::fill_rule::FillRule as OvFill;
|
||||
use i_overlay::core::overlay_rule::OverlayRule;
|
||||
use i_overlay::float::single::SingleFloatOverlay;
|
||||
let cut = contours(&ring).overlay(h, OverlayRule::Difference, OvFill::NonZero);
|
||||
let mut p = VPath::new();
|
||||
for c in cut.into_iter().flatten() {
|
||||
let pts: Vec<V2> = c.iter().map(|q| v2(q[0], q[1])).collect();
|
||||
p.segs.extend(VPath::polyline(&pts, true).segs);
|
||||
}
|
||||
(p, false)
|
||||
}
|
||||
};
|
||||
ops.push(DrawOp::Fill {
|
||||
path: vector::rounded_rect(r, radii.map(|x| (x + e).max(0.0))),
|
||||
paint: PaintSpec::Solid(rgba(solid, each)),
|
||||
even_odd: false,
|
||||
path,
|
||||
paint: PaintSpec::Solid(rgba(solid, a)),
|
||||
even_odd,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn contours(p: &VPath) -> Vec<Vec<[f64; 2]>> {
|
||||
p.flatten(0.25)
|
||||
.into_iter()
|
||||
.filter(|c| c.pts.len() >= 3)
|
||||
.map(|c| c.pts.iter().map(|q| [q.x, q.y]).collect())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Draw ops for one node (not its children), in world space.
|
||||
pub fn node_draw(n: &Node, wr: Rect, opacity: f64) -> NodeDraw {
|
||||
let mut ops = Vec::new();
|
||||
@@ -354,88 +402,9 @@ pub fn node_draw(n: &Node, wr: Rect, opacity: f64) -> NodeDraw {
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(c) = n.style.stroke {
|
||||
if let Some(sides) = n.style.stroke_sides
|
||||
&& matches!(n.kind, NodeKind::Frame { .. } | NodeKind::Rect)
|
||||
&& sides.iter().any(|&w| (w - sides[0]).abs() > 1e-9)
|
||||
{
|
||||
let col = rgba(c, opacity);
|
||||
let side_specs = [
|
||||
(
|
||||
sides[0],
|
||||
Rect::new(wr.x, wr.y, wr.w, sides[0].min(wr.h)),
|
||||
[
|
||||
v2(wr.x, wr.y + sides[0] * 0.5),
|
||||
v2(wr.right(), wr.y + sides[0] * 0.5),
|
||||
],
|
||||
),
|
||||
(
|
||||
sides[1],
|
||||
Rect::new(
|
||||
(wr.right() - sides[1]).max(wr.x),
|
||||
wr.y,
|
||||
sides[1].min(wr.w),
|
||||
wr.h,
|
||||
),
|
||||
[
|
||||
v2(wr.right() - sides[1] * 0.5, wr.y),
|
||||
v2(wr.right() - sides[1] * 0.5, wr.bottom()),
|
||||
],
|
||||
),
|
||||
(
|
||||
sides[2],
|
||||
Rect::new(
|
||||
wr.x,
|
||||
(wr.bottom() - sides[2]).max(wr.y),
|
||||
wr.w,
|
||||
sides[2].min(wr.h),
|
||||
),
|
||||
[
|
||||
v2(wr.x, wr.bottom() - sides[2] * 0.5),
|
||||
v2(wr.right(), wr.bottom() - sides[2] * 0.5),
|
||||
],
|
||||
),
|
||||
(
|
||||
sides[3],
|
||||
Rect::new(wr.x, wr.y, sides[3].min(wr.w), wr.h),
|
||||
[
|
||||
v2(wr.x + sides[3] * 0.5, wr.y),
|
||||
v2(wr.x + sides[3] * 0.5, wr.bottom()),
|
||||
],
|
||||
),
|
||||
];
|
||||
for (w, sr, line_pts) in side_specs {
|
||||
if w > 0.0 && sr.w > 0.0 && sr.h > 0.0 {
|
||||
if !n.style.dash.is_empty() {
|
||||
ops.push(DrawOp::Stroke {
|
||||
path: VPath::polyline(&line_pts, false),
|
||||
color: col,
|
||||
width: w,
|
||||
cap: Cap::Butt,
|
||||
join: n.style.join,
|
||||
dash: n.style.dash.clone(),
|
||||
});
|
||||
} else {
|
||||
ops.push(DrawOp::Fill {
|
||||
path: vector::rounded_rect(sr, [0.0; 4]),
|
||||
paint: PaintSpec::Solid(col),
|
||||
even_odd: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if n.style.stroke_width > 0.0
|
||||
&& let Some(sp) = stroke_path(n, wr)
|
||||
{
|
||||
ops.push(DrawOp::Stroke {
|
||||
path: sp,
|
||||
color: rgba(c, opacity),
|
||||
width: n.style.stroke_width,
|
||||
cap: n.style.cap,
|
||||
join: n.style.join,
|
||||
dash: n.style.dash.clone(),
|
||||
});
|
||||
}
|
||||
// A clipping frame's border goes over its children (`border_draw`), as in CSS.
|
||||
if !border_on_top(n) {
|
||||
stroke_ops(n, wr, opacity, &mut ops);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -445,6 +414,105 @@ pub fn node_draw(n: &Node, wr: Rect, opacity: f64) -> NodeDraw {
|
||||
}
|
||||
}
|
||||
|
||||
/// A clipping frame with children draws its border after them, so filled children cannot hide it.
|
||||
pub fn border_on_top(n: &Node) -> bool {
|
||||
matches!(n.kind, NodeKind::Frame { clip: true }) && !n.children.is_empty() && n.style.stroke.is_some() && n.style.rough.is_none()
|
||||
}
|
||||
|
||||
/// The border of a [`border_on_top`] frame, drawn after its children.
|
||||
pub fn border_draw(n: &Node, wr: Rect, opacity: f64) -> NodeDraw {
|
||||
let mut ops = Vec::new();
|
||||
stroke_ops(n, wr, opacity, &mut ops);
|
||||
NodeDraw { ops, blend: n.style.blend }
|
||||
}
|
||||
|
||||
/// A shape's stroke: per-side borders for frames and rectangles, else along its outline.
|
||||
fn stroke_ops(n: &Node, wr: Rect, opacity: f64, ops: &mut Vec<DrawOp>) {
|
||||
if let Some(c) = n.style.stroke {
|
||||
if let Some(sides) = n.style.stroke_sides
|
||||
&& matches!(n.kind, NodeKind::Frame { .. } | NodeKind::Rect)
|
||||
&& sides.iter().any(|&w| (w - sides[0]).abs() > 1e-9)
|
||||
{
|
||||
let col = rgba(c, opacity);
|
||||
let side_specs = [
|
||||
(
|
||||
sides[0],
|
||||
Rect::new(wr.x, wr.y, wr.w, sides[0].min(wr.h)),
|
||||
[
|
||||
v2(wr.x, wr.y + sides[0] * 0.5),
|
||||
v2(wr.right(), wr.y + sides[0] * 0.5),
|
||||
],
|
||||
),
|
||||
(
|
||||
sides[1],
|
||||
Rect::new(
|
||||
(wr.right() - sides[1]).max(wr.x),
|
||||
wr.y,
|
||||
sides[1].min(wr.w),
|
||||
wr.h,
|
||||
),
|
||||
[
|
||||
v2(wr.right() - sides[1] * 0.5, wr.y),
|
||||
v2(wr.right() - sides[1] * 0.5, wr.bottom()),
|
||||
],
|
||||
),
|
||||
(
|
||||
sides[2],
|
||||
Rect::new(
|
||||
wr.x,
|
||||
(wr.bottom() - sides[2]).max(wr.y),
|
||||
wr.w,
|
||||
sides[2].min(wr.h),
|
||||
),
|
||||
[
|
||||
v2(wr.x, wr.bottom() - sides[2] * 0.5),
|
||||
v2(wr.right(), wr.bottom() - sides[2] * 0.5),
|
||||
],
|
||||
),
|
||||
(
|
||||
sides[3],
|
||||
Rect::new(wr.x, wr.y, sides[3].min(wr.w), wr.h),
|
||||
[
|
||||
v2(wr.x + sides[3] * 0.5, wr.y),
|
||||
v2(wr.x + sides[3] * 0.5, wr.bottom()),
|
||||
],
|
||||
),
|
||||
];
|
||||
for (w, sr, line_pts) in side_specs {
|
||||
if w > 0.0 && sr.w > 0.0 && sr.h > 0.0 {
|
||||
if !n.style.dash.is_empty() {
|
||||
ops.push(DrawOp::Stroke {
|
||||
path: VPath::polyline(&line_pts, false),
|
||||
color: col,
|
||||
width: w,
|
||||
cap: Cap::Butt,
|
||||
join: n.style.join,
|
||||
dash: n.style.dash.clone(),
|
||||
});
|
||||
} else {
|
||||
ops.push(DrawOp::Fill {
|
||||
path: vector::rounded_rect(sr, [0.0; 4]),
|
||||
paint: PaintSpec::Solid(col),
|
||||
even_odd: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if n.style.stroke_width > 0.0
|
||||
&& let Some(sp) = stroke_path(n, wr)
|
||||
{
|
||||
ops.push(DrawOp::Stroke {
|
||||
path: sp,
|
||||
color: rgba(c, opacity),
|
||||
width: n.style.stroke_width,
|
||||
cap: n.style.cap,
|
||||
join: n.style.join,
|
||||
dash: n.style.dash.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A connector's line and heads. With `rough`, only the heads are produced (the line comes
|
||||
/// from the rough renderer).
|
||||
pub fn connector_ops(n: &Node, wr: Rect, opacity: f64, ops: &mut Vec<DrawOp>, heads_only: bool) {
|
||||
@@ -618,5 +686,8 @@ pub fn draw_bounds(n: &Node, wr: Rect) -> Rect {
|
||||
if matches!(n.kind, NodeKind::Sticky { .. }) {
|
||||
pad = pad.max(4.0);
|
||||
}
|
||||
if let Some(s) = n.style.shadow {
|
||||
pad = pad.max(s.spread + s.blur + s.x.abs().max(s.y.abs()));
|
||||
}
|
||||
wr.inflate(pad)
|
||||
}
|
||||
|
||||
@@ -347,7 +347,7 @@ impl CanvasRenderer {
|
||||
array_stride: std::mem::size_of::<Vertex>() as u64,
|
||||
step_mode: wgpu::VertexStepMode::Vertex,
|
||||
attributes: &wgpu::vertex_attr_array![
|
||||
0 => Float32x2, 1 => Float32x4, 2 => Float32x2, 3 => Uint32
|
||||
0 => Float32x2, 1 => Unorm8x4, 2 => Float32x2, 3 => Uint32
|
||||
],
|
||||
})],
|
||||
},
|
||||
|
||||
@@ -13,13 +13,19 @@ use lyon::tessellation::{
|
||||
#[derive(Debug, Clone, Copy, Pod, Zeroable, PartialEq)]
|
||||
pub struct Vertex {
|
||||
pub pos: [f32; 2],
|
||||
/// Straight-alpha color; for gradient and image paints only alpha (opacity) is used.
|
||||
pub color: [f32; 4],
|
||||
/// Straight-alpha color, 8 bits per channel (24-byte vertices: meshes are the bulk of the
|
||||
/// canvas's memory, on the CPU and the GPU); for gradient and image paints only alpha
|
||||
/// (opacity) is used.
|
||||
pub color: [u8; 4],
|
||||
/// Paint coordinates: gradient space or image texture coordinates.
|
||||
pub uv: [f32; 2],
|
||||
/// Paint kind in the low byte (see `PAINT_*`), gradient atlas row above it.
|
||||
pub kind: u32,
|
||||
pub _pad: u32,
|
||||
}
|
||||
|
||||
/// `0..=1` color channels as the vertex's 8-bit ones.
|
||||
pub fn unorm(c: [f32; 4]) -> [u8; 4] {
|
||||
c.map(|v| (v.clamp(0.0, 1.0) * 255.0).round() as u8)
|
||||
}
|
||||
|
||||
pub const PAINT_SOLID: u32 = 0;
|
||||
@@ -32,10 +38,9 @@ impl Vertex {
|
||||
pub fn solid(pos: [f32; 2], color: [f32; 4]) -> Vertex {
|
||||
Vertex {
|
||||
pos,
|
||||
color,
|
||||
color: unorm(color),
|
||||
uv: [0.0; 2],
|
||||
kind: PAINT_SOLID,
|
||||
_pad: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ use super::mesh::{Batch, MeshBuilder};
|
||||
use super::tess::{Piece, Tess};
|
||||
use crate::editor::{Editor, hit, snap::Guide};
|
||||
use crate::geom::{Camera, Rect, V2, v2};
|
||||
use crate::model::{BlendMode, Node};
|
||||
use crate::model::BlendMode;
|
||||
use crate::model::{Color, Document, NodeId, NodeKind, TextAlign};
|
||||
use crate::text;
|
||||
use lyon::tessellation::{FillRule, LineCap};
|
||||
@@ -94,7 +94,6 @@ pub struct Item {
|
||||
}
|
||||
|
||||
struct Entry {
|
||||
node: Node,
|
||||
wr: Rect,
|
||||
opacity: f64,
|
||||
bucket: i32,
|
||||
@@ -104,16 +103,35 @@ struct Entry {
|
||||
|
||||
/// Per-node tessellation cache. A node is re-tessellated only when it (or its world position,
|
||||
/// inherited opacity or the zoom bucket) changes, so panning and idle frames do no work.
|
||||
/// Changed layers come from the editor's change log (`forget_changes`), not from comparing
|
||||
/// nodes, so the cache holds no copy of the document.
|
||||
#[derive(Default)]
|
||||
pub struct SceneCache {
|
||||
entries: HashMap<NodeId, Entry>,
|
||||
/// Borders drawn after a clipping frame's children (`draw::border_on_top`).
|
||||
borders: HashMap<NodeId, Entry>,
|
||||
/// Item list of the previous frame and what it was built for (doc stamp, zoom bucket,
|
||||
/// culled world rect): reused while the view stays inside that rect.
|
||||
last: Option<(u64, i32, Rect, Vec<Item>)>,
|
||||
/// Tessellations performed during the last `items` call (for tests and profiling).
|
||||
pub last_misses: usize,
|
||||
/// Zoom bucket the canvas is drawn at, the zoom of the last frame and when it last changed:
|
||||
/// while the zoom moves the bucket holds and the GPU scales the meshes.
|
||||
view: Option<(i32, f64, web_time::Instant)>,
|
||||
/// `Editor::doc_stamp` the entries are up to date with.
|
||||
stamp: u64,
|
||||
}
|
||||
|
||||
/// How long the zoom must hold still before the canvas is re-tessellated for it (the pinch
|
||||
/// zoom settle of browsers and design tools).
|
||||
const ZOOM_SETTLE: std::time::Duration = std::time::Duration::from_millis(150);
|
||||
/// Buckets the held geometry may lag the zoom by: past it, curves would look faceted (or
|
||||
/// tessellate far finer than shown) even mid-gesture.
|
||||
const ZOOM_LAG: i32 = 2;
|
||||
|
||||
/// Screen size (px at the bucket's zoom) under which a layer is not drawn.
|
||||
const MIN_SCREEN_PX: f64 = 0.5;
|
||||
|
||||
/// Zoom buckets are powers of two: geometry built for 2^b is accurate up to 2^(b+1).
|
||||
pub fn zoom_bucket(zoom: f64) -> i32 {
|
||||
zoom.max(1e-6).log2().floor() as i32
|
||||
@@ -122,9 +140,49 @@ pub fn zoom_bucket(zoom: f64) -> i32 {
|
||||
impl SceneCache {
|
||||
pub fn clear(&mut self) {
|
||||
self.entries.clear();
|
||||
self.borders.clear();
|
||||
self.last = None;
|
||||
}
|
||||
|
||||
/// Drops the entries of layers `ed` changed since the cache last caught up (all of them
|
||||
/// when that is unknown).
|
||||
fn forget_changes(&mut self, ed: &Editor) {
|
||||
if self.stamp == ed.doc_stamp {
|
||||
return;
|
||||
}
|
||||
match ed.changes_since(self.stamp) {
|
||||
Some(ids) => {
|
||||
for id in ids {
|
||||
self.entries.remove(&id);
|
||||
self.borders.remove(&id);
|
||||
}
|
||||
}
|
||||
None => {
|
||||
self.entries.clear();
|
||||
self.borders.clear();
|
||||
}
|
||||
}
|
||||
self.stamp = ed.doc_stamp;
|
||||
}
|
||||
|
||||
/// The bucket to draw `zoom` at: the held one while the zoom keeps moving (within
|
||||
/// [`ZOOM_LAG`]), the zoom's own once it held still for [`ZOOM_SETTLE`].
|
||||
fn view_bucket(&mut self, zoom: f64, now: web_time::Instant) -> i32 {
|
||||
let want = zoom_bucket(zoom);
|
||||
let (held, last_zoom, changed) = self.view.unwrap_or((want, zoom, now));
|
||||
let changed = if zoom == last_zoom { changed } else { now };
|
||||
let settled = now.duration_since(changed) >= ZOOM_SETTLE;
|
||||
let bucket = if settled || (want - held).abs() > ZOOM_LAG { want } else { held };
|
||||
self.view = Some((bucket, zoom, changed));
|
||||
bucket
|
||||
}
|
||||
|
||||
/// When the canvas needs another frame to re-tessellate for a zoom that stopped moving.
|
||||
pub fn refine_at(&self) -> Option<web_time::Instant> {
|
||||
let (bucket, zoom, changed) = self.view?;
|
||||
(bucket != zoom_bucket(zoom)).then(|| changed + ZOOM_SETTLE)
|
||||
}
|
||||
|
||||
/// Items for `roots` (or the whole page) in paint order. `cull` skips subtrees outside a
|
||||
/// world rect.
|
||||
pub fn items(
|
||||
@@ -154,6 +212,7 @@ impl SceneCache {
|
||||
let used = self.entries.values().filter(|e| e.used).count();
|
||||
if self.entries.len() > used * 4 + 2000 {
|
||||
self.entries.retain(|_, e| e.used);
|
||||
self.borders.retain(|id, _| self.entries.contains_key(id));
|
||||
}
|
||||
out
|
||||
}
|
||||
@@ -180,9 +239,12 @@ impl SceneCache {
|
||||
let bounds = draw::draw_bounds(n, wr);
|
||||
let visible = cull.is_none_or(|c| c.intersects(&bounds))
|
||||
&& clip.is_none_or(|c| c.intersects(&bounds));
|
||||
if visible && !n.is_group() {
|
||||
// Level of detail: a layer under half a pixel at this bucket shows as nothing, so it
|
||||
// is not tessellated (its children are looked at on their own).
|
||||
let tiny = bounds.w.max(bounds.h) * 2f64.powi(bucket) < MIN_SCREEN_PX;
|
||||
if visible && !tiny && !n.is_group() {
|
||||
let hit = self.entries.get(&id).is_some_and(|e| {
|
||||
e.bucket == bucket && e.wr == wr && e.opacity == opacity && e.node == *n
|
||||
e.bucket == bucket && e.wr == wr && e.opacity == opacity
|
||||
});
|
||||
if !hit {
|
||||
self.last_misses += 1;
|
||||
@@ -191,7 +253,6 @@ impl SceneCache {
|
||||
self.entries.insert(
|
||||
id,
|
||||
Entry {
|
||||
node: n.clone(),
|
||||
wr,
|
||||
opacity,
|
||||
bucket,
|
||||
@@ -228,9 +289,25 @@ impl SceneCache {
|
||||
{
|
||||
return;
|
||||
}
|
||||
let o = wr.min();
|
||||
let o = wr.min() - crate::geom::v2(0.0, n.scroll_y);
|
||||
for c in &n.children {
|
||||
self.walk(doc, *c, o, child_clip, opacity, bucket, tess, cull, out);
|
||||
// Absolute children of a scrolled frame stay put (`Document::world_rect`).
|
||||
let pinned = doc.get(*c).is_some_and(|k| k.layout_child.absolute);
|
||||
let co = if pinned { wr.min() } else { o };
|
||||
self.walk(doc, *c, co, child_clip, opacity, bucket, tess, cull, out);
|
||||
}
|
||||
if visible && draw::border_on_top(n) {
|
||||
let hit = self.borders.get(&id).is_some_and(|e| e.bucket == bucket && e.wr == wr && e.opacity == opacity);
|
||||
if !hit {
|
||||
self.last_misses += 1;
|
||||
let d = draw::border_draw(n, wr, opacity);
|
||||
let pieces = Arc::new(tess.ops(&d.ops));
|
||||
self.borders.insert(id, Entry { wr, opacity, bucket, pieces, used: true });
|
||||
}
|
||||
let e = &self.borders[&id];
|
||||
if !e.pieces.is_empty() {
|
||||
out.push(Item { id, pieces: e.pieces.clone(), clip, blend: n.style.blend });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -314,11 +391,17 @@ pub fn overlay(mb: &mut MeshBuilder, ed: &Editor, offset: V2) {
|
||||
[0.42, 0.42, 0.46, 1.0]
|
||||
};
|
||||
let r = r.translate(offset);
|
||||
let mut name = n.name.clone();
|
||||
// The longest prefix that fits, from one layout's char boundaries (one line, no wrap).
|
||||
let max_w = r.w;
|
||||
while !name.is_empty() && text::layout(&name, 12.0, None, TextAlign::Left).width > max_w {
|
||||
name.pop();
|
||||
}
|
||||
let full = text::layout(&n.name, 12.0, None, TextAlign::Left);
|
||||
let name = match full.lines.first() {
|
||||
Some(l) if full.width > max_w => {
|
||||
let ellipsis = text::layout("\u{2026}", 12.0, None, TextAlign::Left).width;
|
||||
let fit = l.carets.iter().rposition(|x| *x <= max_w - ellipsis).unwrap_or(0);
|
||||
format!("{}\u{2026}", n.name.chars().take(fit).collect::<String>().trim_end())
|
||||
}
|
||||
_ => n.name.clone(),
|
||||
};
|
||||
draw_text(
|
||||
mb,
|
||||
&name,
|
||||
@@ -626,7 +709,8 @@ pub fn canvas(ed: &Editor, cache: &mut SceneCache, offset: V2, show_grid: bool)
|
||||
grid(&mut under, &v);
|
||||
}
|
||||
let vis = ed.camera.visible_world(ed.viewport);
|
||||
let bucket = zoom_bucket(ed.camera.zoom);
|
||||
let bucket = cache.view_bucket(ed.camera.zoom, web_time::Instant::now());
|
||||
cache.forget_changes(ed);
|
||||
let items = match &cache.last {
|
||||
Some((stamp, b, cull, items))
|
||||
if *stamp == ed.doc_stamp && *b == bucket && cull.contains_rect(&vis) =>
|
||||
@@ -636,7 +720,7 @@ pub fn canvas(ed: &Editor, cache: &mut SceneCache, offset: V2, show_grid: bool)
|
||||
}
|
||||
_ => {
|
||||
let cull = vis.inflate(vis.w.max(vis.h) * 0.5);
|
||||
let items = cache.items(&ed.doc, None, ed.camera.zoom, Some(cull));
|
||||
let items = cache.items(&ed.doc, None, 2f64.powi(bucket), Some(cull));
|
||||
cache.last = Some((ed.doc_stamp, bucket, cull, items.clone()));
|
||||
items
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
use super::draw::{DrawOp, PaintSpec};
|
||||
use super::mesh::{
|
||||
Mesh, PAINT_IMAGE, PAINT_IMAGE_TILE, PAINT_LINEAR, PAINT_RADIAL, PAINT_SOLID, Vertex,
|
||||
Mesh, PAINT_IMAGE, PAINT_IMAGE_TILE, PAINT_LINEAR, PAINT_RADIAL, PAINT_SOLID, Vertex, unorm,
|
||||
};
|
||||
use crate::geom::{Rect, V2, v2};
|
||||
use crate::model::{Cap, Join, Stop, sample_stops};
|
||||
@@ -269,10 +269,9 @@ fn paint_fn(paint: &PaintSpec) -> (Option<String>, Box<dyn Fn(V2) -> Vertex>) {
|
||||
None,
|
||||
Box::new(move |p| Vertex {
|
||||
pos: pos(p),
|
||||
color: [1.0, 1.0, 1.0, opacity],
|
||||
color: unorm([1.0, 1.0, 1.0, opacity]),
|
||||
uv: [((p - p0).dot(d) / l2) as f32, 0.0],
|
||||
kind: PAINT_LINEAR | row << 8,
|
||||
_pad: 0,
|
||||
}),
|
||||
)
|
||||
}
|
||||
@@ -295,10 +294,9 @@ fn paint_fn(paint: &PaintSpec) -> (Option<String>, Box<dyn Fn(V2) -> Vertex>) {
|
||||
let v = (rx.x * q.y - rx.y * q.x) / det;
|
||||
Vertex {
|
||||
pos: pos(p),
|
||||
color: [1.0, 1.0, 1.0, opacity],
|
||||
color: unorm([1.0, 1.0, 1.0, opacity]),
|
||||
uv: [u as f32, v as f32],
|
||||
kind: PAINT_RADIAL | row << 8,
|
||||
_pad: 0,
|
||||
}
|
||||
}),
|
||||
)
|
||||
@@ -312,13 +310,12 @@ fn paint_fn(paint: &PaintSpec) -> (Option<String>, Box<dyn Fn(V2) -> Vertex>) {
|
||||
Some(asset),
|
||||
Box::new(move |p| Vertex {
|
||||
pos: pos(p),
|
||||
color: [1.0, 1.0, 1.0, opacity],
|
||||
color: unorm([1.0, 1.0, 1.0, opacity]),
|
||||
uv: [
|
||||
((p.x - place.x) / place.w.max(1e-9)) as f32,
|
||||
((p.y - place.y) / place.h.max(1e-9)) as f32,
|
||||
],
|
||||
kind: if tile { PAINT_IMAGE_TILE } else { PAINT_IMAGE },
|
||||
_pad: 0,
|
||||
}),
|
||||
),
|
||||
}
|
||||
|
||||
@@ -95,10 +95,20 @@ pub type Counts = (usize, usize, usize, bool);
|
||||
/// What `run` changed, against its snapshots. On a page where the run recorded its nodes, rows of
|
||||
/// nodes it did not change (another agent's, the user's) do not count.
|
||||
pub fn count(project: &Path, ed: &Editor, run: &Run) -> Counts {
|
||||
count_in(project, run, |id| {
|
||||
let i = ed.pages.iter().position(|s| s.id == id)?;
|
||||
Some((ed.page_doc(i), ed.pages[i].name.as_str()))
|
||||
})
|
||||
}
|
||||
|
||||
/// [`count`] against pages looked up by id, with their names: the editor's, or copies on
|
||||
/// another thread. A page the lookup does not find was deleted.
|
||||
pub fn count_in<'a>(project: &Path, run: &Run, page_of: impl Fn(&str) -> Option<(&'a Document, &'a str)>) -> Counts {
|
||||
let (mut added, mut changed, mut removed, mut empty) = (0, 0, 0, true);
|
||||
for p in &run.pages {
|
||||
let page = ed.pages.iter().position(|s| s.id == p.page_id);
|
||||
let after = page.map(|i| ed.page_doc(i)).cloned().unwrap_or_default();
|
||||
let page = page_of(&p.page_id);
|
||||
let none = Document::default();
|
||||
let after = page.map_or(&none, |(d, _)| d);
|
||||
let Some(v) = &p.version else {
|
||||
if page.is_some() {
|
||||
added += after.root.len().max(1);
|
||||
@@ -110,10 +120,10 @@ pub fn count(project: &Path, ed: &Editor, run: &Run) -> Counts {
|
||||
empty = false;
|
||||
continue;
|
||||
};
|
||||
let mut d = diff_pages(&before, &after);
|
||||
let mut d = diff_pages(&before, after);
|
||||
keep_own(&mut d, p);
|
||||
let (a, m, r) = (d.added, d.modified, d.removed);
|
||||
let renamed = page.is_some_and(|i| ed.pages[i].name != before.name);
|
||||
let renamed = page.is_some_and(|(_, name)| name != before.name);
|
||||
let deleted = page.is_none();
|
||||
added += a;
|
||||
changed += m + usize::from(renamed);
|
||||
@@ -1430,7 +1440,7 @@ mod tests {
|
||||
let remaining = t.open_runs().next().unwrap().pages[1].version.clone().unwrap();
|
||||
if corrupt {
|
||||
std::fs::write(
|
||||
dir.join("versions").join(format!("{missing}.json")),
|
||||
crate::project::packed::path(&dir.join("versions"), &missing),
|
||||
"broken",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
@@ -73,10 +73,16 @@ impl Session {
|
||||
for i in 0..ed.pages.len() {
|
||||
ed.mark_page_saved(i);
|
||||
}
|
||||
// Pages migrated on load (cache rebuilt, component keys) are written by the next save.
|
||||
// Pages migrated on load (cache rebuilt, component keys, still plain JSON) are written
|
||||
// by the next save.
|
||||
for &i in &loaded.changed {
|
||||
ed.pages[i].saved_gen = u64::MAX;
|
||||
}
|
||||
for i in 0..ed.pages.len() {
|
||||
if !loaded.pending.contains(&i) && crate::project::is_legacy_page(dir, &ed.pages[i].id) {
|
||||
ed.pages[i].saved_gen = u64::MAX;
|
||||
}
|
||||
}
|
||||
// The other pages are read in the background; the editor installs them as they arrive.
|
||||
ed.load_pages_in_background(dir, &loaded.pending);
|
||||
self.design_disk = (!loaded.design_new).then(|| ed.design.clone());
|
||||
@@ -226,6 +232,11 @@ impl Session {
|
||||
p.save_design(&ed.design)?;
|
||||
self.design_disk = Some(ed.design.clone());
|
||||
}
|
||||
// The manifest's version goes first too: an app older than packed pages must refuse the
|
||||
// project before it can find a page missing and save it empty.
|
||||
if !dirty.is_empty() {
|
||||
p.write_manifest()?;
|
||||
}
|
||||
for &i in &dirty {
|
||||
let data = ed.page_data(i);
|
||||
// Images first, so a page never references a file that isn't there.
|
||||
@@ -523,12 +534,12 @@ mod tests {
|
||||
assert_eq!(ed2.doc.mode, orig.mode);
|
||||
assert!(!ed2.project_dirty());
|
||||
let raw = crate::vfs::read_to_string(dir.join("project.json")).unwrap();
|
||||
// The manifest is rewritten at version 2 so 0.4.0 refuses the project instead of
|
||||
// writing pages without bindings and never updating design.json.
|
||||
assert!(raw.contains("\"version\": 2"));
|
||||
// An older app reads the page files directly and still sees the cache fields.
|
||||
// The manifest is rewritten at the current version so older apps refuse the project
|
||||
// instead of writing pages without bindings or missing the packed page files.
|
||||
assert!(raw.contains("\"version\": 3"));
|
||||
// The page file itself still carries the cache fields.
|
||||
let old = crate::project::format::page_from_json(
|
||||
&crate::vfs::read_to_string(dir.join("pages/pg1.json")).unwrap(),
|
||||
&crate::project::packed::read(&crate::project::page_file(&dir, "pg1")).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(old.doc.variables, orig.variables);
|
||||
@@ -557,7 +568,7 @@ mod tests {
|
||||
assert!(on_disk.find_by_css("brand").is_some(), "design saved before the page failed");
|
||||
// The manifest turned v2 with it.
|
||||
let raw = crate::vfs::read_to_string(dir.join("project.json")).unwrap();
|
||||
assert!(raw.contains("\"version\": 2"));
|
||||
assert!(raw.contains("\"version\": 3"));
|
||||
crate::vfs::remove_dir_all(&s.root).ok();
|
||||
}
|
||||
|
||||
@@ -572,9 +583,11 @@ mod tests {
|
||||
let mut ed = Editor::default();
|
||||
sess.open(&mut ed, &dir).unwrap();
|
||||
assert!(ed.design.collections.is_empty());
|
||||
assert!(!ed.project_dirty());
|
||||
// Dirty only to pack the plain page file.
|
||||
assert!(ed.project_dirty());
|
||||
sess.save(&mut ed).unwrap();
|
||||
assert!(dir.join("design.json").is_file());
|
||||
assert!(!crate::project::is_legacy_page(&dir, "pg1"));
|
||||
crate::vfs::remove_dir_all(&s.root).ok();
|
||||
}
|
||||
|
||||
|
||||
@@ -148,15 +148,21 @@ fn remember_base(dir: &Path, path: &str, bytes: &[u8]) {
|
||||
}
|
||||
|
||||
/// The merge of this folder's and the server's copy of a page, both edited from the last synced
|
||||
/// one; None when that copy or any of the three is not JSON (the folder's version then wins).
|
||||
/// one; None when that copy or any of the three is not JSON or packed JSON (the folder's
|
||||
/// version then wins).
|
||||
fn merged_copy(dir: &Path, path: &str, local: &[u8], remote: &[u8]) -> Option<Vec<u8>> {
|
||||
if !path.ends_with(".json") {
|
||||
use crate::project::packed;
|
||||
let is_packed = path.ends_with(&format!(".{}", packed::EXT));
|
||||
if !is_packed && !path.ends_with(".json") {
|
||||
return None;
|
||||
}
|
||||
let parse = |b: &[u8]| serde_json::from_slice::<serde_json::Value>(b).ok();
|
||||
let parse = |b: &[u8]| match is_packed {
|
||||
true => serde_json::from_slice::<serde_json::Value>(&packed::unpack(b).ok()?).ok(),
|
||||
false => serde_json::from_slice::<serde_json::Value>(b).ok(),
|
||||
};
|
||||
let base = parse(&vfs::read(base_file(dir, path)).ok()?)?;
|
||||
let merged = crate::merge::merge(&base, &parse(local)?, &parse(remote)?);
|
||||
serde_json::to_vec(&merged).ok()
|
||||
let merged = serde_json::to_vec(&crate::merge::merge(&base, &parse(local)?, &parse(remote)?)).ok()?;
|
||||
Some(if is_packed { packed::pack(&merged) } else { merged })
|
||||
}
|
||||
|
||||
/// Every file under `dir` that syncs, as relative `/` paths with their fingerprints.
|
||||
|
||||
@@ -99,7 +99,8 @@ pub struct TextLayout {
|
||||
pub font_size: f64,
|
||||
}
|
||||
|
||||
fn advance_in(face: FaceId, c: char, font_size: f64) -> (u16, f64) {
|
||||
/// Glyph id and advance (in ems) of `c`, `?` standing in for a missing glyph.
|
||||
fn lookup(face: FaceId, c: char) -> (u16, f64) {
|
||||
let f = face_of(face);
|
||||
let gid = f
|
||||
.glyph_index(c)
|
||||
@@ -107,7 +108,23 @@ fn advance_in(face: FaceId, c: char, font_size: f64) -> (u16, f64) {
|
||||
.map(|g| g.0)
|
||||
.unwrap_or(0);
|
||||
let adv = f.glyph_hor_advance(ttf_parser::GlyphId(gid)).unwrap_or(0) as f64;
|
||||
(gid, adv / f.units_per_em() as f64 * font_size)
|
||||
(gid, adv / f.units_per_em() as f64)
|
||||
}
|
||||
|
||||
/// Chars below this are looked up once per face: a cmap lookup re-parses the subtable.
|
||||
const CACHED_CHARS: usize = 256;
|
||||
|
||||
fn advance_in(face: FaceId, c: char, font_size: f64) -> (u16, f64) {
|
||||
static TABLES: [OnceLock<Vec<(u16, f64)>>; 4] = [const { OnceLock::new() }; 4];
|
||||
let (gid, em) = match (c as usize) < CACHED_CHARS {
|
||||
true => TABLES[(face & 3) as usize].get_or_init(|| {
|
||||
(0..CACHED_CHARS as u32)
|
||||
.map(|i| lookup(face, char::from_u32(i).unwrap_or('?')))
|
||||
.collect()
|
||||
})[c as usize],
|
||||
false => lookup(face, c),
|
||||
};
|
||||
(gid, em * font_size)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -176,13 +176,88 @@ pub fn rename_field(
|
||||
r
|
||||
}
|
||||
|
||||
/// Inter (Regular/Medium/SemiBold) as the proportional family, egui's fonts as fallbacks.
|
||||
/// A typeface the interface can be set in: Regular, Medium and SemiBold (Latin subsets from
|
||||
/// Google Fonts, SIL OFL 1.1; Varela Round has one weight).
|
||||
pub struct UiFont {
|
||||
pub name: &'static str,
|
||||
faces: [&'static [u8]; 3],
|
||||
}
|
||||
|
||||
macro_rules! ui_font {
|
||||
($name:literal, $file:literal) => {
|
||||
UiFont {
|
||||
name: $name,
|
||||
faces: [
|
||||
include_bytes!(concat!("../assets/fonts/", $file, "-Regular.ttf")),
|
||||
include_bytes!(concat!("../assets/fonts/", $file, "-Medium.ttf")),
|
||||
include_bytes!(concat!("../assets/fonts/", $file, "-SemiBold.ttf")),
|
||||
],
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/// Neutral sans first, then the rounded ones.
|
||||
pub static UI_FONTS: [UiFont; 8] = [
|
||||
UiFont {
|
||||
name: "Inter",
|
||||
faces: [INTER_REGULAR, INTER_MEDIUM, INTER_SEMIBOLD],
|
||||
},
|
||||
ui_font!("Geist", "Geist"),
|
||||
ui_font!("Figtree", "Figtree"),
|
||||
ui_font!("DM Sans", "DMSans"),
|
||||
ui_font!("Nunito", "Nunito"),
|
||||
ui_font!("Quicksand", "Quicksand"),
|
||||
ui_font!("Fredoka", "Fredoka"),
|
||||
UiFont {
|
||||
name: "Varela Round",
|
||||
faces: [VARELA_ROUND; 3],
|
||||
},
|
||||
];
|
||||
|
||||
static VARELA_ROUND: &[u8] = include_bytes!("../assets/fonts/VarelaRound-Regular.ttf");
|
||||
|
||||
static UI_FONT: AtomicU8 = AtomicU8::new(0);
|
||||
|
||||
/// Index into [`UI_FONTS`].
|
||||
pub fn ui_font() -> usize {
|
||||
UI_FONT.load(Ordering::Relaxed) as usize
|
||||
}
|
||||
|
||||
/// Sets the interface font; [`apply`] afterwards installs it.
|
||||
pub fn set_ui_font(i: usize) {
|
||||
UI_FONT.store(i.min(UI_FONTS.len() - 1) as u8, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// Kept next to the theme choice, by name; a missing or unknown name means Inter.
|
||||
fn ui_font_path() -> Option<std::path::PathBuf> {
|
||||
Choice::path().map(|p| p.with_file_name("font"))
|
||||
}
|
||||
|
||||
pub fn load_ui_font() -> usize {
|
||||
ui_font_path()
|
||||
.and_then(|p| crate::vfs::read_to_string(p).ok())
|
||||
.and_then(|s| UI_FONTS.iter().position(|f| f.name == s.trim()))
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
pub fn save_ui_font() {
|
||||
if let Some(p) = ui_font_path() {
|
||||
if let Some(dir) = p.parent() {
|
||||
let _ = crate::vfs::create_dir_all(dir);
|
||||
}
|
||||
let _ = crate::vfs::write(p, UI_FONTS[ui_font()].name);
|
||||
}
|
||||
}
|
||||
|
||||
/// The chosen [`UiFont`] (Regular/Medium/SemiBold) as the proportional family, egui's fonts as
|
||||
/// fallbacks.
|
||||
pub fn install_fonts(ctx: &egui::Context) {
|
||||
let mut fonts = egui::FontDefinitions::default();
|
||||
let [regular, medium, semibold] = UI_FONTS[ui_font()].faces;
|
||||
for (name, bytes) in [
|
||||
("Inter", INTER_REGULAR),
|
||||
("Inter-Medium", INTER_MEDIUM),
|
||||
("Inter-SemiBold", INTER_SEMIBOLD),
|
||||
("Inter", regular),
|
||||
("Inter-Medium", medium),
|
||||
("Inter-SemiBold", semibold),
|
||||
] {
|
||||
fonts
|
||||
.font_data
|
||||
@@ -234,7 +309,7 @@ pub fn figma_fields(ui: &mut egui::Ui) {
|
||||
let v = ui.visuals_mut();
|
||||
v.text_edit_bg_color = Some(rest);
|
||||
v.extreme_bg_color = rest;
|
||||
let r5 = CornerRadius::same(RADIUS_MD);
|
||||
let r5 = CornerRadius::same(radius_md());
|
||||
let w = &mut v.widgets;
|
||||
for (s, fill) in [
|
||||
(&mut w.inactive, rest),
|
||||
@@ -244,7 +319,11 @@ pub fn figma_fields(ui: &mut egui::Ui) {
|
||||
] {
|
||||
s.bg_fill = fill;
|
||||
s.weak_bg_fill = fill;
|
||||
s.bg_stroke = Stroke::NONE;
|
||||
s.bg_stroke = if is_acrylic() {
|
||||
acrylic_edge(dark)
|
||||
} else {
|
||||
Stroke::NONE
|
||||
};
|
||||
s.corner_radius = r5;
|
||||
}
|
||||
}
|
||||
@@ -252,15 +331,148 @@ pub fn figma_fields(ui: &mut egui::Ui) {
|
||||
/// Height of an inspector field row.
|
||||
pub const FIELD_H: f32 = 32.0;
|
||||
|
||||
/// Corner radii of the rounded design language (tokens `radius-md`, `radius-lg`, `radius-xl` of the design system).
|
||||
pub const RADIUS_MD: u8 = 8;
|
||||
pub const RADIUS_LG: u8 = 12;
|
||||
pub const RADIUS_PANEL: u8 = 16;
|
||||
pub const RADIUS_XL: u8 = 16;
|
||||
/// Corner radii of the rounded design language (tokens `radius-md`, `radius-lg`, `radius-xl` of
|
||||
/// the design system), at the user's roundness ([`set_roundness`]).
|
||||
pub fn radius_md() -> u8 {
|
||||
ru(8)
|
||||
}
|
||||
pub fn radius_lg() -> u8 {
|
||||
ru(12)
|
||||
}
|
||||
pub fn radius_panel() -> u8 {
|
||||
ru(16)
|
||||
}
|
||||
pub fn radius_xl() -> u8 {
|
||||
ru(16)
|
||||
}
|
||||
|
||||
/// How round the chrome's corners are, as a factor on every radius (pills stay pills).
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Roundness(pub f32);
|
||||
|
||||
impl Roundness {
|
||||
pub const ALL: [(Roundness, &'static str); 4] = [
|
||||
(Roundness(0.0), "Square"),
|
||||
(Roundness(0.5), "Subtle"),
|
||||
(Roundness(1.0), "Default"),
|
||||
(Roundness(1.5), "Round"),
|
||||
];
|
||||
|
||||
/// Kept next to the theme choice; a missing or unknown value means the default.
|
||||
fn path() -> Option<std::path::PathBuf> {
|
||||
Choice::path().map(|p| p.with_file_name("roundness"))
|
||||
}
|
||||
|
||||
pub fn load() -> Roundness {
|
||||
Roundness::path()
|
||||
.and_then(|p| crate::vfs::read_to_string(p).ok())
|
||||
.and_then(|s| s.trim().parse::<f32>().ok())
|
||||
.and_then(|v| Roundness::ALL.into_iter().find(|(r, _)| (r.0 - v).abs() < 1e-3))
|
||||
.map_or(Roundness(1.0), |(r, _)| r)
|
||||
}
|
||||
|
||||
pub fn save(self) {
|
||||
if let Some(p) = Roundness::path() {
|
||||
if let Some(dir) = p.parent() {
|
||||
let _ = crate::vfs::create_dir_all(dir);
|
||||
}
|
||||
let _ = crate::vfs::write(p, self.0.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The roundness factor, as `f32` bits.
|
||||
static ROUNDNESS: AtomicU32 = AtomicU32::new(0x3f80_0000);
|
||||
|
||||
pub fn roundness() -> Roundness {
|
||||
Roundness(f32::from_bits(ROUNDNESS.load(Ordering::Relaxed)))
|
||||
}
|
||||
|
||||
/// Sets the roundness; [`apply`] afterwards restyles egui's own widgets.
|
||||
pub fn set_roundness(r: Roundness) {
|
||||
ROUNDNESS.store(r.0.to_bits(), Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// A chrome corner radius of `px` points at the user's roundness.
|
||||
pub fn r(px: f32) -> f32 {
|
||||
px * roundness().0
|
||||
}
|
||||
|
||||
/// [`r`] for `u8` radii; 255 (a pill) stays a pill.
|
||||
pub fn ru(px: u8) -> u8 {
|
||||
if px == u8::MAX {
|
||||
return px;
|
||||
}
|
||||
(f32::from(px) * roundness().0).round().min(254.0) as u8
|
||||
}
|
||||
|
||||
/// egui's `small_button` zeroes the padding; this keeps the theme's but stays compact.
|
||||
pub fn small_button(ui: &mut egui::Ui, text: impl Into<egui::WidgetText>) -> egui::Response {
|
||||
ui.add(egui::Button::new(text).min_size(vec2(0.0, 24.0)))
|
||||
pub fn small_button(ui: &mut egui::Ui, text: &str) -> egui::Response {
|
||||
small_icon_button(ui, None, text)
|
||||
}
|
||||
|
||||
/// A compact secondary button, an optional icon before the label (an icon alone when `text`
|
||||
/// is empty), painted as a control surface so it takes the acrylic material.
|
||||
pub fn small_icon_button(ui: &mut egui::Ui, icon: Option<Icon>, text: &str) -> egui::Response {
|
||||
const H: f32 = 28.0;
|
||||
const PAD: f32 = 10.0;
|
||||
const ICON: f32 = 14.0;
|
||||
let font = FontId::proportional(BODY);
|
||||
let galley = ui
|
||||
.painter()
|
||||
.layout_no_wrap(text.to_owned(), font, Color32::PLACEHOLDER);
|
||||
let icon_w = match (icon, text.is_empty()) {
|
||||
(None, _) => 0.0,
|
||||
(Some(_), true) => ICON,
|
||||
(Some(_), false) => ICON + 6.0,
|
||||
};
|
||||
let w = if text.is_empty() {
|
||||
H
|
||||
} else {
|
||||
galley.size().x + icon_w + 2.0 * PAD
|
||||
};
|
||||
let (rect, resp) = ui.allocate_exact_size(vec2(w, H), egui::Sense::click());
|
||||
if !ui.is_rect_visible(rect) {
|
||||
return resp;
|
||||
}
|
||||
let p = pal(ui);
|
||||
let hover = crate::ui::motion::hover(ui.ctx(), resp.id.with("hover"), &resp);
|
||||
let lift = if resp.is_pointer_button_down_on() {
|
||||
p.pressed
|
||||
} else {
|
||||
p.hover.gamma_multiply(hover)
|
||||
};
|
||||
fill_control(ui, rect, radius_md(), blend(p.field, lift));
|
||||
let ink = if ui.is_enabled() { p.text } else { p.tertiary };
|
||||
let mut x = if text.is_empty() {
|
||||
rect.center().x - ICON / 2.0
|
||||
} else {
|
||||
rect.left() + PAD
|
||||
};
|
||||
if let Some(i) = icon {
|
||||
let r = Rect::from_min_size(pos2(x, rect.center().y - ICON / 2.0), Vec2::splat(ICON));
|
||||
paint_icon(ui.painter(), r, i, ink);
|
||||
x += icon_w;
|
||||
}
|
||||
ui.painter()
|
||||
.galley(pos2(x, rect.center().y - galley.size().y / 2.0), galley, ink);
|
||||
resp
|
||||
}
|
||||
|
||||
/// Adds one egui widget (a button, a field) and, under acrylic, lights its surface from beneath:
|
||||
/// egui paints these with a translucent tint, so the gradient shows through it.
|
||||
pub fn lit(ui: &mut egui::Ui, add: impl FnOnce(&mut egui::Ui) -> egui::Response) -> egui::Response {
|
||||
if !is_acrylic() {
|
||||
return add(ui);
|
||||
}
|
||||
let slot = ui.painter().add(egui::Shape::Noop);
|
||||
let r = add(ui);
|
||||
let dark = ui.visuals().dark_mode;
|
||||
let radius = f32::from(ui.visuals().widgets.inactive.corner_radius.nw);
|
||||
let (top, bottom) = craft_ui::material::lit(pal(ui).field, dark);
|
||||
ui.painter()
|
||||
.set(slot, craft_ui::material::vertical_gradient(r.rect, radius, top, bottom));
|
||||
r
|
||||
}
|
||||
|
||||
pub fn semibold(size: f32) -> FontId {
|
||||
@@ -309,9 +521,13 @@ fn style_for(s: &mut egui::Style, dark: bool, p: Palette) {
|
||||
};
|
||||
v.extreme_bg_color = p.field;
|
||||
v.text_edit_bg_color = Some(p.field);
|
||||
v.window_stroke = Stroke::NONE;
|
||||
v.window_corner_radius = CornerRadius::same(RADIUS_XL);
|
||||
v.menu_corner_radius = CornerRadius::same(RADIUS_LG);
|
||||
v.window_stroke = if is_acrylic() {
|
||||
acrylic_edge(dark)
|
||||
} else {
|
||||
Stroke::NONE
|
||||
};
|
||||
v.window_corner_radius = CornerRadius::same(radius_xl());
|
||||
v.menu_corner_radius = CornerRadius::same(radius_lg());
|
||||
v.window_shadow = Shadow {
|
||||
offset: [0, 8],
|
||||
blur: 28,
|
||||
@@ -324,8 +540,14 @@ fn style_for(s: &mut egui::Style, dark: bool, p: Palette) {
|
||||
spread: 0,
|
||||
color: Color32::from_black_alpha(if dark { 90 } else { 30 }),
|
||||
};
|
||||
v.selection.bg_fill = acc;
|
||||
v.selection.stroke = Stroke::new(1.0, Color32::WHITE);
|
||||
// Acrylic selects with a wash of the accent (as `fill_selected`), not a solid block.
|
||||
if is_acrylic() {
|
||||
v.selection.bg_fill = Color32::from_rgba_unmultiplied(acc.r(), acc.g(), acc.b(), 72);
|
||||
v.selection.stroke = Stroke::new(1.0, p.text);
|
||||
} else {
|
||||
v.selection.bg_fill = acc;
|
||||
v.selection.stroke = Stroke::new(1.0, Color32::WHITE);
|
||||
}
|
||||
v.slider_trailing_fill = true;
|
||||
v.handle_shape = egui::style::HandleShape::Circle;
|
||||
v.indent_has_left_vline = false;
|
||||
@@ -333,7 +555,7 @@ fn style_for(s: &mut egui::Style, dark: bool, p: Palette) {
|
||||
v.interact_cursor = None;
|
||||
v.collapsing_header_frame = false;
|
||||
|
||||
let r6 = CornerRadius::same(RADIUS_MD);
|
||||
let r6 = CornerRadius::same(radius_md());
|
||||
let w = &mut v.widgets;
|
||||
w.noninteractive.bg_fill = p.inspector;
|
||||
w.noninteractive.weak_bg_fill = p.inspector;
|
||||
@@ -358,15 +580,19 @@ fn style_for(s: &mut egui::Style, dark: bool, p: Palette) {
|
||||
w.active.fg_stroke = Stroke::new(1.0, p.text);
|
||||
w.active.corner_radius = r6;
|
||||
w.active.expansion = 0.0;
|
||||
if is_acrylic() {
|
||||
// egui's own widgets (text inputs, combo boxes) get the acrylic edge.
|
||||
let edge = acrylic_edge(dark);
|
||||
w.inactive.bg_stroke = edge;
|
||||
w.hovered.bg_stroke = edge;
|
||||
w.active.bg_stroke = edge;
|
||||
}
|
||||
w.open = w.active;
|
||||
}
|
||||
|
||||
/// `over` composited on `base`.
|
||||
pub fn blend(base: Color32, over: Color32) -> Color32 {
|
||||
let a = over.a() as f32 / 255.0;
|
||||
let [or, og, ob, _] = over.to_srgba_unmultiplied();
|
||||
let mix = |b: u8, o: u8| (b as f32 * (1.0 - a) + o as f32 * a).round() as u8;
|
||||
Color32::from_rgb(mix(base.r(), or), mix(base.g(), og), mix(base.b(), ob))
|
||||
craft_ui::material::blend(base, over)
|
||||
}
|
||||
|
||||
/// The appearance setting: follow the system, force light or dark, or a Catppuccin flavour.
|
||||
@@ -554,10 +780,248 @@ pub fn canvas_colors() -> Option<(Color32, Color32)> {
|
||||
|
||||
/// The accent follows the chosen flavour (mauve), else the system accent.
|
||||
pub fn sync_accent() {
|
||||
set_accent(match choice() {
|
||||
Choice::Catppuccin(f) => f.palette().mauve,
|
||||
_ => system_accent(),
|
||||
});
|
||||
set_accent(accent_option(accent_choice()));
|
||||
}
|
||||
|
||||
/// Accent colours the user can pick; "Theme" (0) keeps the theme's own (mauve for Catppuccin,
|
||||
/// the system accent otherwise).
|
||||
pub const ACCENTS: [&str; 9] = [
|
||||
"Theme", "Blue", "Purple", "Pink", "Red", "Orange", "Yellow", "Green", "Teal",
|
||||
];
|
||||
|
||||
static ACCENT_CHOICE: AtomicU8 = AtomicU8::new(0);
|
||||
|
||||
/// Index into [`ACCENTS`].
|
||||
pub fn accent_choice() -> usize {
|
||||
ACCENT_CHOICE.load(Ordering::Relaxed) as usize
|
||||
}
|
||||
|
||||
/// Sets the accent; [`apply`] afterwards restyles with it.
|
||||
pub fn set_accent_choice(i: usize) {
|
||||
ACCENT_CHOICE.store(i.min(ACCENTS.len() - 1) as u8, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// Accent `i` under the current theme: Catppuccin's own hues there, Apple's system colours
|
||||
/// elsewhere.
|
||||
pub fn accent_option(i: usize) -> Color32 {
|
||||
if let Choice::Catppuccin(f) = choice() {
|
||||
let c = f.palette();
|
||||
return [c.mauve, c.blue, c.mauve, c.pink, c.red, c.peach, c.yellow, c.green, c.teal][i];
|
||||
}
|
||||
if i == 0 {
|
||||
return system_accent();
|
||||
}
|
||||
let [r, g, b] = [
|
||||
[0x00, 0x7a, 0xff],
|
||||
[0xaf, 0x52, 0xde],
|
||||
[0xff, 0x2d, 0x55],
|
||||
[0xff, 0x3b, 0x30],
|
||||
[0xff, 0x95, 0x00],
|
||||
[0xff, 0xcc, 0x00],
|
||||
[0x34, 0xc7, 0x59],
|
||||
[0x30, 0xb0, 0xc7],
|
||||
][i - 1];
|
||||
Color32::from_rgb(r, g, b)
|
||||
}
|
||||
|
||||
/// Kept next to the theme choice, by name; a missing or unknown name means "Theme".
|
||||
fn accent_path() -> Option<std::path::PathBuf> {
|
||||
Choice::path().map(|p| p.with_file_name("accent"))
|
||||
}
|
||||
|
||||
pub fn load_accent_choice() -> usize {
|
||||
accent_path()
|
||||
.and_then(|p| crate::vfs::read_to_string(p).ok())
|
||||
.and_then(|s| ACCENTS.iter().position(|a| *a == s.trim()))
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
pub fn save_accent_choice() {
|
||||
if let Some(p) = accent_path() {
|
||||
if let Some(dir) = p.parent() {
|
||||
let _ = crate::vfs::create_dir_all(dir);
|
||||
}
|
||||
let _ = crate::vfs::write(p, ACCENTS[accent_choice()]);
|
||||
}
|
||||
}
|
||||
|
||||
/// How the chrome's surfaces are painted, on top of any theme: flat fills, or acrylic (an accent
|
||||
/// glow behind the panels, panels lit from above with a fine edge, a glossy primary button).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub enum Material {
|
||||
#[default]
|
||||
Solid,
|
||||
Acrylic,
|
||||
}
|
||||
|
||||
impl Material {
|
||||
pub const ALL: [Material; 2] = [Material::Solid, Material::Acrylic];
|
||||
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Material::Solid => "Solid",
|
||||
Material::Acrylic => "Acrylic",
|
||||
}
|
||||
}
|
||||
|
||||
fn id(self) -> &'static str {
|
||||
match self {
|
||||
Material::Solid => "solid",
|
||||
Material::Acrylic => "acrylic",
|
||||
}
|
||||
}
|
||||
|
||||
/// Kept next to the theme choice; a missing or unknown file means `Solid`.
|
||||
fn path() -> Option<std::path::PathBuf> {
|
||||
Choice::path().map(|p| p.with_file_name("material"))
|
||||
}
|
||||
|
||||
pub fn load() -> Material {
|
||||
Material::path()
|
||||
.and_then(|p| crate::vfs::read_to_string(p).ok())
|
||||
.and_then(|s| Material::ALL.into_iter().find(|m| m.id() == s.trim()))
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
pub fn save(self) {
|
||||
if let Some(p) = Material::path() {
|
||||
if let Some(dir) = p.parent() {
|
||||
let _ = crate::vfs::create_dir_all(dir);
|
||||
}
|
||||
let _ = crate::vfs::write(p, self.id());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static MATERIAL: AtomicU8 = AtomicU8::new(0);
|
||||
|
||||
pub fn material() -> Material {
|
||||
Material::ALL
|
||||
.get(MATERIAL.load(Ordering::Relaxed) as usize)
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Switches the material; [`apply`] afterwards restyles popups and windows.
|
||||
pub fn set_material(m: Material) {
|
||||
MATERIAL.store(m as u8, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
pub fn is_acrylic() -> bool {
|
||||
material() == Material::Acrylic
|
||||
}
|
||||
|
||||
/// Fill of the gaps around the floating panels: transparent under acrylic, which paints its
|
||||
/// glowing backdrop underneath ([`backdrop_mesh`]).
|
||||
pub fn backdrop_fill(p: Palette) -> Color32 {
|
||||
if is_acrylic() {
|
||||
Color32::TRANSPARENT
|
||||
} else {
|
||||
p.backdrop
|
||||
}
|
||||
}
|
||||
|
||||
/// The shared material for the chosen [`Material`] (`craft_ui::material` paints it).
|
||||
fn shared() -> craft_ui::material::Material {
|
||||
if is_acrylic() {
|
||||
craft_ui::material::Material::Acrylic
|
||||
} else {
|
||||
craft_ui::material::Material::Solid
|
||||
}
|
||||
}
|
||||
|
||||
/// The acrylic backdrop at `pos`: the backdrop colour lit by a soft accent glow that falls off
|
||||
/// from the top of the window, a third of the way across.
|
||||
pub fn backdrop_at(p: Palette, screen: Rect, pos: Pos2) -> Color32 {
|
||||
craft_ui::material::backdrop_at(p.backdrop, accent(), screen, pos)
|
||||
}
|
||||
|
||||
/// `rect` filled with the acrylic backdrop: a grid of vertex colours, so the glow is smooth.
|
||||
pub fn backdrop_mesh(p: Palette, screen: Rect, rect: Rect) -> egui::Mesh {
|
||||
craft_ui::material::backdrop_mesh(p.backdrop, accent(), screen, rect)
|
||||
}
|
||||
|
||||
/// Under acrylic, lights a modal's own frame (`fill`, `radius`): call first thing in the
|
||||
/// modal's content. Paints over the frame's flat fill at the window's rect from the last frame,
|
||||
/// so nothing has to run after the content.
|
||||
pub fn modal_backing(ui: &egui::Ui, fill: Color32, radius: u8) {
|
||||
craft_ui::material::paint_backing(ui, shared(), fill, radius);
|
||||
}
|
||||
|
||||
/// [`modal_backing`] for a popup, menu or context menu: called first in its closure, it lights
|
||||
/// the popup's frame (its rect from the last frame) under acrylic.
|
||||
pub fn popup_backing(ui: &egui::Ui) {
|
||||
let v = ui.visuals();
|
||||
modal_backing(ui, v.window_fill, v.menu_corner_radius.nw);
|
||||
}
|
||||
|
||||
/// Fill of a recessed well that holds tabs or segments: darker than the panel under acrylic.
|
||||
pub fn well_fill(p: Palette, dark: bool) -> Color32 {
|
||||
match (is_acrylic(), dark) {
|
||||
(true, true) => Color32::from_black_alpha(64),
|
||||
(true, false) => Color32::from_black_alpha(14),
|
||||
_ => p.field,
|
||||
}
|
||||
}
|
||||
|
||||
/// A control's surface (field, chip, select, secondary button, segmented track): lit from above
|
||||
/// with a fine edge under acrylic, flat when solid.
|
||||
pub fn fill_control(ui: &egui::Ui, rect: Rect, radius: u8, fill: Color32) {
|
||||
craft_ui::material::paint_control(ui.painter(), shared(), rect, radius, fill, ui.visuals().dark_mode);
|
||||
}
|
||||
|
||||
/// A selected row or item (nav, menu option, layer): an accent wash with an accent edge under
|
||||
/// acrylic, `fill` when solid.
|
||||
pub fn fill_selected(ui: &egui::Ui, rect: Rect, radius: u8, fill: Color32) {
|
||||
craft_ui::material::paint_selected(ui.painter(), shared(), rect, radius, fill, accent());
|
||||
}
|
||||
|
||||
/// An accent surface that is not the primary button (toggle on, active segment, checked box):
|
||||
/// glossy with a light edge under acrylic, no glow, flat when solid.
|
||||
pub fn fill_accent(ui: &egui::Ui, rect: Rect, radius: u8, fill: Color32) {
|
||||
craft_ui::material::paint_accent(ui.painter(), shared(), rect, radius, fill);
|
||||
}
|
||||
|
||||
/// `Frame::show` that, under acrylic, paints the frame's fill lit from above with a fine edge.
|
||||
/// For the surfaces inside the chrome: cards, fields, popovers, the composer, the tool dock.
|
||||
pub trait Surface {
|
||||
fn show_surface<R>(
|
||||
self,
|
||||
ui: &mut egui::Ui,
|
||||
add: impl FnOnce(&mut egui::Ui) -> R,
|
||||
) -> egui::InnerResponse<R>;
|
||||
}
|
||||
|
||||
impl Surface for egui::Frame {
|
||||
fn show_surface<R>(
|
||||
self,
|
||||
ui: &mut egui::Ui,
|
||||
add: impl FnOnce(&mut egui::Ui) -> R,
|
||||
) -> egui::InnerResponse<R> {
|
||||
craft_ui::material::show_surface(self, ui, shared(), add)
|
||||
}
|
||||
}
|
||||
|
||||
/// An acrylic panel under its content: a soft shadow, the fill lit from above, a fine edge.
|
||||
/// The shadow stays inside the 12px panel gap, which clips it.
|
||||
pub fn paint_acrylic_panel(
|
||||
painter: &egui::Painter,
|
||||
rect: Rect,
|
||||
radius: u8,
|
||||
fill: Color32,
|
||||
dark: bool,
|
||||
) {
|
||||
craft_ui::material::paint_panel(painter, rect, radius, fill, dark);
|
||||
}
|
||||
|
||||
/// The fine edge of acrylic surfaces.
|
||||
pub fn acrylic_edge(dark: bool) -> Stroke {
|
||||
craft_ui::material::edge(dark)
|
||||
}
|
||||
|
||||
/// The acrylic primary button: a soft accent glow, a glossy accent gradient and a light edge.
|
||||
pub fn paint_gloss(painter: &egui::Painter, rect: Rect, radius: u8, fill: Color32) {
|
||||
craft_ui::material::paint_gloss(painter, rect, radius, fill);
|
||||
}
|
||||
|
||||
/// Fonts plus the light and dark styles. egui picks the one matching the system appearance
|
||||
@@ -628,6 +1092,8 @@ pub enum Icon {
|
||||
Restore,
|
||||
/// Present (play).
|
||||
Play,
|
||||
/// A scrolling screen (vertical double arrow).
|
||||
Scroll,
|
||||
Group,
|
||||
Pen,
|
||||
Polygon,
|
||||
@@ -660,6 +1126,15 @@ pub enum Icon {
|
||||
Accessibility,
|
||||
Code,
|
||||
Zap,
|
||||
/// Magnifier (Lucide `search`).
|
||||
Search,
|
||||
/// The PaperCraft mark (Lucide `pen-tool`).
|
||||
PenTool,
|
||||
/// Code targets: simplified marks of React, Tailwind, Angular and Rust.
|
||||
React,
|
||||
Tailwind,
|
||||
Angular,
|
||||
Rust,
|
||||
}
|
||||
|
||||
/// Every Lucide name [`icon_by_name`] knows.
|
||||
@@ -1105,6 +1580,68 @@ pub fn paint_icon(painter: &egui::Painter, rect: Rect, icon: Icon, color: Color3
|
||||
Icon::Zap => {
|
||||
closed(&[pt(9.2, 1.5), pt(3.0, 8.8), pt(7.6, 8.8), pt(6.8, 14.5), pt(13.0, 7.2), pt(8.4, 7.2)]);
|
||||
}
|
||||
// Lucide's 24-unit geometry scaled to the 16-unit grid.
|
||||
Icon::Search => {
|
||||
painter.circle_stroke(pt(7.3, 7.3), 5.3 * u, st);
|
||||
line(&[pt(11.1, 11.1), pt(14.0, 14.0)]);
|
||||
}
|
||||
Icon::React => {
|
||||
for k in 0..3 {
|
||||
let a = k as f32 * std::f32::consts::PI / 3.0;
|
||||
let (sa, ca) = a.sin_cos();
|
||||
let pts: Vec<Pos2> = (0..32)
|
||||
.map(|i| {
|
||||
let t = i as f32 / 32.0 * std::f32::consts::TAU;
|
||||
let (x, y) = (6.5 * t.cos(), 2.5 * t.sin());
|
||||
pt(8.0 + x * ca - y * sa, 8.0 + x * sa + y * ca)
|
||||
})
|
||||
.collect();
|
||||
closed(&pts);
|
||||
}
|
||||
painter.circle_filled(c, 1.3 * u, color);
|
||||
}
|
||||
Icon::Tailwind => {
|
||||
let wave = |y0: f32, x0: f32| {
|
||||
let pts: Vec<Pos2> = (0..=16)
|
||||
.map(|i| {
|
||||
let t = i as f32 / 16.0;
|
||||
pt(x0 + 9.0 * t, y0 - 1.6 * (t * std::f32::consts::TAU).sin())
|
||||
})
|
||||
.collect();
|
||||
line(&pts);
|
||||
};
|
||||
wave(6.0, 4.5);
|
||||
wave(10.5, 2.5);
|
||||
}
|
||||
Icon::Angular => {
|
||||
closed(&[pt(8.0, 1.5), pt(14.0, 3.7), pt(13.0, 11.6), pt(8.0, 14.5), pt(3.0, 11.6), pt(2.0, 3.7)]);
|
||||
line(&[pt(5.4, 11.0), pt(8.0, 4.5), pt(10.6, 11.0)]);
|
||||
line(&[pt(6.4, 8.7), pt(9.6, 8.7)]);
|
||||
}
|
||||
Icon::Rust => {
|
||||
painter.circle_stroke(c, 5.0 * u, st);
|
||||
for i in 0..8 {
|
||||
let t = i as f32 / 8.0 * std::f32::consts::TAU;
|
||||
line(&[pt(8.0 + 5.0 * t.cos(), 8.0 + 5.0 * t.sin()), pt(8.0 + 6.8 * t.cos(), 8.0 + 6.8 * t.sin())]);
|
||||
}
|
||||
line(&[pt(6.0, 10.8), pt(6.0, 5.2), pt(8.8, 5.2), pt(10.0, 6.4), pt(8.8, 7.8), pt(6.0, 7.8)]);
|
||||
line(&[pt(8.6, 7.8), pt(10.4, 10.8)]);
|
||||
}
|
||||
Icon::PenTool => {
|
||||
closed(&[pt(8.3, 12.7), pt(12.2, 8.7), pt(13.9, 10.5), pt(10.0, 14.4)]);
|
||||
line(&[
|
||||
pt(12.0, 8.7),
|
||||
pt(11.1, 4.1),
|
||||
pt(10.6, 3.6),
|
||||
pt(2.1, 1.3),
|
||||
pt(1.3, 2.1),
|
||||
pt(3.6, 10.6),
|
||||
pt(4.1, 11.1),
|
||||
pt(8.7, 12.0),
|
||||
]);
|
||||
line(&[pt(1.5, 1.5), pt(6.4, 6.4)]);
|
||||
painter.circle_stroke(pt(7.3, 7.3), 1.3 * u, st);
|
||||
}
|
||||
Icon::Diamond => {
|
||||
closed(&[pt(8.0, 1.8), pt(14.2, 8.0), pt(8.0, 14.2), pt(1.8, 8.0)]);
|
||||
}
|
||||
@@ -1182,6 +1719,11 @@ pub fn paint_icon(painter: &egui::Painter, rect: Rect, icon: Icon, color: Color3
|
||||
start + vec2(3.0 * u, 0.3 * u),
|
||||
]);
|
||||
}
|
||||
Icon::Scroll => {
|
||||
line(&[pt(8.0, 2.0), pt(8.0, 14.0)]);
|
||||
line(&[pt(5.0, 5.0), pt(8.0, 2.0), pt(11.0, 5.0)]);
|
||||
line(&[pt(5.0, 11.0), pt(8.0, 14.0), pt(11.0, 11.0)]);
|
||||
}
|
||||
Icon::Play => {
|
||||
// Lucide's play, with its corners softened like the stroked icons around it.
|
||||
closed(&[pt(4.5, 2.5), pt(13.0, 8.0), pt(4.5, 13.5)]);
|
||||
@@ -1230,9 +1772,15 @@ pub fn icon_button(ui: &mut egui::Ui, icon: Icon, size: f32, selected: bool) ->
|
||||
} else {
|
||||
Color32::TRANSPARENT
|
||||
};
|
||||
ui.painter()
|
||||
.rect_filled(rect, (size * 0.3).clamp(6.0, 12.0), fill);
|
||||
let color = if selected {
|
||||
let radius = ru((size * 0.3).clamp(6.0, 12.0) as u8);
|
||||
if selected {
|
||||
fill_accent(ui, rect, radius, fill);
|
||||
} else {
|
||||
ui.painter().rect_filled(rect, radius, fill);
|
||||
}
|
||||
let color = if selected && is_acrylic() {
|
||||
crate::ui::kit::ink_on(fill)
|
||||
} else if selected {
|
||||
Color32::WHITE
|
||||
} else if !enabled {
|
||||
p.tertiary
|
||||
|
||||
@@ -81,6 +81,7 @@ pub fn chip(ui: &mut Ui, ed: &mut Editor, st: &mut UiState) {
|
||||
let r = super::kit::chip(ui, "Actions", false).on_hover_text("Actions on the selection");
|
||||
let mut act = None;
|
||||
egui::Popup::menu(&r).width(200.0).show(|ui| {
|
||||
crate::theme::popup_backing(ui);
|
||||
act = menu(ui, ed);
|
||||
if act.is_some() {
|
||||
ui.close();
|
||||
|
||||
@@ -59,6 +59,7 @@ impl AgentUi {
|
||||
/// The top-level "Agent" menu.
|
||||
pub fn menu(&mut self, ui: &mut egui::Ui) {
|
||||
ui.menu_button("Agent", |ui| {
|
||||
crate::theme::popup_backing(ui);
|
||||
if super::icon_menu_item(ui, crate::theme::Icon::Export, "Connect…").clicked() {
|
||||
self.open = true;
|
||||
ui.close();
|
||||
@@ -88,7 +89,10 @@ impl AgentUi {
|
||||
.open(&mut open)
|
||||
.collapsible(false)
|
||||
.default_width(460.0)
|
||||
.show(ctx, |ui| self.contents(ui));
|
||||
.show(ctx, |ui| {
|
||||
theme::modal_backing(ui, ui.visuals().window_fill, ui.visuals().window_corner_radius.nw);
|
||||
self.contents(ui)
|
||||
});
|
||||
self.open = open && self.open;
|
||||
}
|
||||
|
||||
|
||||