feat(ui): acrylic material, appearance options, chat polish, perf monitor; enforcer fixes

Appearance: acrylic material with popup and modal backing, corner radius,
UI font (bundled DM Sans, Figtree, Fredoka, Geist, Nunito, Quicksand,
Varela Round), accent colour. Icons on buttons, code targets and agent
logos; redesigned fill/gradient and auto-layout rows; compact topbar.

Chat: agent logo in the header, history popup with search and recency
groups, new-chat button, issue outcomes saved with the chat and issue
bodies shown as fields, question cards with one-click answers, number
keys, an inline other-answer field and an answered state.

Layers: right-click context menu. Performance: virtualised layer reveal,
pruned hit-testing, glyph cache. Settings > Performance: FPS, frame time,
RAM, CPU and GPU overlay with a slow-frame warning.

MCP: compact spec writes bound strokes as variables (#23), lint contrast
averages gradient and translucent backgrounds (#24), lint flags light/dark
theme copies (#25).

Closes #23, closes #24, closes #25

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Marcos Paulo
2026-10-27 18:02:23 -03:00
parent 4f5dd1f399
commit 75faf3efaa
80 changed files with 2804 additions and 514 deletions
+12
View File
@@ -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.
+1
View File
@@ -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

+1
View File
@@ -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

+1
View File
@@ -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

+1
View File
@@ -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

+1
View File
@@ -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

+1
View File
@@ -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

+1
View File
@@ -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

+1
View File
@@ -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

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 19 KiB

+1
View File
@@ -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

+1
View File
@@ -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

+1
View File
@@ -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

Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+1 -1
View File
@@ -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`).
+1 -1
View File
@@ -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) |
+1
View File
@@ -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. |
+8
View File
@@ -79,6 +79,10 @@ 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));
@@ -100,6 +104,7 @@ impl App {
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 {
@@ -732,6 +737,7 @@ impl App {
self.st.chat.gpu = self.offscreen();
}
let Some(g) = self.gfx.as_mut() else { return };
let work = Instant::now();
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;
@@ -782,6 +788,7 @@ impl App {
&self.ed,
!self.st.show_grid_off,
);
self.st.perf.record(self.start.elapsed().as_secs_f64(), work.elapsed().as_secs_f32() * 1000.0);
g.window.pre_present_notify();
g.queue.present(frame);
@@ -1050,6 +1057,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,
+12
View File
@@ -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,
}
+1
View File
@@ -120,6 +120,7 @@ mod tests {
}),
at: 5,
}],
issues: [("t1".to_owned(), crate::chat::IssueOutcome::Filed { number: 7, url: "u".into(), existing: false })].into(),
}
}
+18
View File
@@ -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> {
+25 -6
View File
@@ -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.
+2
View File
@@ -1649,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"]),
+13 -3
View File
@@ -1052,6 +1052,9 @@ pub struct ShotRequest {
/// Draw the notes on the captured roots (see [`with_notes`]).
#[serde(default)]
pub annotations: bool,
/// Render in this variable mode (see [`Editor::page_doc_in_mode`]).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub mode: Option<String>,
}
impl ShotRequest {
@@ -1106,16 +1109,23 @@ fn screenshot(host: &mut dyn Host, a: &Args) -> ToolResult {
None => (def, ed.page_doc(def).root.clone()),
};
let annotations = a.bool("annotations").unwrap_or(false);
let noted = annotations.then(|| with_notes(ed.page_doc(page), &roots, &ed.design.categories()));
// `mode`: the page as it looks in that variable mode, without switching the document.
let moded = match a.str("mode").filter(|m| !m.is_empty()) {
Some(m) => Some(ed.page_doc_in_mode(page, m)?),
None => None,
};
let base = moded.as_ref().unwrap_or_else(|| ed.page_doc(page));
let noted = annotations.then(|| with_notes(base, &roots, &ed.design.categories()));
let (doc, shot_roots) = match &noted {
Some((d, r)) => (d, r),
None => (ed.page_doc(page), &roots),
None => (base, &roots),
};
let b = crate::io::svg::export_bounds(doc, shot_roots).ok_or("nothing to capture (empty)")?;
let want = a.num("scale").unwrap_or(1.0).clamp(0.1, 4.0);
let mut scale = want.min(SHOT_MAX_EDGE / b.w.max(b.h).max(1.0));
let Some(gpu) = gpu else {
let req = ShotRequest { page, roots, scale, annotations };
let mode = a.str("mode").filter(|m| !m.is_empty()).map(str::to_owned);
let req = ShotRequest { page, roots, scale, annotations, mode };
return Err(format!(
"{NEEDS_RENDER}{}",
serde_json::to_string(&req).map_err(|e| e.to_string())?
+132 -12
View File
@@ -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"),
);
@@ -258,7 +282,7 @@ pub fn lint(doc: &Document, roots: &[NodeId], scale: &[f64]) -> Vec<Issue> {
if let Some(name) = hard_stroke(n, &vars) {
add(
"hard-coded-color",
"warning",
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"),
);
@@ -267,12 +291,83 @@ pub fn lint(doc: &Document, roots: &[NodeId], scale: &[f64]) -> Vec<Issue> {
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
}
@@ -518,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};
+1
View File
@@ -245,6 +245,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(),
}),
&[],
+61 -10
View File
@@ -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();
@@ -638,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)
}
+9 -4
View File
@@ -334,11 +334,16 @@ 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 fit = l.carets.iter().rposition(|x| *x <= max_w).unwrap_or(0);
n.name.chars().take(fit).collect()
}
_ => n.name.clone(),
};
draw_text(
mb,
&name,
+19 -2
View File
@@ -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)]
+744 -26
View File
@@ -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) = lit_fill(pal(ui).field, dark);
ui.painter()
.set(slot, 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,6 +580,13 @@ 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;
}
@@ -554,10 +783,422 @@ 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 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 {
const GLOW_ALPHA: f32 = 46.0;
const GLOW_REACH: f32 = 0.8;
let centre = pos2(screen.left() + screen.width() * 0.3, screen.top());
let reach = screen.width().max(screen.height()) * GLOW_REACH;
let d = ((pos - centre).length() / reach).min(1.0);
let a = accent();
let glow = Color32::from_rgba_unmultiplied(
a.r(),
a.g(),
a.b(),
(GLOW_ALPHA * (1.0 - d) * (1.0 - d)) as u8,
);
blend(p.backdrop, glow)
}
/// `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 {
const CELLS: u32 = 12;
let mut mesh = egui::Mesh::default();
for j in 0..=CELLS {
for i in 0..=CELLS {
let t = vec2(i as f32, j as f32) / CELLS as f32;
let pos = rect.min + rect.size() * t;
mesh.colored_vertex(pos, backdrop_at(p, screen, pos));
}
}
let row = CELLS + 1;
for j in 0..CELLS {
for i in 0..CELLS {
let k = j * row + i;
mesh.add_triangle(k, k + 1, k + row);
mesh.add_triangle(k + 1, k + row + 1, k + row);
}
}
mesh
}
/// A rounded rectangle filled with a vertical gradient. A fan from the centre interpolates a
/// colour that is linear in y exactly; the caller's edge stroke covers the unsmoothed outline.
fn vertical_gradient(rect: Rect, radius: f32, top: Color32, bottom: Color32) -> egui::Mesh {
let mut outline = Vec::new();
egui::epaint::tessellator::path::rounded_rectangle(&mut outline, rect, radius.into());
let at = |y: f32| top.lerp_to_gamma(bottom, ((y - rect.top()) / rect.height()).clamp(0.0, 1.0));
let mut mesh = egui::Mesh::default();
mesh.colored_vertex(rect.center(), at(rect.center().y));
for q in &outline {
mesh.colored_vertex(*q, at(q.y));
}
let n = outline.len() as u32;
for i in 0..n {
mesh.add_triangle(0, 1 + i, 1 + (i + 1) % n);
}
mesh
}
fn paint_vertical_gradient(
painter: &egui::Painter,
rect: Rect,
radius: f32,
top: Color32,
bottom: Color32,
) {
painter.add(vertical_gradient(rect, radius, top, bottom));
}
/// The fill of an acrylic surface lit from above: `fill` lightened at the top, shaded at the bottom.
fn lit_fill(fill: Color32, dark: bool) -> (Color32, Color32) {
// A translucent tint (the inspector's fields): brighter at the top, fainter at the bottom.
if fill.a() < 255 {
let [r, g, b, a] = fill.to_srgba_unmultiplied();
let with = |k: f32| Color32::from_rgba_unmultiplied(r, g, b, (f32::from(a) * k).min(255.0) as u8);
return (with(1.6), with(0.8));
}
if dark {
(
blend(fill, Color32::from_white_alpha(26)),
blend(fill, Color32::from_black_alpha(22)),
)
} else {
(
blend(fill, Color32::from_white_alpha(140)),
blend(fill, Color32::from_black_alpha(4)),
)
}
}
/// 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) {
if !is_acrylic() {
return;
}
let Some(rect) = ui.ctx().memory(|m| m.area_rect(ui.layer_id().id)) else {
return;
};
let dark = ui.visuals().dark_mode;
let painter = ui.painter().with_clip_rect(rect);
let (top, bottom) = lit_fill(fill, dark);
paint_vertical_gradient(&painter, rect, f32::from(radius), top, bottom);
painter.rect_stroke(rect, radius, acrylic_edge(dark), egui::StrokeKind::Inside);
}
/// [`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) {
if !is_acrylic() {
ui.painter().rect_filled(rect, radius, fill);
return;
}
let dark = ui.visuals().dark_mode;
let (top, bottom) = lit_fill(fill, dark);
paint_vertical_gradient(ui.painter(), rect, f32::from(radius), top, bottom);
ui.painter()
.rect_stroke(rect, radius, acrylic_edge(dark), egui::StrokeKind::Inside);
}
/// 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) {
if !is_acrylic() {
ui.painter().rect_filled(rect, radius, fill);
return;
}
let a = accent();
let wash = |alpha| Color32::from_rgba_unmultiplied(a.r(), a.g(), a.b(), alpha);
paint_vertical_gradient(
ui.painter(),
rect,
f32::from(radius),
blend(fill, wash(56)),
blend(fill, wash(20)),
);
ui.painter().rect_stroke(
rect,
radius,
Stroke::new(1.0, wash(80)),
egui::StrokeKind::Inside,
);
}
/// 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) {
if !is_acrylic() {
ui.painter().rect_filled(rect, radius, fill);
return;
}
paint_vertical_gradient(
ui.painter(),
rect,
f32::from(radius),
blend(fill, Color32::from_white_alpha(70)),
blend(fill, Color32::from_black_alpha(40)),
);
ui.painter().rect_stroke(
rect,
radius,
Stroke::new(1.0, Color32::from_white_alpha(90)),
egui::StrokeKind::Inside,
);
}
/// `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> {
if !is_acrylic() || self.fill.a() == 0 {
return self.show(ui, add);
}
let dark = ui.visuals().dark_mode;
let (fill, radius) = (self.fill, f32::from(self.corner_radius.nw));
let slot = ui.painter().add(egui::Shape::Noop);
let edge = if self.stroke.width > 0.0 {
self.stroke
} else {
acrylic_edge(dark)
};
let r = self
.fill(Color32::TRANSPARENT)
.stroke(edge)
.show(ui, add);
let (top, bottom) = lit_fill(fill, dark);
ui.painter()
.set(slot, vertical_gradient(r.response.rect, radius, top, bottom));
r
}
}
/// 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,
) {
let shadow = Shadow {
offset: [0, 4],
blur: 12,
spread: 0,
color: Color32::from_black_alpha(if dark { 80 } else { 24 }),
};
painter.add(shadow.as_shape(rect, CornerRadius::same(radius)));
let (top, bottom) = lit_fill(fill, dark);
paint_vertical_gradient(painter, rect, f32::from(radius), top, bottom);
painter.rect_stroke(rect, radius, acrylic_edge(dark), egui::StrokeKind::Inside);
}
/// The fine edge of acrylic surfaces.
pub fn acrylic_edge(dark: bool) -> Stroke {
Stroke::new(
1.0,
if dark {
Color32::from_white_alpha(22)
} else {
Color32::from_black_alpha(22)
},
)
}
/// 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) {
let glow = Shadow {
offset: [0, 0],
blur: 16,
spread: 0,
color: Color32::from_rgba_unmultiplied(fill.r(), fill.g(), fill.b(), 70),
};
painter.add(glow.as_shape(rect, CornerRadius::same(radius)));
paint_vertical_gradient(
painter,
rect,
f32::from(radius),
blend(fill, Color32::from_white_alpha(70)),
blend(fill, Color32::from_black_alpha(40)),
);
painter.rect_stroke(
rect,
radius,
Stroke::new(1.0, Color32::from_white_alpha(90)),
egui::StrokeKind::Inside,
);
}
/// Fonts plus the light and dark styles. egui picks the one matching the system appearance
@@ -662,6 +1303,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.
@@ -1107,6 +1757,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)]);
}
@@ -1237,9 +1949,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
+1
View File
@@ -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();
+5 -1
View File
@@ -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;
}
+30 -26
View File
@@ -5,6 +5,7 @@
//! All edits go through the `Editor` annotation / template ops, so each one is undoable. Hide
//! states (`hidden`) are view state only.
use crate::theme::Surface as _;
use super::{UiState, kit};
use crate::editor::Editor;
use crate::editor::shortcuts::Action;
@@ -260,7 +261,7 @@ fn category_select(ui: &mut Ui, current: &str, cats: &[AnnotationCategory]) -> O
let cur = cats.iter().find(|c| c.id == current);
let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), theme::FIELD_H), Sense::click());
let hover = super::motion::hover(ui.ctx(), resp.id.with("catsel"), &resp);
ui.painter().rect_filled(rect, theme::RADIUS_MD, theme::blend(p.field, p.hover.gamma_multiply(hover)));
theme::fill_control(ui, rect, theme::radius_md(), theme::blend(p.field, p.hover.gamma_multiply(hover)));
if let Some(c) = cur {
ui.painter().circle_filled(egui::pos2(rect.left() + 16.0, rect.center().y), 5.0, super::c32(c.color));
}
@@ -279,15 +280,16 @@ fn category_select(ui: &mut Ui, current: &str, cats: &[AnnotationCategory]) -> O
);
let mut chosen = None;
egui::Popup::menu(&resp).width(rect.width().max(190.0)).show(|ui| {
crate::theme::popup_backing(ui);
ui.spacing_mut().item_spacing.y = 2.0;
for c in cats {
let (row, r) = ui.allocate_exact_size(vec2(ui.available_width(), 32.0), Sense::click());
let on = c.id == current;
let h = super::motion::hover(ui.ctx(), r.id.with("catopt"), &r);
if on {
ui.painter().rect_filled(row, theme::RADIUS_MD, p.field);
theme::fill_selected(ui, row, theme::radius_md(), p.field);
} else if h > 0.0 {
ui.painter().rect_filled(row, theme::RADIUS_MD, p.hover.gamma_multiply(h));
ui.painter().rect_filled(row, theme::radius_md(), p.hover.gamma_multiply(h));
}
let tint = super::c32(c.color);
ui.painter().circle_filled(egui::pos2(row.left() + 14.0, row.center().y), 4.5, tint);
@@ -366,7 +368,7 @@ fn popover(ctx: &egui::Context, ed: &mut Editor, ann: &mut AnnotationsUi, canvas
.show(ctx, |ui| {
theme::figma_fields(ui);
let p = theme::pal(ui);
kit::card_frame(ui).inner_margin(18).show(ui, |ui| {
kit::card_frame(ui).inner_margin(18).show_surface(ui, |ui| {
ui.set_width(POPOVER_W - 36.0);
ui.spacing_mut().item_spacing.y = 8.0;
ui.horizontal(|ui| {
@@ -377,7 +379,7 @@ fn popover(ctx: &egui::Context, ed: &mut Editor, ann: &mut AnnotationsUi, canvas
}
});
});
egui::Frame::new().fill(p.field).corner_radius(theme::RADIUS_MD).inner_margin(8).show(ui, |ui| {
egui::Frame::new().fill(p.field).corner_radius(theme::radius_md()).inner_margin(8).show_surface(ui, |ui| {
ui.set_width(ui.available_width());
ui.horizontal(|ui| {
let (r, _) = ui.allocate_exact_size(vec2(16.0, 16.0), Sense::hover());
@@ -398,22 +400,22 @@ fn popover(ctx: &egui::Context, ed: &mut Editor, ann: &mut AnnotationsUi, canvas
ui.label(RichText::new(format!("{} / {LABEL_MAX}", pop.label.chars().count())).size(theme::SECONDARY).color(p.tertiary));
});
});
let r = ui.add(egui::TextEdit::singleline(&mut pop.label).char_limit(LABEL_MAX).desired_width(f32::INFINITY).margin(theme::INPUT_MARGIN));
let r = crate::theme::lit(ui, |ui| ui.add(egui::TextEdit::singleline(&mut pop.label).char_limit(LABEL_MAX).desired_width(f32::INFINITY).margin(theme::INPUT_MARGIN)));
if std::mem::take(&mut pop.focus) {
r.request_focus();
}
for f in &fields {
label(ui, f);
ui.add(egui::TextEdit::singleline(pop.values.entry(f.clone()).or_default()).desired_width(f32::INFINITY).margin(theme::INPUT_MARGIN));
crate::theme::lit(ui, |ui| ui.add(egui::TextEdit::singleline(pop.values.entry(f.clone()).or_default()).desired_width(f32::INFINITY).margin(theme::INPUT_MARGIN)));
}
label(ui, "Note");
ui.add(
crate::theme::lit(ui, |ui| ui.add(
egui::TextEdit::multiline(&mut pop.body)
.hint_text("Describe the rule, behaviour or data source. Markdown works.")
.desired_rows(4)
.desired_width(f32::INFINITY)
.margin(theme::INPUT_MARGIN),
);
));
ui.add_space(4.0);
ui.horizontal(|ui| {
ui.label(RichText::new("⌘ Return to save").size(theme::SECONDARY).color(p.secondary));
@@ -452,6 +454,7 @@ fn canvas_menu(ctx: &egui::Context, ed: &mut Editor, ann: &mut AnnotationsUi, ca
let mut act = None;
let area = egui::Area::new("annotation-menu".into()).order(egui::Order::Foreground).fixed_pos(pos).show(ctx, |ui| {
egui::Frame::popup(ui.style()).show(ui, |ui| {
crate::theme::popup_backing(ui);
ui.set_min_width(180.0);
act = super::actions::menu(ui, ed);
});
@@ -498,7 +501,7 @@ fn edit_card(ui: &mut Ui, ed: &mut Editor, ann: &mut AnnotationsUi, id: u64, n:
let cats = ed.design.categories();
let p = theme::pal(ui);
let mut done = false;
egui::Frame::new().fill(p.field).corner_radius(theme::RADIUS_LG).inner_margin(10).show(ui, |ui| {
egui::Frame::new().fill(p.field).corner_radius(theme::radius_lg()).inner_margin(10).show_surface(ui, |ui| {
ui.set_width(ui.available_width());
ui.spacing_mut().item_spacing.y = 6.0;
ui.horizontal(|ui| {
@@ -517,8 +520,8 @@ fn edit_card(ui: &mut Ui, ed: &mut Editor, ann: &mut AnnotationsUi, id: u64, n:
ed.update_annotation(id, |a| a.category = c);
}
let Some(d) = ann.editing.as_mut().filter(|d| d.id == id) else { return };
let l = ui.add(egui::TextEdit::singleline(&mut d.label).desired_width(f32::INFINITY).margin(theme::INPUT_MARGIN));
let b = ui.add(egui::TextEdit::multiline(&mut d.body).desired_rows(3).desired_width(f32::INFINITY).margin(theme::INPUT_MARGIN));
let l = crate::theme::lit(ui, |ui| ui.add(egui::TextEdit::singleline(&mut d.label).desired_width(f32::INFINITY).margin(theme::INPUT_MARGIN)));
let b = crate::theme::lit(ui, |ui| ui.add(egui::TextEdit::multiline(&mut d.body).desired_rows(3).desired_width(f32::INFINITY).margin(theme::INPUT_MARGIN)));
let blur = l.lost_focus() || b.lost_focus();
ui.horizontal(|ui| {
ui.label(RichText::new("Anchor").size(theme::SECONDARY).color(p.secondary));
@@ -677,7 +680,7 @@ fn note_row(ui: &mut Ui, a: &Annotation, n: u32, cat: Option<&AnnotationCategory
let mut act = None;
let tint = cat.map_or(Color32::GRAY, |c| super::c32(c.color));
let fill = if selected { p.field } else { Color32::TRANSPARENT };
egui::Frame::new().fill(fill).corner_radius(theme::RADIUS_LG).inner_margin(8).show(ui, |ui| {
egui::Frame::new().fill(fill).corner_radius(theme::radius_lg()).inner_margin(8).show(ui, |ui| {
ui.set_width(ui.available_width());
ui.horizontal_top(|ui| {
number_badge(ui, (!a.detached).then_some(n), if a.detached { p.warning } else { tint });
@@ -892,7 +895,7 @@ fn list_view(ui: &mut Ui, ed: &mut Editor, cu: &mut Customize) {
let over = ed.page_template_id(page).map(String::from);
let (mut edit, mut dup, mut reset, mut del) = (None, None, None, None);
for t in &templates {
egui::Frame::new().fill(p.field).corner_radius(theme::RADIUS_LG).inner_margin(10).show(ui, |ui| {
egui::Frame::new().fill(p.field).corner_radius(theme::radius_lg()).inner_margin(10).show_surface(ui, |ui| {
ui.set_width(ui.available_width());
ui.horizontal(|ui| {
ui.label(RichText::new(&t.name).font(theme::semibold(theme::BODY)));
@@ -905,16 +908,16 @@ fn list_view(ui: &mut Ui, ed: &mut Editor, cu: &mut Customize) {
}
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
if is_builtin(&t.id) {
if theme::small_button(ui, "Reset").clicked() {
if theme::small_icon_button(ui, Some(crate::theme::Icon::Restore), "Reset").clicked() {
reset = Some(t.id.clone());
}
} else if theme::small_button(ui, "Delete").clicked() {
} else if theme::small_icon_button(ui, Some(crate::theme::Icon::Trash), "Delete").clicked() {
del = Some(t.id.clone());
}
if theme::small_button(ui, "Duplicate").clicked() {
dup = Some(t.id.clone());
}
if theme::small_button(ui, "Edit").clicked() {
if theme::small_icon_button(ui, Some(crate::theme::Icon::Pen), "Edit").clicked() {
edit = Some(t.clone());
}
});
@@ -946,7 +949,7 @@ fn edit_view(ui: &mut Ui, d: &mut TemplateDraft) -> Option<bool> {
let mut result = None;
ui.horizontal(|ui| {
label(ui, "Name");
ui.add(egui::TextEdit::singleline(&mut d.tpl.name).desired_width(220.0).margin(theme::INPUT_MARGIN));
crate::theme::lit(ui, |ui| ui.add(egui::TextEdit::singleline(&mut d.tpl.name).desired_width(220.0).margin(theme::INPUT_MARGIN)));
label(ui, "Display");
for (disp, name) in [(Display::Full, "Full"), (Display::Badges, "Badges")] {
if kit::chip(ui, name, d.tpl.display == disp).clicked() {
@@ -958,7 +961,7 @@ fn edit_view(ui: &mut Ui, d: &mut TemplateDraft) -> Option<bool> {
let styling = &mut d.styling;
for tc in &mut d.tpl.categories {
let Some(ci) = cats.iter().position(|c| c.key() == tc.key) else { continue };
egui::Frame::new().fill(p.field).corner_radius(theme::RADIUS_LG).inner_margin(10).show(ui, |ui| {
egui::Frame::new().fill(p.field).corner_radius(theme::radius_lg()).inner_margin(10).show_surface(ui, |ui| {
ui.set_width(ui.available_width());
ui.spacing_mut().item_spacing.y = 6.0;
ui.horizontal(|ui| {
@@ -977,9 +980,9 @@ fn edit_view(ui: &mut Ui, d: &mut TemplateDraft) -> Option<bool> {
let c = &mut cats[ci];
let old_key = c.key().to_string();
ui.horizontal(|ui| {
ui.add(egui::TextEdit::singleline(&mut c.name).hint_text("Name").desired_width(130.0).margin(theme::INPUT_MARGIN));
ui.add(egui::TextEdit::singleline(&mut c.key).hint_text("Key").desired_width(70.0).margin(theme::INPUT_MARGIN));
ui.add(egui::TextEdit::singleline(&mut c.icon).hint_text("Icon").desired_width(90.0).margin(theme::INPUT_MARGIN));
crate::theme::lit(ui, |ui| ui.add(egui::TextEdit::singleline(&mut c.name).hint_text("Name").desired_width(130.0).margin(theme::INPUT_MARGIN)));
crate::theme::lit(ui, |ui| ui.add(egui::TextEdit::singleline(&mut c.key).hint_text("Key").desired_width(70.0).margin(theme::INPUT_MARGIN)));
crate::theme::lit(ui, |ui| ui.add(egui::TextEdit::singleline(&mut c.icon).hint_text("Icon").desired_width(90.0).margin(theme::INPUT_MARGIN)));
let mut col = super::c32(c.color);
if egui::color_picker::color_edit_button_srgba(ui, &mut col, egui::color_picker::Alpha::Opaque).changed() {
c.color = super::from32(col);
@@ -990,12 +993,12 @@ fn edit_view(ui: &mut Ui, d: &mut TemplateDraft) -> Option<bool> {
}
}
label(ui, "Guidance for the note");
ui.add(egui::TextEdit::multiline(&mut tc.prompt).desired_rows(2).desired_width(f32::INFINITY).margin(theme::INPUT_MARGIN));
crate::theme::lit(ui, |ui| ui.add(egui::TextEdit::multiline(&mut tc.prompt).desired_rows(2).desired_width(f32::INFINITY).margin(theme::INPUT_MARGIN)));
label(ui, "Fields");
let mut remove = None;
for (i, f) in tc.fields.iter_mut().enumerate() {
ui.horizontal(|ui| {
ui.add(egui::TextEdit::singleline(f).desired_width(200.0).margin(theme::INPUT_MARGIN));
crate::theme::lit(ui, |ui| ui.add(egui::TextEdit::singleline(f).desired_width(200.0).margin(theme::INPUT_MARGIN)));
if theme::icon_button(ui, Icon::Minus, 22.0, false).on_hover_text("Remove field").clicked() {
remove = Some(i);
}
@@ -1004,7 +1007,7 @@ fn edit_view(ui: &mut Ui, d: &mut TemplateDraft) -> Option<bool> {
if let Some(i) = remove {
tc.fields.remove(i);
}
if theme::small_button(ui, "+ Field").clicked() {
if theme::small_icon_button(ui, Some(crate::theme::Icon::Plus), "Field").clicked() {
tc.fields.push(String::new());
}
});
@@ -1049,9 +1052,10 @@ fn customize(ctx: &egui::Context, ed: &mut Editor, ann: &mut AnnotationsUi) {
return;
}
let p = theme::pal_from_ctx(ctx);
let frame = egui::Frame::new().fill(p.sidebar).corner_radius(24.0).inner_margin(24);
let frame = egui::Frame::new().fill(p.sidebar).corner_radius(crate::theme::r(24.0)).inner_margin(24);
let mut close = false;
let modal = egui::Modal::new(egui::Id::new("annotation-customize")).frame(frame).show(ctx, |ui| {
theme::modal_backing(ui, theme::pal(ui).sidebar, theme::ru(24));
theme::figma_fields(ui);
ui.set_width(MODAL_W.min(ctx.content_rect().width() - 96.0));
ui.spacing_mut().item_spacing.y = 8.0;
+2 -1
View File
@@ -2,6 +2,7 @@
//! drag to move, click to select, and the view states (global mode, per-category, per-note).
//! Drawn as an egui overlay only, so exports and Present mode never include it.
use crate::theme::Surface as _;
use crate::editor::Editor;
use crate::editor::shortcuts;
use crate::geom::V2;
@@ -498,7 +499,7 @@ fn pill(ctx: &egui::Context, ed: &Editor, view: &mut AnnotationsView, canvas: Re
.fill(p.popover)
.corner_radius(egui::CornerRadius::same(255))
.inner_margin(egui::Margin::symmetric(14, 6))
.show(ui, |ui| {
.show_surface(ui, |ui| {
ui.horizontal(|ui| {
let (ir, _) = ui.allocate_exact_size(vec2(14.0, 14.0), Sense::hover());
theme::paint_icon(ui.painter(), ir, Icon::Page, p.secondary);
+10 -7
View File
@@ -87,12 +87,12 @@ fn picker(ui: &mut Ui, ed: &Editor, kind: Kind, current: Option<&str>) -> Option
let id = ui.make_persistent_id("bind-search");
let mut query = ui.data(|d| d.get_temp::<String>(id)).unwrap_or_default();
ui.set_width(POPUP_W);
ui.add(
crate::theme::lit(ui, |ui| ui.add(
egui::TextEdit::singleline(&mut query)
.hint_text(format!("Search {} variables", kind_label(kind)))
.desired_width(f32::INFINITY)
.margin(theme::INPUT_MARGIN),
);
));
ui.data_mut(|d| d.insert_temp(id, query.clone()));
let list = pick_list(&ed.design, &ed.pages[ed.active_page].modes, kind, &query);
let count: usize = list.iter().map(|g| g.items.len()).sum();
@@ -124,11 +124,11 @@ fn picker(ui: &mut Ui, ed: &Editor, kind: Kind, current: Option<&str>) -> Option
let on = current == Some(it.css.as_str());
let hover = crate::ui::motion::hover(ui.ctx(), r.id.with("pick"), &r);
let fill = if on { p.field } else { p.hover.gamma_multiply(hover) };
ui.painter().rect_filled(rect, theme::RADIUS_MD, fill);
ui.painter().rect_filled(rect, theme::radius_md(), fill);
let mark = Rect::from_center_size(rect.left_center() + vec2(14.0, 0.0), vec2(14.0, 14.0));
match it.color {
Some(c) => {
ui.painter().rect_filled(mark, 3.0, super::c32(c));
ui.painter().rect_filled(mark, crate::theme::r(3.0), super::c32(c));
}
None => {
ui.painter().text(mark.center(), Align2::CENTER_CENTER, "#", theme::semibold(theme::BODY), p.secondary);
@@ -165,7 +165,10 @@ fn open_picker(resp: &egui::Response, ed: &Editor, kind: Kind, current: Option<&
egui::Popup::menu(resp)
.close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside)
.width(POPUP_W)
.show(|ui| picker(ui, ed, kind, current))
.show(|ui| {
crate::theme::popup_backing(ui);
picker(ui, ed, kind, current)
})
.and_then(|r| r.inner)
}
@@ -194,7 +197,7 @@ pub type ChipAction = Option<Option<String>>;
pub fn bound_chip(ui: &mut Ui, ed: &Editor, kind: Kind, css: &str, value: &str, width: f32) -> ChipAction {
let p = theme::pal(ui);
let (rect, resp) = ui.allocate_exact_size(vec2(width, theme::FIELD_H), Sense::click());
ui.painter().rect_filled(rect, 5.0, ui.visuals().widgets.inactive.bg_fill);
theme::fill_control(ui, rect, theme::radius_md(), ui.visuals().widgets.inactive.bg_fill);
let x = Rect::from_center_size(rect.right_center() - vec2(14.0, 0.0), vec2(16.0, 16.0));
let detach = cross(ui, x);
let mut right = x.left() - 4.0;
@@ -209,7 +212,7 @@ pub fn bound_chip(ui: &mut Ui, ed: &Editor, kind: Kind, css: &str, value: &str,
rect.left_top() + vec2(4.0, 4.0),
egui::pos2((rect.left() + 4.0 + g.size().x + 16.0).min(right), rect.bottom() - 4.0),
);
ui.painter().rect_filled(chip, 5.0, theme::accent().gamma_multiply(0.18));
ui.painter().rect_filled(chip, crate::theme::r(5.0), theme::accent().gamma_multiply(0.18));
ui.painter().with_clip_rect(chip.shrink2(vec2(6.0, 0.0))).galley(
egui::pos2(chip.left() + 8.0, chip.center().y - g.size().y / 2.0),
g,
+148 -51
View File
@@ -5,6 +5,7 @@
//! The mode and the column width are per machine (`chat_ui.json` in the config folder, next to the
//! theme choice), not in the project folder, so they do not sync to other people.
use crate::theme::Surface as _;
use super::chat_msgs::{self, Acts, View};
#[path = "chat_agents.rs"]
pub mod chat_agents;
@@ -14,7 +15,7 @@ pub use chat_pick::{layer_chip, open_ask};
use chat_agents::{Act, PanelIn};
use crate::chat::events::{Event, ImageAttachment};
use crate::chat::agents::{self, AgentKind, AgentState, AgentStatus};
use crate::chat::{Backend, BackendKind, Chat, Message, Role, ToolCall, store};
use crate::chat::{Backend, BackendKind, Chat, IssueOutcome, Message, Role, ToolCall, store};
use crate::project::format::{new_id, now_ms};
use crate::theme::{self, Icon};
use crate::ui::{UiState, kit};
@@ -360,6 +361,8 @@ pub struct ChatState {
pub chat: Option<Chat>,
/// Chats of the open project, newest first (refreshed on save).
pub chats: Vec<Chat>,
/// Filter typed in the chat history popup.
pub history_query: String,
pub turn: Option<TurnHandle>,
pub rx: Option<Receiver<Event>>,
/// Chat the running turn belongs to: its events go there, whichever chat is open.
@@ -999,24 +1002,58 @@ impl ChatState {
#[cfg(not(target_arch = "wasm32"))]
fn poll_issues(&mut self, ctx: &egui::Context) {
use crate::issues::Filed;
let issues = &mut self.issues;
let mut done = Vec::new();
self.issue_rx.retain(|(key, rx)| match rx.try_recv() {
Ok(r) => {
issues.insert(key.clone(), match r {
Ok(Filed::New { number, url }) => IssueState::Filed { number, url, existing: false },
Ok(Filed::Duplicate { number, url, .. }) => IssueState::Filed { number, url, existing: true },
Err(e) => IssueState::Failed(e),
});
done.push((key.clone(), r));
false
}
Err(std::sync::mpsc::TryRecvError::Empty) => true,
Err(_) => false,
});
for (key, r) in done {
let state = match r {
Ok(Filed::New { number, url }) => IssueState::Filed { number, url, existing: false },
Ok(Filed::Duplicate { number, url, .. }) => IssueState::Filed { number, url, existing: true },
Err(e) => IssueState::Failed(e),
};
if let IssueState::Filed { number, url, existing } = &state {
self.record_issue(&key, IssueOutcome::Filed { number: *number, url: url.clone(), existing: *existing });
}
self.issues.insert(key, state);
}
if !self.issue_rx.is_empty() {
ctx.request_repaint_after(REPAINT);
}
}
/// Saves what became of a drafted issue in the chat that drafted it, so reopening the chat
/// shows it filed or discarded.
pub fn record_issue(&mut self, key: &str, o: IssueOutcome) {
let holds = |c: &Chat| c.messages.iter().any(|m| m.tools.iter().any(|t| t.id == key));
let Some(dir) = self.project.clone() else { return };
let target = match self.chat.as_mut().filter(|c| holds(c)) {
Some(c) => Some(c),
None => self.chats.iter_mut().find(|c| holds(c)),
};
let Some(c) = target else { return };
c.issues.insert(key.to_owned(), o);
if let Err(e) = store::save(&dir, c) {
log::warn!("could not save chat: {e}");
}
}
/// The open chat's saved issue outcomes, for the cards.
fn merge_issue_records(&mut self) {
let Some(c) = &self.chat else { return };
for (k, o) in &c.issues {
self.issues.entry(k.clone()).or_insert_with(|| match o {
IssueOutcome::Filed { number, url, existing } => IssueState::Filed { number: *number, url: url.clone(), existing: *existing },
IssueOutcome::Discarded => IssueState::Discarded,
});
}
}
/// Reads the background scan's answer; starts the first scan.
pub fn poll_detect(&mut self, ctx: &egui::Context, prefs: &super::settings::ChatPrefs) {
if self.prefs != *prefs {
@@ -1345,7 +1382,7 @@ fn image_offer(ui: &mut Ui, st: &mut UiState) {
}
let p = theme::pal(ui);
let mut answer = None;
egui::Frame::new().fill(p.popover).corner_radius(theme::RADIUS_PANEL).inner_margin(egui::Margin::symmetric(12, 10)).show(ui, |ui| {
egui::Frame::new().fill(p.popover).corner_radius(theme::radius_panel()).inner_margin(egui::Margin::symmetric(12, 10)).show_surface(ui, |ui| {
ui.set_width(ui.available_width());
ui.spacing_mut().item_spacing.y = 6.0;
ui.label(RichText::new("Generate images with Codex?").font(theme::semibold(theme::BODY)));
@@ -1378,6 +1415,7 @@ fn issue_form(ctx: &egui::Context, st: &mut UiState) {
let mut send = false;
let state = st.chat.issues.get(FORM_ISSUE).cloned();
egui::Modal::new(egui::Id::new("issue-form")).show(ctx, |ui| {
theme::modal_backing(ui, ui.visuals().window_fill, theme::radius_xl());
ui.set_width(ISSUE_FORM_W);
ui.spacing_mut().item_spacing.y = 8.0;
ui.label(RichText::new("Report a PaperCraft issue").font(theme::semibold(theme::BODY + 3.0)));
@@ -1386,8 +1424,8 @@ fn issue_form(ctx: &egui::Context, st: &mut UiState) {
if let Some(k) = kit::segmented(ui, &kinds.map(|(_, l)| (l, None)), kinds.iter().position(|(k, _)| *k == draft.kind).unwrap_or(0)) {
draft.kind = kinds[k].0.into();
}
ui.add(egui::TextEdit::singleline(&mut draft.title).hint_text("What goes wrong, in a line").desired_width(f32::INFINITY).margin(theme::INPUT_MARGIN));
ui.add(egui::TextEdit::multiline(&mut draft.body).desired_rows(7).desired_width(f32::INFINITY).margin(theme::INPUT_MARGIN));
crate::theme::lit(ui, |ui| ui.add(egui::TextEdit::singleline(&mut draft.title).hint_text("What goes wrong, in a line").desired_width(f32::INFINITY).margin(theme::INPUT_MARGIN)));
crate::theme::lit(ui, |ui| ui.add(egui::TextEdit::multiline(&mut draft.body).desired_rows(7).desired_width(f32::INFINITY).margin(theme::INPUT_MARGIN)));
match &state {
Some(IssueState::Sending) => {
ui.label(RichText::new("Sending\u{2026}").color(theme::pal(ui).secondary));
@@ -1611,9 +1649,9 @@ pub fn inspector_overlay(ctx: &egui::Context, ed: &mut Editor, st: &mut UiState,
egui::Frame::new()
.fill(p.inspector)
.stroke(egui::Stroke::new(1.0, p.separator))
.corner_radius(theme::RADIUS_PANEL)
.corner_radius(theme::radius_panel())
.inner_margin(egui::Margin::symmetric(14, 12))
.show(ui, |ui| {
.show_surface(ui, |ui| {
ui.set_min_size(size - egui::vec2(28.0, 24.0));
ui.set_max_size(size - egui::vec2(28.0, 24.0));
super::inspector::panel(ui, ed, st);
@@ -1639,7 +1677,7 @@ pub fn canvas_pill(ctx: &egui::Context, st: &UiState, canvas: egui::Rect) {
.stroke(egui::Stroke::new(1.0, p.warning))
.corner_radius(egui::CornerRadius::same(255))
.inner_margin(egui::Margin::symmetric(12, 6))
.show(ui, |ui| {
.show_surface(ui, |ui| {
ui.horizontal(|ui| {
let (r, _) = ui.allocate_exact_size(egui::vec2(14.0, 14.0), egui::Sense::hover());
theme::paint_icon(ui.painter(), r, Icon::Lock, p.warning);
@@ -1659,6 +1697,7 @@ fn column(ui: &mut Ui, ed: &mut Editor, st: &mut UiState) {
issue_form(&ctx, st);
}
st.chat.ensure_chat();
st.chat.merge_issue_records();
if std::mem::take(&mut st.settings.chat_changed) {
// Paths, key or Re-scan changed in Settings: what was detected is stale.
st.chat.rescan(&ctx, &st.settings.chat);
@@ -1760,6 +1799,7 @@ fn apply_acts(ctx: &egui::Context, ed: &mut Editor, st: &mut UiState, acts: Acts
st.chat.file_issue(key, d, repo);
}
if let Some(key) = acts.discard_issue {
st.chat.record_issue(&key, crate::chat::IssueOutcome::Discarded);
st.chat.issues.insert(key, IssueState::Discarded);
}
if let Some(id) = acts.restore {
@@ -1791,7 +1831,7 @@ fn header(ui: &mut Ui, st: &mut UiState) -> Option<egui::Rect> {
ui.horizontal(|ui| {
let right = 64.0;
if let Some(mut t) = st.chat.renaming.take() {
let r = ui.add(egui::TextEdit::singleline(&mut t).desired_width(ui.available_width() - right));
let r = crate::theme::lit(ui, |ui| ui.add(egui::TextEdit::singleline(&mut t).desired_width(ui.available_width() - right)));
if !r.has_focus() && !r.lost_focus() {
r.request_focus();
}
@@ -1811,8 +1851,11 @@ fn header(ui: &mut Ui, st: &mut UiState) -> Option<egui::Rect> {
if kit::lottie_button(ui, "settings", "Chat settings", 26.0).clicked() {
st.settings.open_chat();
}
let list = theme::icon_button(ui, Icon::Page, 26.0, false).on_hover_text("Chats");
let list = theme::icon_button(ui, Icon::Restore, 26.0, false).on_hover_text("Chat history");
chat_menu(&list, st);
if theme::icon_button(ui, Icon::Plus, 26.0, false).on_hover_text("New chat").clicked() && !st.chat.is_running() {
st.chat.start_new_chat();
}
});
});
let label = match (&st.chat.chat, &st.chat.detect) {
@@ -1824,7 +1867,15 @@ fn header(ui: &mut Ui, st: &mut UiState) -> Option<egui::Rect> {
let ds_tokens = if st.chat.ds_text().is_empty() { 0 } else { st.chat.ds.tokens() };
let mut rect = None;
ui.horizontal(|ui| {
let r = kit::chip_ink(ui, &label, st.chat.picker_open.then(theme::accent));
const LOGO: f32 = 14.0;
let backend = st.chat.chat.as_ref().map(|c| c.backend.clone());
let has_logo = backend.as_ref().is_some_and(|b| chat_agents::backend_logo(b, &st.settings.chat).is_some());
let ink = st.chat.picker_open.then(theme::accent);
let r = kit::chip_lead(ui, &label, ink, if has_logo { LOGO + 4.0 } else { 0.0 });
if let Some(b) = backend.filter(|_| has_logo) {
let at = egui::Rect::from_center_size(egui::pos2(r.rect.left() + 10.0 + LOGO / 2.0, r.rect.center().y), egui::vec2(LOGO, LOGO));
chat_agents::paint_backend_logo(ui, at, &b, &st.settings.chat, ink.unwrap_or(p.secondary));
}
rect = Some(r.rect);
if r.clicked() {
st.chat.picker_open = !st.chat.picker_open;
@@ -1856,56 +1907,100 @@ fn header(ui: &mut Ui, st: &mut UiState) -> Option<egui::Rect> {
st.chat.fresh_next = !st.chat.fresh_next;
}
let (bar, _) = ui.allocate_exact_size(egui::vec2(36.0, 3.0), egui::Sense::hover());
ui.painter().rect_filled(bar, 2.0, p.separator);
ui.painter().rect_filled(bar, crate::theme::r(2.0), p.separator);
let frac = (used as f32 / window as f32).clamp(0.0, 1.0);
let fill = egui::Rect::from_min_size(bar.min, egui::vec2(bar.width() * frac, bar.height()));
ui.painter().rect_filled(fill, 2.0, if frac > CONTEXT_WARN || long { p.warning } else { theme::accent() });
ui.painter().rect_filled(fill, crate::theme::r(2.0), if frac > CONTEXT_WARN || long { p.warning } else { theme::accent() });
});
});
rect
}
/// New chat, the other chats of the project (switch), and delete.
/// Chat history: search, the project's chats grouped by when they were last used (switch,
/// delete on hover), and a link to report an issue.
fn chat_menu(anchor: &egui::Response, st: &mut UiState) {
const DAY_MS: u64 = 24 * 60 * 60 * 1000;
const ROW_H: f32 = 32.0;
const LOGO: f32 = 14.0;
const LIST_MAX_H: f32 = 360.0;
let running = st.chat.is_running();
egui::Popup::menu(anchor).width(280.0).show(|ui| {
egui::Popup::menu(anchor).width(300.0).show(|ui| {
crate::theme::popup_backing(ui);
let p = theme::pal(ui);
ui.spacing_mut().item_spacing.y = 2.0;
if kit::button_wide(ui, kit::ButtonKind::Primary, Some(Icon::Plus), "New chat").clicked() && !running {
st.chat.start_new_chat();
ui.close();
}
if ui.add(egui::Button::new(RichText::new("Report a PaperCraft issue\u{2026}").size(theme::SECONDARY + 1.0)).frame(false)).clicked() {
st.chat.issues.remove(FORM_ISSUE);
st.chat.issue_form = Some(IssueDraft { kind: "bug".into(), title: String::new(), body: ISSUE_TEMPLATE.into() });
ui.close();
}
kit::icon_field(ui, Icon::Search, &mut st.chat.history_query, "Search chats");
ui.add_space(4.0);
let current = st.chat.chat.as_ref().map(|c| c.id.clone());
let now = now_ms();
for c in st.chat.chats.clone() {
ui.horizontal(|ui| {
let needle = st.chat.history_query.trim().to_lowercase();
let chats: Vec<Chat> = st.chat.chats.iter().filter(|c| needle.is_empty() || c.title.to_lowercase().contains(&needle)).cloned().collect();
let group = |updated: u64| match now.saturating_sub(updated) / DAY_MS {
0 => "Today",
1 => "Yesterday",
2..=6 => "This week",
_ => "Older",
};
egui::ScrollArea::vertical().max_height(LIST_MAX_H).show(ui, |ui| {
let mut last = "";
for c in &chats {
let g = group(c.updated);
if g != last {
ui.add_space(if last.is_empty() { 0.0 } else { 6.0 });
ui.label(RichText::new(g.to_uppercase()).size(theme::SECONDARY - 1.0).color(p.tertiary));
last = g;
}
let on = current.as_deref() == Some(c.id.as_str());
ui.add_enabled_ui(!running, |ui| {
let title = RichText::new(&c.title).font(if on { theme::semibold(theme::BODY) } else { egui::FontId::proportional(theme::BODY) });
let w = ui.available_width() - 30.0;
let r = ui.add_sized([w, 24.0], egui::Button::selectable(on, title).frame(false));
if r.on_hover_text(super::home::relative_time(c.updated, now)).clicked() {
st.chat.switch_to(&c.id);
ui.close();
}
});
// The chat of a running turn cannot be deleted: its events and history still land there.
let (row, r) = ui.allocate_exact_size(egui::vec2(ui.available_width(), ROW_H), egui::Sense::click());
let hover = ui.rect_contains_pointer(row);
if on {
theme::fill_selected(ui, row, theme::radius_md(), p.field);
} else if hover {
ui.painter().rect_filled(row, theme::radius_md(), p.hover);
}
let ink = if on { p.text } else { p.secondary };
let logo = egui::Rect::from_center_size(egui::pos2(row.left() + 8.0 + LOGO / 2.0, row.center().y), egui::vec2(LOGO, LOGO));
if !chat_agents::paint_backend_logo(ui, logo, &c.backend, &st.settings.chat, ink) {
theme::paint_icon(ui.painter(), logo, Icon::Note, ink);
}
// The trash takes the time's place on hover; a running turn's chat cannot be deleted.
let busy = running && st.chat.turn_chat.as_deref() == Some(c.id.as_str());
let del = ui.add_enabled_ui(!busy, |ui| theme::icon_button(ui, Icon::Trash, 24.0, false).on_hover_text("Delete chat").clicked()).inner;
if del {
st.chat.delete_chat = Some(c.id.clone());
let trash = egui::Rect::from_center_size(egui::pos2(row.right() - 16.0, row.center().y), egui::vec2(24.0, 24.0));
let right = if hover && !busy {
trash.left()
} else {
let t = super::home::relative_time(c.updated, now);
let g = ui.painter().layout_no_wrap(t, egui::FontId::proportional(theme::SECONDARY), p.tertiary);
let x = row.right() - 10.0 - g.size().x;
ui.painter().galley(egui::pos2(x, row.center().y - g.size().y / 2.0), g, p.tertiary);
x
};
let font = if on { theme::semibold(theme::BODY) } else { egui::FontId::proportional(theme::BODY) };
let title_x = logo.right() + 10.0;
let clip = egui::Rect::from_min_max(egui::pos2(title_x, row.top()), egui::pos2(right - 8.0, row.bottom()));
ui.painter().with_clip_rect(clip).text(egui::pos2(title_x, row.center().y), egui::Align2::LEFT_CENTER, &c.title, font, if on { p.text } else { p.text.gamma_multiply(0.9) });
if hover && !busy {
let del = ui.put(trash, |ui: &mut Ui| theme::icon_button(ui, Icon::Trash, 24.0, false)).on_hover_text("Delete chat");
if del.clicked() {
st.chat.delete_chat = Some(c.id.clone());
ui.close();
continue;
}
}
if r.clicked() && !running {
st.chat.switch_to(&c.id);
ui.close();
}
});
}
if st.chat.chats.is_empty() {
ui.label(RichText::new("No saved chats yet").size(theme::SECONDARY).color(p.tertiary));
}
if chats.is_empty() {
let msg = if st.chat.chats.is_empty() { "No saved chats yet" } else { "No chats match" };
ui.label(RichText::new(msg).size(theme::SECONDARY).color(p.tertiary));
}
});
ui.separator();
if super::icon_menu_item(ui, Icon::Question, "Report a PaperCraft issue\u{2026}").clicked() {
st.chat.issues.remove(FORM_ISSUE);
st.chat.issue_form = Some(IssueDraft { kind: "bug".into(), title: String::new(), body: ISSUE_TEMPLATE.into() });
ui.close();
}
});
}
@@ -1964,7 +2059,7 @@ fn sel_chip(ui: &mut Ui, label: &str) -> bool {
let shown = if rest.is_empty() { &parts[..2] } else { &parts[..3] };
let w = 2.0 * GAP + shown.iter().chain([&parts[3]]).map(|g| g.size().x + GAP).sum::<f32>();
let (rect, resp) = ui.allocate_exact_size(egui::vec2(w, kit::CHIP_H), egui::Sense::click());
ui.painter().rect_filled(rect, egui::CornerRadius::same(255), theme::blend(p.field, p.hover.gamma_multiply(f32::from(u8::from(resp.hovered())))));
theme::fill_control(ui, rect, 255, theme::blend(p.field, p.hover.gamma_multiply(f32::from(u8::from(resp.hovered())))));
let mut x = rect.left() + GAP + 2.0;
for g in shown.iter().chain([&parts[3]]) {
let pos = egui::pos2(x, rect.center().y - g.size().y / 2.0);
@@ -1990,7 +2085,7 @@ fn composer(ui: &mut Ui, ed: &mut Editor, st: &mut UiState, column: egui::Rect,
let _ = column;
let enter = focused && ui.input_mut(|i| !i.modifiers.shift && i.consume_key(egui::Modifiers::NONE, egui::Key::Enter));
let mut submit = false;
egui::Frame::new().fill(p.popover).corner_radius(theme::RADIUS_PANEL).inner_margin(egui::Margin::symmetric(12, 10)).show(ui, |ui| {
egui::Frame::new().fill(p.popover).corner_radius(theme::radius_panel()).inner_margin(egui::Margin::symmetric(12, 10)).show_surface(ui, |ui| {
ui.set_width(ui.available_width());
ui.spacing_mut().item_spacing.y = 6.0;
// Messages added to the running turn: waiting for the agent's next step, or seen.
@@ -2056,7 +2151,9 @@ fn composer(ui: &mut Ui, ed: &mut Editor, st: &mut UiState, column: egui::Rect,
);
});
// Buttons on the right; the chips wrap in what is left instead of running under them.
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
// Bounded to one button's height: a centred layout given the open height would claim
// all of it, stretching the composer over the whole chat column.
ui.allocate_ui_with_layout(egui::vec2(ui.available_width(), kit::BUTTON_H), egui::Layout::right_to_left(egui::Align::Center), |ui| {
if running {
let stopping = st.chat.stopping;
let label = if stopping { "Stopping\u{2026}" } else { "Stop" };
+85 -13
View File
@@ -2,6 +2,7 @@
//! column. `group_agents`, `backend_for`, `current_pick` and `auth_error` are plain functions so
//! they are tested without a frame; the drawing only calls them.
use crate::theme::Surface as _;
use super::{Detect, EndpointState};
use crate::chat::agents::{self, AgentKind, AgentState};
use crate::chat::{Backend, BackendKind};
@@ -272,6 +273,68 @@ pub struct PanelIn<'a> {
enum Badge<'a> {
Letter(&'a str),
Icon(Icon),
/// A brand mark: an SVG drawn in `currentColor`, tinted with the row's ink.
Logo(&'static str, &'static [u8]),
}
/// The vendor's mark for an agent (LobeHub icons, MIT); agents without one keep their letter.
fn logo(agent: &str) -> Option<(&'static str, &'static [u8])> {
macro_rules! svg {
($f:literal) => {
($f, include_bytes!(concat!("../../assets/agents/", $f, ".svg")).as_slice())
};
}
Some(match agent {
"claude-code" | "claude-acp" => svg!("claude"),
"codex" | "codex-acp" => svg!("openai"),
"gemini" => svg!("gemini"),
"cursor" => svg!("cursor"),
"goose" => svg!("goose"),
"kimi" => svg!("kimi"),
"vibe" => svg!("mistral"),
"hermes" => svg!("nousresearch"),
"qwen" => svg!("qwen"),
"cline" => svg!("cline"),
"copilot" => svg!("githubcopilot"),
"codebuddy" => svg!("tencent"),
_ => return None,
})
}
/// The mark of the agent behind `b`: its registry logo, Claude's for a Claude API endpoint.
pub fn backend_logo(b: &Backend, prefs: &ChatPrefs) -> Option<(&'static str, &'static [u8])> {
match current_pick(b, prefs) {
Pick::Agent(id) => logo(&id),
Pick::Endpoint(id) => prefs.endpoint(Some(&id)).is_claude_host().then(|| logo("claude-code")).flatten(),
}
}
/// Paints `b`'s logo into `rect` tinted `ink`; false when it has none.
pub fn paint_backend_logo(ui: &Ui, rect: Rect, b: &Backend, prefs: &ChatPrefs, ink: Color32) -> bool {
let Some((name, svg)) = backend_logo(b, prefs) else { return false };
let Some(t) = logo_texture(ui.ctx(), name, svg) else { return false };
let uv = Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0));
ui.painter().image(t.id(), rect, uv, ink);
true
}
/// Pixels a logo is rasterised at (white, so the tint gives its colour).
const LOGO_PX: u32 = 64;
fn logo_texture(ctx: &egui::Context, name: &str, svg: &[u8]) -> Option<egui::TextureHandle> {
let id = Id::new(("agent-logo", name));
if let Some(t) = ctx.data(|d| d.get_temp::<egui::TextureHandle>(id)) {
return Some(t);
}
let svg = std::str::from_utf8(svg).ok()?.replace("currentColor", "#fff");
let tree = usvg::Tree::from_str(&svg, &usvg::Options::default()).ok()?;
let scale = LOGO_PX as f32 / tree.size().width().max(tree.size().height());
let mut pixmap = resvg::tiny_skia::Pixmap::new(LOGO_PX, LOGO_PX)?;
resvg::render(&tree, resvg::tiny_skia::Transform::from_scale(scale, scale), &mut pixmap.as_mut());
let img = egui::ColorImage::from_rgba_premultiplied([LOGO_PX as usize; 2], pixmap.data());
let t = ctx.load_texture(format!("agent-logo-{name}"), img, egui::TextureOptions::LINEAR);
ctx.data_mut(|d| d.insert_temp(id, t.clone()));
Some(t)
}
struct Spec<'a> {
@@ -292,18 +355,24 @@ fn shell(ui: &mut Ui, s: &Spec) -> (Rect, bool) {
let resp = ui.interact(rect, s.id, Sense::click());
let hover = s.click && resp.hovered();
if let Some(f) = s.fill.or(hover.then_some(p.hover)) {
ui.painter().rect_filled(rect, theme::RADIUS_LG, f);
ui.painter().rect_filled(rect, theme::radius_lg(), f);
}
if hover {
ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand);
}
let badge = Rect::from_min_size(pos2(rect.left() + PAD, rect.center().y - BADGE / 2.0), vec2(BADGE, BADGE));
ui.painter().rect_filled(badge, theme::RADIUS_LG, p.field);
theme::fill_control(ui, badge, theme::radius_lg(), p.field);
match s.badge {
Badge::Letter(l) => {
ui.painter().text(badge.center(), egui::Align2::CENTER_CENTER, l, theme::semibold(theme::BODY), s.ink);
}
Badge::Icon(i) => theme::paint_icon(ui.painter(), badge.shrink(7.0), i, s.ink),
Badge::Logo(name, svg) => {
if let Some(t) = logo_texture(ui.ctx(), name, svg) {
let uv = Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0));
ui.painter().image(t.id(), badge.shrink(7.0), uv, s.ink);
}
}
}
let x = badge.right() + PAD + 2.0;
let text = ui.painter().with_clip_rect(Rect::from_min_max(rect.min, pos2(rect.right() - RIGHT_W, rect.bottom())));
@@ -345,7 +414,10 @@ fn agent_row(ui: &mut Ui, scope: &str, r: &Row, selected: bool, focused: bool) -
let letter = r.name.chars().next().map(|c| c.to_uppercase().to_string()).unwrap_or_default();
let spec = Spec {
id,
badge: Badge::Letter(&letter),
badge: match &r.pick {
Pick::Agent(a) => logo(a).map_or(Badge::Letter(&letter), |(n, svg)| Badge::Logo(n, svg)),
Pick::Endpoint(_) => Badge::Letter(&letter),
},
name: &r.name,
version: &r.version,
// A missing agent's sub line is drawn below, with its links.
@@ -412,11 +484,11 @@ fn nav_row(ui: &mut Ui, scope: &str, icon: Icon, name: &str, sub: &str, card: bo
enum Fold {
None,
Action(&'static str),
Action(Icon),
Open(bool),
}
/// "DETECTED 7" with an action ("Add") or a fold chevron on the right. True when clicked.
/// "DETECTED 7" with an action (a + icon) or a fold chevron on the right. True when clicked.
fn section(ui: &mut Ui, title: &str, count: Option<usize>, fold: Fold) -> bool {
let p = theme::pal(ui);
ui.add_space(6.0);
@@ -434,8 +506,8 @@ fn section(ui: &mut Ui, title: &str, count: Option<usize>, fold: Fold) -> bool {
ui.painter().text(pos2(edge, rect.center().y), egui::Align2::RIGHT_CENTER, n.to_string(), font, p.tertiary);
}
}
Fold::Action(t) => {
ui.painter().text(pos2(edge, rect.center().y), egui::Align2::RIGHT_CENTER, t, font, theme::accent());
Fold::Action(icon) => {
theme::paint_icon(ui.painter(), Rect::from_center_size(pos2(edge - 7.0, rect.center().y), vec2(14.0, 14.0)), icon, theme::accent());
}
Fold::Open(open) => {
let icon = if open { Icon::ChevronDown } else { Icon::ChevronRight };
@@ -475,7 +547,7 @@ pub fn panel(ctx: &egui::Context, ps: &mut PanelState, i: &PanelIn) -> Option<Ac
// Dimmed backdrop over the column: a click outside the panel closes it.
egui::Area::new("chat-agent-backdrop".into()).order(egui::Order::Foreground).fade_in(false).fixed_pos(i.column.min).show(ctx, |ui| {
let (r, resp) = ui.allocate_exact_size(i.column.size(), Sense::click());
ui.painter().rect_filled(r, theme::RADIUS_PANEL, Color32::from_black_alpha(BACKDROP_ALPHA));
ui.painter().rect_filled(r, theme::radius_panel(), Color32::from_black_alpha(BACKDROP_ALPHA));
if resp.clicked() {
act = Some(Act::Close);
}
@@ -488,10 +560,10 @@ pub fn panel(ctx: &egui::Context, ps: &mut PanelState, i: &PanelIn) -> Option<Ac
egui::Frame::new()
.fill(p.popover)
.stroke(egui::Stroke::new(1.0, p.separator))
.corner_radius(theme::RADIUS_PANEL)
.corner_radius(theme::radius_panel())
.inner_margin(PANEL_PAD)
.shadow(egui::Shadow { offset: [0, 12], blur: SHADOW_BLUR, spread: 0, color: Color32::from_black_alpha(SHADOW_ALPHA) })
.show(ui, |ui| {
.show_surface(ui, |ui| {
ui.set_width(i.column.width() - 2.0 * PANEL_PAD);
ui.spacing_mut().item_spacing.y = 2.0;
ui.horizontal(|ui| {
@@ -503,7 +575,7 @@ pub fn panel(ctx: &egui::Context, ps: &mut PanelState, i: &PanelIn) -> Option<Ac
});
});
ui.add_space(4.0);
let search = ui.add(egui::TextEdit::singleline(&mut ps.query).hint_text("Search agents").margin(theme::INPUT_MARGIN).desired_width(f32::INFINITY));
let search = kit::icon_field(ui, Icon::Search, &mut ps.query, "Search agents");
if std::mem::take(&mut ps.fresh) {
search.request_focus();
}
@@ -529,7 +601,7 @@ pub fn panel(ctx: &egui::Context, ps: &mut PanelState, i: &PanelIn) -> Option<Ac
if !g.missing.is_empty() && ps.missing_open {
draw_rows(ui, &g.missing, &selected, ps.sel, &mut n, i.running, &mut act);
}
if !g.endpoints.is_empty() && section(ui, "API endpoints", None, Fold::Action("Add")) {
if !g.endpoints.is_empty() && section(ui, "API endpoints", None, Fold::Action(Icon::Plus)) {
act = Some(Act::Settings);
}
draw_rows(ui, &g.endpoints, &selected, ps.sel, &mut n, i.running, &mut act);
@@ -611,7 +683,7 @@ pub fn setup(ui: &mut Ui, d: &Detect, prefs: &ChatPrefs) -> Option<Act> {
let ready: Vec<&Row> = g.detected.iter().chain(g.custom.iter().filter(|r| r.is_selectable())).collect();
let mut act = None;
ui.add_space(ui.available_height() * 0.04);
egui::Frame::new().fill(p.popover).corner_radius(theme::RADIUS_PANEL).inner_margin(PANEL_PAD).show(ui, |ui| {
egui::Frame::new().fill(p.popover).corner_radius(theme::radius_panel()).inner_margin(PANEL_PAD).show_surface(ui, |ui| {
ui.set_width(ui.available_width());
ui.spacing_mut().item_spacing.y = 4.0;
kit::badge(ui, Icon::Laser, theme::accent(), 40.0);
+2 -1
View File
@@ -2,6 +2,7 @@
//! fenced code blocks (with a copy button), bold, italic, inline code and links. Unclosed markers
//! stay literal, so half-streamed text never flickers into a different shape.
use crate::theme::Surface as _;
use crate::theme;
use egui::text::{LayoutJob, TextFormat};
use egui::{Align, FontFamily, FontId, Id, RichText, Ui};
@@ -195,7 +196,7 @@ fn spans(ui: &mut Ui, spans: &[Span], size: f32, heading: bool) {
fn code_block(ui: &mut Ui, lang: &str, text: &str) {
let p = theme::pal(ui);
egui::Frame::new().fill(p.field).corner_radius(theme::RADIUS_LG).inner_margin(egui::Margin::symmetric(12, 10)).show(ui, |ui| {
egui::Frame::new().fill(p.field).corner_radius(theme::radius_lg()).inner_margin(egui::Margin::symmetric(12, 10)).show_surface(ui, |ui| {
ui.set_width(ui.available_width());
ui.horizontal(|ui| {
let label = if lang.is_empty() { "code" } else { lang };
+214 -43
View File
@@ -2,6 +2,7 @@
//! groups, plan, approval, checkpoint and run cards, usage line, empty state. Drawing returns the
//! user's choices in [`Acts`]; `chat.rs` applies them (it owns the state and the editor).
use crate::theme::Surface as _;
use super::chat::Approval;
use super::chat_md;
use super::kit;
@@ -21,7 +22,8 @@ const ASK_TOOL: &str = "ask_user";
const REFS_TOOL: &str = "fetch_reference_images";
const ISSUE_TOOL: &str = "report_issue";
/// Lines of an issue draft's body shown on its card.
const ISSUE_PREVIEW_LINES: usize = 6;
/// Fields of an issue body shown before "Show all".
const ISSUE_PREVIEW_FIELDS: usize = 3;
const GEN_TOOL: &str = "generate_image";
const REF_THUMB_H: f32 = 96.0;
/// Opacity of a fetched image the agent did not keep.
@@ -429,7 +431,7 @@ fn mark(ui: &mut Ui, state: ToolState) {
fn card(ui: &Ui) -> egui::Frame {
egui::Frame::new()
.fill(theme::pal(ui).popover)
.corner_radius(theme::RADIUS_LG)
.corner_radius(theme::radius_lg())
.inner_margin(egui::Margin::symmetric(12, 8))
}
@@ -464,7 +466,7 @@ fn code_section(ui: &mut Ui, id: Id, label: &str, value: impl FnOnce() -> Value,
}
made
});
egui::Frame::new().fill(p.field).corner_radius(theme::RADIUS_MD).inner_margin(egui::Margin::symmetric(10, 8)).show(ui, |ui| {
egui::Frame::new().fill(p.field).corner_radius(theme::radius_md()).inner_margin(egui::Margin::symmetric(10, 8)).show_surface(ui, |ui| {
ui.set_width(ui.available_width());
let ink = if error { p.danger } else { p.secondary };
ui.label(RichText::new(label).size(theme::SECONDARY).color(ink));
@@ -532,7 +534,7 @@ pub fn image_thumb(ui: &mut Ui, key: &str, h: f32, bytes: impl FnOnce() -> Optio
let [w, th] = tex.size();
let w = (h * w as f32 / th.max(1) as f32).clamp(h * 0.5, h * 3.0);
let resp = ui
.add(egui::Image::new(&tex).fit_to_exact_size(vec2(w, h)).corner_radius(theme::RADIUS_MD).sense(Sense::click()))
.add(egui::Image::new(&tex).fit_to_exact_size(vec2(w, h)).corner_radius(theme::radius_md()).sense(Sense::click()))
.on_hover_cursor(egui::CursorIcon::ZoomIn);
if resp.clicked() {
ui.data_mut(|d| d.insert_temp(Id::new("chat-lightbox"), Some(tex)));
@@ -548,7 +550,7 @@ pub fn lightbox(ctx: &egui::Context) {
let size = tex.size_vec2();
let size = size * (max.x / size.x).min(max.y / size.y).min(1.0);
let modal = egui::Modal::new(id.with("modal")).frame(egui::Frame::NONE).show(ctx, |ui| {
ui.add(egui::Image::new(&tex).fit_to_exact_size(size).corner_radius(theme::RADIUS_MD).sense(Sense::click())).clicked()
ui.add(egui::Image::new(&tex).fit_to_exact_size(size).corner_radius(theme::radius_md()).sense(Sense::click())).clicked()
});
if modal.inner || modal.should_close() {
ctx.data_mut(|d| d.remove::<Option<egui::TextureHandle>>(id));
@@ -563,7 +565,7 @@ fn tool_card(ui: &mut Ui, t: &ToolCall, state: ToolState, acts: &mut Acts) {
let open = get_open(ui, id, false);
let shot = t.result.as_ref().and_then(|r| thumb(ui, &t.id, r));
let target = tool_target(&t.args);
card(ui).show(ui, |ui| {
card(ui).show_surface(ui, |ui| {
ui.set_width(ui.available_width());
ui.spacing_mut().item_spacing.y = 6.0;
let ink = match state {
@@ -594,7 +596,7 @@ fn tool_card(ui: &mut Ui, t: &ToolCall, state: ToolState, acts: &mut Acts) {
ui.horizontal(|ui| {
let h = THUMB_H;
let w = h * s.size[0] as f32 / s.size[1].max(1) as f32;
ui.add(egui::Image::new(&s.tex).fit_to_exact_size(vec2(w.clamp(24.0, 120.0), h)).corner_radius(theme::RADIUS_MD));
ui.add(egui::Image::new(&s.tex).fit_to_exact_size(vec2(w.clamp(24.0, 120.0), h)).corner_radius(theme::radius_md()));
ui.vertical(|ui| {
muted(ui, format!("{} x {}", s.size[0], s.size[1]));
muted(ui, format!("Result image, {} KB", (s.bytes / KB).max(1)));
@@ -736,7 +738,7 @@ fn user_bubble(ui: &mut Ui, text: &str) {
if prompt.is_empty() {
return;
}
egui::Frame::new().fill(p.popover).corner_radius(theme::RADIUS_PANEL).inner_margin(egui::Margin::symmetric(12, 9)).show(ui, |ui| {
egui::Frame::new().fill(p.popover).corner_radius(theme::radius_panel()).inner_margin(egui::Margin::symmetric(12, 9)).show_surface(ui, |ui| {
ui.set_max_width(ui.available_width() * 0.88);
ui.add(egui::Label::new(prompt).wrap());
});
@@ -764,7 +766,7 @@ fn checkpoint(ui: &mut Ui, run: &Run, acts: &mut Acts) {
fn run_card(ui: &mut Ui, run: &Run, acts: &mut Acts) {
let p = theme::pal(ui);
let changes = run.added + run.changed + run.removed;
card(ui).show(ui, |ui| {
card(ui).show_surface(ui, |ui| {
ui.set_width(ui.available_width());
ui.horizontal(|ui| {
kit::badge(ui, Icon::Pen, theme::accent(), 30.0);
@@ -805,7 +807,7 @@ fn run_card(ui: &mut Ui, run: &Run, acts: &mut Acts) {
pub fn approval_card(ui: &mut Ui, a: &Approval, acts: &mut Acts) {
let p = theme::pal(ui);
let (title, detail) = approval_text(a);
card(ui).show(ui, |ui| {
card(ui).show_surface(ui, |ui| {
ui.set_width(ui.available_width());
ui.horizontal(|ui| {
ui.vertical(|ui| {
@@ -833,7 +835,7 @@ pub fn plan_card(ui: &mut Ui, todos: &[Todo]) {
let id = Id::new("chat-plan");
let open = get_open(ui, id, true);
let done = todos.iter().filter(|t| t.status == TodoStatus::Done).count();
card(ui).show(ui, |ui| {
card(ui).show_surface(ui, |ui| {
ui.set_width(ui.available_width());
ui.spacing_mut().item_spacing.y = 4.0;
if header(
@@ -931,7 +933,7 @@ fn issue_card(ui: &mut Ui, t: &ToolCall, state: Option<&super::chat::IssueState>
let p = theme::pal(ui);
let s = |k: &str| t.args[k].as_str().unwrap_or_default().to_owned();
let draft = super::chat::IssueDraft { kind: s("kind"), title: s("title"), body: s("body") };
card(ui).show(ui, |ui| {
card(ui).show_surface(ui, |ui| {
ui.set_width(ui.available_width());
ui.spacing_mut().item_spacing.y = 6.0;
ui.horizontal(|ui| {
@@ -941,7 +943,23 @@ fn issue_card(ui: &mut Ui, t: &ToolCall, state: Option<&super::chat::IssueState>
kit::chip_ink(ui, &draft.kind, None);
});
ui.label(RichText::new(&draft.title).font(theme::semibold(theme::BODY)));
ui.add(egui::Label::new(RichText::new(draft.body.lines().take(ISSUE_PREVIEW_LINES).collect::<Vec<_>>().join("\n")).size(theme::SECONDARY).color(p.secondary)).wrap());
// `**Expected:** …` lines read as a field list: the label above its value (markdown).
let fields = issue_fields(&draft.body);
let more_id = Id::new(("issue-more", &t.id));
let all = ui.data(|d| d.get_temp::<bool>(more_id)).unwrap_or(false);
let shown = if all { fields.len() } else { fields.len().min(ISSUE_PREVIEW_FIELDS) };
for (i, (label, value)) in fields.iter().take(shown).enumerate() {
if !label.is_empty() {
ui.label(RichText::new(label.to_uppercase()).size(theme::SECONDARY - 1.0).color(p.tertiary));
}
ui.scope(|ui| {
ui.visuals_mut().override_text_color = Some(p.secondary);
super::chat_md::render(ui, Id::new(("issue-field", &t.id, i)), value, theme::SECONDARY + 1.0);
});
}
if fields.len() > ISSUE_PREVIEW_FIELDS && ui.link(RichText::new(if all { "Show less" } else { "Show all" }).size(theme::SECONDARY)).clicked() {
ui.data_mut(|d| d.insert_temp(more_id, !all));
}
match state {
Some(IssueState::Sending) => {
ui.horizontal(|ui| {
@@ -970,6 +988,27 @@ fn issue_card(ui: &mut Ui, t: &ToolCall, state: Option<&super::chat::IssueState>
});
}
/// An issue body as `(label, value)` pairs: a `**Label:** value` line starts a field, other lines
/// continue it; text before the first label has an empty label.
fn issue_fields(body: &str) -> Vec<(String, String)> {
let mut out: Vec<(String, String)> = Vec::new();
for line in body.lines() {
let field = line.trim_start().strip_prefix("**").and_then(|r| r.split_once(":**"));
match field {
Some((label, rest)) => out.push((label.trim().to_owned(), rest.trim().to_owned())),
None => match out.last_mut() {
Some((_, v)) => {
v.push('\n');
v.push_str(line);
}
None => out.push((String::new(), line.to_owned())),
},
}
}
out.retain(|(l, v)| !l.is_empty() || !v.trim().is_empty());
out
}
/// An `ask_user` call that went through: drawn as a question card, not a tool card.
fn is_question(t: &ToolCall) -> bool {
t.name == ASK_TOOL && t.error.is_none()
@@ -1003,7 +1042,7 @@ fn reference_gallery(ui: &mut Ui, t: &ToolCall, offered: &[String], acts: &mut A
if images.is_empty() {
return;
}
card(ui).show(ui, |ui| {
card(ui).show_surface(ui, |ui| {
ui.set_width(ui.available_width());
ui.spacing_mut().item_spacing.y = 6.0;
ui.horizontal(|ui| {
@@ -1042,59 +1081,163 @@ fn reference_gallery(ui: &mut Ui, t: &ToolCall, offered: &[String], acts: &mut A
});
}
/// The agent's `ask_user` questions as a card: a row of answer chips per question (a palette
/// option shows its swatches), then Send. Picks live in egui memory until sent.
fn question_card(ui: &mut Ui, t: &ToolCall, open: bool, acts: &mut Acts) {
/// The agent's `ask_user` questions as a card: a row of answers per question (a palette option
/// shows its swatches), an inline field for another answer, then Send.
///
/// One single-choice question answers on click (or its number key). Several questions, or
/// several picks, collect in egui memory until Send. Once answered (`answer` is the next user
/// message), the card shows what was picked and dims the rest.
fn question_card(ui: &mut Ui, t: &ToolCall, open: bool, answer: Option<&str>, acts: &mut Acts) {
let Some(qs) = t.args["questions"].as_array() else { return };
let id = Id::new(("chat-ask", &t.id));
let other_id = id.with("other");
let mut picks: Vec<Vec<usize>> = ui.data(|d| d.get_temp(id)).unwrap_or_else(|| vec![Vec::new(); qs.len()]);
card(ui).show(ui, |ui| {
let mut other: String = ui.data(|d| d.get_temp(other_id)).unwrap_or_default();
let one_click = qs.len() == 1 && !qs[0]["multiple"].as_bool().unwrap_or(false);
// What the answer message picked, per question (labels), when the card is closed.
let answered: Option<Vec<Vec<String>>> = answer.filter(|_| !open).map(|a| qs.iter().map(|q| picked_in(a, q)).collect());
let p = theme::pal(ui);
// Number keys pick an option of a single open question while no field has focus.
let key_pick = (open && qs.len() == 1 && ui.memory(|m| m.focused().is_none()))
.then(|| ui.input(|i| (1..=9).find(|n| i.key_pressed(digit_key(*n)))))
.flatten()
.map(|n| n - 1);
card(ui).show_surface(ui, |ui| {
ui.set_width(ui.available_width());
ui.spacing_mut().item_spacing.y = 6.0;
for (qi, q) in qs.iter().enumerate() {
ui.label(RichText::new(q["question"].as_str().unwrap_or_default()).font(theme::semibold(theme::BODY)));
ui.horizontal(|ui| {
let (r, _) = ui.allocate_exact_size(vec2(14.0, 14.0), Sense::hover());
theme::paint_icon(ui.painter(), r, Icon::Question, theme::accent());
ui.add(egui::Label::new(RichText::new(q["question"].as_str().unwrap_or_default()).font(theme::semibold(theme::BODY))).wrap());
});
let multiple = q["multiple"].as_bool().unwrap_or(false);
let options: Vec<&Value> = q["options"].as_array().into_iter().flatten().collect();
ui.horizontal_wrapped(|ui| {
for (oi, o) in q["options"].as_array().into_iter().flatten().enumerate() {
for (oi, o) in options.iter().enumerate() {
let colors: Vec<Color32> = o["colors"].as_array().into_iter().flatten().filter_map(|c| crate::model::Color::from_hex(c.as_str()?)).map(super::c32).collect();
let on = picks[qi].contains(&oi);
let label = o["label"].as_str().unwrap_or_default();
let on = match &answered {
Some(a) => a[qi].iter().any(|l| l == label),
None => picks[qi].contains(&oi),
};
let dim = answered.as_ref().is_some_and(|a| !a[qi].is_empty() && !on);
let hint = (open && qs.len() == 1 && oi < 9).then_some(oi + 1);
// A reference option shows its image; a double click opens it in the lightbox.
let tex = o["image"].as_str().and_then(|path| image_tex(ui, path, || crate::vfs::read(path).ok()));
let mut r = match &tex {
Some(tex) => option_tile(ui, label, tex, on, oi == 0),
None => option_chip(ui, label, &colors, on, oi == 0),
};
let mut r = ui
.scope(|ui| {
if dim {
ui.multiply_opacity(ANSWER_DIM);
}
match &tex {
Some(tex) => option_tile(ui, label, tex, on, oi == 0),
None => option_chip(ui, label, &colors, on, oi == 0, hint),
}
})
.inner;
if let Some(tex) = tex.filter(|_| r.double_clicked()) {
ui.data_mut(|d| d.insert_temp(Id::new("chat-lightbox"), Some(tex)));
}
if let Some(d) = o["detail"].as_str() {
r = r.on_hover_text(d);
}
if r.clicked() && open {
match (multiple, on) {
(true, true) => picks[qi].retain(|&x| x != oi),
(true, false) => picks[qi].push(oi),
(false, _) => picks[qi] = vec![oi],
}
let picked = (r.clicked() || key_pick == Some(oi)) && open;
if !picked {
continue;
}
match (multiple, picks[qi].contains(&oi)) {
(true, true) => picks[qi].retain(|&x| x != oi),
(true, false) => picks[qi].push(oi),
(false, _) => picks[qi] = vec![oi],
}
if one_click {
acts.send = Some(answers_text(qs, &picks));
}
}
});
// An answer in the message that is none of the options (typed, or Auto mode's note).
if let (Some(a), Some(text)) = (&answered, answer)
&& a[qi].is_empty()
&& let Some(own) = own_answer(text, q)
{
ui.horizontal(|ui| {
let (r, _) = ui.allocate_exact_size(vec2(14.0, 14.0), Sense::hover());
theme::paint_icon(ui.painter(), r, Icon::ArrowRight, p.tertiary);
ui.add(egui::Label::new(RichText::new(own).size(theme::SECONDARY + 1.0).color(p.secondary)).wrap());
});
}
}
if answered.is_some() {
ui.horizontal(|ui| {
let (r, _) = ui.allocate_exact_size(vec2(12.0, 12.0), Sense::hover());
let c = r.center();
let s = egui::Stroke::new(1.6, p.success);
ui.painter().line_segment([c + vec2(-4.0, 0.5), c + vec2(-1.0, 3.5)], s);
ui.painter().line_segment([c + vec2(-1.0, 3.5), c + vec2(4.5, -3.0)], s);
muted(ui, "Answered");
});
}
if !open {
return;
}
// Another answer, typed: Enter sends it (for the single question, or as a note).
let typed = kit::icon_field(ui, Icon::Pencil, &mut other, if qs.len() == 1 { "Other answer\u{2026}" } else { "Add a note\u{2026}" });
if typed.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) && !other.trim().is_empty() {
let text = std::mem::take(&mut other);
acts.send = Some(match qs.len() {
1 => format!("Answers:\n- {} {}", qs[0]["question"].as_str().unwrap_or_default(), text.trim()),
_ => format!("{}\n\nNote: {}", answers_text(qs, &picks), text.trim()),
});
}
ui.horizontal(|ui| {
if kit::button(ui, kit::ButtonKind::Primary, Some(Icon::ArrowRight), "Send answers").clicked() {
if !one_click && kit::button(ui, kit::ButtonKind::Primary, Some(Icon::ArrowRight), "Send answers").clicked() {
acts.send = Some(answers_text(qs, &picks));
}
if kit::chip(ui, "Use your defaults", false).on_hover_text("The option the agent recommends for each question").clicked() {
if kit::chip(ui, "Use recommended", false).on_hover_text("The option the agent recommends (marked with a dot) for each question").clicked() {
acts.send = Some("Go with the option you recommended for each question.".into());
}
muted(ui, "or type your own answer below");
if one_click {
muted(ui, "Click an answer or press its number");
}
});
});
ui.data_mut(|d| d.insert_temp(id, picks));
ui.data_mut(|d| {
d.insert_temp(id, picks);
d.insert_temp(other_id, other);
});
}
/// Opacity of the answers not picked, once a question is answered.
const ANSWER_DIM: f32 = 0.45;
fn digit_key(n: usize) -> egui::Key {
use egui::Key::*;
[Num1, Num2, Num3, Num4, Num5, Num6, Num7, Num8, Num9][n - 1]
}
/// The text after `- {question} ` in an answers message, if the message has that line.
fn answer_line<'a>(answer: &'a str, q: &Value) -> Option<&'a str> {
let question = q["question"].as_str()?;
answer.lines().find_map(|l| l.strip_prefix("- ")?.strip_prefix(question)).map(str::trim)
}
/// Labels of `q`'s options that the answers message picked.
fn picked_in(answer: &str, q: &Value) -> Vec<String> {
let Some(line) = answer_line(answer, q) else { return vec![] };
let parts: Vec<&str> = line.split(", ").map(str::trim).collect();
q["options"].as_array().into_iter().flatten().filter_map(|o| o["label"].as_str()).filter(|l| parts.contains(l)).map(str::to_owned).collect()
}
/// A typed answer to `q`: its line in the message when it is no option, else the whole
/// message when it is not an answers list at all ("go with your recommendation").
fn own_answer(answer: &str, q: &Value) -> Option<String> {
match answer_line(answer, q) {
Some("your call") => None,
Some(line) => Some(line.to_owned()),
None if !answer.starts_with("Answers:") => Some(answer.lines().next().unwrap_or_default().to_owned()),
None => None,
}
}
/// Auto mode's reply when the chat's last message ends on questions: every question answered
@@ -1134,16 +1277,25 @@ const OUTLINE_ALPHA: f32 = 0.45;
const RECOMMENDED_DOT: f32 = 6.0;
/// An answer chip: the recommended dot, swatches, then the label; the picked one in the accent.
fn option_chip(ui: &mut Ui, label: &str, colors: &[Color32], on: bool, recommended: bool) -> egui::Response {
fn option_chip(ui: &mut Ui, label: &str, colors: &[Color32], on: bool, recommended: bool, key: Option<usize>) -> egui::Response {
const SWATCH: f32 = 12.0;
const PAD: f32 = 10.0;
const KEY_W: f32 = 18.0;
let p = theme::pal(ui);
let ink = if on { theme::accent() } else { p.text };
let galley = ui.painter().layout_no_wrap(label.to_owned(), theme::semibold(theme::SECONDARY + 1.0), ink);
let keyw = if key.is_some() { KEY_W + 6.0 } else { 0.0 };
let dot = if recommended { RECOMMENDED_DOT + 6.0 } else { 0.0 };
let sw = if colors.is_empty() { 0.0 } else { colors.len() as f32 * (SWATCH - 3.0) + 3.0 + 6.0 };
let (rect, resp) = ui.allocate_exact_size(vec2(2.0 * PAD + dot + sw + galley.size().x, CHIP_TALL), Sense::click());
let (rect, resp) = ui.allocate_exact_size(vec2(2.0 * PAD + keyw + dot + sw + galley.size().x, CHIP_TALL), Sense::click());
option_frame(ui, rect, 255, on, resp.hovered());
// The number key that picks it, in a small cap.
if let Some(k) = key {
let cap = egui::Rect::from_center_size(egui::pos2(rect.left() + PAD - 2.0 + KEY_W / 2.0, rect.center().y), vec2(KEY_W, KEY_W));
ui.painter().rect_filled(cap, theme::radius_md() / 2, p.hover);
ui.painter().text(cap.center(), egui::Align2::CENTER_CENTER, k.to_string(), egui::FontId::proportional(theme::SECONDARY - 1.0), p.secondary);
}
let rect = egui::Rect::from_min_max(rect.min + vec2(keyw, 0.0), rect.max);
if recommended {
ui.painter().circle_filled(egui::pos2(rect.left() + PAD + RECOMMENDED_DOT / 2.0, rect.center().y), RECOMMENDED_DOT / 2.0, theme::accent());
}
@@ -1172,9 +1324,9 @@ fn option_tile(ui: &mut Ui, label: &str, tex: &egui::TextureHandle, on: bool, re
job.halign = Align::Center;
let galley = ui.painter().layout_job(job);
let (rect, resp) = ui.allocate_exact_size(vec2(img_w + 2.0 * PAD, OPTION_IMAGE_H + 3.0 * PAD + galley.size().y), Sense::click());
option_frame(ui, rect, theme::RADIUS_MD, on, resp.hovered());
option_frame(ui, rect, theme::radius_md(), on, resp.hovered());
let img = egui::Rect::from_min_size(rect.min + vec2(PAD, PAD), vec2(img_w, OPTION_IMAGE_H));
egui::Image::new(tex).corner_radius(theme::RADIUS_MD - 2).paint_at(ui, img);
egui::Image::new(tex).corner_radius(theme::radius_md() - 2).paint_at(ui, img);
ui.painter().galley(egui::pos2(rect.center().x, img.bottom() + PAD), galley, ink);
let resp = resp.on_hover_cursor(egui::CursorIcon::PointingHand).on_hover_text(format!("{label}\nDouble-click to view it larger"));
if recommended { resp.on_hover_text("Recommended") } else { resp }
@@ -1210,7 +1362,8 @@ pub fn list(ui: &mut Ui, chat: &Chat, v: &View, acts: &mut Acts) {
}
// Questions close the turn, after the summary the agent writes once the call returns.
for t in questions {
question_card(ui, t, is_last && !v.running, acts);
let answer = all.get(n + 1).map(|&(next, _)| msgs[next].text.as_str());
question_card(ui, t, is_last && !v.running, answer, acts);
}
let text = reply.iter().map(|m| m.text.as_str()).collect::<Vec<_>>().join("\n\n");
if !text.is_empty() && !(is_last && v.running) {
@@ -1274,7 +1427,7 @@ pub fn list(ui: &mut Ui, chat: &Chat, v: &View, acts: &mut Acts) {
/// A card with a title, a hint and Retry: the sign-in state of an agent.
pub fn notice_card(ui: &mut Ui, title: &str, hint: &str, acts: &mut Acts) {
let p = theme::pal(ui);
card(ui).show(ui, |ui| {
card(ui).show_surface(ui, |ui| {
ui.set_width(ui.available_width());
ui.label(RichText::new(title).font(theme::semibold(theme::BODY)).color(p.warning));
ui.add(egui::Label::new(RichText::new(hint).size(theme::SECONDARY).color(p.secondary)).wrap());
@@ -1290,7 +1443,7 @@ pub fn error_row(ui: &mut Ui, e: &str, signin: Option<&super::chat::chat_agents:
return super::chat::chat_agents::signin_card(ui, s, acts);
}
let p = theme::pal(ui);
card(ui).show(ui, |ui| {
card(ui).show_surface(ui, |ui| {
ui.set_width(ui.available_width());
ui.add(egui::Label::new(RichText::new(e).color(p.danger)).wrap());
if kit::button(ui, kit::ButtonKind::Secondary, Some(Icon::Restore), "Retry").clicked() {
@@ -1326,7 +1479,7 @@ pub fn empty(ui: &mut Ui, acts: &mut Acts) {
.response;
let click = ui.interact(r.rect, Id::new(("chat-suggest", text)), Sense::click());
if click.hovered() {
ui.painter().rect_filled(r.rect, theme::RADIUS_LG, p.hover);
ui.painter().rect_filled(r.rect, theme::radius_lg(), p.hover);
ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand);
}
if click.clicked() {
@@ -1498,6 +1651,24 @@ mod tests {
assert_eq!(auto_answers(&[Message { role: Role::Assistant, text: "done".into(), ..Default::default() }]), None);
}
#[test]
fn answers_message_marks_picked_options() {
let q = serde_json::json!({"question": "Theme?", "options": [{"label": "Dark"}, {"label": "Light"}, {"label": "Both"}]});
let msg = "Answers:\n- Theme? Dark, Both";
assert_eq!(picked_in(msg, &q), ["Dark", "Both"]);
assert_eq!(own_answer(msg, &q), Some("Dark, Both".into()));
assert_eq!(picked_in("Answers:\n- Theme? something warmer", &q), Vec::<String>::new());
assert_eq!(own_answer("Answers:\n- Theme? your call", &q), None);
assert_eq!(own_answer("Go with the option you recommended for each question.", &q).as_deref(), Some("Go with the option you recommended for each question."));
}
#[test]
fn issue_body_reads_as_labelled_fields() {
let f = issue_fields("**Expected:** a\n**Actual:** b\nmore\n**Repro:** `x`");
assert_eq!(f, [("Expected".into(), "a".into()), ("Actual".into(), "b\nmore".into()), ("Repro".into(), "`x`".into())]);
assert_eq!(issue_fields("plain"), [(String::new(), "plain".into())]);
}
#[test]
fn answers_name_each_pick_and_leave_the_rest_to_the_agent() {
let qs = [
+3 -2
View File
@@ -1,6 +1,7 @@
//! Pointing at the canvas from chat: Pick mode (click a layer, Shift+drag an area), Option+click
//! in Chat mode, the crops chips carry, and the on-canvas Ask box (⌘K on a selection).
use crate::theme::Surface as _;
use super::{SelChip, chip_label, push_chip};
use crate::editor::{Editor, InputEvent};
use crate::geom::{Rect, v2};
@@ -220,7 +221,7 @@ pub fn ask_popover(ctx: &egui::Context, ed: &mut Editor, st: &mut UiState, canva
let (mut send, mut close) = (false, false);
egui::Area::new("chat-ask".into()).order(egui::Order::Foreground).constrain_to(canvas).fixed_pos(pos).show(ctx, |ui| {
let p = theme::pal(ui);
kit::card_frame(ui).inner_margin(12).show(ui, |ui| {
kit::card_frame(ui).inner_margin(12).show_surface(ui, |ui| {
ui.set_width(ASK_W - 24.0);
ui.spacing_mut().item_spacing.y = 8.0;
ui.horizontal(|ui| {
@@ -228,7 +229,7 @@ pub fn ask_popover(ctx: &egui::Context, ed: &mut Editor, st: &mut UiState, canva
ui.add(egui::Label::new(RichText::new(&name).font(theme::semibold(theme::BODY))).truncate());
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| close = kit::close_button(ui).clicked());
});
let r = ui.add(egui::TextEdit::singleline(&mut ask.text).hint_text("Ask about this layer").desired_width(f32::INFINITY).margin(theme::INPUT_MARGIN));
let r = crate::theme::lit(ui, |ui| ui.add(egui::TextEdit::singleline(&mut ask.text).hint_text("Ask about this layer").desired_width(f32::INFINITY).margin(theme::INPUT_MARGIN)));
if std::mem::take(&mut ask.focus) {
r.request_focus();
}
+3 -2
View File
@@ -32,7 +32,7 @@ pub(super) fn dialog(
) -> Option<bool> {
let frame = egui::Frame::new()
.fill(theme::pal_from_ctx(ctx).sidebar)
.corner_radius(24.0)
.corner_radius(crate::theme::r(24.0))
.inner_margin(24.0);
let response = egui::Modal::new(egui::Id::new(id))
.frame(frame)
@@ -43,6 +43,7 @@ pub(super) fn dialog(
super::motion::MEDIUM,
super::motion::ease_out_cubic,
));
theme::modal_backing(ui, theme::pal(ui).sidebar, theme::ru(24));
ui.set_width(DIALOG_WIDTH.min(ui.ctx().content_rect().width() - 48.0));
ui.spacing_mut().item_spacing.y = 10.0;
super::kit::badge(ui, theme::Icon::Trash, ui.visuals().error_fg_color, 48.0);
@@ -93,7 +94,7 @@ fn paint_ghost(ctx: &egui::Context) {
ctx.request_repaint();
let frame = egui::Frame::new()
.fill(theme::pal_from_ctx(ctx).sidebar)
.corner_radius(24.0)
.corner_radius(crate::theme::r(24.0))
.inner_margin(24.0);
egui::Modal::new(egui::Id::new("editor-removal-ghost"))
.frame(frame)
+2 -2
View File
@@ -117,7 +117,7 @@ pub fn panel(ui: &mut Ui, ed: &mut Editor, st: &mut UiState) {
heading(ui, &title);
if !issues.is_empty() {
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if crate::theme::small_button(ui, "Fix all")
if crate::theme::small_icon_button(ui, Some(crate::theme::Icon::Zap), "Fix all")
.on_hover_text("Auto-fix grid, color variables, text scale, and touch targets")
.clicked()
{
@@ -168,7 +168,7 @@ pub fn panel(ui: &mut Ui, ed: &mut Editor, st: &mut UiState) {
}
if fixable {
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if crate::theme::small_button(ui, "Fix")
if crate::theme::small_icon_button(ui, Some(crate::theme::Icon::Zap), "Fix")
.on_hover_text(&issue.fix)
.clicked()
{
+25 -12
View File
@@ -135,9 +135,14 @@ pub fn draw(ui: &mut Ui, hv: &mut HomeView, _native_chrome: bool, translucent: b
let p = theme::pal(ui);
let floating = super::is_floating(translucent);
let gap = super::PANEL_GAP;
if floating && theme::is_acrylic() {
// No canvas under the home screen: the glow covers the whole window.
let screen = ui.ctx().content_rect();
ui.painter().add(theme::backdrop_mesh(p, screen, screen));
}
let backdrop = |l: i8, r: i8, t: i8, b: i8| {
egui::Frame::NONE
.fill(p.backdrop)
.fill(theme::backdrop_fill(p))
.inner_margin(egui::Margin {
left: l,
right: r,
@@ -158,7 +163,7 @@ pub fn draw(ui: &mut Ui, hv: &mut HomeView, _native_chrome: bool, translucent: b
.show(ui, |ui| {
let body = |ui: &mut Ui| {
ui.horizontal(|ui| {
super::kit::badge(ui, Icon::Pen, theme::accent(), 30.0);
super::kit::brand_mark(ui, 30.0);
ui.label(RichText::new("PaperCraft").font(theme::semibold(17.0)));
});
ui.add_space(14.0);
@@ -366,21 +371,22 @@ fn share_dialog(ui: &mut Ui, hv: &mut HomeView) {
};
let frame = egui::Frame::new()
.fill(theme::pal_from_ctx(ui.ctx()).sidebar)
.corner_radius(24.0)
.corner_radius(crate::theme::r(24.0))
.inner_margin(24.0);
let response = egui::Modal::new(egui::Id::new("share-dialog"))
.frame(frame)
.show(ui.ctx(), |ui| {
theme::modal_backing(ui, theme::pal(ui).sidebar, theme::ru(24));
ui.set_width(SHARE_DIALOG_W.min(ui.ctx().content_rect().width() - 48.0));
ui.spacing_mut().item_spacing.y = 10.0;
super::kit::badge(ui, Icon::Export, theme::accent(), 48.0);
ui.label(RichText::new(title).font(theme::semibold(18.0)));
let r = ui.add(
let r = crate::theme::lit(ui, |ui| ui.add(
egui::TextEdit::singleline(&mut d.text)
.hint_text(hint)
.desired_width(f32::INFINITY)
.margin(theme::INPUT_MARGIN),
);
));
if d.dir.is_some() {
ui.label(
RichText::new("Anyone with the link can open and edit the project.")
@@ -429,10 +435,10 @@ fn sidebar_item(
ui.allocate_exact_size(egui::vec2(ui.available_width(), 38.0), egui::Sense::click());
let hover = super::motion::hover(ui.ctx(), resp.id.with("nav"), &resp);
if selected {
ui.painter().rect_filled(row, theme::RADIUS_LG, p.field);
theme::fill_selected(ui, row, theme::radius_lg(), p.field);
} else if hover > 0.0 {
ui.painter()
.rect_filled(row, theme::RADIUS_LG, p.hover.gamma_multiply(hover));
.rect_filled(row, theme::radius_lg(), p.hover.gamma_multiply(hover));
}
let ir = egui::Rect::from_center_size(
egui::pos2(row.left() + 20.0, row.center().y),
@@ -530,7 +536,7 @@ fn card_frame(
let hover = super::motion::hover(ui.ctx(), resp.id.with("card"), &resp);
ui.painter().rect_filled(
rect,
theme::RADIUS_PANEL,
theme::radius_panel(),
theme::blend(p.field, theme::accent().gamma_multiply(0.12 * hover)),
);
if let Some(t) = thumb.and_then(|t| hv.thumb(ui.ctx(), t)) {
@@ -569,16 +575,19 @@ fn project_card(ui: &mut Ui, hv: &mut HomeView, e: &Entry) {
if resp.double_clicked() {
hv.actions.push(HomeAction::Open(e.dir.clone()));
}
resp.context_menu(|ui| card_menu(ui, hv, e));
resp.context_menu(|ui| {
crate::theme::popup_backing(ui);
card_menu(ui, hv, e)
});
ui.add_space(6.0);
let renaming = hv.renaming.as_ref().is_some_and(|(d, _)| *d == e.dir);
if renaming {
let (_, name) = hv.renaming.as_mut().unwrap();
let r = ui.add(
let r = crate::theme::lit(ui, |ui| ui.add(
egui::TextEdit::singleline(name)
.margin(crate::theme::INPUT_MARGIN)
.desired_width(CARD_W),
);
));
// Check before taking focus back: `lost_focus` reads the focus as it is now, so
// requesting it first would hide Enter, Escape and a click elsewhere.
if r.lost_focus() {
@@ -605,7 +614,10 @@ fn project_card(ui: &mut Ui, hv: &mut HomeView, e: &Entry) {
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
let more =
theme::icon_button(ui, Icon::More, 20.0, false).on_hover_text("More");
egui::Popup::menu(&more).show(|ui| card_menu(ui, hv, e));
egui::Popup::menu(&more).show(|ui| {
crate::theme::popup_backing(ui);
card_menu(ui, hv, e)
});
});
});
}
@@ -702,6 +714,7 @@ fn trash_grid(ui: &mut Ui, hv: &mut HomeView) {
let thumb = crate::vfs::is_file(&thumb).then_some(thumb);
let resp = card_frame(ui, hv, thumb.as_deref(), egui::Id::new(&e.dir));
resp.context_menu(|ui| {
crate::theme::popup_backing(ui);
if super::icon_menu_item(ui, crate::theme::Icon::Restore, "Restore")
.clicked()
{
+139 -92
View File
@@ -84,7 +84,7 @@ fn segmented(ui: &mut Ui, cells: &[(Cell, bool, &str)], width: f32) -> Option<us
ui.visuals().widgets.hovered.bg_fill,
ui.visuals().widgets.active.bg_fill,
);
ui.painter().rect_filled(rect, 5.0, rest);
theme::fill_control(ui, rect, theme::radius_md(), rest);
let cw = width / cells.len() as f32;
let mut clicked = None;
for (i, (cell, on, tip)) in cells.iter().enumerate() {
@@ -93,11 +93,20 @@ fn segmented(ui: &mut Ui, cells: &[(Cell, bool, &str)], width: f32) -> Option<us
let resp = ui
.allocate_rect(r, egui::Sense::click())
.on_hover_text(*tip);
if *on || resp.hovered() {
let acrylic_on = *on && theme::is_acrylic();
if acrylic_on {
theme::fill_accent(ui, r.shrink(3.0), crate::theme::ru(6), theme::accent());
} else if *on || resp.hovered() {
ui.painter()
.rect_filled(r.shrink(2.0), 4.0, if *on { on_fill } else { hover });
.rect_filled(r.shrink(2.0), crate::theme::r(4.0), if *on { on_fill } else { hover });
}
let ink = if *on { p.text } else { p.secondary };
let ink = if acrylic_on {
super::kit::ink_on(theme::accent())
} else if *on {
p.text
} else {
p.secondary
};
match cell {
Cell::Icon(ic) => theme::paint_icon(
ui.painter(),
@@ -132,8 +141,7 @@ fn color_field(
width: f32,
) -> Option<Color> {
let (rect, _) = ui.allocate_exact_size(egui::vec2(width, theme::FIELD_H), egui::Sense::hover());
ui.painter()
.rect_filled(rect, 5.0, ui.visuals().widgets.inactive.bg_fill);
theme::fill_control(ui, rect, theme::radius_md(), ui.visuals().widgets.inactive.bg_fill);
let sw = egui::Rect::from_min_size(
rect.left_top() + egui::vec2(5.0, (theme::FIELD_H - 18.0) / 2.0),
egui::Vec2::splat(18.0),
@@ -277,13 +285,15 @@ fn field(
} else {
egui::Stroke::NONE
};
ui.painter().rect(
rect,
egui::CornerRadius::same(5),
fill,
edit_stroke,
egui::StrokeKind::Inside,
);
theme::fill_control(ui, rect, theme::radius_md(), fill);
if buf.is_some() {
ui.painter().rect_stroke(
rect,
theme::radius_md(),
edit_stroke,
egui::StrokeKind::Inside,
);
}
let ink = if enabled { p.text } else { p.tertiary };
if !label.is_empty() {
ui.painter().text(
@@ -323,13 +333,16 @@ fn field(
ui.data_mut(|d| d.insert_temp(id, text));
}
} else {
ui.painter().text(
egui::pos2(text_x, rect.center().y),
egui::Align2::LEFT_CENTER,
format!("{}{suffix}", fmt_num(value)),
font,
ink,
);
// Clipped to the field: a long value must not run into the next one.
ui.painter()
.with_clip_rect(rect.shrink2(egui::vec2(inset / 2.0, 0.0)))
.text(
egui::pos2(text_x, rect.center().y),
egui::Align2::LEFT_CENTER,
format!("{}{suffix}", fmt_num(value)),
font,
ink,
);
}
out
}
@@ -415,10 +428,21 @@ fn icon_btn(ui: &mut Ui, icon: theme::Icon, on: bool, tip: &str) -> bool {
.clicked()
}
/// The scroll bar runs in the panel's right margin, close to its edge; [`SCROLL_GUTTER`] keeps
/// the content clear of it.
pub const MARGIN: egui::Margin = egui::Margin {
left: 14,
right: 4,
top: 12,
bottom: 12,
};
const SCROLL_GUTTER: f32 = 10.0;
pub fn panel(ui: &mut Ui, ed: &mut Editor, st: &mut UiState) {
egui::ScrollArea::vertical()
.auto_shrink(false)
.show(ui, |ui| {
ui.set_max_width(ui.available_width() - SCROLL_GUTTER);
theme::figma_fields(ui);
let ids = ed.top_selection();
ui.horizontal(|ui| {
@@ -589,13 +613,10 @@ fn feedback_section(ui: &mut Ui, ed: &mut Editor, st: &mut UiState, ids: &[NodeI
}
ui.horizontal(|ui| {
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
let add = crate::theme::small_button(ui, "Add").clicked();
let r = ui.add(
egui::TextEdit::singleline(&mut st.feedback_draft)
.hint_text("Note for the agent…")
.desired_width(ui.available_width())
.margin(crate::theme::INPUT_MARGIN),
);
let add = crate::theme::small_icon_button(ui, Some(theme::Icon::Plus), "")
.on_hover_text("Add note")
.clicked();
let r = super::kit::icon_field(ui, theme::Icon::Note, &mut st.feedback_draft, "Note for the agent…");
let enter = r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter));
if (add || enter) && !st.feedback_draft.trim().is_empty() {
let text = std::mem::take(&mut st.feedback_draft).trim().to_string();
@@ -633,7 +654,7 @@ fn swatches(ui: &mut Ui, current: Color) -> Option<Color> {
ui.spacing_mut().item_spacing.x = 4.0;
for c in STICKY_COLORS {
let (r, resp) = ui.allocate_exact_size(egui::vec2(20.0, 20.0), egui::Sense::click());
ui.painter().rect_filled(r, 5.0, c32(c));
ui.painter().rect_filled(r, crate::theme::r(5.0), c32(c));
if c == current {
ui.painter().rect_stroke(
r,
@@ -776,7 +797,7 @@ fn sketch_section(ui: &mut Ui, ed: &mut Editor, ids: &[NodeId], first: &Node) {
});
}
});
if crate::theme::small_button(ui, "New wobble")
if crate::theme::small_icon_button(ui, Some(crate::theme::Icon::Sketch), "New wobble")
.on_hover_text("Re-roll the random seed")
.clicked()
{
@@ -821,6 +842,7 @@ fn export_section(ui: &mut Ui, ed: &mut Editor, st: &mut UiState) {
st.export = *preset;
}
chip.context_menu(|ui| {
crate::theme::popup_backing(ui);
if super::icon_menu_item(ui, crate::theme::Icon::Trash, "Remove preset").clicked() {
remove = Some(i);
ui.close();
@@ -877,25 +899,22 @@ fn export_section(ui: &mut Ui, ed: &mut Editor, st: &mut UiState) {
(Scope::Page, _) => format!("page “{}”", ed.page_name()),
(Scope::AllPages, _) => format!("{} pages", ed.pages.len()),
};
if ui
.add(
if theme::lit(ui, |ui| ui.add(
egui::Button::new(format!("Export {what}…"))
.min_size(egui::vec2(ui.available_width(), 24.0)),
)
))
.clicked()
{
ed.requests.push(AppRequest::Export(*spec));
}
ui.horizontal(|ui| {
let w = half(ui);
if ui
.add(egui::Button::new("Copy as PNG").min_size(egui::vec2(w, 24.0)))
if theme::lit(ui, |ui| ui.add(egui::Button::new("Copy as PNG").min_size(egui::vec2(w, 24.0))))
.clicked()
{
ed.run(crate::editor::shortcuts::Action::CopyAsPng);
}
if ui
.add(egui::Button::new("Copy as SVG").min_size(egui::vec2(w, 24.0)))
if theme::lit(ui, |ui| ui.add(egui::Button::new("Copy as SVG").min_size(egui::vec2(w, 24.0))))
.clicked()
{
ed.run(crate::editor::shortcuts::Action::CopyAsSvg);
@@ -924,7 +943,8 @@ fn export_section(ui: &mut Ui, ed: &mut Editor, st: &mut UiState) {
.find(|(k, _)| *k == st.code_target)
.map_or("react", |(k, _)| *k);
row(ui, "Target", |ui| {
if let Some(v) = combo(ui, "code-target", cur, &targets, ui.available_width()) {
let icons = { use theme::Icon as I; [I::React, I::Tailwind, I::Angular, I::Code, I::Rust] };
if let Some(v) = super::kit::select_icons(ui, cur, &targets, &icons, ui.available_width()) {
st.code_target = v.into();
}
});
@@ -942,8 +962,7 @@ fn export_section(ui: &mut Ui, ed: &mut Editor, st: &mut UiState) {
}
ui.horizontal(|ui| {
let w = half(ui);
if ui
.add(egui::Button::new("Copy Code").min_size(egui::vec2(w, 24.0)))
if theme::lit(ui, |ui| ui.add(egui::Button::new("Copy Code").min_size(egui::vec2(w, 24.0))))
.clicked()
&& let Ok(code) =
crate::mcp::codegen::code_for_target(&ed.doc, root_id, &st.code_target)
@@ -951,8 +970,7 @@ fn export_section(ui: &mut Ui, ed: &mut Editor, st: &mut UiState) {
ui.ctx().copy_text(code);
ed.status = format!("Copied {} code to clipboard", st.code_target);
}
if ui
.add(egui::Button::new("Copy AI Spec").min_size(egui::vec2(w, 24.0)))
if theme::lit(ui, |ui| ui.add(egui::Button::new("Copy AI Spec").min_size(egui::vec2(w, 24.0))))
.clicked()
&& let Ok(md) =
crate::mcp::codegen::to_ai_spec_markdown(&ed.doc, root_id, &st.code_target)
@@ -1101,7 +1119,7 @@ fn props_section(ui: &mut Ui, ed: &mut Editor, st: &mut UiState, ids: &[NodeId],
}
_ => {
let mut v = current.clone();
let r = ui.add(egui::TextEdit::singleline(&mut v).margin(crate::theme::INPUT_MARGIN).desired_width(width));
let r = crate::theme::lit(ui, |ui| ui.add(egui::TextEdit::singleline(&mut v).margin(crate::theme::INPUT_MARGIN).desired_width(width)));
if r.lost_focus() && v != current {
set = Some(v);
}
@@ -1132,13 +1150,13 @@ fn header(ui: &mut Ui, ed: &mut Editor, ids: &[NodeId], first: &Node) {
}
});
ui.horizontal(|ui| {
if crate::theme::small_button(ui, "Group").clicked() {
if crate::theme::small_icon_button(ui, Some(crate::theme::Icon::Group), "Group").clicked() {
ed.group_selection();
}
if crate::theme::small_button(ui, "Frame").clicked() {
if crate::theme::small_icon_button(ui, Some(crate::theme::Icon::Frame), "Frame").clicked() {
ed.run(crate::editor::shortcuts::Action::WrapInFrame);
}
if crate::theme::small_button(ui, "Component").clicked() {
if crate::theme::small_icon_button(ui, Some(crate::theme::Icon::Component), "Component").clicked() {
ed.create_component();
}
});
@@ -1176,15 +1194,16 @@ fn header(ui: &mut Ui, ed: &mut Editor, ids: &[NodeId], first: &Node) {
};
ui.horizontal_wrapped(|ui| {
if first.component {
if crate::theme::small_button(ui, "Create instance").clicked() {
if crate::theme::small_icon_button(ui, Some(crate::theme::Icon::Instance), "Create instance").clicked() {
ed.instance_of_selection();
}
} else if is_inst_root {
if crate::theme::small_button(ui, "Go to main").clicked() {
if crate::theme::small_icon_button(ui, Some(crate::theme::Icon::Component), "Go to main").clicked() {
ed.go_to_main();
}
if !other_comps.is_empty() {
ui.menu_button("Swap", |ui| {
crate::theme::popup_backing(ui);
for (comp_id, cname) in &other_comps {
if ui.button(cname).clicked() {
ed.swap_instance(first.id, *comp_id);
@@ -1193,14 +1212,14 @@ fn header(ui: &mut Ui, ed: &mut Editor, ids: &[NodeId], first: &Node) {
}
});
}
if has_subtree_overrides && crate::theme::small_button(ui, "Reset").clicked() {
if has_subtree_overrides && crate::theme::small_icon_button(ui, Some(crate::theme::Icon::Restore), "Reset").clicked() {
ed.reset_overrides();
}
if crate::theme::small_button(ui, "Detach").clicked() {
ed.detach_instance();
}
} else if (first.is_frame() || first.is_group())
&& crate::theme::small_button(ui, "Create component").clicked()
&& crate::theme::small_icon_button(ui, Some(crate::theme::Icon::Component), "Create component").clicked()
{
ed.create_component();
}
@@ -1210,24 +1229,24 @@ fn header(ui: &mut Ui, ed: &mut Editor, ids: &[NodeId], first: &Node) {
.instance
.as_ref()
.is_some_and(|l| !l.overrides.is_empty())
&& crate::theme::small_button(ui, "Reset overrides").clicked()
&& crate::theme::small_icon_button(ui, Some(crate::theme::Icon::Restore), "Reset overrides").clicked()
{
ed.reset_overrides();
}
if first.is_container()
&& !first.children.is_empty()
&& crate::theme::small_button(ui, "Ungroup").clicked()
&& crate::theme::small_icon_button(ui, Some(crate::theme::Icon::Group), "Ungroup").clicked()
{
ed.ungroup_selection();
}
if !first.is_container()
&& !matches!(first.kind, NodeKind::Path { .. })
&& crate::theme::small_button(ui, "Flatten").clicked()
&& crate::theme::small_icon_button(ui, Some(crate::theme::Icon::Pen), "Flatten").clicked()
{
ed.flatten_selection();
}
if matches!(first.kind, NodeKind::Path { .. })
&& crate::theme::small_button(ui, "Edit points").clicked()
&& crate::theme::small_icon_button(ui, Some(crate::theme::Icon::PenTool), "Edit points").clicked()
{
ed.start_path_edit(first.id);
}
@@ -1530,7 +1549,7 @@ fn auto_layout(ui: &mut Ui, ed: &mut Editor, ids: &[NodeId], first: &Node) {
}
});
};
ui.horizontal(|ui| {
row(ui, "Direction", |ui| {
if icon_btn(
ui,
theme::Icon::ArrowRight,
@@ -1550,10 +1569,11 @@ fn auto_layout(ui: &mut Ui, ed: &mut Editor, ids: &[NodeId], first: &Node) {
if icon_btn(ui, theme::Icon::Wrap, l.wrap, "Wrap") {
set(ed, &|l| l.wrap = !l.wrap);
}
ui.add_space(6.0);
});
row(ui, "Gap", |ui| {
let room = ui.available_width() - 24.0 - ui.spacing().item_spacing.x;
ui.add_enabled_ui(!l.gap_auto, |ui| {
let room = (ui.available_width() - 34.0).max(60.0);
if let Some(v) = bound_field(ui, ed, ids, "gap", "Gap ", "", l.gap, 0.5, -1e4..=1e4, room) {
if let Some(v) = bound_field(ui, ed, ids, "gap", "", "", l.gap, 0.5, -1e4..=1e4, room) {
set(ed, &|l| l.gap = v);
}
});
@@ -1561,15 +1581,19 @@ fn auto_layout(ui: &mut Ui, ed: &mut Editor, ids: &[NodeId], first: &Node) {
set(ed, &|l| l.gap_auto = !l.gap_auto);
}
});
ui.horizontal(|ui| {
let cw = ((ui.available_width() - 3.0 * ui.spacing().item_spacing.x) / 4.0).max(36.0);
for (i, p) in ["T ", "R ", "B ", "L "].iter().enumerate() {
let side = ["padding-top", "padding-right", "padding-bottom", "padding-left"][i];
if let Some(v) = bound_field(ui, ed, ids, side, p, "", l.padding[i], 0.5, 0.0..=1e4, cw) {
set(ed, &|l| l.padding[i] = v);
// Two sides per row: four fields with bind buttons do not fit one panel row.
for pair in [[0usize, 1], [2, 3]] {
row(ui, if pair[0] == 0 { "Padding" } else { "" }, |ui| {
let cw = half(ui);
for i in pair {
let side = ["padding-top", "padding-right", "padding-bottom", "padding-left"][i];
let p = ["T ", "R ", "B ", "L "][i];
if let Some(v) = bound_field(ui, ed, ids, side, p, "", l.padding[i], 0.5, 0.0..=1e4, cw) {
set(ed, &|l| l.padding[i] = v);
}
}
}
});
});
}
let opts = [
(Align::Start, "Start"),
(Align::Center, "Center"),
@@ -1665,7 +1689,9 @@ fn text_section(ui: &mut Ui, ed: &mut Editor, ids: &[NodeId], first: &Node) {
});
}
let lh = first.text_style.line_height.unwrap_or(0.0) * 100.0;
if let Some(v) = num_field(ui, ed, "LH ", lh, 1.0, 0.0..=1000.0, w) {
// Line height and spacing share the three columns the align buttons leave.
let w = w * 1.5;
if let Some(v) = field(ui, ed, "LH ", "%", lh, 1.0, 0.0..=1000.0, w) {
apply(ed, "line height", ids, |n| {
n.text_style.line_height = (v > 0.0).then_some(v / 100.0)
});
@@ -1692,12 +1718,12 @@ fn text_section(ui: &mut Ui, ed: &mut Editor, ids: &[NodeId], first: &Node) {
return;
};
let mut t = text.clone();
let r = ui.add(
let r = crate::theme::lit(ui, |ui| ui.add(
egui::TextEdit::multiline(&mut t)
.margin(crate::theme::INPUT_MARGIN)
.desired_rows(2)
.desired_width(f32::INFINITY),
);
));
if r.changed() {
apply(ed, "text", ids, |n| {
if let NodeKind::Text { text, .. } = &mut n.kind {
@@ -1750,7 +1776,13 @@ fn fill_row(ui: &mut Ui, ed: &mut Editor, ids: &[NodeId], i: usize, p: &Paint) {
Paint::Image { .. } => 3,
};
let opts: Vec<(u8, &str)> = kinds.iter().map(|(l, k)| (*k, *l)).collect();
if let Some(k) = combo(ui, "kind", cur, &opts, 62.0) {
// A gradient's controls sit on rows below, so its type takes the row.
let kind_w = if [1, 2].contains(&cur) {
ui.available_width() - 24.0 - ui.spacing().item_spacing.x
} else {
84.0
};
if let Some(k) = combo(ui, "kind", cur, &opts, kind_w) {
let c = p.preview_color();
let np = match k {
0 => Paint::solid(c),
@@ -1807,7 +1839,7 @@ fn fill_row(ui: &mut Ui, ed: &mut Editor, ids: &[NodeId], i: usize, p: &Paint) {
},
);
}
if crate::theme::small_button(ui, "Replace…").clicked() {
if crate::theme::small_icon_button(ui, Some(crate::theme::Icon::Image), "Replace…").clicked() {
ed.requests.push(AppRequest::ImageFill(ids.to_vec(), i));
}
}
@@ -1850,22 +1882,43 @@ fn fill_row(ui: &mut Ui, ed: &mut Editor, ids: &[NodeId], i: usize, p: &Paint) {
}
}
};
ui.horizontal(|ui| {
ui.label(
RichText::new("Stops")
.size(theme::SECONDARY + 1.0)
.color(theme::pal(ui).secondary),
);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if crate::theme::icon_button(ui, crate::theme::Icon::Plus, 24.0, false)
.on_hover_text("Add stop")
.clicked()
{
let mut ns = stops.clone();
let c = ns.last().map_or(Color::BLACK, |s| s.color);
ns.push(Stop { pos: 0.5, color: c });
ns.sort_by(|a, b| a.pos.total_cmp(&b.pos));
set(ed, rebuild(ns, *from, *to));
}
});
});
for (si, s) in stops.iter().enumerate() {
ui.horizontal(|ui| {
ui.add_space(LABEL_W * 0.5);
ui.spacing_mut().interact_size = egui::vec2(theme::FIELD_H, theme::FIELD_H);
if let Some(c) = color_button(ui, ed, s.color) {
let mut ns = stops.clone();
ns[si].color = c;
set(ed, rebuild(ns, *from, *to));
}
if let Some(v) = num_field(ui, ed, "", s.pos * 100.0, 0.5, 0.0..=100.0, 56.0) {
// Room for the remove button, kept on every row so the fields line up.
let w = ui.available_width() - 24.0 - ui.spacing().item_spacing.x;
if let Some(v) = field(ui, ed, "", "%", s.pos * 100.0, 0.5, 0.0..=100.0, w) {
let mut ns = stops.clone();
ns[si].pos = v / 100.0;
set(ed, rebuild(ns, *from, *to));
}
ui.label(RichText::new("%").weak());
if stops.len() > 2
&& crate::theme::icon_button(ui, crate::theme::Icon::Minus, 24.0, false)
.on_hover_text("Remove stop")
.clicked()
{
let mut ns = stops.clone();
@@ -1874,28 +1927,22 @@ fn fill_row(ui: &mut Ui, ed: &mut Editor, ids: &[NodeId], i: usize, p: &Paint) {
}
});
}
ui.horizontal(|ui| {
ui.add_space(LABEL_W * 0.5);
if crate::theme::small_button(ui, "+ stop").clicked() {
let mut ns = stops.clone();
let c = ns.last().map_or(Color::BLACK, |s| s.color);
ns.push(Stop { pos: 0.5, color: c });
ns.sort_by(|a, b| a.pos.total_cmp(&b.pos));
set(ed, rebuild(ns, *from, *to));
if linear {
let d = *to - *from;
let ang = d.y.atan2(d.x).to_degrees();
let w = ui.available_width() - LABEL_W - ui.spacing().item_spacing.x;
if let Some(v) = row(ui, "Angle", |ui| field(ui, ed, "", "°", ang, 1.0, -360.0..=360.0, w)) {
let a = v.to_radians();
let half = v2(a.cos(), a.sin()) * 0.5;
let c = v2(0.5, 0.5);
set(ed, rebuild(stops.clone(), c - half, c + half));
}
if linear {
let d = *to - *from;
let ang = d.y.atan2(d.x).to_degrees();
if let Some(v) = num_field(ui, ed, "Angle ", ang, 1.0, -360.0..=360.0, 70.0) {
let a = v.to_radians();
let half = v2(a.cos(), a.sin()) * 0.5;
let c = v2(0.5, 0.5);
set(ed, rebuild(stops.clone(), c - half, c + half));
}
} else if let Some(v) = num_field(ui, ed, "R ", to.x * 100.0, 0.5, 1.0..=400.0, 70.0) {
} else {
let w = ui.available_width() - LABEL_W - ui.spacing().item_spacing.x;
if let Some(v) = row(ui, "Radius", |ui| field(ui, ed, "", "%", to.x * 100.0, 0.5, 1.0..=400.0, w)) {
set(ed, rebuild(stops.clone(), *from, v2(v / 100.0, v / 100.0)));
}
});
}
}
}
+133 -38
View File
@@ -2,6 +2,7 @@
//! rows, key caps, empty states and banners. They mirror the components of the `PaperCraft` design
//! file (Button / Primary, Chip / Accent, Setting row, Key cap, Banner / Info ...).
use crate::theme::Surface as _;
use crate::theme::{self, Icon};
use egui::{
Align2, Color32, CornerRadius, FontId, Rect, Response, RichText, Sense, Stroke, Ui, vec2,
@@ -32,12 +33,17 @@ fn galley_width(ui: &Ui, text: &str, font: FontId) -> f32 {
/// A rounded button of the design system. `icon` goes before the label.
pub fn button(ui: &mut Ui, kind: ButtonKind, icon: Option<Icon>, label: &str) -> Response {
paint_button(ui, kind, icon, label, false)
paint_button(ui, kind, icon, label, false, BUTTON_H)
}
/// Like [`button`], as tall as a segmented control (for bars).
pub fn button_compact(ui: &mut Ui, kind: ButtonKind, icon: Option<Icon>, label: &str) -> Response {
paint_button(ui, kind, icon, label, false, SEGMENTED_H)
}
/// Like [`button`], stretched over the available width, label centred.
pub fn button_wide(ui: &mut Ui, kind: ButtonKind, icon: Option<Icon>, label: &str) -> Response {
paint_button(ui, kind, icon, label, true)
paint_button(ui, kind, icon, label, true, BUTTON_H)
}
fn paint_button(
@@ -46,6 +52,7 @@ fn paint_button(
icon: Option<Icon>,
label: &str,
wide: bool,
h: f32,
) -> Response {
let p = theme::pal(ui);
let font = theme::semibold(theme::BODY);
@@ -56,7 +63,7 @@ fn paint_button(
} else {
text_w + icon_w + 32.0
};
let (rect, resp) = ui.allocate_exact_size(vec2(w, BUTTON_H), Sense::click());
let (rect, resp) = ui.allocate_exact_size(vec2(w, h), Sense::click());
let enabled = ui.is_enabled();
let hover = crate::ui::motion::hover(ui.ctx(), resp.id.with("btn"), &resp);
let (fill, ink) = match kind {
@@ -75,7 +82,13 @@ fn paint_button(
theme::blend(p.field, Color32::from_white_alpha(0))
};
let ink = if enabled { ink } else { p.tertiary };
ui.painter().rect_filled(rect, theme::RADIUS_LG, fill);
if enabled && kind == ButtonKind::Primary && theme::is_acrylic() {
theme::paint_gloss(ui.painter(), rect, theme::radius_lg(), fill);
} else if kind == ButtonKind::Secondary {
theme::fill_control(ui, rect, theme::radius_lg(), fill);
} else {
ui.painter().rect_filled(rect, theme::radius_lg(), fill);
}
let mut x = if wide {
rect.center().x - (text_w + icon_w) / 2.0
} else {
@@ -121,7 +134,7 @@ pub fn on_accent(p: theme::Palette) -> Color32 {
/// A small pill label. `active` uses the accent text, like Chip / Accent.
pub fn chip(ui: &mut Ui, label: &str, active: bool) -> Response {
chip_ink(ui, label, active.then(theme::accent))
chip_painted(ui, label, active.then(theme::accent), active)
}
/// A chip with its own ink (for example `p.success` for Accepted); None is the idle grey.
@@ -129,19 +142,39 @@ pub fn chip(ui: &mut Ui, label: &str, active: bool) -> Response {
pub const CHIP_H: f32 = 22.0;
pub fn chip_ink(ui: &mut Ui, label: &str, ink: Option<Color32>) -> Response {
chip_painted(ui, label, ink, false)
}
/// [`chip_ink`] with `lead` points of room before the label, for an icon the caller paints at
/// `rect.left() + 10`.
pub fn chip_lead(ui: &mut Ui, label: &str, ink: Option<Color32>, lead: f32) -> Response {
chip_painted_lead(ui, label, ink, false, lead)
}
fn chip_painted(ui: &mut Ui, label: &str, ink: Option<Color32>, selected: bool) -> Response {
chip_painted_lead(ui, label, ink, selected, 0.0)
}
fn chip_painted_lead(ui: &mut Ui, label: &str, ink: Option<Color32>, selected: bool, lead: f32) -> Response {
let p = theme::pal(ui);
let font = theme::semibold(theme::SECONDARY);
let w = galley_width(ui, label, font.clone()) + 20.0;
let w = galley_width(ui, label, font.clone()) + 20.0 + lead;
let (rect, resp) = ui.allocate_exact_size(vec2(w, CHIP_H), Sense::click());
let hover = crate::ui::motion::hover(ui.ctx(), resp.id.with("chip"), &resp);
ui.painter().rect_filled(
rect,
CornerRadius::same(PILL),
theme::blend(p.field, p.hover.gamma_multiply(hover)),
);
let fill = theme::blend(p.field, p.hover.gamma_multiply(hover));
if selected {
theme::fill_selected(ui, rect, PILL, fill);
} else {
theme::fill_control(ui, rect, PILL, fill);
}
let ink = ink.unwrap_or(p.secondary);
ui.painter()
.text(rect.center(), Align2::CENTER_CENTER, label, font, ink);
ui.painter().text(
rect.center() + vec2(lead / 2.0, 0.0),
Align2::CENTER_CENTER,
label,
font,
ink,
);
resp
}
@@ -151,7 +184,7 @@ pub fn key_cap(ui: &mut Ui, label: &str) {
let font = theme::semibold(theme::SECONDARY);
let w = (galley_width(ui, label, font.clone()) + 14.0).max(24.0);
let (rect, _) = ui.allocate_exact_size(vec2(w, 22.0), Sense::hover());
ui.painter().rect_filled(rect, theme::RADIUS_MD, p.field);
theme::fill_control(ui, rect, theme::radius_md(), p.field);
ui.painter().text(
rect.center(),
Align2::CENTER_CENTER,
@@ -244,11 +277,44 @@ pub fn setting_row<R>(
.inner
}
/// The PaperCraft mark: the pen tool on an accent tile, glossy and glowing under acrylic.
pub fn brand_mark(ui: &mut Ui, size: f32) {
let (rect, _) = ui.allocate_exact_size(vec2(size, size), Sense::hover());
let fill = theme::accent();
let radius = (size * 0.3) as u8;
if theme::is_acrylic() {
theme::paint_gloss(ui.painter(), rect, radius, fill);
} else {
ui.painter().rect_filled(rect, radius, fill);
}
theme::paint_icon(ui.painter(), rect.shrink(size * 0.22), Icon::PenTool, ink_on(fill));
}
/// A single-line field with `icon` in front of the text (a magnifier for search).
pub fn icon_field(ui: &mut Ui, icon: Icon, text: &mut String, hint: &str) -> Response {
const ICON_ROOM: i8 = 30;
let p = theme::pal(ui);
let mut margin = theme::INPUT_MARGIN;
margin.left = ICON_ROOM;
let resp = crate::theme::lit(ui, |ui| ui.add(
egui::TextEdit::singleline(text)
.hint_text(hint)
.desired_width(f32::INFINITY)
.margin(margin),
));
let at = Rect::from_center_size(
egui::pos2(resp.rect.left() + 16.0, resp.rect.center().y),
vec2(15.0, 15.0),
);
theme::paint_icon(ui.painter(), at, icon, p.secondary);
resp
}
/// A rounded icon badge, as at the top of a dialog.
pub fn badge(ui: &mut Ui, icon: Icon, tint: Color32, size: f32) {
let p = theme::pal(ui);
let (rect, _) = ui.allocate_exact_size(vec2(size, size), Sense::hover());
ui.painter().rect_filled(rect, theme::RADIUS_PANEL, p.field);
theme::fill_control(ui, rect, theme::radius_panel(), p.field);
theme::paint_icon(ui.painter(), rect.shrink(size * 0.28), icon, tint);
}
@@ -281,9 +347,9 @@ pub fn banner(ui: &mut Ui, icon: Icon, tint: Color32, text: &str, action: Option
let mut clicked = false;
egui::Frame::new()
.fill(p.field)
.corner_radius(theme::RADIUS_PANEL)
.corner_radius(theme::radius_panel())
.inner_margin(egui::Margin::symmetric(16, 10))
.show(ui, |ui| {
.show_surface(ui, |ui| {
ui.horizontal(|ui| {
let (r, _) = ui.allocate_exact_size(vec2(18.0, 18.0), Sense::hover());
theme::paint_icon(ui.painter(), r, icon, tint);
@@ -303,7 +369,7 @@ pub fn card_frame(ui: &Ui) -> egui::Frame {
let p = theme::pal(ui);
egui::Frame::new()
.fill(p.sidebar)
.corner_radius(theme::RADIUS_XL + 8)
.corner_radius(theme::radius_xl() + 8)
.inner_margin(24)
.stroke(Stroke::NONE)
}
@@ -314,7 +380,7 @@ pub fn close_button(ui: &mut Ui) -> Response {
let (rect, resp) = ui.allocate_exact_size(vec2(30.0, 30.0), Sense::click());
let hover = crate::ui::motion::hover(ui.ctx(), resp.id.with("close"), &resp);
ui.painter()
.rect_filled(rect, theme::RADIUS_LG, p.hover.gamma_multiply(hover));
.rect_filled(rect, theme::radius_lg(), p.hover.gamma_multiply(hover));
crate::ui::lottie_icon::paint_hover(
ui,
resp.id,
@@ -342,8 +408,11 @@ pub fn toggle(ui: &mut Ui, on: &mut bool) -> Response {
crate::ui::motion::ease_out_cubic,
);
let fill = theme::blend(p.field, theme::accent().gamma_multiply(t));
ui.painter()
.rect_filled(rect, CornerRadius::same(PILL), fill);
if t > 0.5 {
theme::fill_accent(ui, rect, PILL, fill);
} else {
theme::fill_control(ui, rect, PILL, fill);
}
let knob_x = egui::lerp((rect.left() + 12.0)..=(rect.right() - 12.0), t);
let knob = if t > 0.5 { on_accent(p) } else { p.secondary };
ui.painter()
@@ -357,10 +426,10 @@ pub fn nav_item(ui: &mut Ui, icon: Icon, label: &str, selected: bool) -> bool {
let (row, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 38.0), Sense::click());
let hover = crate::ui::motion::hover(ui.ctx(), resp.id.with("nav"), &resp);
if selected {
ui.painter().rect_filled(row, theme::RADIUS_LG, p.field);
theme::fill_selected(ui, row, theme::radius_lg(), p.field);
} else if hover > 0.0 {
ui.painter()
.rect_filled(row, theme::RADIUS_LG, p.hover.gamma_multiply(hover));
.rect_filled(row, theme::radius_lg(), p.hover.gamma_multiply(hover));
}
let ir = Rect::from_center_size(
egui::pos2(row.left() + 20.0, row.center().y),
@@ -437,7 +506,11 @@ pub fn checkbox(ui: &mut Ui, on: &mut bool, label: &str) -> Response {
theme::blend(p.field, p.hover.gamma_multiply(hover)),
theme::accent().gamma_multiply(t),
);
ui.painter().rect_filled(bx, 6.0, fill);
if t > 0.5 {
theme::fill_accent(ui, bx, crate::theme::ru(6), fill);
} else {
theme::fill_control(ui, bx, crate::theme::ru(6), fill);
}
if t > 0.0 {
let c = bx.center();
let ink = on_accent(p).gamma_multiply(t);
@@ -464,8 +537,22 @@ pub fn select<T: PartialEq + Copy>(
current: T,
options: &[(T, &str)],
width: f32,
) -> Option<T> {
select_icons(ui, current, options, &[], width)
}
/// [`select`] with `icons[i]` before option `i`, in the trigger and the menu.
pub fn select_icons<T: PartialEq + Copy>(
ui: &mut Ui,
current: T,
options: &[(T, &str)],
icons: &[Icon],
width: f32,
) -> Option<T> {
let p = theme::pal(ui);
let icon_of = |i: usize| icons.get(i).copied();
let indent = if icons.is_empty() { 0.0 } else { 22.0 };
let cur_i = options.iter().position(|(o, _)| *o == current);
let label = options
.iter()
.find(|(o, _)| *o == current)
@@ -477,13 +564,18 @@ pub fn select<T: PartialEq + Copy>(
};
let (rect, resp) = ui.allocate_exact_size(vec2(w, theme::FIELD_H), Sense::click());
let hover = crate::ui::motion::hover(ui.ctx(), resp.id.with("select"), &resp);
ui.painter().rect_filled(
theme::fill_control(
ui,
rect,
theme::RADIUS_MD,
theme::radius_md(),
theme::blend(p.field, p.hover.gamma_multiply(hover)),
);
if let Some(i) = cur_i.and_then(icon_of) {
let r = Rect::from_center_size(egui::pos2(rect.left() + 19.0, rect.center().y), vec2(14.0, 14.0));
theme::paint_icon(ui.painter(), r, i, p.secondary);
}
ui.painter().text(
egui::pos2(rect.left() + 12.0, rect.center().y),
egui::pos2(rect.left() + 12.0 + indent, rect.center().y),
Align2::LEFT_CENTER,
label,
FontId::proportional(theme::BODY),
@@ -498,20 +590,25 @@ pub fn select<T: PartialEq + Copy>(
egui::Popup::menu(&resp)
.width(rect.width().max(150.0))
.show(|ui| {
crate::theme::popup_backing(ui);
ui.spacing_mut().item_spacing.y = 2.0;
for (o, l) in options {
for (k, (o, l)) in options.iter().enumerate() {
let (row, r) =
ui.allocate_exact_size(vec2(ui.available_width(), 32.0), Sense::click());
let on = *o == current;
let h = crate::ui::motion::hover(ui.ctx(), r.id.with("opt"), &r);
if on {
ui.painter().rect_filled(row, theme::RADIUS_MD, p.field);
theme::fill_selected(ui, row, theme::radius_md(), p.field);
} else if h > 0.0 {
ui.painter()
.rect_filled(row, theme::RADIUS_MD, p.hover.gamma_multiply(h));
.rect_filled(row, theme::radius_md(), p.hover.gamma_multiply(h));
}
if let Some(i) = icon_of(k) {
let ir = Rect::from_center_size(egui::pos2(row.left() + 19.0, row.center().y), vec2(14.0, 14.0));
theme::paint_icon(ui.painter(), ir, i, if on { p.text } else { p.secondary });
}
ui.painter().text(
egui::pos2(row.left() + 12.0, row.center().y),
egui::pos2(row.left() + 12.0 + indent, row.center().y),
Align2::LEFT_CENTER,
*l,
if on {
@@ -556,11 +653,11 @@ pub fn slider(ui: &mut Ui, value: &mut f64, range: std::ops::RangeInclusive<f64>
}
let t = (((*value - lo) / (hi - lo).max(f64::EPSILON)).clamp(0.0, 1.0)) as f32;
let rail = Rect::from_center_size(rect.center(), vec2(rect.width() - 8.0, 6.0));
ui.painter().rect_filled(rail, 3.0, p.field);
ui.painter().rect_filled(rail, crate::theme::r(3.0), p.field);
let x = rail.left() + 4.0 + t * (rail.width() - 8.0);
let mut fill = rail;
fill.set_right(x);
ui.painter().rect_filled(fill, 3.0, theme::accent());
ui.painter().rect_filled(fill, crate::theme::r(3.0), theme::accent());
let hover = crate::ui::motion::hover(ui.ctx(), resp.id.with("slider"), &resp);
ui.painter()
.circle_filled(egui::pos2(x, rect.center().y), 7.0 + hover, p.text);
@@ -625,8 +722,7 @@ pub fn segmented(ui: &mut Ui, items: &[(&str, Option<&str>)], selected: usize) -
let inset = 2.0;
let total = widths.iter().sum::<f32>() + 2.0 * inset;
let (outer, _) = ui.allocate_exact_size(vec2(total, SEGMENTED_H), Sense::hover());
ui.painter()
.rect_filled(outer, CornerRadius::same(PILL), p.field);
theme::fill_control(ui, outer, PILL, p.field);
let mut x = outer.left() + inset;
let mut picked = None;
for (i, ((label, off), w)) in items.iter().zip(&widths).enumerate() {
@@ -641,8 +737,7 @@ pub fn segmented(ui: &mut Ui, items: &[(&str, Option<&str>)], selected: usize) -
let on = i == selected;
let hover = crate::ui::motion::hover(ui.ctx(), resp.id.with("seg"), &resp);
if on {
ui.painter()
.rect_filled(rect, CornerRadius::same(PILL), theme::accent());
theme::fill_accent(ui, rect, PILL, theme::accent());
} else if off.is_none() {
ui.painter().rect_filled(
rect,
@@ -679,7 +774,7 @@ pub fn skeleton(painter: &egui::Painter, rect: egui::Rect, t: f32, alpha: f32) {
return;
}
let p = theme::pal_from_ctx(painter.ctx());
painter.rect_filled(rect, 6.0, p.field.gamma_multiply(alpha));
painter.rect_filled(rect, crate::theme::r(6.0), p.field.gamma_multiply(alpha));
let band = rect.width() * SHIMMER_BAND;
let x = rect.left() - band + (t * SHIMMER_SPEED).fract() * (rect.width() + band);
let (l, r) = (x.max(rect.left()), (x + band).min(rect.right()));
+12 -10
View File
@@ -1,5 +1,6 @@
//! The Libraries modal: publish this file, enable libraries from other projects, review updates.
use crate::theme::Surface as _;
use super::{UiState, confirm, home::relative_time, kit};
use crate::editor::Editor;
use crate::libraries::{self, EnabledStatus, LibrarySummary, PublishDiff, Published};
@@ -279,8 +280,9 @@ pub fn show(ctx: &egui::Context, ed: &mut Editor, st: &mut UiState) {
fn draw(ctx: &egui::Context, ed: &mut Editor, st: &UiState, lib: &mut LibrariesUi, dir: Option<&Path>) {
let p = theme::pal_from_ctx(ctx);
let mut act = None;
let frame = egui::Frame::new().fill(p.sidebar).corner_radius(24.0).inner_margin(24);
let frame = egui::Frame::new().fill(p.sidebar).corner_radius(crate::theme::r(24.0)).inner_margin(24);
let modal = egui::Modal::new(egui::Id::new("libraries-modal")).frame(frame).show(ctx, |ui| {
theme::modal_backing(ui, theme::pal(ui).sidebar, theme::ru(24));
ui.set_width(MODAL_W.min(ctx.content_rect().width() - 96.0));
if lib.tab != Tab::File {
ui.set_height(MODAL_H.min(ctx.content_rect().height() - 96.0));
@@ -403,7 +405,7 @@ fn file_tab(ui: &mut Ui, st: &UiState, lib: &mut LibrariesUi, full: bool) -> Opt
let p = theme::pal(ui);
let mut act = None;
let next = lib.scan.previous.map_or(1, |(v, _)| v + 1);
card(ui).show(ui, |ui| {
card(ui).show_surface(ui, |ui| {
ui.set_width(ui.available_width());
ui.horizontal(|ui| {
ui.vertical(|ui| {
@@ -439,13 +441,13 @@ fn file_tab(ui: &mut Ui, st: &UiState, lib: &mut LibrariesUi, full: bool) -> Opt
if full {
ui.add_space(4.0);
kit::section_header(ui, "Description", None);
ui.add(
crate::theme::lit(ui, |ui| ui.add(
egui::TextEdit::multiline(&mut lib.description)
.hint_text("What this library is for")
.desired_rows(3)
.desired_width(f32::INFINITY)
.margin(theme::INPUT_MARGIN),
);
));
ui.label(
RichText::new("Hidden variables and components named with a leading _ or . are not published.")
.size(theme::SECONDARY)
@@ -463,12 +465,12 @@ fn libraries_tab(ui: &mut Ui, st: &UiState, lib: &mut LibrariesUi) -> Option<Act
ui.horizontal(|ui| {
kit::section_header(ui, "Available libraries", None);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
ui.add(
crate::theme::lit(ui, |ui| ui.add(
egui::TextEdit::singleline(&mut lib.search)
.hint_text("Search libraries")
.desired_width(200.0)
.margin(theme::INPUT_MARGIN),
);
));
});
});
let q = lib.search.to_lowercase();
@@ -477,7 +479,7 @@ fn libraries_tab(ui: &mut Ui, st: &UiState, lib: &mut LibrariesUi) -> Option<Act
let mut any = false;
for r in lib.scan.rows.iter().filter(|r| r.name.to_lowercase().contains(&q)) {
any = true;
card(ui).show(ui, |ui| {
card(ui).show_surface(ui, |ui| {
ui.set_width(ui.available_width());
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 10.0;
@@ -546,7 +548,7 @@ fn swatch(ui: &mut Ui, s: &Shown) {
}
Shown::Color(c, hex) => {
let (r, _) = ui.allocate_exact_size(egui::vec2(16.0, 16.0), egui::Sense::hover());
ui.painter().rect_filled(r, 4.0, *c);
ui.painter().rect_filled(r, crate::theme::r(4.0), *c);
ui.label(RichText::new(hex.to_uppercase()).size(theme::BODY));
}
}
@@ -578,7 +580,7 @@ fn updates_tab(ui: &mut Ui, lib: &mut LibrariesUi) -> Option<Act> {
ui.add_space(4.0);
kit::section_header(ui, &format!("Variables · {} changed", u.vars.len()), None);
if !u.vars.is_empty() {
card(ui).show(ui, |ui| {
card(ui).show_surface(ui, |ui| {
ui.set_width(ui.available_width());
for r in &u.vars {
ui.horizontal(|ui| {
@@ -615,7 +617,7 @@ fn updates_tab(ui: &mut Ui, lib: &mut LibrariesUi) -> Option<Act> {
kit::section_header(ui, &title, None);
ui.horizontal_wrapped(|ui| {
for (name, how) in &u.comps {
card(ui).show(ui, |ui| {
card(ui).show_surface(ui, |ui| {
ui.horizontal(|ui| {
ui.label(RichText::new(name).font(theme::semibold(theme::SECONDARY)));
chip_how(ui, how);
+10 -7
View File
@@ -2,6 +2,7 @@
//! each enabled library, and the personal shape library ("My shapes"). Click a tile to place it;
//! tiles do not drag onto the canvas (neither did the shape library).
use crate::theme::Surface as _;
use super::{UiState, kit};
use crate::editor::Editor;
use crate::geom::{Rect, V2, v2};
@@ -400,7 +401,7 @@ fn tile(ui: &mut Ui, w: f32, tex: Option<&egui::TextureHandle>) -> egui::Respons
let p = theme::pal(ui);
let (rect, resp) = ui.allocate_exact_size(egui::vec2(w, (w * TILE_RATIO).round()), egui::Sense::click());
let bg = if resp.hovered() { p.hover } else { p.control };
ui.painter().rect_filled(rect, 12.0, bg);
ui.painter().rect_filled(rect, crate::theme::r(12.0), bg);
if let Some(t) = tex {
ui.painter().image(
t.id(),
@@ -447,11 +448,11 @@ fn subtree_fingerprint(doc: &Document, id: NodeId) -> u64 {
fn name_label(ui: &mut Ui, lib: &mut LibraryUi, key: &str, name: &str, w: f32) -> Option<String> {
if lib.renaming.as_ref().is_some_and(|(k, _)| k == key) {
let (_, draft) = lib.renaming.as_mut().unwrap();
let r = ui.add(
let r = crate::theme::lit(ui, |ui| ui.add(
egui::TextEdit::singleline(draft)
.margin(theme::INPUT_MARGIN)
.desired_width(w),
);
));
// Check before taking focus back (see `home::project_card`).
if r.lost_focus() {
return lib.renaming.take().map(|r| r.1);
@@ -502,6 +503,7 @@ fn asset_grid(
acts.push(Act::Place(a.clone()));
}
resp.on_hover_text("Click to place an instance").context_menu(|ui| {
crate::theme::popup_backing(ui);
if let Origin::Page(_) = a.origin {
if super::icon_menu_item(ui, Icon::Component, "Go to main").clicked() {
acts.push(Act::Go(a.clone()));
@@ -545,6 +547,7 @@ fn shape_grid(ui: &mut Ui, lib: &mut LibraryUi, acts: &mut Vec<Act>) {
acts.push(Act::InsertShape(it.clone()));
}
resp.on_hover_text("Click to insert").context_menu(|ui| {
crate::theme::popup_backing(ui);
if super::icon_menu_item(ui, Icon::Plus, "Insert").clicked() {
acts.push(Act::InsertShape(it.clone()));
ui.close();
@@ -601,12 +604,12 @@ fn header(ui: &mut Ui, st: &mut UiState, acts: &mut Vec<Act>) {
});
});
ui.add_space(6.0);
ui.add(
crate::theme::lit(ui, |ui| ui.add(
egui::TextEdit::singleline(&mut st.library.filter)
.hint_text("Search assets")
.desired_width(f32::INFINITY)
.margin(theme::INPUT_MARGIN),
);
));
ui.add_space(6.0);
}
@@ -617,9 +620,9 @@ fn missing_rows(ui: &mut Ui, ed: &Editor, missing: &[NodeId], acts: &mut Vec<Act
let target = find_named(ed, &name);
egui::Frame::new()
.fill(p.field)
.corner_radius(theme::RADIUS_PANEL)
.corner_radius(theme::radius_panel())
.inner_margin(egui::Margin::symmetric(12, 8))
.show(ui, |ui| {
.show_surface(ui, |ui| {
ui.horizontal(|ui| {
ui.label(RichText::new("!").font(theme::semibold(16.0)).color(p.warning));
ui.vertical(|ui| {
+209 -56
View File
@@ -19,6 +19,7 @@ pub mod library;
pub mod lottie_icon;
pub mod motion;
pub mod pages;
pub mod perf;
pub mod palette;
pub mod panels;
pub mod libraries;
@@ -27,6 +28,7 @@ pub mod variables;
#[cfg(not(target_arch = "wasm32"))]
pub mod update;
use crate::theme::Surface as _;
use crate::editor::shortcuts::{self, Action};
use crate::editor::{Editor, Tool};
use crate::model::{Color, NodeId, NodeKind};
@@ -174,6 +176,8 @@ pub struct UiState {
pub selected_annotation: Option<u64>,
/// Annotations UI state (popover, Customize modal, inline edit), not saved.
pub ann_ui: annotations::AnnotationsUi,
/// Frame times, RAM, CPU and GPU for Settings > Performance and its overlay.
pub perf: perf::Perf,
}
/// Where a dragged layer lands relative to a row.
@@ -519,7 +523,7 @@ fn agent_glow(ctx: &egui::Context, st: &UiState, canvas: egui::Rect) {
))
.rect_stroke(
canvas.shrink(1.5),
f32::from(theme::RADIUS_PANEL),
f32::from(theme::radius_panel()),
egui::Stroke::new(2.0, theme::accent().gamma_multiply(pulse)),
egui::StrokeKind::Inside,
);
@@ -660,7 +664,7 @@ fn presenter(ctx: &egui::Context, ed: &mut Editor, st: &mut UiState, canvas: egu
.fill(p.sidebar)
.corner_radius(egui::CornerRadius::same(255))
.inner_margin(egui::Margin::symmetric(8, 6))
.show(ui, |ui| {
.show_surface(ui, |ui| {
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 6.0;
if kit::lottie_button(ui, "chevron-left", "Previous frame (←)", 32.0)
@@ -698,7 +702,7 @@ fn presenter(ctx: &egui::Context, ed: &mut Editor, st: &mut UiState, canvas: egu
.fill(p.sidebar)
.corner_radius(egui::CornerRadius::same(255))
.inner_margin(egui::Margin::symmetric(14, 6))
.show(ui, |ui| {
.show_surface(ui, |ui| {
ui.label(
RichText::new(format!("Presenting · {}", ed.page_name()))
.size(theme::SECONDARY)
@@ -722,9 +726,17 @@ pub(crate) fn is_floating(translucent: bool) -> bool {
/// A rounded panel that floats on the backdrop and fills the space it is given.
fn floating_panel(ui: &mut Ui, fill: Color32, margin: egui::Margin, add: impl FnOnce(&mut Ui)) {
let fill = if theme::is_acrylic() {
let dark = ui.visuals().dark_mode;
let rect = ui.available_rect_before_wrap();
theme::paint_acrylic_panel(ui.painter(), rect, theme::radius_panel(), fill, dark);
Color32::TRANSPARENT
} else {
fill
};
egui::Frame::new()
.fill(fill)
.corner_radius(theme::RADIUS_PANEL)
.corner_radius(theme::radius_panel())
.inner_margin(margin)
.show(ui, |ui| {
ui.set_min_height(ui.available_height());
@@ -733,9 +745,39 @@ fn floating_panel(ui: &mut Ui, fill: Color32, margin: egui::Margin, add: impl Fn
});
}
/// The acrylic backdrop around the canvas (which the renderer draws underneath the UI, so it
/// must stay unpainted).
fn acrylic_backdrop(p: theme::Palette, screen: egui::Rect, canvas: egui::Rect) -> egui::Shape {
let bands = [
(screen.min, egui::pos2(screen.right(), canvas.top())),
(egui::pos2(screen.left(), canvas.bottom()), screen.max),
(
egui::pos2(screen.left(), canvas.top()),
canvas.left_bottom(),
),
(
canvas.right_top(),
egui::pos2(screen.right(), canvas.bottom()),
),
];
egui::Shape::Vec(
bands
.into_iter()
.map(|(min, max)| egui::Rect::from_min_max(min, max))
.filter(|r| r.is_positive())
.map(|r| theme::backdrop_mesh(p, screen, r).into())
.collect(),
)
}
/// Paints the four corners of `rect` outside a rounded rectangle of radius `r`, so the canvas
/// (drawn by the renderer underneath the UI) shows with rounded corners.
fn round_corners(painter: &egui::Painter, rect: egui::Rect, r: f32, colour: Color32) {
fn round_corners(
painter: &egui::Painter,
rect: egui::Rect,
r: f32,
colour_at: impl Fn(egui::Pos2) -> Color32,
) {
const SEGMENTS: usize = 10;
let mut mesh = egui::Mesh::default();
let corners = [
@@ -746,6 +788,7 @@ fn round_corners(painter: &egui::Painter, rect: egui::Rect, r: f32, colour: Colo
];
for (corner, dir) in corners {
let centre = corner + dir * r;
let colour = colour_at(corner);
let base = mesh.vertices.len() as u32;
mesh.colored_vertex(corner, colour);
for i in 0..=SEGMENTS {
@@ -853,11 +896,13 @@ pub fn draw(ui: &mut Ui, ed: &mut Editor, st: &mut UiState) -> egui::Rect {
let p = theme::pal(ui);
let sidebar_fill = theme::sidebar_fill(st.translucent_sidebar, p);
let floating = is_floating(st.translucent_sidebar);
// The acrylic backdrop goes under the panels, but needs the canvas rect from after them.
let backdrop_slot = ui.painter().add(egui::Shape::Noop);
let gap = PANEL_GAP;
let side_frame = |left: i8, right: i8, top: i8, bottom: i8| {
if floating {
egui::Frame::NONE
.fill(p.backdrop)
.fill(theme::backdrop_fill(p))
.inner_margin(egui::Margin {
left,
right,
@@ -906,7 +951,7 @@ pub fn draw(ui: &mut Ui, ed: &mut Editor, st: &mut UiState) -> egui::Rect {
.frame(egui::Frame::NONE)
.show(ui, |ui| account_footer(ui, st));
ui.horizontal(|ui| {
kit::badge(ui, Icon::Pen, theme::accent(), 28.0);
kit::brand_mark(ui, 28.0);
ui.label(RichText::new("PaperCraft").font(theme::semibold(16.0)));
});
ui.add_space(10.0);
@@ -928,11 +973,11 @@ pub fn draw(ui: &mut Ui, ed: &mut Editor, st: &mut UiState) -> egui::Rect {
} else {
egui::Frame::NONE
.fill(p.inspector)
.inner_margin(egui::Margin::symmetric(14, 10))
.inner_margin(inspector::MARGIN)
})
.show(ui, |ui| {
if floating {
floating_panel(ui, p.inspector, egui::Margin::symmetric(14, 12), |ui| {
floating_panel(ui, p.inspector, inspector::MARGIN, |ui| {
inspector::panel(ui, ed, st)
});
} else {
@@ -968,17 +1013,23 @@ pub fn draw(ui: &mut Ui, ed: &mut Editor, st: &mut UiState) -> egui::Rect {
if floating {
egui::Panel::bottom("canvas-gap")
.exact_size(gap)
.frame(egui::Frame::NONE.fill(p.backdrop))
.frame(egui::Frame::NONE.fill(theme::backdrop_fill(p)))
.show(ui, |_| {});
}
let canvas = ui.available_rect_before_wrap();
if floating {
round_corners(
ui.painter(),
canvas,
f32::from(theme::RADIUS_PANEL),
p.backdrop,
);
let screen = ui.ctx().content_rect();
if theme::is_acrylic() {
ui.painter()
.set(backdrop_slot, acrylic_backdrop(p, screen, canvas));
}
round_corners(ui.painter(), canvas, f32::from(theme::radius_panel()), |q| {
if theme::is_acrylic() {
theme::backdrop_at(p, screen, q)
} else {
p.backdrop
}
});
}
toolbar(ui.ctx(), ed, canvas);
annotations::overlays(ui.ctx(), ed, st, canvas);
@@ -1001,6 +1052,7 @@ pub fn draw(ui: &mut Ui, ed: &mut Editor, st: &mut UiState) -> egui::Rect {
annotations_canvas::draw(ui.ctx(), ed, &mut st.annotations, &mut st.selected_annotation, canvas);
props_modal::show(ui.ctx(), ed, st);
run_toast(ui.ctx(), st, canvas);
perf::overlay(ui.ctx(), &mut st.perf, canvas);
palette::draw(ui.ctx(), ed, st, canvas);
if st.show_help {
help(ui.ctx(), st, canvas);
@@ -1032,7 +1084,10 @@ fn menu_entries(ui: &mut Ui, ed: &mut Editor, entries: &[shortcuts::MenuEntry])
ui.separator();
}
shortcuts::MenuEntry::Submenu(label, items) => {
ui.menu_button(*label, |ui| menu_entries(ui, ed, items));
ui.menu_button(*label, |ui| {
crate::theme::popup_backing(ui);
menu_entries(ui, ed, items)
});
}
}
}
@@ -1051,7 +1106,10 @@ fn top_bar(ui: &mut Ui, ed: &mut Editor, st: &mut UiState) {
// Not enough room for every menu: they fold into one.
ui.scope(|ui| {
flat_menus(ui);
let menu = ui.menu_button("Menu", |ui| flat_menu(ui, ed));
let menu = ui.menu_button("Menu", |ui| {
crate::theme::popup_backing(ui);
flat_menu(ui, ed)
});
let _ = menu;
});
} else {
@@ -1122,6 +1180,7 @@ fn top_bar(ui: &mut Ui, ed: &mut Editor, st: &mut UiState) {
if many {
let page = page.on_hover_text("Switch page").on_hover_cursor(egui::CursorIcon::PointingHand);
egui::Popup::menu(&page).show(|ui| {
crate::theme::popup_backing(ui);
ui.set_min_width(180.0);
egui::ScrollArea::vertical().max_height(PAGE_MENU_MAX_H).show(ui, |ui| {
for i in 0..ed.pages.len() {
@@ -1142,8 +1201,9 @@ fn top_bar(ui: &mut Ui, ed: &mut Editor, st: &mut UiState) {
agent_pill(ui, st, !tight, right_w);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
ui.spacing_mut().item_spacing.x = 2.0;
let share = kit::button(ui, kit::ButtonKind::Primary, Some(Icon::Export), "Share");
let share = kit::button_compact(ui, kit::ButtonKind::Primary, Some(Icon::Export), "Share");
egui::Popup::menu(&share).show(|ui| {
crate::theme::popup_backing(ui);
ui.set_min_width(190.0);
for (icon, label, action) in [
(Icon::Image, "Export PNG…", Action::ExportPng),
@@ -1211,11 +1271,13 @@ fn top_bar(ui: &mut Ui, ed: &mut Editor, st: &mut UiState) {
egui::Frame::new()
.fill(p.field)
.corner_radius(egui::CornerRadius::same(255))
.inner_margin(egui::Margin::symmetric(6, 2))
.show(ui, |ui| {
.inner_margin(egui::Margin::symmetric(4, 2))
.show_surface(ui, |ui| {
// As tall as the Design/Chat switch.
ui.spacing_mut().item_spacing.x = 2.0;
ui.spacing_mut().interact_size.y = ZOOM_BTN;
ui.horizontal(|ui| {
if theme::icon_button(ui, theme::Icon::Minus, 26.0, false)
if theme::icon_button(ui, theme::Icon::Minus, ZOOM_BTN, false)
.on_hover_text("Zoom out")
.clicked()
{
@@ -1227,12 +1289,12 @@ fn top_bar(ui: &mut Ui, ed: &mut Editor, st: &mut UiState) {
.font(theme::semibold(theme::SECONDARY + 1.0)),
)
.frame(false)
.min_size(egui::vec2(48.0, 22.0)),
.min_size(egui::vec2(48.0, ZOOM_BTN)),
);
if z.on_hover_text("Zoom to fit").clicked() {
ed.run(Action::ZoomFit);
}
if theme::icon_button(ui, theme::Icon::Plus, 26.0, false)
if theme::icon_button(ui, theme::Icon::Plus, ZOOM_BTN, false)
.on_hover_text("Zoom in")
.clicked()
{
@@ -1247,12 +1309,16 @@ fn top_bar(ui: &mut Ui, ed: &mut Editor, st: &mut UiState) {
});
}
/// Zoom pill buttons: with the pill's 2px margin, as tall as [`kit::SEGMENTED_H`].
const ZOOM_BTN: f32 = kit::SEGMENTED_H - 4.0;
/// egui menus for platforms without a native menu bar (inline, so the toolbar row keeps
/// the title and zoom controls).
fn menu_bar(ui: &mut Ui, ed: &mut Editor, agent: &mut Option<agent::AgentUi>) {
flat_menus(ui);
for (title, entries) in shortcuts::menus() {
ui.menu_button(title, |ui| {
crate::theme::popup_backing(ui);
menu_entries(ui, ed, &entries);
});
}
@@ -1272,7 +1338,7 @@ pub(super) fn icon_menu_item(ui: &mut Ui, icon: Icon, label: &str) -> egui::Resp
let hover = motion::hover(ui.ctx(), resp.id.with("menu-item"), &resp);
if hover > 0.0 {
ui.painter()
.rect_filled(rect, theme::RADIUS_MD, p.hover.gamma_multiply(hover));
.rect_filled(rect, theme::radius_md(), p.hover.gamma_multiply(hover));
}
let at = egui::Rect::from_center_size(
egui::pos2(rect.left() + 18.0, rect.center().y),
@@ -1326,6 +1392,7 @@ fn flat_menus(ui: &mut Ui) {
fn theme_menu(ui: &mut Ui) {
flat_menus(ui);
ui.menu_button("Theme", |ui| {
crate::theme::popup_backing(ui);
for c in theme::Choice::ALL {
if ui
.selectable_label(theme::choice() == c, c.label())
@@ -1436,9 +1503,10 @@ fn side_tabs(ui: &mut Ui, st: &mut UiState) {
};
let pad = 3.0;
let gap = 2.0;
let dark = ui.visuals().dark_mode;
egui::Frame::new()
.fill(p.field)
.corner_radius(theme::RADIUS_LG)
.fill(theme::well_fill(p, dark))
.corner_radius(theme::radius_lg())
.inner_margin(pad as i8)
.show(ui, |ui| {
let total = ui.available_width();
@@ -1452,12 +1520,12 @@ fn side_tabs(ui: &mut Ui, st: &mut UiState) {
ui.allocate_exact_size(egui::vec2(w, 30.0), egui::Sense::click());
let on = current == tab;
let hover = motion::hover(ui.ctx(), resp.id.with("side-tab"), &resp);
let fill = if on {
p.sidebar
if on {
theme::fill_selected(ui, rect, crate::theme::ru(9), p.sidebar);
} else {
p.hover.gamma_multiply(hover)
};
ui.painter().rect_filled(rect, 9.0, fill);
ui.painter()
.rect_filled(rect, crate::theme::r(9.0), p.hover.gamma_multiply(hover));
}
let tint = if on { theme::accent() } else { p.secondary };
let hovered = resp.hovered();
let icon_box = if wide {
@@ -1586,12 +1654,7 @@ fn layers(ui: &mut Ui, ed: &mut Editor, st: &mut UiState) {
}
}
if ed.doc.nodes.len() > 12 || !st.layer_filter.is_empty() {
ui.add(
egui::TextEdit::singleline(&mut st.layer_filter)
.hint_text("Search layers")
.desired_width(f32::INFINITY)
.margin(crate::theme::INPUT_MARGIN),
);
kit::icon_field(ui, Icon::Search, &mut st.layer_filter, "Search layers");
ui.add_space(2.0);
}
egui::ScrollArea::vertical()
@@ -1661,7 +1724,7 @@ fn layer_drag(ui: &mut Ui, ed: &mut Editor, st: &mut UiState) {
}
LayerDrop::Into(_) => {
ui.painter()
.rect_stroke(r, 6.0, stroke, egui::StrokeKind::Inside);
.rect_stroke(r, crate::theme::r(6.0), stroke, egui::StrokeKind::Inside);
}
}
}
@@ -1785,8 +1848,10 @@ fn layer_subtree_height(
/// A layer row and its unfolded children. A page can hold thousands of layers, but only a
/// screenful is visible: rows far outside the list are not built, their space is just reserved.
fn layer_item(ui: &mut Ui, ed: &mut Editor, st: &mut UiState, id: NodeId, depth: usize) {
// A filter builds the whole tree (matches can be anywhere), and so does scrolling to a layer.
if st.layer_filter.trim().is_empty() && st.reveal.is_none() {
// A filter builds the whole tree (matches can be anywhere). Scrolling to a layer builds only
// the subtree that holds it, so the rest stays virtual.
let holds_reveal = st.reveal.is_some_and(|t| is_within(&ed.doc, t, id));
if st.layer_filter.trim().is_empty() && !holds_reveal {
let h = layer_subtree_height(&ed.doc, &st.collapsed, id, &mut st.layer_heights);
let top = ui.cursor().top();
let view = ui.clip_rect();
@@ -1800,6 +1865,18 @@ fn layer_item(ui: &mut Ui, ed: &mut Editor, st: &mut UiState, id: NodeId, depth:
motion::pop_in_if(ui, ("layer-pop", id), index, animate, |ui| layer_row(ui, ed, st, id, depth));
}
/// Whether `id` is `root` or below it.
fn is_within(doc: &crate::model::Document, id: NodeId, root: NodeId) -> bool {
let mut cur = Some(id);
while let Some(c) = cur {
if c == root {
return true;
}
cur = doc.get(c).and_then(|n| n.parent);
}
false
}
/// One sidebar row, drawn like an NSOutlineView row: 24pt tall, 6pt radius selection in the
/// accent colour, subtle hover, disclosure chevron, symbol, name, and eye/lock on hover.
/// Whether `id` or anything below it has a name containing `needle` (lowercase).
@@ -1836,9 +1913,9 @@ fn layer_row(ui: &mut Ui, ed: &mut Editor, st: &mut UiState, id: NodeId, depth:
}
let hovered = ui.rect_contains_pointer(row);
if selected {
ui.painter().rect_filled(row, 10.0, p.field);
theme::fill_selected(ui, row, crate::theme::ru(10), p.field);
} else if hovered {
ui.painter().rect_filled(row, 10.0, p.hover);
ui.painter().rect_filled(row, crate::theme::r(10.0), p.hover);
}
let fg = if n.visible { p.text } else { p.secondary };
let fg2 = p.secondary;
@@ -1897,7 +1974,7 @@ fn layer_row(ui: &mut Ui, ed: &mut Editor, st: &mut UiState, id: NodeId, depth:
let t = motion::hover(ui.ctx(), ui.id().with(("trail-hover", tip, id)), &b);
if t > 0.0 {
let tint = p.hover;
ui.painter().rect_filled(r, 5.0, tint.gamma_multiply(t));
ui.painter().rect_filled(r, crate::theme::r(5.0), tint.gamma_multiply(t));
}
paint(ui, r.shrink(3.0));
b.on_hover_text(tip).clicked()
@@ -1956,6 +2033,15 @@ fn layer_row(ui: &mut Ui, ed: &mut Editor, st: &mut UiState, id: NodeId, depth:
clip.galley(egui::pos2(x, cy - galley.size().y / 2.0), galley, fg);
}
// Right-click acts on the row's layer: it joins the selection first unless already in it.
if resp.secondary_clicked() && !selected {
ed.end_text_edit();
ed.selection = vec![id];
}
resp.context_menu(|ui| {
crate::theme::popup_backing(ui);
layer_menu(ui, ed, st, id)
});
if resp.double_clicked() {
st.renaming = Some((id, n.name.clone()));
} else if resp.clicked() {
@@ -1980,6 +2066,73 @@ fn layer_row(ui: &mut Ui, ed: &mut Editor, st: &mut UiState, id: NodeId, depth:
}
}
/// The layers panel's right-click menu: the common edits on the selection, then its Actions.
fn layer_menu(ui: &mut Ui, ed: &mut Editor, st: &mut UiState, id: NodeId) {
use crate::editor::shortcuts::Action as A;
let Some(n) = ed.doc.get(id) else { return };
let (name, visible, locked) = (n.name.clone(), n.visible, n.locked);
let container = n.is_container() && !n.children.is_empty();
let frame_like = n.is_frame() || n.is_group();
let mut run = None;
let mut item = |ui: &mut Ui, icon: Icon, label: &str, keys: &str, act: A| {
if icon_menu_item_keys(ui, icon, label, keys).clicked() {
run = Some(act);
ui.close();
}
};
item(ui, Icon::Export, "Copy", "⌘C", A::Copy);
item(ui, Icon::Page, "Paste", "⌘V", A::Paste);
item(ui, Icon::Instance, "Duplicate", "⌘D", A::Duplicate);
if icon_menu_item_keys(ui, Icon::Text, "Rename", "⌘R").clicked() {
st.renaming = Some((id, name));
ui.close();
}
ui.separator();
item(ui, Icon::Group, "Group", "⌘G", A::Group);
if container {
item(ui, Icon::Group, "Ungroup", "⇧⌘G", A::Ungroup);
}
item(ui, Icon::Frame, "Wrap in frame", "⌥⌘G", A::WrapInFrame);
if frame_like {
item(ui, Icon::Component, "Create component", "⌥⌘K", A::CreateComponent);
}
ui.separator();
item(ui, Icon::ChevronUp, "Bring to front", "⇧⌘]", A::ToFront);
item(ui, Icon::ChevronDown, "Send to back", "⇧⌘[", A::ToBack);
ui.separator();
let (eye, eye_label) = if visible { (Icon::EyeSlash, "Hide") } else { (Icon::Eye, "Show") };
item(ui, eye, eye_label, "⇧⌘H", A::ToggleVisible);
let (lock, lock_label) = if locked { (Icon::LockOpen, "Unlock") } else { (Icon::Lock, "Lock") };
item(ui, lock, lock_label, "⇧⌘L", A::ToggleLock);
item(ui, Icon::Search, "Zoom to layer", "⇧2", A::ZoomSelection);
ui.separator();
ui.menu_button("Actions", |ui| {
crate::theme::popup_backing(ui);
if let Some(a) = actions::menu(ui, ed) {
actions::dispatch(ed, st, a);
ui.close();
}
});
ui.separator();
item(ui, Icon::Trash, "Delete", "⌫", A::Delete);
if let Some(a) = run {
ed.run(a);
}
}
/// [`icon_menu_item`] with its shortcut on the right.
fn icon_menu_item_keys(ui: &mut Ui, icon: Icon, label: &str, keys: &str) -> egui::Response {
let r = icon_menu_item(ui, icon, label);
ui.painter().text(
egui::pos2(r.rect.right() - 10.0, r.rect.center().y),
Align2::RIGHT_CENTER,
keys,
egui::FontId::proportional(theme::SECONDARY),
theme::pal(ui).tertiary,
);
r
}
fn tool_icon(t: Tool) -> Icon {
match t {
Tool::Select => Icon::Pointer,
@@ -2012,10 +2165,10 @@ fn toolbar(ctx: &egui::Context, ed: &mut Editor, canvas: egui::Rect) {
let p = theme::pal(ui);
egui::Frame::popup(ui.style())
.fill(p.popover)
.corner_radius(theme::RADIUS_PANEL)
.corner_radius(theme::radius_panel())
.inner_margin(4.0)
.stroke(egui::Stroke::NONE)
.show(ui, |ui| {
.show_surface(ui, |ui| {
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 4.0;
for t in Tool::ALL {
@@ -2057,9 +2210,9 @@ fn toast(ctx: &egui::Context, text: &str, error: bool) {
};
egui::Frame::popup(ui.style())
.fill(fill)
.corner_radius(16.0)
.corner_radius(crate::theme::r(16.0))
.inner_margin(egui::Margin::symmetric(16, 10))
.show(ui, |ui| {
.show_surface(ui, |ui| {
ui.set_max_width(520.0);
ui.label(RichText::new(text).color(ink));
});
@@ -2139,7 +2292,7 @@ fn review_overlay(ctx: &egui::Context, ed: &Editor, st: &UiState, canvas: egui::
egui::pos2(fr.right() - size.x, fr.top() - size.y - 6.0),
size,
);
painter.rect_filled(tag, 255, p.warning);
painter.rect_filled(tag, crate::theme::r(255.0), p.warning);
painter.galley(tag.min + egui::vec2(8.0, 3.0), galley, p.warning);
}
});
@@ -2242,7 +2395,7 @@ fn agent_overlay(ctx: &egui::Context, ed: &mut Editor, st: &mut UiState, canvas:
let ink = kit::ink_on(accent);
let galley = painter.layout_no_wrap(format!("{}\u{2026}", agent_verb(&d.name)), theme::semibold(theme::SECONDARY), ink);
let tag = egui::Rect::from_min_size(r.left_top() - egui::vec2(0.0, galley.size().y + 8.0), galley.size() + egui::vec2(12.0, 4.0));
painter.rect_filled(tag, 255, accent);
painter.rect_filled(tag, crate::theme::r(255.0), accent);
painter.galley(tag.min + egui::vec2(6.0, 2.0), galley, ink);
}
}
@@ -2267,7 +2420,7 @@ fn agent_overlay(ctx: &egui::Context, ed: &mut Editor, st: &mut UiState, canvas:
let galley = painter.layout_no_wrap(text, theme::semibold(theme::SECONDARY), ink.gamma_multiply(a));
let at = tip + egui::vec2(AGENT_CURSOR * 0.6, AGENT_CURSOR * 0.9);
let tag = egui::Rect::from_min_size(at, galley.size() + egui::vec2(12.0, 4.0));
painter.rect_filled(tag, 255, accent.gamma_multiply(a));
painter.rect_filled(tag, crate::theme::r(255.0), accent.gamma_multiply(a));
painter.galley(tag.min + egui::vec2(6.0, 2.0), galley, ink);
});
let Some(m) = st.agent_marks.last().filter(|m| m.page_id != page) else { return };
@@ -2312,9 +2465,9 @@ fn run_toast(ctx: &egui::Context, st: &mut UiState, canvas: egui::Rect) {
egui::Frame::popup(ui.style())
.fill(p.popover)
.stroke(egui::Stroke::new(1.0, p.hover))
.corner_radius(16.0)
.corner_radius(crate::theme::r(16.0))
.inner_margin(egui::Margin::symmetric(16, 12))
.show(ui, |ui| {
.show_surface(ui, |ui| {
ui.horizontal(|ui| {
let (r, _) =
ui.allocate_exact_size(egui::vec2(18.0, 18.0), egui::Sense::hover());
@@ -2373,7 +2526,7 @@ fn status(ctx: &egui::Context, ed: &Editor, canvas: egui::Rect) {
.fill(p.sidebar)
.corner_radius(egui::CornerRadius::same(255))
.inner_margin(egui::Margin::symmetric(14, 6))
.show(ui, |ui| {
.show_surface(ui, |ui| {
ui.label(
RichText::new(&ed.status)
.color(p.secondary)
@@ -2394,10 +2547,10 @@ fn help(ctx: &egui::Context, st: &mut UiState, canvas: egui::Rect) {
let p = theme::pal(ui);
egui::Frame::popup(ui.style())
.fill(p.sidebar)
.corner_radius(24.0)
.corner_radius(crate::theme::r(24.0))
.inner_margin(24.0)
.stroke(egui::Stroke::NONE)
.show(ui, |ui| {
.show_surface(ui, |ui| {
ui.set_width(width);
ui.horizontal(|ui| {
ui.vertical(|ui| {
+17 -7
View File
@@ -75,9 +75,10 @@ pub fn panel(ui: &mut Ui, ed: &mut Editor, st: &mut UiState) {
let (sel, resp) =
ui.allocate_exact_size(egui::vec2(ui.available_width(), 38.0), egui::Sense::click());
let hover = super::motion::hover(ui.ctx(), resp.id.with("page-selector"), &resp);
ui.painter().rect_filled(
theme::fill_control(
ui,
sel,
theme::RADIUS_LG,
theme::radius_lg(),
theme::blend(p.field, p.hover.gamma_multiply(hover)),
);
theme::paint_icon(
@@ -107,8 +108,13 @@ pub fn panel(ui: &mut Ui, ed: &mut Editor, st: &mut UiState) {
egui::pos2(sel.right() - 44.0 - cw / 2.0 + 14.0, sel.center().y),
egui::vec2(cw, 20.0),
);
let chip_fill = if theme::is_acrylic() {
theme::well_fill(p, ui.visuals().dark_mode)
} else {
p.sidebar
};
ui.painter()
.rect_filled(chip, egui::CornerRadius::same(255), p.sidebar);
.rect_filled(chip, egui::CornerRadius::same(255), chip_fill);
ui.painter().text(
chip.center(),
egui::Align2::CENTER_CENTER,
@@ -186,6 +192,7 @@ pub fn panel(ui: &mut Ui, ed: &mut Editor, st: &mut UiState) {
})
.inner;
response.context_menu(|ui| {
crate::theme::popup_backing(ui);
ui.set_width(MENU_WIDTH);
if super::icon_menu_item(ui, crate::theme::Icon::Text, "Rename group").clicked()
{
@@ -262,13 +269,14 @@ fn group_dialog(ctx: &egui::Context, ed: &mut Editor, st: &mut UiState) {
};
let mut done = false;
let response = egui::Modal::new(egui::Id::new("page-group-name")).show(ctx, |ui| {
theme::modal_backing(ui, ui.visuals().window_fill, theme::radius_xl());
ui.set_width(MENU_WIDTH);
ui.heading(title);
let input = ui.add(
let input = crate::theme::lit(ui, |ui| ui.add(
egui::TextEdit::singleline(&mut draft.name)
.hint_text("Group name")
.margin(theme::INPUT_MARGIN),
);
));
if input.gained_focus() || !ui.memory(|m| m.focused().is_some()) {
input.request_focus();
}
@@ -314,9 +322,9 @@ fn page_row(ui: &mut Ui, ed: &mut Editor, st: &mut UiState, i: usize) {
egui::Sense::click_and_drag(),
);
if active {
ui.painter().rect_filled(row, 10.0, p.field);
theme::fill_selected(ui, row, crate::theme::ru(10), p.field);
} else if resp.hovered() {
ui.painter().rect_filled(row, 10.0, p.hover);
ui.painter().rect_filled(row, crate::theme::r(10.0), p.hover);
}
// Drop indicator while dragging.
if let Some((from, to)) = &st.pages.drag
@@ -394,6 +402,7 @@ fn page_row(ui: &mut Ui, ed: &mut Editor, st: &mut UiState, i: usize) {
*to = page_id.clone();
}
resp.context_menu(|ui| {
crate::theme::popup_backing(ui);
ui.set_width(MENU_WIDTH);
if super::icon_menu_item(ui, crate::theme::Icon::Text, "Rename").clicked() {
st.pages.renaming = Some((i, ed.pages[i].name.clone()));
@@ -404,6 +413,7 @@ fn page_row(ui: &mut Ui, ed: &mut Editor, st: &mut UiState, i: usize) {
ui.close();
}
ui.menu_button("Move to group", |ui| {
crate::theme::popup_backing(ui);
ui.set_width(MENU_WIDTH);
if ui
.selectable_label(ed.page_group(&page_id).is_none(), "Ungrouped")
+7 -6
View File
@@ -1,5 +1,6 @@
//! ⌘K command palette: fuzzy search over every action, tool, page, layer and library item.
use crate::theme::Surface as _;
use super::UiState;
use crate::editor::shortcuts::{self, Action};
use crate::editor::{Editor, Tool};
@@ -388,16 +389,16 @@ pub fn draw(ctx: &egui::Context, ed: &mut Editor, st: &mut UiState, canvas: egui
let p = theme::pal(ui);
egui::Frame::popup(ui.style())
.fill(p.sidebar)
.corner_radius(24.0)
.corner_radius(crate::theme::r(24.0))
.inner_margin(16.0)
.stroke(egui::Stroke::NONE)
.show(ui, |ui| {
.show_surface(ui, |ui| {
ui.set_width(width);
let r = egui::Frame::new()
.fill(p.field)
.corner_radius(16.0)
.corner_radius(crate::theme::r(16.0))
.inner_margin(egui::Margin::symmetric(14, 6))
.show(ui, |ui| {
.show_surface(ui, |ui| {
ui.horizontal(|ui| {
let (ir, _) = ui.allocate_exact_size(
egui::vec2(18.0, 18.0),
@@ -441,10 +442,10 @@ pub fn draw(ctx: &egui::Context, ed: &mut Editor, st: &mut UiState, canvas: egui
egui::Sense::click(),
);
if sel {
ui.painter().rect_filled(row, 12.0, p.field);
theme::fill_selected(ui, row, crate::theme::ru(12), p.field);
ui.scroll_to_rect(row, None);
} else if resp.hovered() {
ui.painter().rect_filled(row, 12.0, p.hover);
ui.painter().rect_filled(row, crate::theme::r(12.0), p.hover);
}
ui.painter().text(
egui::pos2(row.left() + 14.0, row.center().y),
+19 -17
View File
@@ -1,5 +1,6 @@
//! Sidebar views beyond layers, library and design: history, comments and the agent log.
use crate::theme::Surface as _;
use super::{UiState, kit};
use crate::editor::Editor;
use crate::model::NodeId;
@@ -48,10 +49,10 @@ fn row(
ui.allocate_exact_size(egui::vec2(ui.available_width(), 44.0), egui::Sense::click());
let hover = super::motion::hover(ui.ctx(), resp.id.with("row"), &resp);
if active {
ui.painter().rect_filled(rect, theme::RADIUS_LG, p.field);
theme::fill_selected(ui, rect, theme::radius_lg(), p.field);
} else if hover > 0.0 {
ui.painter()
.rect_filled(rect, theme::RADIUS_LG, p.hover.gamma_multiply(hover));
.rect_filled(rect, theme::radius_lg(), p.hover.gamma_multiply(hover));
}
ui.painter()
.circle_filled(egui::pos2(rect.left() + 14.0, rect.center().y), 4.0, dot);
@@ -156,12 +157,12 @@ fn versions_section(ui: &mut Ui, ed: &mut Editor, st: &mut UiState) {
st.versions = crate::versions::list(&dir, &page_id);
st.versions_key = key;
}
ui.add(
crate::theme::lit(ui, |ui| ui.add(
egui::TextEdit::singleline(&mut st.version_draft)
.hint_text("Name this version")
.desired_width(f32::INFINITY)
.margin(theme::INPUT_MARGIN),
);
));
ui.add_space(4.0);
if kit::button_wide(
ui,
@@ -210,11 +211,11 @@ fn versions_section(ui: &mut Ui, ed: &mut Editor, st: &mut UiState) {
if let Some((id, draft)) = &mut st.version_renaming
&& *id == v.id
{
let r = ui.add(
let r = crate::theme::lit(ui, |ui| ui.add(
egui::TextEdit::singleline(draft)
.desired_width(f32::INFINITY)
.margin(theme::INPUT_MARGIN),
);
));
// Check before taking focus back (see `home::project_card`).
if r.lost_focus() {
if !ui.input(|i| i.key_pressed(egui::Key::Escape)) {
@@ -232,6 +233,7 @@ fn versions_section(ui: &mut Ui, ed: &mut Editor, st: &mut UiState) {
restore = Some(v.id.clone());
}
resp.context_menu(|ui| {
crate::theme::popup_backing(ui);
if super::icon_menu_item(ui, crate::theme::Icon::Restore, "Restore").clicked() {
restore = Some(v.id.clone());
ui.close();
@@ -360,9 +362,9 @@ pub fn comments(ui: &mut Ui, ed: &mut Editor, st: &mut UiState) {
let mut select = false;
egui::Frame::new()
.fill(p.field)
.corner_radius(theme::RADIUS_PANEL)
.corner_radius(theme::radius_panel())
.inner_margin(12)
.show(ui, |ui| {
.show_surface(ui, |ui| {
ui.set_width(ui.available_width());
ui.spacing_mut().item_spacing.y = 6.0;
ui.horizontal(|ui| {
@@ -446,7 +448,7 @@ pub fn comments(ui: &mut Ui, ed: &mut Editor, st: &mut UiState) {
ui.add_space(8.0);
let target = ed.selection.first().copied();
ui.add_enabled_ui(target.is_some(), |ui| {
ui.add(
crate::theme::lit(ui, |ui| ui.add(
egui::TextEdit::singleline(&mut st.feedback_draft)
.hint_text(if target.is_some() {
"Note for the agent…"
@@ -455,7 +457,7 @@ pub fn comments(ui: &mut Ui, ed: &mut Editor, st: &mut UiState) {
})
.desired_width(f32::INFINITY)
.margin(theme::INPUT_MARGIN),
);
));
ui.add_space(4.0);
if kit::button_wide(ui, kit::ButtonKind::Primary, None, "Add note").clicked()
&& !st.feedback_draft.trim().is_empty()
@@ -538,9 +540,9 @@ fn runs_section(ui: &mut Ui, ed: &mut Editor, st: &mut UiState) {
egui::Frame::new()
.fill(p.field)
.stroke(egui::Stroke::new(1.0, theme::accent()))
.corner_radius(theme::RADIUS_LG)
.corner_radius(theme::radius_lg())
.inner_margin(12)
.show(ui, |ui| {
.show_surface(ui, |ui| {
ui.set_width(ui.available_width());
ui.horizontal(|ui| {
kit::badge(ui, Icon::Pen, theme::accent(), 32.0);
@@ -609,9 +611,9 @@ fn runs_section(ui: &mut Ui, ed: &mut Editor, st: &mut UiState) {
let card = egui::Frame::new()
.fill(p.field)
.stroke(stroke)
.corner_radius(theme::RADIUS_LG)
.corner_radius(theme::radius_lg())
.inner_margin(12)
.show(ui, |ui| {
.show_surface(ui, |ui| {
ui.set_width(ui.available_width());
ui.spacing_mut().item_spacing.y = 6.0;
let top = egui::vec2(ui.available_width(), 22.0);
@@ -906,13 +908,13 @@ fn diff_row(
ui.allocate_exact_size(egui::vec2(ui.available_width(), 48.0), egui::Sense::click());
let hover = super::motion::hover(ui.ctx(), resp.id.with("rv"), &resp);
ui.painter()
.rect_filled(rect, theme::RADIUS_LG, p.hover.gamma_multiply(hover));
.rect_filled(rect, theme::radius_lg(), p.hover.gamma_multiply(hover));
let badge = egui::Rect::from_min_size(
rect.left_center() - egui::vec2(0.0, 14.0),
egui::vec2(28.0, 28.0),
);
ui.painter()
.rect_filled(badge, theme::RADIUS_MD, tint.gamma_multiply(0.18));
.rect_filled(badge, theme::radius_md(), tint.gamma_multiply(0.18));
theme::paint_icon(ui.painter(), badge.shrink(6.0), icon, tint);
let x = rect.left() + 40.0;
let clip = ui.painter().with_clip_rect(egui::Rect::from_min_max(
@@ -1144,7 +1146,7 @@ fn variable_row(ui: &mut Ui, n: &crate::mcp::diff::NodeDiff) {
"removed" => (p.danger, Icon::Minus),
_ => (p.warning, Icon::Pencil),
};
ui.painter().rect_filled(badge, theme::RADIUS_MD, tint.gamma_multiply(0.18));
ui.painter().rect_filled(badge, theme::radius_md(), tint.gamma_multiply(0.18));
theme::paint_icon(ui.painter(), badge.shrink(6.0), icon, tint);
let x = rect.left() + 40.0;
let clip = ui.painter().with_clip_rect(rect);
+280
View File
@@ -0,0 +1,280 @@
//! Performance monitor: frame times from the shell, process RAM and CPU sampled once a second,
//! the GPU adapter, an optional overlay on the canvas and a flag when frames get too slow.
use super::kit;
use crate::theme::{self, Icon, Surface as _};
use egui::{RichText, Ui};
use std::collections::VecDeque;
/// Frame-rate floors to choose from in Settings.
pub const THRESHOLDS: [u32; 5] = [30, 45, 60, 90, 120];
const DEFAULT_MIN_FPS: u32 = 60;
/// Frames kept for the averages (about two seconds of steady animation).
const WINDOW: usize = 120;
/// Frames that must run slow before the flag shows, so one hitch doesn't raise it.
const SLOW_STREAK: usize = 20;
const SAMPLE_SECS: f64 = 1.0;
const MB: f64 = 1024.0 * 1024.0;
#[derive(Clone, Copy, PartialEq, Eq)]
pub struct PerfPrefs {
pub overlay: bool,
pub min_fps: u32,
}
impl Default for PerfPrefs {
fn default() -> Self {
PerfPrefs { overlay: false, min_fps: DEFAULT_MIN_FPS }
}
}
impl PerfPrefs {
fn path() -> Option<std::path::PathBuf> {
Some(dirs::config_dir()?.join("papercraft").join("performance"))
}
/// `"<overlay 0|1> <min fps>"`.
pub fn load() -> PerfPrefs {
let s = Self::path().and_then(|p| crate::vfs::read_to_string(p).ok()).unwrap_or_default();
let mut it = s.split_whitespace();
let d = PerfPrefs::default();
PerfPrefs {
overlay: it.next().map_or(d.overlay, |v| v == "1"),
min_fps: it.next().and_then(|v| v.parse().ok()).unwrap_or(d.min_fps),
}
}
pub fn save(self) {
let Some(p) = Self::path() else { return };
if let Some(dir) = p.parent() {
let _ = crate::vfs::create_dir_all(dir);
}
let _ = crate::vfs::write(p, format!("{} {}", u8::from(self.overlay), self.min_fps));
}
}
#[derive(Default)]
pub struct Perf {
pub prefs: PerfPrefs,
/// GPU adapter, "name (backend)"; set by the shell.
pub gpu: String,
/// (time in seconds, CPU time of the frame in ms), oldest first.
frames: VecDeque<(f64, f32)>,
ram: Option<f64>,
cpu: Option<f32>,
last_sample: Option<(f64, f64)>,
}
impl Perf {
/// One rendered frame that took `ms` of CPU time, at `now` seconds.
pub fn record(&mut self, now: f64, ms: f32) {
if self.frames.len() == WINDOW {
self.frames.pop_front();
}
self.frames.push_back((now, ms));
if self.last_sample.is_some_and(|(t, _)| now - t < SAMPLE_SECS) {
return;
}
self.ram = rss_bytes().map(|b| b as f64 / MB);
let cpu_now = cpu_seconds();
if let (Some((t, c)), Some(c2)) = (self.last_sample, cpu_now) {
self.cpu = Some(((c2 - c) / (now - t) * 100.0) as f32);
}
self.last_sample = cpu_now.map(|c| (now, c));
}
/// Frames shown in the last second.
pub fn fps(&self) -> usize {
let Some(&(last, _)) = self.frames.back() else { return 0 };
self.frames.iter().filter(|(t, _)| last - t < 1.0).count()
}
/// Average and worst frame time over the window, in ms.
pub fn frame_ms(&self) -> (f32, f32) {
if self.frames.is_empty() {
return (0.0, 0.0);
}
let sum: f32 = self.frames.iter().map(|f| f.1).sum();
(sum / self.frames.len() as f32, self.frames.iter().map(|f| f.1).fold(0.0, f32::max))
}
/// Frames are slower than the floor allows: the last [`SLOW_STREAK`] frames all took
/// longer than one frame at `min_fps`. Measured on work, not on the frame rate, since the
/// app draws nothing while idle.
pub fn is_slow(&self) -> bool {
let budget = 1000.0 / self.prefs.min_fps as f32;
self.frames.len() >= SLOW_STREAK && self.frames.iter().rev().take(SLOW_STREAK).all(|f| f.1 > budget)
}
/// The frame rate the current work allows (1000 / average frame time).
fn capacity(&self) -> f32 {
let (avg, _) = self.frame_ms();
if avg > 0.0 { 1000.0 / avg } else { 0.0 }
}
/// The readout: one row per metric.
pub fn stats(&self, ui: &mut Ui) {
let p = theme::pal(ui);
let (avg, worst) = self.frame_ms();
let budget = 1000.0 / self.prefs.min_fps as f32;
let warn = |bad: bool| if bad { p.danger } else { p.text };
let na = || "n/a".to_string();
let rows = [
("FPS", format!("{} drawn \u{b7} {:.0} possible", self.fps(), self.capacity()), warn(self.is_slow())),
("Frame", format!("{avg:.1} ms avg \u{b7} {worst:.1} ms worst"), warn(avg > budget)),
("RAM", self.ram.map_or_else(na, |m| format!("{m:.0} MB")), p.text),
("CPU", self.cpu.map_or_else(na, |c| format!("{c:.0}%")), p.text),
("GPU", if self.gpu.is_empty() { na() } else { self.gpu.clone() }, p.text),
];
egui::Grid::new("perf-stats").num_columns(2).spacing([12.0, 4.0]).show(ui, |ui| {
for (label, value, ink) in rows {
ui.label(RichText::new(label).size(theme::SECONDARY).color(p.tertiary));
ui.label(RichText::new(value).size(theme::SECONDARY).color(ink).monospace());
ui.end_row();
}
});
}
}
/// Settings > Performance.
pub fn settings_page(ui: &mut Ui, perf: &mut Perf) {
let before = perf.prefs;
kit::setting_row(ui, "Show overlay", "FPS, frame time, RAM, CPU and GPU over the canvas.", |ui| {
kit::toggle(ui, &mut perf.prefs.overlay);
});
ui.add_space(12.0);
kit::section_header(ui, "Warn below", None);
ui.add_space(4.0);
ui.horizontal_wrapped(|ui| {
ui.spacing_mut().item_spacing = egui::vec2(6.0, 8.0);
for fps in THRESHOLDS {
if kit::chip(ui, &format!("{fps} FPS"), perf.prefs.min_fps == fps).clicked() {
perf.prefs.min_fps = fps;
}
}
});
ui.label(RichText::new("A badge shows on the canvas while frames take longer than this rate allows.").size(theme::SECONDARY).color(theme::pal(ui).tertiary));
ui.add_space(16.0);
kit::section_header(ui, "Now", None);
ui.add_space(4.0);
perf.stats(ui);
// Keep the readout live while the page is open.
ui.ctx().request_repaint_after(std::time::Duration::from_secs_f64(SAMPLE_SECS));
if perf.prefs != before {
perf.prefs.save();
}
}
/// The overlay in the canvas's top-right corner, or, with the overlay off, a warning badge
/// while frames are slow (a click shows the overlay).
pub fn overlay(ctx: &egui::Context, perf: &mut Perf, canvas: egui::Rect) {
const INSET: f32 = 16.0;
let slow = perf.is_slow();
if !perf.prefs.overlay && !slow {
return;
}
if perf.prefs.overlay {
ctx.request_repaint_after(std::time::Duration::from_secs_f64(SAMPLE_SECS));
}
let mut open = false;
egui::Area::new("perf-overlay".into())
.order(egui::Order::Foreground)
.pivot(egui::Align2::RIGHT_TOP)
.fixed_pos(canvas.right_top() + egui::vec2(-INSET, INSET))
.show(ctx, |ui| {
let p = theme::pal(ui);
let frame = egui::Frame::popup(ui.style()).fill(p.popover).corner_radius(theme::r(12.0)).inner_margin(egui::Margin::symmetric(12, 8));
if !perf.prefs.overlay {
let r = frame
.stroke(egui::Stroke::new(1.0, p.danger))
.show_surface(ui, |ui| {
ui.horizontal(|ui| {
let (r, _) = ui.allocate_exact_size(egui::vec2(14.0, 14.0), egui::Sense::hover());
theme::paint_icon(ui.painter(), r, Icon::Zap, p.danger);
ui.label(RichText::new(format!("Slow: {:.0} FPS, below {}", perf.capacity(), perf.prefs.min_fps)).size(theme::SECONDARY).color(p.danger));
});
})
.response
.interact(egui::Sense::click())
.on_hover_cursor(egui::CursorIcon::PointingHand)
.on_hover_text("Show the performance overlay");
open = r.clicked();
return;
}
frame.stroke(egui::Stroke::new(1.0, if slow { p.danger } else { p.hover })).show_surface(ui, |ui| perf.stats(ui));
});
if open {
perf.prefs.overlay = true;
perf.prefs.save();
}
}
/// Resident memory of this process, in bytes: the footprint Activity Monitor shows on macOS.
#[cfg(target_os = "macos")]
fn rss_bytes() -> Option<u64> {
let mut info: libc::rusage_info_v2 = unsafe { std::mem::zeroed() };
// SAFETY: `info` is a rusage_info_v2, the flavor asked for.
let ok = unsafe { libc::proc_pid_rusage(libc::getpid(), libc::RUSAGE_INFO_V2, (&raw mut info).cast()) } == 0;
ok.then_some(info.ri_phys_footprint)
}
#[cfg(target_os = "linux")]
fn rss_bytes() -> Option<u64> {
let statm = std::fs::read_to_string("/proc/self/statm").ok()?;
let pages: u64 = statm.split_whitespace().nth(1)?.parse().ok()?;
// SAFETY: sysconf has no preconditions.
Some(pages * unsafe { libc::sysconf(libc::_SC_PAGESIZE) } as u64)
}
#[cfg(not(any(target_os = "macos", target_os = "linux")))]
fn rss_bytes() -> Option<u64> {
None
}
/// User plus system CPU time this process has used, in seconds.
#[cfg(all(unix, not(target_arch = "wasm32")))]
fn cpu_seconds() -> Option<f64> {
let mut u: libc::rusage = unsafe { std::mem::zeroed() };
// SAFETY: `u` is a valid rusage to fill.
if unsafe { libc::getrusage(libc::RUSAGE_SELF, &mut u) } != 0 {
return None;
}
let secs = |t: libc::timeval| t.tv_sec as f64 + t.tv_usec as f64 / 1e6;
Some(secs(u.ru_utime) + secs(u.ru_stime))
}
#[cfg(not(all(unix, not(target_arch = "wasm32"))))]
fn cpu_seconds() -> Option<f64> {
None
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn slow_only_after_a_streak_over_budget() {
let mut p = Perf::default();
for i in 0..SLOW_STREAK - 1 {
p.record(i as f64 * 0.03, 30.0);
}
assert!(!p.is_slow(), "too few frames");
p.record(1.0, 30.0);
assert!(p.is_slow(), "30 ms frames are under 60 FPS");
p.record(1.01, 5.0);
assert!(!p.is_slow(), "one fast frame clears it");
p.prefs.min_fps = 30;
for i in 0..SLOW_STREAK {
p.record(2.0 + i as f64 * 0.03, 30.0);
}
assert!(!p.is_slow(), "30 ms fits a 30 FPS floor");
}
#[test]
fn samples_memory() {
let mut p = Perf::default();
p.record(0.0, 1.0);
#[cfg(any(target_os = "macos", target_os = "linux"))]
assert!(p.ram.is_some_and(|m| m > 1.0));
}
}
+16 -13
View File
@@ -2,6 +2,7 @@
//! default), and for an options property its options either as a table (option × layer style,
//! with colour swatches, toggles, icon and image fields) or as highlighted JSON source.
use crate::theme::Surface as _;
use super::{UiState, kit};
use crate::editor::Editor;
use crate::model::{NodeId, PropDef, PropOption};
@@ -172,10 +173,11 @@ pub fn show(ctx: &egui::Context, ed: &mut Editor, st: &mut UiState) {
layers.insert(0, "$root".into());
let name = ed.doc.get(main).map(|n| n.name.clone()).unwrap_or_default();
let p = theme::pal_from_ctx(ctx);
let frame = egui::Frame::new().fill(p.sidebar).corner_radius(24.0).inner_margin(24);
let frame = egui::Frame::new().fill(p.sidebar).corner_radius(crate::theme::r(24.0)).inner_margin(24);
let (mut close, mut save, mut template) = (false, false, None);
let m = &mut st.props_modal;
egui::Modal::new(egui::Id::new("component-props")).frame(frame).show(ctx, |ui| {
theme::modal_backing(ui, theme::pal(ui).sidebar, theme::ru(24));
theme::figma_fields(ui);
ui.set_width(MODAL_W.min(ctx.content_rect().width() - 96.0));
ui.spacing_mut().item_spacing.y = 10.0;
@@ -196,6 +198,7 @@ pub fn show(ctx: &egui::Context, ed: &mut Editor, st: &mut UiState) {
}
}
ui.menu_button(RichText::new("+ Property").size(theme::SECONDARY), |ui| {
crate::theme::popup_backing(ui);
for (kind, label) in KINDS {
if ui.button(label).clicked() {
let layer = (kind != "enum").then(|| layers.get(1).cloned()).flatten();
@@ -222,10 +225,10 @@ pub fn show(ctx: &egui::Context, ed: &mut Editor, st: &mut UiState) {
return;
};
let src = &mut m.source[m.tab];
egui::Frame::new().fill(p.field).corner_radius(12.0).inner_margin(12).show(ui, |ui| {
egui::Frame::new().fill(p.field).corner_radius(crate::theme::r(12.0)).inner_margin(12).show_surface(ui, |ui| {
ui.set_width(ui.available_width());
ui.horizontal(|ui| {
ui.add(egui::TextEdit::singleline(&mut d.name).hint_text("Name").desired_width(200.0).margin(theme::INPUT_MARGIN));
crate::theme::lit(ui, |ui| ui.add(egui::TextEdit::singleline(&mut d.name).hint_text("Name").desired_width(200.0).margin(theme::INPUT_MARGIN)));
let current = KINDS.iter().position(|(k, _)| *k == d.kind).unwrap_or(0);
let items: Vec<(&str, Option<&str>)> = KINDS.iter().map(|(_, l)| (*l, None)).collect();
if let Some(i) = kit::segmented(ui, &items, current) {
@@ -253,7 +256,7 @@ pub fn show(ctx: &egui::Context, ed: &mut Editor, st: &mut UiState) {
def = on.to_string();
}
} else {
ui.add(egui::TextEdit::singleline(&mut def).hint_text(if d.kind == "icon" { "lucide:check" } else { "Text" }).desired_width(180.0));
crate::theme::lit(ui, |ui| ui.add(egui::TextEdit::singleline(&mut def).hint_text(if d.kind == "icon" { "lucide:check" } else { "Text" }).desired_width(180.0)));
}
d.default = (!def.is_empty()).then_some(def);
});
@@ -283,13 +286,13 @@ pub fn show(ctx: &egui::Context, ed: &mut Editor, st: &mut UiState) {
let mut gone = None;
for (j, (o, s)) in d.options.iter_mut().zip(src.iter_mut()).enumerate() {
ui.horizontal(|ui| {
ui.add(egui::TextEdit::singleline(&mut o.value).hint_text("Option").desired_width(OPTION_W));
crate::theme::lit(ui, |ui| ui.add(egui::TextEdit::singleline(&mut o.value).hint_text("Option").desired_width(OPTION_W)));
if theme::icon_button(ui, Icon::Trash, 22.0, false).on_hover_text("Delete this option").clicked() {
gone = Some(j);
}
});
let mut layouter = |ui: &egui::Ui, buf: &dyn egui::TextBuffer, w: f32| ui.fonts_mut(|f| f.layout_job(highlight(ui, buf.as_str(), w)));
let r = ui.add(egui::TextEdit::multiline(s).code_editor().desired_rows(3).desired_width(f32::INFINITY).layouter(&mut layouter));
let r = crate::theme::lit(ui, |ui| ui.add(egui::TextEdit::multiline(s).code_editor().desired_rows(3).desired_width(f32::INFINITY).layouter(&mut layouter)));
if r.changed() {
match serde_json::from_str::<Value>(if s.trim().is_empty() { "{}" } else { s }) {
Ok(v) => o.set = v,
@@ -368,7 +371,7 @@ fn table(ui: &mut egui::Ui, ed: &Editor, d: &mut PropDef, layers: &[String], new
ui.end_row();
for (ri, o) in d.options.iter_mut().enumerate() {
ui.horizontal(|ui| {
ui.add(egui::TextEdit::singleline(&mut o.value).desired_width(OPTION_W - 30.0));
crate::theme::lit(ui, |ui| ui.add(egui::TextEdit::singleline(&mut o.value).desired_width(OPTION_W - 30.0)));
if theme::icon_button(ui, Icon::Trash, 20.0, false).on_hover_text("Delete this option").clicked() {
gone_row = Some(ri);
}
@@ -391,9 +394,9 @@ fn table(ui: &mut egui::Ui, ed: &Editor, d: &mut PropDef, layers: &[String], new
// that is not one yet still shows, to be swapped for a token.
let (mark, _) = ui.allocate_exact_size(egui::vec2(14.0, 14.0), egui::Sense::hover());
if let Some(c) = swatch(ed, &v) {
ui.painter().rect_filled(mark, 4.0, c);
ui.painter().rect_filled(mark, crate::theme::r(4.0), c);
}
ui.painter().rect_stroke(mark, 4.0, egui::Stroke::new(1.0, p.secondary.gamma_multiply(0.4)), egui::StrokeKind::Inside);
ui.painter().rect_stroke(mark, crate::theme::r(4.0), egui::Stroke::new(1.0, p.secondary.gamma_multiply(0.4)), egui::StrokeKind::Inside);
let vars: Vec<String> = ed.doc.variables.keys().map(|n| format!("var(--{n})")).collect();
let mut opts: Vec<(&str, &str)> = vars.iter().map(|x| (x.as_str(), &x[6..x.len() - 1])).collect();
if !v.is_empty() && !vars.contains(&v) {
@@ -414,11 +417,11 @@ fn table(ui: &mut egui::Ui, ed: &Editor, d: &mut PropDef, layers: &[String], new
"text" => theme::paint_icon(ui.painter(), mark, Icon::Text, p.secondary),
_ => match swatch(ed, &v) {
Some(c) => {
ui.painter().rect_filled(mark, 4.0, c);
ui.painter().rect_stroke(mark, 4.0, egui::Stroke::new(1.0, p.secondary.gamma_multiply(0.5)), egui::StrokeKind::Inside);
ui.painter().rect_filled(mark, crate::theme::r(4.0), c);
ui.painter().rect_stroke(mark, crate::theme::r(4.0), egui::Stroke::new(1.0, p.secondary.gamma_multiply(0.5)), egui::StrokeKind::Inside);
}
None => {
ui.painter().rect_stroke(mark, 4.0, egui::Stroke::new(1.0, p.secondary.gamma_multiply(0.3)), egui::StrokeKind::Inside);
ui.painter().rect_stroke(mark, crate::theme::r(4.0), egui::Stroke::new(1.0, p.secondary.gamma_multiply(0.3)), egui::StrokeKind::Inside);
}
},
}
@@ -428,7 +431,7 @@ fn table(ui: &mut egui::Ui, ed: &Editor, d: &mut PropDef, layers: &[String], new
"text" => "Text",
_ => "var(--primary)",
};
ui.add(egui::TextEdit::singleline(&mut v).hint_text(hint).desired_width(CELL_W - 22.0));
crate::theme::lit(ui, |ui| ui.add(egui::TextEdit::singleline(&mut v).hint_text(hint).desired_width(CELL_W - 22.0)));
}
}
});
+94 -13
View File
@@ -1,5 +1,6 @@
//! Settings dialog: appearance, canvas, chat agents and keys, updates.
use crate::theme::Surface as _;
use super::{UiState, kit, motion};
use crate::chat::BackendKind;
use crate::chat::endpoint::{self, Endpoint};
@@ -16,6 +17,7 @@ pub enum Page {
Canvas,
Chat,
Plugins,
Performance,
Updates,
}
@@ -292,11 +294,12 @@ pub fn show(ctx: &egui::Context, st: &mut UiState) {
}
let live = st.settings.open;
let p = theme::pal_from_ctx(ctx);
let frame = egui::Frame::new().fill(p.sidebar).corner_radius(24.0);
let frame = egui::Frame::new().fill(p.sidebar).corner_radius(crate::theme::r(24.0));
let modal = egui::Modal::new(egui::Id::new("settings-modal"))
.frame(frame)
.show(ctx, |ui| {
ui.multiply_opacity(t);
theme::modal_backing(ui, theme::pal(ui).sidebar, theme::ru(24));
if !live {
ui.disable();
}
@@ -305,12 +308,13 @@ pub fn show(ctx: &egui::Context, st: &mut UiState) {
ui.horizontal_top(|ui| {
egui::Frame::new()
.fill(p.backdrop)
.corner_radius(24.0)
.corner_radius(crate::theme::r(24.0))
.inner_margin(egui::Margin::symmetric(16, 24))
.show(ui, |ui| {
.show_surface(ui, |ui| {
ui.set_min_width(180.0);
ui.set_max_width(180.0);
ui.set_min_height(ui.available_height());
// The modal's full height, less this frame's vertical margin.
ui.set_min_height(modal_height(ctx) - 48.0);
ui.vertical(|ui| {
ui.label(RichText::new("Settings").font(theme::semibold(20.0)));
ui.add_space(12.0);
@@ -319,6 +323,7 @@ pub fn show(ctx: &egui::Context, st: &mut UiState) {
(Page::Canvas, Icon::Grid, "Canvas"),
(Page::Chat, Icon::Component, "Chat"),
(Page::Plugins, Icon::Library, "Plugins"),
(Page::Performance, Icon::Zap, "Performance"),
(Page::Updates, Icon::Restore, "Updates"),
] {
if kit::nav_item(ui, icon, label, st.settings.page == page) {
@@ -334,7 +339,10 @@ pub fn show(ctx: &egui::Context, st: &mut UiState) {
ui.add_space(24.0);
ui.vertical(|ui| {
ui.set_width(ui.available_width() - 8.0);
content(ui, ctx, st);
// A long page (Appearance) scrolls inside the modal.
egui::ScrollArea::vertical()
.max_height(modal_height(ctx) - 24.0)
.show(ui, |ui| content(ui, ctx, st));
});
});
});
@@ -357,7 +365,7 @@ fn plugins_page(ui: &mut Ui, prefs: &mut ChatPrefs) {
for plugin in row {
let mut on = prefs.plugins.iter().any(|id| id == plugin.id);
ui.allocate_ui_with_layout(egui::vec2(w, PLUGIN_CARD_H), egui::Layout::top_down(egui::Align::Min), |ui| {
egui::Frame::new().fill(p.backdrop).corner_radius(theme::RADIUS_PANEL).inner_margin(egui::Margin::same(14)).show(ui, |ui| {
egui::Frame::new().fill(p.backdrop).corner_radius(theme::radius_panel()).inner_margin(egui::Margin::same(14)).show_surface(ui, |ui| {
ui.set_width(w - 28.0);
ui.set_height(PLUGIN_CARD_H - 28.0);
ui.spacing_mut().item_spacing.y = 4.0;
@@ -408,10 +416,11 @@ fn content(ui: &mut Ui, ctx: &egui::Context, st: &mut UiState) {
let p = theme::pal(ui);
ui.horizontal(|ui| {
let (title, sub) = match st.settings.page {
Page::Appearance => ("Appearance", "Theme, scale and motion."),
Page::Appearance => ("Appearance", "Theme, accent, material, corners, font, scale and motion."),
Page::Canvas => ("Canvas", "How the canvas looks."),
Page::Chat => ("Chat", "Agents, API endpoints and defaults for design by conversation."),
Page::Plugins => ("Plugins", "Assets the agent can place in a design. Fetched from each service once, then cached."),
Page::Performance => ("Performance", "Frame rate, memory, CPU and GPU, and a warning when frames get slow."),
Page::Updates => ("Updates", "Keep PaperCraft current."),
};
ui.vertical(|ui| {
@@ -439,6 +448,61 @@ fn content(ui: &mut Ui, ctx: &egui::Context, st: &mut UiState) {
}
}
});
ui.add_space(12.0);
kit::section_header(ui, "Accent", None);
ui.add_space(4.0);
ui.horizontal_wrapped(|ui| {
ui.spacing_mut().item_spacing = egui::vec2(8.0, 8.0);
for (i, name) in theme::ACCENTS.iter().enumerate() {
if accent_swatch(ui, theme::accent_option(i), theme::accent_choice() == i)
.on_hover_text(*name)
.clicked()
{
theme::set_accent_choice(i);
theme::apply(ctx);
theme::save_accent_choice();
}
}
});
ui.add_space(12.0);
kit::section_header(ui, "Material", None);
ui.add_space(4.0);
ui.horizontal_wrapped(|ui| {
ui.spacing_mut().item_spacing = egui::vec2(6.0, 8.0);
for m in theme::Material::ALL {
if kit::chip(ui, m.label(), theme::material() == m).clicked() {
theme::set_material(m);
theme::apply(ctx);
m.save();
}
}
});
ui.add_space(12.0);
kit::section_header(ui, "Corner radius", None);
ui.add_space(4.0);
ui.horizontal_wrapped(|ui| {
ui.spacing_mut().item_spacing = egui::vec2(6.0, 8.0);
for (r, label) in theme::Roundness::ALL {
if kit::chip(ui, label, theme::roundness() == r).clicked() {
theme::set_roundness(r);
theme::apply(ctx);
r.save();
}
}
});
ui.add_space(12.0);
kit::section_header(ui, "Font", None);
ui.add_space(4.0);
ui.horizontal_wrapped(|ui| {
ui.spacing_mut().item_spacing = egui::vec2(6.0, 8.0);
for (i, f) in theme::UI_FONTS.iter().enumerate() {
if kit::chip(ui, f.name, theme::ui_font() == i).clicked() {
theme::set_ui_font(i);
theme::apply(ctx);
theme::save_ui_font();
}
}
});
// In a browser the page zoom (Ctrl +/-) already does this.
#[cfg(not(target_arch = "wasm32"))]
{
@@ -498,6 +562,7 @@ fn content(ui: &mut Ui, ctx: &egui::Context, st: &mut UiState) {
#[cfg(not(target_arch = "wasm32"))]
plugins_page(ui, &mut st.settings.chat);
}
Page::Performance => super::perf::settings_page(ui, &mut st.perf),
Page::Updates => {
kit::setting_row(
ui,
@@ -523,14 +588,14 @@ fn content(ui: &mut Ui, ctx: &egui::Context, st: &mut UiState) {
ui.add_space(6.0);
let s = &mut st.settings;
kit::setting_row(ui, "Repository", "Gitea or GitHub repository URL.", |ui| {
if ui.add(egui::TextEdit::singleline(&mut s.chat.issue_repo).desired_width(320.0).margin(theme::INPUT_MARGIN)).lost_focus() {
if crate::theme::lit(ui, |ui| ui.add(egui::TextEdit::singleline(&mut s.chat.issue_repo).desired_width(320.0).margin(theme::INPUT_MARGIN))).lost_focus() {
s.chat.save();
}
});
let saved = crate::issues::token().is_some();
let desc = if saved { "Saved in the keychain." } else { "Optional: without one, your tea login for this server is used." };
kit::setting_row(ui, "Access token", desc, |ui| {
ui.add(egui::TextEdit::singleline(&mut s.issue_token).password(true).hint_text(if saved { "•••• saved" } else { "token" }).desired_width(200.0).margin(theme::INPUT_MARGIN));
crate::theme::lit(ui, |ui| ui.add(egui::TextEdit::singleline(&mut s.issue_token).password(true).hint_text(if saved { "•••• saved" } else { "token" }).desired_width(200.0).margin(theme::INPUT_MARGIN)));
if kit::button(ui, kit::ButtonKind::Secondary, None, if s.issue_token.is_empty() && saved { "Remove" } else { "Save" }).clicked() {
s.issue_note = Some(match crate::issues::set_token(&s.issue_token) {
Ok(()) => if s.issue_token.is_empty() { "Token removed".into() } else { "Token saved".into() },
@@ -807,7 +872,7 @@ mod native {
/// The raised surface of a section, 16 px inside.
fn card(ui: &mut Ui, add: impl FnOnce(&mut Ui)) {
let p = theme::pal(ui);
egui::Frame::new().fill(p.backdrop).corner_radius(theme::RADIUS_PANEL).inner_margin(CARD_PAD).show(ui, |ui| {
egui::Frame::new().fill(p.backdrop).corner_radius(theme::radius_panel()).inner_margin(CARD_PAD).show_surface(ui, |ui| {
ui.set_width(ui.available_width());
ui.spacing_mut().item_spacing.y = 4.0;
add(ui);
@@ -818,7 +883,7 @@ mod native {
fn row_frame(ui: &mut Ui, open: bool, add: impl FnOnce(&mut Ui)) {
let p = theme::pal(ui);
let fill = if open { theme::blend(p.backdrop, p.hover) } else { egui::Color32::TRANSPARENT };
egui::Frame::new().fill(fill).corner_radius(theme::RADIUS_LG).inner_margin(ROW_PAD).show(ui, |ui| {
egui::Frame::new().fill(fill).corner_radius(theme::radius_lg()).inner_margin(ROW_PAD).show(ui, |ui| {
ui.set_width(ui.available_width());
ui.spacing_mut().item_spacing.y = 8.0;
add(ui);
@@ -873,7 +938,7 @@ mod native {
}
fn text_field(ui: &mut Ui, text: &mut String, hint: &str) -> egui::Response {
ui.add(egui::TextEdit::singleline(text).margin(theme::INPUT_MARGIN).hint_text(hint).desired_width(ui.available_width()))
crate::theme::lit(ui, |ui| ui.add(egui::TextEdit::singleline(text).margin(theme::INPUT_MARGIN).hint_text(hint).desired_width(ui.available_width())))
}
/// A text field for an optional string: edits show at once, `Enter` or leaving the field
@@ -1145,7 +1210,7 @@ mod native {
};
let draft = page.key_drafts.entry(ep.id.clone()).or_default();
field_row(ui, "API key", |ui| {
ui.add(egui::TextEdit::singleline(draft).password(true).margin(theme::INPUT_MARGIN).hint_text(hint).desired_width(ui.available_width()));
crate::theme::lit(ui, |ui| ui.add(egui::TextEdit::singleline(draft).password(true).margin(theme::INPUT_MARGIN).hint_text(hint).desired_width(ui.available_width())));
note(ui, &format!("Stored in the {}. Never written to the project or logs.", keychain_name()));
});
let mut save_clicked = false;
@@ -1289,6 +1354,21 @@ fn chat_page(ui: &mut Ui, _ctx: &egui::Context, _settings: &mut SettingsUi) {
ui.label(RichText::new("Chat needs the PaperCraft desktop app.").color(p.secondary));
}
/// A round colour swatch, ringed when chosen.
fn accent_swatch(ui: &mut egui::Ui, c: egui::Color32, on: bool) -> egui::Response {
const D: f32 = 24.0;
let (rect, r) = ui.allocate_exact_size(egui::vec2(D, D), egui::Sense::click());
let center = rect.center();
ui.painter().circle_filled(center, D / 2.0 - 3.0, c);
if on {
let ring = theme::pal(ui).text;
ui.painter().circle_stroke(center, D / 2.0 - 0.75, egui::Stroke::new(1.5, ring));
} else if r.hovered() {
ui.painter().circle_stroke(center, D / 2.0 - 0.75, egui::Stroke::new(1.0, theme::pal(ui).secondary));
}
r
}
#[cfg(test)]
mod tests {
use super::*;
@@ -1436,3 +1516,4 @@ mod tests {
assert!(!st.settings.chat_changed, "drawing alone changes nothing");
}
}
+4 -3
View File
@@ -121,12 +121,13 @@ impl UpdaterUi {
let mut installer = None;
let frame = egui::Frame::new()
.fill(theme::pal_from_ctx(ctx).sidebar)
.corner_radius(24.0)
.corner_radius(crate::theme::r(24.0))
.inner_margin(24.0);
let modal = egui::Modal::new(egui::Id::new("updater-modal"))
.frame(frame)
.show(ctx, |ui| {
ui.multiply_opacity(t);
theme::modal_backing(ui, theme::pal(ui).sidebar, theme::ru(24));
ui.set_width(WINDOW_WIDTH);
ui.spacing_mut().item_spacing.y = 10.0;
let p = theme::pal(ui);
@@ -171,10 +172,10 @@ impl UpdaterUi {
State::Downloading { received, total } => {
let progress = if *total > 0 { *received as f32 / *total as f32 } else { 0.0 };
let (r, _) = ui.allocate_exact_size(egui::vec2(ui.available_width(), 8.0), egui::Sense::hover());
ui.painter().rect_filled(r, 4.0, p.field);
ui.painter().rect_filled(r, crate::theme::r(4.0), p.field);
let mut fill = r;
fill.set_width(r.width() * progress.clamp(0.0, 1.0));
ui.painter().rect_filled(fill, 4.0, theme::accent());
ui.painter().rect_filled(fill, crate::theme::r(4.0), theme::accent());
ui.label(egui::RichText::new(format!("Downloaded {received} of {total} bytes")).size(theme::SECONDARY).color(p.secondary));
}
State::Ready(_) => {}
+31 -24
View File
@@ -2,6 +2,7 @@
//! mode in the middle, the selected variable's details on the right. Every edit goes through
//! `Editor::edit_design` (one undo step per page); a refused edit shows in the footer.
use crate::theme::Surface as _;
use super::{UiState, kit, motion};
use crate::editor::Editor;
use crate::mcp::codegen;
@@ -349,10 +350,10 @@ fn nav_row(
let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 30.0), Sense::click());
let hover = motion::hover(ui.ctx(), resp.id.with("nav"), &resp);
if selected {
ui.painter().rect_filled(rect, theme::RADIUS_MD, p.sidebar);
theme::fill_selected(ui, rect, theme::radius_md(), p.sidebar);
} else if hover > 0.0 {
ui.painter()
.rect_filled(rect, theme::RADIUS_MD, p.hover.gamma_multiply(hover));
.rect_filled(rect, theme::radius_md(), p.hover.gamma_multiply(hover));
}
let mut x = rect.left() + 8.0 + indent;
let mut chev_hit = false;
@@ -406,7 +407,7 @@ fn nav_row(
egui::pos2(rx - w, mid - 10.0),
egui::pos2(rx, mid + 10.0),
);
ui.painter().rect_filled(chip, 255, p.sidebar);
ui.painter().rect_filled(chip, crate::theme::r(255.0), p.sidebar);
ui.painter()
.text(chip.center(), Align2::CENTER_CENTER, right, font, p.secondary);
} else {
@@ -461,12 +462,13 @@ pub fn show(ctx: &egui::Context, ed: &mut Editor, st: &mut UiState) {
let mut copy: Option<(&str, String)> = None;
let frame = egui::Frame::new()
.fill(p.sidebar)
.corner_radius(24.0)
.corner_radius(crate::theme::r(24.0))
.inner_margin(MODAL_PAD);
let modal = egui::Modal::new(egui::Id::new("variables-modal"))
.frame(frame)
.show(ctx, |ui| {
ui.multiply_opacity(t);
theme::modal_backing(ui, theme::pal(ui).sidebar, theme::ru(24));
if !live {
ui.disable();
}
@@ -486,7 +488,7 @@ pub fn show(ctx: &egui::Context, ed: &mut Editor, st: &mut UiState) {
egui::pos2(right.left() - GAP, body.bottom()),
);
for r in [left, main, right] {
ui.painter().rect_filled(r, theme::RADIUS_PANEL, p.field);
theme::fill_control(ui, r, theme::radius_panel(), p.field);
}
child(ui, header, |ui| title(ui, st));
child(ui, left.shrink(12.0), |ui| {
@@ -623,12 +625,12 @@ fn collections_column(
ui.add_space(4.0);
match &mut st.variables.new_coll {
Some(name) => {
let r = ui.add(
let r = crate::theme::lit(ui, |ui| ui.add(
egui::TextEdit::singleline(name)
.hint_text("Collection name")
.desired_width(f32::INFINITY)
.margin(theme::INPUT_MARGIN),
);
));
r.request_focus();
let enter = ui.input(|i| i.key_pressed(egui::Key::Enter));
let esc = ui.input(|i| i.key_pressed(egui::Key::Escape));
@@ -644,7 +646,7 @@ fn collections_column(
ui.allocate_exact_size(vec2(ui.available_width(), 30.0), Sense::click());
let hover = motion::hover(ui.ctx(), resp.id.with("newc"), &resp);
ui.painter()
.rect_filled(rect, theme::RADIUS_MD, p.hover.gamma_multiply(hover));
.rect_filled(rect, theme::radius_md(), p.hover.gamma_multiply(hover));
theme::paint_icon(
ui.painter(),
Rect::from_center_size(egui::pos2(rect.left() + 16.0, rect.center().y), vec2(14.0, 14.0)),
@@ -701,12 +703,12 @@ fn main_column(
};
let ro = c.library.is_some();
ui.horizontal(|ui| {
ui.add(
crate::theme::lit(ui, |ui| ui.add(
egui::TextEdit::singleline(&mut st.variables.search)
.hint_text("Search variables")
.desired_width(220.0)
.margin(theme::INPUT_MARGIN),
);
));
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
ui.add_enabled_ui(!ro, |ui| {
if kit::button(ui, kit::ButtonKind::Secondary, Some(Icon::Plus), "Mode").clicked() {
@@ -714,6 +716,7 @@ fn main_column(
}
let r = kit::button(ui, kit::ButtonKind::Primary, Some(Icon::Plus), "Variable");
egui::Popup::menu(&r).show(|ui| {
crate::theme::popup_backing(ui);
for k in [Kind::Color, Kind::Number, Kind::String, Kind::Boolean] {
if super::icon_menu_item(ui, kind_icon(k), kind_label(k)).clicked() {
let name = unique_name(c, st.variables.group.as_deref(), k);
@@ -800,11 +803,11 @@ fn table_header(ui: &mut Ui, c: &Collection, mode_w: f32, st: &mut UiState, ops:
.is_some_and(|(cc, mm, _)| cc == &c.id && mm == &m.id);
if editing {
let draft = &mut st.variables.mode_edit.as_mut().unwrap().2;
let r = ui.add(
let r = crate::theme::lit(ui, |ui| ui.add(
egui::TextEdit::singleline(draft)
.desired_width(90.0)
.margin(egui::Margin::symmetric(6, 3)),
);
));
r.request_focus();
let enter = ui.input(|i| i.key_pressed(egui::Key::Enter));
let esc = ui.input(|i| i.key_pressed(egui::Key::Escape));
@@ -829,6 +832,7 @@ fn table_header(ui: &mut Ui, c: &Collection, mode_w: f32, st: &mut UiState, ops:
}
let r = theme::icon_button(ui, Icon::More, 22.0, false).on_hover_text("Mode options");
egui::Popup::menu(&r).show(|ui| {
crate::theme::popup_backing(ui);
if super::icon_menu_item(ui, Icon::Pen, "Rename").clicked() {
st.variables.mode_edit = Some((c.id.clone(), m.id.clone(), m.name.clone()));
ui.close();
@@ -895,10 +899,10 @@ fn var_row(
let selected = st.variables.selected.as_deref() == Some(v.id.as_str());
let hot = ui.rect_contains_pointer(rect);
if selected {
ui.painter().rect_filled(rect, theme::RADIUS_MD, p.sidebar);
theme::fill_selected(ui, rect, theme::radius_md(), p.sidebar);
} else if hot {
ui.painter()
.rect_filled(rect, theme::RADIUS_MD, p.hover);
.rect_filled(rect, theme::radius_md(), p.hover);
}
if resp.clicked() || (hot && ui.input(|i| i.pointer.primary_pressed())) {
st.variables.selected = Some(v.id.clone());
@@ -956,7 +960,7 @@ fn value_cell(
let tw = ui.fonts_mut(|f| f.layout_no_wrap(text.clone(), font.clone(), p.text).size().x);
let (rect, resp) = ui.allocate_exact_size(vec2((tw + 20.0).min(width), 24.0), Sense::click());
let a = theme::accent();
ui.painter().rect_filled(rect, 255, a.gamma_multiply(0.18));
ui.painter().rect_filled(rect, crate::theme::r(255.0), a.gamma_multiply(0.18));
ui.painter().with_clip_rect(rect).text(
egui::pos2(rect.left() + 10.0, rect.center().y),
Align2::LEFT_CENTER,
@@ -968,8 +972,8 @@ fn value_cell(
}
Value::Color(col) => {
let (r, _) = ui.allocate_exact_size(vec2(16.0, 16.0), Sense::hover());
ui.painter().rect_filled(r, 4.0, c32(*col));
ui.painter().rect_stroke(r, 4.0, egui::Stroke::new(1.0, p.hover), egui::StrokeKind::Inside);
ui.painter().rect_filled(r, crate::theme::r(4.0), c32(*col));
ui.painter().rect_stroke(r, crate::theme::r(4.0), egui::Stroke::new(1.0, p.hover), egui::StrokeKind::Inside);
ui.add_enabled_ui(!ro, |ui| {
if let Some(t) = commit_text(ui, salt, &col.to_hex().to_uppercase(), 84.0, false, false)
&& let Some(new) = Color::from_hex(t.trim())
@@ -1020,7 +1024,10 @@ fn value_cell(
egui::Popup::menu(&anchor)
.close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside)
.width(280.0)
.show(|ui| alias_picker(ui, ds, v, mode, &value, ops));
.show(|ui| {
crate::theme::popup_backing(ui);
alias_picker(ui, ds, v, mode, &value, ops)
});
}
fn alias_picker(
@@ -1034,12 +1041,12 @@ fn alias_picker(
let p = theme::pal(ui);
let id = ui.make_persistent_id(("alias-filter", &v.id, mode));
let mut filter = ui.data(|d| d.get_temp::<String>(id)).unwrap_or_default();
ui.add(
crate::theme::lit(ui, |ui| ui.add(
egui::TextEdit::singleline(&mut filter)
.hint_text("Search variables")
.desired_width(f32::INFINITY)
.margin(theme::INPUT_MARGIN),
);
));
ui.data_mut(|d| d.insert_temp(id, filter.clone()));
let needle = filter.trim().to_lowercase();
ui.add_space(4.0);
@@ -1066,7 +1073,7 @@ fn alias_picker(
let (rect, r) = ui.allocate_exact_size(vec2(ui.available_width(), 28.0), Sense::click());
let h = motion::hover(ui.ctx(), r.id.with("alias"), &r);
ui.painter()
.rect_filled(rect, theme::RADIUS_MD, p.hover.gamma_multiply(h));
.rect_filled(rect, theme::radius_md(), p.hover.gamma_multiply(h));
ui.painter().text(
egui::pos2(rect.left() + 10.0, rect.center().y),
Align2::LEFT_CENTER,
@@ -1144,7 +1151,7 @@ fn details_column(
label(ui, "CSS name");
ui.horizontal(|ui| {
let (rect, _) = ui.allocate_exact_size(vec2(ui.available_width() - 64.0, theme::FIELD_H), Sense::hover());
ui.painter().rect_filled(rect, theme::RADIUS_MD, p.sidebar.gamma_multiply(0.8));
theme::fill_control(ui, rect, theme::radius_md(), p.sidebar.gamma_multiply(0.8));
ui.painter().text(
egui::pos2(rect.left() + 10.0, rect.center().y),
Align2::LEFT_CENTER,
@@ -1180,9 +1187,9 @@ fn details_column(
let used = *st.variables.used.1.entry(css.clone()).or_insert_with(|| used_count(ed, &css));
egui::Frame::new()
.fill(p.sidebar)
.corner_radius(theme::RADIUS_LG)
.corner_radius(theme::radius_lg())
.inner_margin(egui::Margin::symmetric(12, 10))
.show(ui, |ui| {
.show_surface(ui, |ui| {
ui.set_width(ui.available_width() - 24.0);
ui.label(
RichText::new(format!("Used {used} time{}", if used == 1 { "" } else { "s" }))
+11 -1
View File
@@ -151,6 +151,10 @@ impl Web {
color_space: Default::default(),
};
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());
let demo = window().location().hash().is_ok_and(|h| h == "#demo");
let store = Store::open(WEB_ROOT).map_err(|e| format!("open store: {e}"))?;
@@ -453,7 +457,13 @@ impl Web {
/// Render a screenshot offscreen and answer call `id` when the readback lands.
fn start_screenshot(&mut self, id: u64, req: ShotRequest) {
let doc = self.ed.page_doc(req.page).clone();
let doc = match req.mode.as_deref() {
Some(m) => match self.ed.page_doc_in_mode(req.page, m) {
Ok(d) => d,
Err(e) => return self.relay.send(&Reply::err(id, e)),
},
None => self.ed.page_doc(req.page).clone(),
};
let cats = self.ed.design.categories();
let gpu = self.gpu_handle();
self.finish(
+72
View File
@@ -0,0 +1,72 @@
//! The acrylic material: the backdrop around the panels glows, the canvas is left alone.
//! Own test binary, as the theme and material are process-wide.
mod common;
use common::*;
use papercraft::demo;
use papercraft::render::frame;
use papercraft::theme::{self, Choice, Material};
use papercraft::ui::UiState;
#[test]
fn acrylic_lights_the_backdrop_and_leaves_the_canvas() {
let Some(g) = gpu() else { return };
let mocha = Choice::Catppuccin(craft_ui::theme::catppuccin::Flavor::Mocha);
theme::apply_choice(&egui::Context::default(), mocha);
let render = |m: Material, chat: bool| {
theme::set_material(m);
let mut e = demo::showcase();
let mut st = UiState {
chat_mode: chat,
..UiState::default()
};
frame::capture(g, &mut e, &mut st, [1440.0, 900.0], 1.0, |e| {
e.zoom_to_fit();
// A frame selected, so the inspector shows its fields, selects and buttons.
let first = e.doc.root[0];
e.select(vec![first]);
})
};
let solid = render(Material::Solid, false);
let acrylic = render(Material::Acrylic, false);
render(Material::Acrylic, true)
.save(shot("app_acrylic_mocha_chat.png"))
.unwrap();
{
theme::set_material(Material::Acrylic);
let mut e = demo::showcase();
let mut st = UiState::default();
st.settings.open = true;
frame::capture(g, &mut e, &mut st, [1440.0, 900.0], 2.0, |_| {})
.save(shot("app_acrylic_mocha_settings.png"))
.unwrap();
// Rounder corners and a rounded font, restyled through `apply`.
theme::set_roundness(theme::Roundness(1.5));
theme::set_ui_font(5);
theme::apply_choice(&egui::Context::default(), mocha);
let mut e = demo::showcase();
frame::capture(g, &mut e, &mut st, [1440.0, 900.0], 2.0, |_| {})
.save(shot("app_acrylic_round_settings.png"))
.unwrap();
theme::set_roundness(theme::Roundness(1.0));
theme::set_ui_font(0);
}
render(Material::Solid, true)
.save(shot("app_solid_mocha_chat.png"))
.unwrap();
solid.save(shot("app_solid_mocha.png")).unwrap();
acrylic.save(shot("app_acrylic_mocha.png")).unwrap();
// The top gap a third of the way across sits under the glow: flat crust when solid,
// brighter when acrylic.
let (x, y) = (430, 4);
let lum = |p: [u8; 4]| p[0] as u32 + p[1] as u32 + p[2] as u32;
assert!(
lum(acrylic.get_pixel(x, y).0) > lum(solid.get_pixel(x, y).0) + 6,
"glow in the gap: {:?} vs {:?}",
acrylic.get_pixel(x, y).0,
solid.get_pixel(x, y).0
);
// The middle of the canvas is the renderer's, untouched by the backdrop.
assert_eq!(acrylic.get_pixel(720, 450), solid.get_pixel(720, 450));
theme::set_material(Material::Solid);
}
+49
View File
@@ -150,6 +150,21 @@ fn compact_spec_is_one_line_per_node_with_tokens_and_no_reference_code() {
assert!(text.len() * 4 < json_len, "compact {} vs json {json_len}", text.len());
}
#[test]
fn compact_spec_writes_a_bound_stroke_as_its_variable() {
let mut h = host();
call(&mut h, "set_variables", json!({"variables": {"border": "#e2e6df"}}));
let board = call(
&mut h,
"create_artboard",
json!({"name": "Card", "styles": {"width": "200px", "height": "100px", "border": "1px solid #e2e6df"}}),
);
call(&mut h, "bind_variable", json!({"nodeIds": [board["id"]], "field": "stroke", "variable": "border"}));
let compact = call(&mut h, "get_design_spec", json!({"nodeId": board["id"], "format": "compact"}));
let text = compact.as_str().expect("compact is text");
assert!(text.contains("border=--border/1"), "{text}");
}
#[test]
fn inventory_lists_screens_by_size_class_and_open_questions() {
let mut h = host();
@@ -211,3 +226,37 @@ fn vector_svg_in_the_spec_starts_at_the_origin() {
>= vector["bounds"]["width"].as_f64().unwrap()
);
}
#[test]
fn theme_copies_are_flagged_and_a_mode_previews_without_switching() {
let mut h = host();
call(&mut h, "set_variables", json!({"mode": "Light", "variables": {"bg": "#ffffff"}}));
call(&mut h, "set_variables", json!({"mode": "Dark", "variables": {"bg": "#111111"}}));
let board = |h: &mut exec::SessionHost, name: &str, x: u32, bg: &str| {
let b = call(h, "create_artboard", json!({"name": name, "styles": {"width": "200px", "height": "100px", "left": format!("{x}px"), "backgroundColor": bg}}));
call(h, "write_html", json!({"targetNodeId": b["id"], "mode": "insert-children", "html": "<div layer-name=\"Title\" style=\"font-size:16px\">Hi</div>"}));
b
};
let a = board(&mut h, "Home", 0, "var(--bg)");
let b = board(&mut h, "Home copy", 400, "#222222");
let issues = |h: &mut exec::SessionHost, b: &Value| call(h, "lint_design", json!({"nodeId": b["id"]}));
let lint = issues(&mut h, &b).to_string();
assert!(lint.contains("theme-copy"), "same layers, other colours: {lint}");
let named = board(&mut h, "Home / Dark", 800, "var(--bg)");
assert!(issues(&mut h, &named).to_string().contains("theme-copy"), "name differs by a theme word");
let other = call(&mut h, "create_artboard", json!({"name": "Settings", "styles": {"width": "200px", "height": "100px", "left": "1200px", "backgroundColor": "#222222"}}));
call(&mut h, "write_html", json!({"targetNodeId": other["id"], "mode": "insert-children", "html": "<div layer-name=\"Title\" style=\"font-size:16px\">Account</div>"}));
let lo = issues(&mut h, &other).to_string(); assert!(!lo.contains("theme-copy"), "a different screen is not a copy: {lo}");
// Preview Dark while the document stays Light.
let page = h.ed.active_page;
let before = h.ed.page_doc(page).clone();
let dark = h.ed.page_doc_in_mode(page, "Dark").unwrap();
let id = a["id"].as_str().unwrap();
let fill = |d: &papercraft::model::Document| {
let n = d.nodes.values().find(|n| n.name == "Home").unwrap();
n.style.fills.last().unwrap().preview_color().to_hex()
};
assert_eq!(fill(&dark).to_lowercase(), "#111111", "{id}");
assert_eq!(fill(h.ed.page_doc(page)), fill(&before), "document untouched");
}
+24
View File
@@ -105,3 +105,27 @@ fn ten_thousand_elements_cache_and_pan_without_rework() {
drop(draw);
assert_eq!(r.uploads, 1, "panning reuses the uploaded geometry");
}
/// Selecting a layer scrolls the layers panel to it without building every row: the panel
/// stays virtual, so a selection change on a large page costs about one frame.
#[test]
fn revealing_a_layer_builds_only_the_rows_on_screen() {
let mut e = ten_thousand();
let ctx = egui::Context::default();
papercraft::ui::setup_style(&ctx);
let mut st = papercraft::ui::UiState::default();
let input = || egui::RawInput {
screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1600.0, 1000.0))),
..Default::default()
};
let mut frame = |e: &mut Editor, st: &mut papercraft::ui::UiState| {
papercraft::render::frame::layout(&ctx, input(), e, st).output.textures_delta.clear();
};
frame(&mut e, &mut st);
let target = e.doc.root[9000];
e.selection = vec![target];
st.reveal = Some(target);
frame(&mut e, &mut st);
assert!(st.layer_rows.iter().any(|(id, ..)| *id == target), "the target row is built");
assert!(st.layer_rows.len() < 500, "{} rows built", st.layer_rows.len());
}
+74
View File
@@ -0,0 +1,74 @@
//! `box-shadow` falloff matches CSS (Gaussian, sigma = blur / 2) and is cut out under the box.
use papercraft::geom::{Rect, V2, v2};
use papercraft::model::{Color, Node, NodeId, NodeKind, Shadow};
use papercraft::render::draw::{DrawOp, PaintSpec, node_draw};
/// Composited shadow alpha at `q` from the node's fill ops (shadow color only).
fn alpha_at(ops: &[DrawOp], q: V2) -> f64 {
let clear: f64 = ops
.iter()
.filter_map(|op| match op {
DrawOp::Fill {
path,
paint: PaintSpec::Solid(c),
even_odd,
} if c[0] == 1.0 => path
.contains(q, *even_odd, 0.1)
.then_some(1.0 - c[3] as f64),
_ => None,
})
.product();
1.0 - clear
}
fn glow(x: f64, y: f64) -> Vec<DrawOp> {
let mut n = Node::new(NodeId(1), NodeKind::Rect, Rect::new(0.0, 0.0, 200.0, 100.0));
n.style.corner_radius = 16.0;
// White glow, so the box's own (absent) fill can't be mistaken for shadow.
n.style.shadow = Some(Shadow {
x,
y,
blur: 40.0,
spread: 0.0,
color: Color::rgba(255, 255, 255, 128),
});
node_draw(&n, n.rect, 1.0).ops
}
#[test]
fn centered_glow_follows_css_falloff_and_skips_the_box() {
let ops = glow(0.0, 0.0);
let a = |d: f64| alpha_at(&ops, v2(100.0, 100.0 + d));
let full = 128.0 / 255.0;
assert!(
(a(0.5) - full * 0.5).abs() < 0.06,
"half the alpha at the edge: {}",
a(0.5)
);
assert!(a(20.0) < full * 0.2, "one sigma out is faint: {}", a(20.0));
assert!(
a(10.0) > a(20.0) && a(20.0) > a(30.0),
"fades monotonically"
);
assert!(a(42.0) < 0.01, "gone past the blur radius: {}", a(42.0));
assert_eq!(
alpha_at(&ops, v2(100.0, 50.0)),
0.0,
"nothing under the box"
);
}
#[test]
fn offset_shadow_is_cut_out_under_the_box() {
let ops = glow(0.0, 24.0);
assert_eq!(
alpha_at(&ops, v2(100.0, 90.0)),
0.0,
"nothing under the box"
);
assert!(
alpha_at(&ops, v2(100.0, 110.0)) > 0.2,
"shadow below the box"
);
}
+1 -1
View File
@@ -196,7 +196,7 @@ main() {
done
echo "==> keeping the current app as $(basename "$backup")"
# Only the newest previous bundle is kept; a day of installs once left twelve.
ls -dt "$dest_dir/$dest_base".backup-* 2>/dev/null | tail -n +2 | while read -r old; do rm -rf "$old"; done
ls -dt "$dest_dir/$dest_base".backup-* 2>/dev/null | tail -n +2 | while read -r old; do rm -rf "$old"; done || true
mv "$dest" "$backup"
fi
swapped=1