Initial commit

This commit is contained in:
jess 2026-04-15 02:39:18 -07:00
commit 18ff0a0525
53 changed files with 22225 additions and 0 deletions

3
.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
.DS_Store
target/
build/

3
.gitmodules vendored Normal file
View File

@ -0,0 +1,3 @@
[submodule "libraries/Cord"]
path = libraries/Cord
url = git@ssh-git.else-if.org:jess/Cord.git

6
Cargo.toml Normal file
View File

@ -0,0 +1,6 @@
[workspace]
members = ["core", "viewport"]
resolver = "2"
[profile.release]
panic = "abort"

428
Info.plist Normal file
View File

@ -0,0 +1,428 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleExecutable</key>
<string>Acord</string>
<key>CFBundleIdentifier</key>
<string>org.else-if.acord</string>
<key>CFBundleName</key>
<string>Acord</string>
<key>CFBundleDisplayName</key>
<string>Acord</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleVersion</key>
<string>0.1.0</string>
<key>CFBundleShortVersionString</key>
<string>0.1.0</string>
<key>LSMinimumSystemVersion</key>
<string>14.0</string>
<key>LSApplicationCategoryType</key>
<string>public.app-category.developer-tools</string>
<key>CFBundleIconFile</key>
<string>AppIcon</string>
<key>NSHighResolutionCapable</key>
<true/>
<key>NSSupportsAutomaticTermination</key>
<false/>
<key>NSSupportsSuddenTermination</key>
<false/>
<key>CFBundleDocumentTypes</key>
<array>
<dict>
<key>CFBundleTypeName</key>
<string>Markdown</string>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>LSHandlerRank</key>
<string>Alternate</string>
<key>LSItemContentTypes</key>
<array>
<string>net.daringfireball.markdown</string>
</array>
<key>CFBundleTypeExtensions</key>
<array>
<string>md</string>
<string>markdown</string>
<string>mdown</string>
</array>
</dict>
<dict>
<key>CFBundleTypeName</key>
<string>CSV</string>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>LSHandlerRank</key>
<string>Alternate</string>
<key>LSItemContentTypes</key>
<array>
<string>public.comma-separated-values-text</string>
</array>
<key>CFBundleTypeExtensions</key>
<array>
<string>csv</string>
</array>
</dict>
<dict>
<key>CFBundleTypeName</key>
<string>JSON</string>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>LSHandlerRank</key>
<string>Alternate</string>
<key>LSItemContentTypes</key>
<array>
<string>public.json</string>
</array>
<key>CFBundleTypeExtensions</key>
<array>
<string>json</string>
</array>
</dict>
<dict>
<key>CFBundleTypeName</key>
<string>TOML</string>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>LSHandlerRank</key>
<string>Alternate</string>
<key>CFBundleTypeExtensions</key>
<array>
<string>toml</string>
</array>
</dict>
<dict>
<key>CFBundleTypeName</key>
<string>YAML</string>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>LSHandlerRank</key>
<string>Alternate</string>
<key>CFBundleTypeExtensions</key>
<array>
<string>yaml</string>
<string>yml</string>
</array>
</dict>
<dict>
<key>CFBundleTypeName</key>
<string>XML</string>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>LSHandlerRank</key>
<string>Alternate</string>
<key>LSItemContentTypes</key>
<array>
<string>public.xml</string>
<string>public.svg-image</string>
</array>
<key>CFBundleTypeExtensions</key>
<array>
<string>xml</string>
<string>svg</string>
</array>
</dict>
<dict>
<key>CFBundleTypeName</key>
<string>Rust Source</string>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>LSHandlerRank</key>
<string>Alternate</string>
<key>CFBundleTypeExtensions</key>
<array>
<string>rs</string>
</array>
</dict>
<dict>
<key>CFBundleTypeName</key>
<string>C/C++ Source</string>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>LSHandlerRank</key>
<string>Alternate</string>
<key>LSItemContentTypes</key>
<array>
<string>public.c-source</string>
<string>public.c-plus-plus-source</string>
<string>public.c-header</string>
</array>
<key>CFBundleTypeExtensions</key>
<array>
<string>c</string>
<string>cpp</string>
<string>cc</string>
<string>cxx</string>
<string>h</string>
<string>hpp</string>
<string>hxx</string>
</array>
</dict>
<dict>
<key>CFBundleTypeName</key>
<string>JavaScript/TypeScript</string>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>LSHandlerRank</key>
<string>Alternate</string>
<key>LSItemContentTypes</key>
<array>
<string>com.netscape.javascript-source</string>
</array>
<key>CFBundleTypeExtensions</key>
<array>
<string>js</string>
<string>jsx</string>
<string>ts</string>
<string>tsx</string>
</array>
</dict>
<dict>
<key>CFBundleTypeName</key>
<string>HTML</string>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>LSHandlerRank</key>
<string>Alternate</string>
<key>LSItemContentTypes</key>
<array>
<string>public.html</string>
</array>
<key>CFBundleTypeExtensions</key>
<array>
<string>html</string>
<string>htm</string>
</array>
</dict>
<dict>
<key>CFBundleTypeName</key>
<string>CSS</string>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>LSHandlerRank</key>
<string>Alternate</string>
<key>CFBundleTypeExtensions</key>
<array>
<string>css</string>
<string>scss</string>
<string>less</string>
</array>
</dict>
<dict>
<key>CFBundleTypeName</key>
<string>Python Source</string>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>LSHandlerRank</key>
<string>Alternate</string>
<key>LSItemContentTypes</key>
<array>
<string>public.python-script</string>
</array>
<key>CFBundleTypeExtensions</key>
<array>
<string>py</string>
</array>
</dict>
<dict>
<key>CFBundleTypeName</key>
<string>Go Source</string>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>LSHandlerRank</key>
<string>Alternate</string>
<key>CFBundleTypeExtensions</key>
<array>
<string>go</string>
</array>
</dict>
<dict>
<key>CFBundleTypeName</key>
<string>Ruby Source</string>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>LSHandlerRank</key>
<string>Alternate</string>
<key>LSItemContentTypes</key>
<array>
<string>public.ruby-script</string>
</array>
<key>CFBundleTypeExtensions</key>
<array>
<string>rb</string>
</array>
</dict>
<dict>
<key>CFBundleTypeName</key>
<string>PHP Source</string>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>LSHandlerRank</key>
<string>Alternate</string>
<key>LSItemContentTypes</key>
<array>
<string>public.php-script</string>
</array>
<key>CFBundleTypeExtensions</key>
<array>
<string>php</string>
</array>
</dict>
<dict>
<key>CFBundleTypeName</key>
<string>Lua Source</string>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>LSHandlerRank</key>
<string>Alternate</string>
<key>CFBundleTypeExtensions</key>
<array>
<string>lua</string>
</array>
</dict>
<dict>
<key>CFBundleTypeName</key>
<string>Shell Script</string>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>LSHandlerRank</key>
<string>Alternate</string>
<key>LSItemContentTypes</key>
<array>
<string>public.shell-script</string>
</array>
<key>CFBundleTypeExtensions</key>
<array>
<string>sh</string>
<string>bash</string>
<string>zsh</string>
<string>fish</string>
</array>
</dict>
<dict>
<key>CFBundleTypeName</key>
<string>Java/Kotlin Source</string>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>LSHandlerRank</key>
<string>Alternate</string>
<key>CFBundleTypeExtensions</key>
<array>
<string>java</string>
<string>kt</string>
<string>kts</string>
</array>
</dict>
<dict>
<key>CFBundleTypeName</key>
<string>Swift Source</string>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>LSHandlerRank</key>
<string>Alternate</string>
<key>LSItemContentTypes</key>
<array>
<string>public.swift-source</string>
</array>
<key>CFBundleTypeExtensions</key>
<array>
<string>swift</string>
</array>
</dict>
<dict>
<key>CFBundleTypeName</key>
<string>Zig Source</string>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>LSHandlerRank</key>
<string>Alternate</string>
<key>CFBundleTypeExtensions</key>
<array>
<string>zig</string>
</array>
</dict>
<dict>
<key>CFBundleTypeName</key>
<string>SQL</string>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>LSHandlerRank</key>
<string>Alternate</string>
<key>CFBundleTypeExtensions</key>
<array>
<string>sql</string>
</array>
</dict>
<dict>
<key>CFBundleTypeName</key>
<string>Makefile</string>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>LSHandlerRank</key>
<string>Alternate</string>
<key>CFBundleTypeExtensions</key>
<array>
<string>mk</string>
</array>
</dict>
<dict>
<key>CFBundleTypeName</key>
<string>Dockerfile</string>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>LSHandlerRank</key>
<string>Alternate</string>
<key>CFBundleTypeExtensions</key>
<array>
<string>dockerfile</string>
</array>
</dict>
<dict>
<key>CFBundleTypeName</key>
<string>Configuration</string>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>LSHandlerRank</key>
<string>Alternate</string>
<key>CFBundleTypeExtensions</key>
<array>
<string>ini</string>
<string>cfg</string>
<string>conf</string>
<string>env</string>
</array>
</dict>
<dict>
<key>CFBundleTypeName</key>
<string>Lock File</string>
<key>CFBundleTypeRole</key>
<string>Viewer</string>
<key>LSHandlerRank</key>
<string>Alternate</string>
<key>CFBundleTypeExtensions</key>
<array>
<string>lock</string>
</array>
</dict>
<dict>
<key>CFBundleTypeName</key>
<string>Plain Text</string>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>LSHandlerRank</key>
<string>Alternate</string>
<key>LSItemContentTypes</key>
<array>
<string>public.plain-text</string>
</array>
<key>CFBundleTypeExtensions</key>
<array>
<string>txt</string>
<string>text</string>
<string>log</string>
</array>
</dict>
</array>
</dict>
</plist>

12
LICENCE Normal file
View File

@ -0,0 +1,12 @@
This is free to use, without conditions.
There is no licence here on purpose. Individuals, students, hobbyists — take what
you need, make it yours, don't think twice. You'd flatter me.
The absence of a licence is deliberate. A licence is a legal surface. Words can be
reinterpreted, and corporations employ lawyers whose job is exactly that. Silence is
harder to exploit than language. If a company wants to use this, the lack of explicit
permission makes it just inconvenient enough to matter.
This won't change the balance of power. But it shifts the weight, even slightly, away
from the system that co-opts open work for closed profit. That's enough for me.

45
assets/Acord.svg Normal file
View File

@ -0,0 +1,45 @@
<?xml version="1.0" encoding="utf-8"?>
<svg viewBox="0 0 489.61898 491.035" version="1.1" id="svg29" width="489.61899" height="491.035" xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="none">
<defs id="defs26">
<path id="text-path-1" d="m 77.219,406.062 c 77.897,-187.809 190.069,-151.33 256.629,-99.786 18.407,14.254 76.718,135.623 76.524,160.854"/>
<path id="path-2" d="m 77.219,406.062 c 77.897,-187.809 190.069,-151.33 256.629,-99.786 18.407,14.254 76.718,135.623 76.524,160.854"/>
<path id="path-3" d="m 77.219,406.062 c 77.897,-187.809 190.069,-151.33 256.629,-99.786 18.407,14.254 76.718,135.623 76.524,160.854"/>
<linearGradient gradientUnits="userSpaceOnUse" x1="242.681" y1="70.670998" x2="242.681" y2="419.965" id="gradient-0" gradientTransform="matrix(0.11880809,-1.1178257,0.27505632,0.03710317,76.138972,683.49193)">
<stop offset="0" style="stop-color: rgb(255, 0, 232); stop-opacity: 0.73;" id="stop1"/>
<stop offset="0.151" style="stop-color: rgb(97, 0, 200); stop-opacity: 0.84;" id="stop2"/>
<stop offset="0.267" style="stop-color: rgb(0, 87, 255);" id="stop3"/>
<stop offset="0.382" style="stop-color: rgb(0, 236, 255);" id="stop4"/>
<stop offset="0.539" style="stop-color: rgb(22, 197, 13); stop-opacity: 0.82;" id="stop5"/>
<stop offset="0.725" style="stop-color: rgb(255, 233, 0);" id="stop6"/>
<stop offset="0.897" style="stop-color: rgb(248, 142, 0);" id="stop7"/>
<stop offset="1" style="stop-color: rgb(233, 0, 0); stop-opacity: 0.827;" id="stop8"/>
</linearGradient>
<linearGradient gradientUnits="userSpaceOnUse" x1="247.952" y1="1.068" x2="247.952" y2="489.103" id="gradient-2" gradientTransform="translate(-12.620192)">
<stop offset="0" style="stop-color: rgb(166, 0, 140)" id="stop15"/>
<stop offset="1" style="stop-color: rgb(4, 6, 239);" id="stop16"/>
</linearGradient>
<linearGradient gradientUnits="userSpaceOnUse" x1="227.47" y1="250.679" x2="227.47" y2="545.37598" id="gradient-1" gradientTransform="matrix(0.531844,0.846845,-0.939658,0.590138,518.63561,-7.578286)">
<stop offset="0" style="stop-color: rgb(249, 0, 106);" id="stop17"/>
<stop offset="0.088" style="stop-color: rgb(86, 0, 154);" id="stop18"/>
<stop offset="0.16" style="stop-color: rgb(87, 0, 255);" id="stop19"/>
<stop offset="0.229" style="stop-color: rgb(32, 40, 252);" id="stop20"/>
<stop offset="0.354" style="stop-color: rgb(86, 151, 244);" id="stop21"/>
<stop offset="0.43" style="stop-color: rgb(76, 245, 241);" id="stop22"/>
<stop offset="0.551" style="stop-color: rgb(2, 255, 0);" id="stop23"/>
<stop offset="0.676" style="stop-color: rgb(211, 224, 69);" id="stop24"/>
<stop offset="0.827" style="stop-color: rgb(244, 182, 87);" id="stop25"/>
<stop offset="1" style="stop-color: rgb(225, 72, 72);" id="stop26"/>
</linearGradient>
</defs>
<g id="g29" transform="translate(9.4781923,0.43200004)">
<rect style="mix-blend-mode:darken;fill:#031056;fill-rule:nonzero;stroke:url(#gradient-2);stroke-width:3px;paint-order:stroke" x="-7.9781923" y="1.068" width="486.61899" height="488.035" rx="79" ry="79" id="rect26"/>
<path style="stroke:#ffffff;stroke-width:1.06069" d="M 349.92887,207.47326 97.322642,39.308335 141.23747,366.24784" id="path26"/>
<path d="M 119.56386,429.97631 94.617429,36.739247 357.01616,205.03927 Z M 96.495189,40.380102 139.09489,370.77245 353.7893,205.40516 Z" style="fill:url(#gradient-0);fill-rule:nonzero;stroke:#ffd500;stroke-width:7.42481px;stroke-linecap:round;stroke-linejoin:round;paint-order:stroke" id="path27"/>
<path d="M 127.57 310.87 L 115.83 331.32 L 90.01 327.31 L 68.65 364.54 L 85.39 384.36 L 73.95 404.31 L 1.63 314.6 L 13.04 294.71 Z M 70.86 324.34 L 28.94 317.34 L 56.28 349.75 Z M 163.16 219.47 L 148.38 233.85 Q 144.13 229.39 139.41 228.86 Q 134.68 228.34 129.88 231.58 Q 123.51 235.88 122.68 242.84 Q 121.86 249.8 128.82 260.12 Q 136.56 271.6 143.61 273.74 Q 150.66 275.87 157.16 271.49 Q 162.01 268.21 163.25 263.36 Q 164.48 258.51 161.21 250.91 L 179.89 242.46 Q 185.06 255.76 181.63 266.65 Q 178.19 277.53 165.74 285.93 Q 151.6 295.47 137.17 292.22 Q 122.73 288.96 112.09 273.17 Q 101.32 257.2 103.76 242.61 Q 106.2 228.03 120.59 218.32 Q 132.37 210.38 142.74 210.76 Q 153.12 211.14 163.16 219.47 Z M 212.59 220.86 Q 214.01 210.71 220.36 201.91 Q 226.7 193.11 236.57 189.4 Q 246.45 185.68 257.77 187.27 Q 275.25 189.73 284.82 202.65 Q 294.39 215.58 291.96 232.91 Q 289.5 250.39 276.6 260.3 Q 263.71 270.21 246.59 267.8 Q 236 266.31 227.06 260.17 Q 218.13 254.04 214.42 244.08 Q 210.72 234.13 212.59 220.86 Z M 233.32 224.89 Q 231.71 236.35 236.29 243.2 Q 240.88 250.06 248.85 251.18 Q 256.83 252.3 263.09 246.97 Q 269.35 241.64 270.98 230.03 Q 272.58 218.72 268.03 211.87 Q 263.48 205.02 255.5 203.89 Q 247.53 202.77 241.23 208.1 Q 234.93 213.43 233.32 224.89 Z M 316.78 294.13 L 299.46 283.01 L 341.49 217.55 L 357.57 227.88 L 351.6 237.19 Q 359.96 233.25 364.6 233.27 Q 369.24 233.29 373.44 235.98 Q 379.35 239.78 382.74 246.57 L 367.68 258.23 Q 365.13 252.58 361.37 250.17 Q 357.73 247.83 353.92 248.21 Q 350.1 248.59 345.29 252.9 Q 340.48 257.21 329.76 273.91 Z M 379.82 382.06 L 370.78 365.22 L 380.85 359.81 Q 372.72 358.77 366.78 354.6 Q 360.85 350.43 357.73 344.63 Q 351.39 332.82 356.38 319.29 Q 361.38 305.75 378.41 296.61 Q 395.84 287.25 409.3 290.58 Q 422.77 293.9 429.49 306.42 Q 435.66 317.91 430.61 331.43 L 464.68 313.13 L 474.42 331.26 Z M 389.58 314.47 Q 378.61 320.36 375.34 326.02 Q 370.6 334.22 374.82 342.1 Q 378.19 348.36 385.86 349.88 Q 393.54 351.41 404.13 345.73 Q 415.94 339.39 418.84 332.34 Q 421.75 325.29 418.18 318.65 Q 414.72 312.2 407.25 310.59 Q 399.78 308.99 389.58 314.47 Z" transform="matrix(0.999336, 0, 0, 1.125805, -4.66082, -66.978394)" style="paint-order: stroke; stroke: rgb(255, 0, 0); stroke-linecap: round; stroke-linejoin: round; stroke-width: 6.20419px; text-wrap-mode: nowrap;"/>
<ellipse style="stroke:#000000;stroke-width:1.06069" cx="225.81729" cy="233.35777" rx="0.32278565" ry="0.054038659" id="ellipse27"/>
<ellipse style="stroke:#000000;stroke-width:1.06069" cx="225.81729" cy="233.35777" rx="0.029980088" ry="0.023641912" id="ellipse28"/>
<path d="M 127.57 310.87 L 115.83 331.32 L 90.01 327.31 L 68.65 364.54 L 85.39 384.36 L 73.95 404.31 L 1.63 314.6 L 13.04 294.71 Z M 70.86 324.34 L 28.94 317.34 L 56.28 349.75 Z M 163.16 219.47 L 148.38 233.85 Q 144.13 229.39 139.41 228.86 Q 134.68 228.34 129.88 231.58 Q 123.51 235.88 122.68 242.84 Q 121.86 249.8 128.82 260.12 Q 136.56 271.6 143.61 273.74 Q 150.66 275.87 157.16 271.49 Q 162.01 268.21 163.25 263.36 Q 164.48 258.51 161.21 250.91 L 179.89 242.46 Q 185.06 255.76 181.63 266.65 Q 178.19 277.53 165.74 285.93 Q 151.6 295.47 137.17 292.22 Q 122.73 288.96 112.09 273.17 Q 101.32 257.2 103.76 242.61 Q 106.2 228.03 120.59 218.32 Q 132.37 210.38 142.74 210.76 Q 153.12 211.14 163.16 219.47 Z M 212.59 220.86 Q 214.01 210.71 220.36 201.91 Q 226.7 193.11 236.57 189.4 Q 246.45 185.68 257.77 187.27 Q 275.25 189.73 284.82 202.65 Q 294.39 215.58 291.96 232.91 Q 289.5 250.39 276.6 260.3 Q 263.71 270.21 246.59 267.8 Q 236 266.31 227.06 260.17 Q 218.13 254.04 214.42 244.08 Q 210.72 234.13 212.59 220.86 Z M 233.32 224.89 Q 231.71 236.35 236.29 243.2 Q 240.88 250.06 248.85 251.18 Q 256.83 252.3 263.09 246.97 Q 269.35 241.64 270.98 230.03 Q 272.58 218.72 268.03 211.87 Q 263.48 205.02 255.5 203.89 Q 247.53 202.77 241.23 208.1 Q 234.93 213.43 233.32 224.89 Z M 316.78 294.13 L 299.46 283.01 L 341.49 217.55 L 357.57 227.88 L 351.6 237.19 Q 359.96 233.25 364.6 233.27 Q 369.24 233.29 373.44 235.98 Q 379.35 239.78 382.74 246.57 L 367.68 258.23 Q 365.13 252.58 361.37 250.17 Q 357.73 247.83 353.92 248.21 Q 350.1 248.59 345.29 252.9 Q 340.48 257.21 329.76 273.91 Z M 379.82 382.06 L 370.78 365.22 L 380.85 359.81 Q 372.72 358.77 366.78 354.6 Q 360.85 350.43 357.73 344.63 Q 351.39 332.82 356.38 319.29 Q 361.38 305.75 378.41 296.61 Q 395.84 287.25 409.3 290.58 Q 422.77 293.9 429.49 306.42 Q 435.66 317.91 430.61 331.43 L 464.68 313.13 L 474.42 331.26 Z M 389.58 314.47 Q 378.61 320.36 375.34 326.02 Q 370.6 334.22 374.82 342.1 Q 378.19 348.36 385.86 349.88 Q 393.54 351.41 404.13 345.73 Q 415.94 339.39 418.84 332.34 Q 421.75 325.29 418.18 318.65 Q 414.72 312.2 407.25 310.59 Q 399.78 308.99 389.58 314.47 Z" transform="matrix(0.999336, 0, 0, 1.125805, -2.662937, -80.566888)" style="fill: rgb(40, 37, 37); paint-order: stroke; stroke: rgb(71, 71, 71); stroke-linecap: round; stroke-linejoin: round; stroke-width: 4px; text-wrap-mode: nowrap; vector-effect: non-scaling-stroke;"/>
<rect style="stroke:#000000;stroke-width:1.06069" x="225.55846" y="233.32962" width="0.51665688" height="0.05516446" id="rect28"/>
<path d="M 127.57 310.87 L 115.83 331.32 L 90.01 327.31 L 68.65 364.54 L 85.39 384.36 L 73.95 404.31 L 1.63 314.6 L 13.04 294.71 Z M 70.86 324.34 L 28.94 317.34 L 56.28 349.75 Z M 163.16 219.47 L 148.38 233.85 Q 144.13 229.39 139.41 228.86 Q 134.68 228.34 129.88 231.58 Q 123.51 235.88 122.68 242.84 Q 121.86 249.8 128.82 260.12 Q 136.56 271.6 143.61 273.74 Q 150.66 275.87 157.16 271.49 Q 162.01 268.21 163.25 263.36 Q 164.48 258.51 161.21 250.91 L 179.89 242.46 Q 185.06 255.76 181.63 266.65 Q 178.19 277.53 165.74 285.93 Q 151.6 295.47 137.17 292.22 Q 122.73 288.96 112.09 273.17 Q 101.32 257.2 103.76 242.61 Q 106.2 228.03 120.59 218.32 Q 132.37 210.38 142.74 210.76 Q 153.12 211.14 163.16 219.47 Z M 212.59 220.86 Q 214.01 210.71 220.36 201.91 Q 226.7 193.11 236.57 189.4 Q 246.45 185.68 257.77 187.27 Q 275.25 189.73 284.82 202.65 Q 294.39 215.58 291.96 232.91 Q 289.5 250.39 276.6 260.3 Q 263.71 270.21 246.59 267.8 Q 236 266.31 227.06 260.17 Q 218.13 254.04 214.42 244.08 Q 210.72 234.13 212.59 220.86 Z M 233.32 224.89 Q 231.71 236.35 236.29 243.2 Q 240.88 250.06 248.85 251.18 Q 256.83 252.3 263.09 246.97 Q 269.35 241.64 270.98 230.03 Q 272.58 218.72 268.03 211.87 Q 263.48 205.02 255.5 203.89 Q 247.53 202.77 241.23 208.1 Q 234.93 213.43 233.32 224.89 Z M 316.78 294.13 L 299.46 283.01 L 341.49 217.55 L 357.57 227.88 L 351.6 237.19 Q 359.96 233.25 364.6 233.27 Q 369.24 233.29 373.44 235.98 Q 379.35 239.78 382.74 246.57 L 367.68 258.23 Q 365.13 252.58 361.37 250.17 Q 357.73 247.83 353.92 248.21 Q 350.1 248.59 345.29 252.9 Q 340.48 257.21 329.76 273.91 Z M 379.82 382.06 L 370.78 365.22 L 380.85 359.81 Q 372.72 358.77 366.78 354.6 Q 360.85 350.43 357.73 344.63 Q 351.39 332.82 356.38 319.29 Q 361.38 305.75 378.41 296.61 Q 395.84 287.25 409.3 290.58 Q 422.77 293.9 429.49 306.42 Q 435.66 317.91 430.61 331.43 L 464.68 313.13 L 474.42 331.26 Z M 389.58 314.47 Q 378.61 320.36 375.34 326.02 Q 370.6 334.22 374.82 342.1 Q 378.19 348.36 385.86 349.88 Q 393.54 351.41 404.13 345.73 Q 415.94 339.39 418.84 332.34 Q 421.75 325.29 418.18 318.65 Q 414.72 312.2 407.25 310.59 Q 399.78 308.99 389.58 314.47 Z" style="fill: url(&quot;#gradient-1&quot;); paint-order: fill; stroke: rgb(0, 0, 0); stroke-linecap: round; stroke-linejoin: round; stroke-width: 0px; text-wrap-mode: nowrap; vector-effect: non-scaling-stroke;" transform="matrix(0.999336, 0, 0, 1.125805, -1.747034, -80.556085)"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 11 KiB

43
core/Cargo.toml Normal file
View File

@ -0,0 +1,43 @@
[package]
name = "acord-core"
version = "0.1.0"
edition = "2024"
[lib]
crate-type = ["rlib"]
[dependencies]
cord-expr = { path = "../../Cord/crates/cord-expr" }
cord-trig = { path = "../../Cord/crates/cord-trig" }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
uuid = { version = "1", features = ["v4"] }
tree-sitter = "0.24"
tree-sitter-highlight = "0.24"
tree-sitter-language = "0.1"
tree-sitter-rust = "0.23"
tree-sitter-c = "0.23"
tree-sitter-cpp = "0.23"
tree-sitter-javascript = "0.23"
tree-sitter-typescript = "0.23"
tree-sitter-python = "0.23"
tree-sitter-go = "0.23"
tree-sitter-ruby = "0.23"
tree-sitter-bash = "0.23"
tree-sitter-java = "0.23"
tree-sitter-html = "0.23"
tree-sitter-css = "0.23"
tree-sitter-json = "0.24"
tree-sitter-lua = "0.4"
tree-sitter-php = "0.23"
tree-sitter-toml-ng = "0.7"
tree-sitter-yaml = "0.6"
tree-sitter-swift = "0.6"
tree-sitter-zig = "1"
tree-sitter-sequel = "0.3"
tree-sitter-md = "0.5"
tree-sitter-make = "1"
[build-dependencies]
cbindgen = "0.29"

21
core/build.rs Normal file
View File

@ -0,0 +1,21 @@
fn main() {
let crate_dir = std::env::var("CARGO_MANIFEST_DIR").unwrap();
let config = cbindgen::Config::from_file("cbindgen.toml")
.unwrap_or_default();
match cbindgen::Builder::new()
.with_crate(&crate_dir)
.with_config(config)
.generate()
{
Ok(bindings) => {
let path = format!("{}/include/acord.h", crate_dir);
bindings.write_to_file(&path);
println!("cargo:warning=cbindgen: wrote {}", path);
}
Err(e) => {
println!("cargo:warning=cbindgen: {}", e);
}
}
}

17
core/cbindgen.toml Normal file
View File

@ -0,0 +1,17 @@
language = "C"
header = "/* Generated by cbindgen — do not edit */"
include_guard = "SWIFTLY_H"
include_version = false
tab_width = 4
documentation = false
style = "both"
[export]
include = []
exclude = []
[fn]
prefix = ""
[parse]
parse_deps = false

39
core/include/acord.h Normal file
View File

@ -0,0 +1,39 @@
/* Generated by cbindgen — do not edit */
#ifndef SWIFTLY_H
#define SWIFTLY_H
#include <stdarg.h>
#include <stdbool.h>
#include <stdint.h>
#include <stdlib.h>
typedef struct AcordDoc AcordDoc;
struct AcordDoc *acord_doc_new(void);
void acord_doc_free(struct AcordDoc *doc);
void acord_doc_set_text(struct AcordDoc *doc, const char *text);
char *acord_doc_get_text(const struct AcordDoc *doc);
char *acord_doc_evaluate(struct AcordDoc *doc);
char *acord_eval_line(const char *text);
bool acord_doc_save(const struct AcordDoc *doc, const char *path);
struct AcordDoc *acord_doc_load(const char *path);
char *acord_cache_save(const struct AcordDoc *doc);
struct AcordDoc *acord_cache_load(const char *uuid);
char *acord_list_notes(void);
char *acord_highlight(const char *source, const char *lang);
void acord_free_string(char *s);
#endif /* SWIFTLY_H */

View File

@ -0,0 +1,58 @@
[
"FROM"
"AS"
"RUN"
"CMD"
"LABEL"
"EXPOSE"
"ENV"
"ADD"
"COPY"
"ENTRYPOINT"
"VOLUME"
"USER"
"WORKDIR"
"ARG"
"ONBUILD"
"STOPSIGNAL"
"HEALTHCHECK"
"SHELL"
"MAINTAINER"
"CROSS_BUILD"
(heredoc_marker)
(heredoc_end)
] @keyword
[
":"
"@"
] @operator
(comment) @comment
(image_spec
(image_tag
":" @punctuation.special)
(image_digest
"@" @punctuation.special))
[
(double_quoted_string)
(single_quoted_string)
(json_string)
(heredoc_line)
] @string
(expansion
[
"$"
"{"
"}"
] @punctuation.special
) @none
((variable) @constant
(#match? @constant "^[A-Z][A-Z_0-9]*$"))

View File

@ -0,0 +1,380 @@
;; Based on the nvim-treesitter highlighting, which is under the Apache license.
;; See https://github.com/nvim-treesitter/nvim-treesitter/blob/f8ab59861eed4a1c168505e3433462ed800f2bae/queries/kotlin/highlights.scm
;;
;; The only difference in this file is that queries using #lua-match?
;; have been removed.
;;; Identifiers
(simple_identifier) @variable
; `it` keyword inside lambdas
; FIXME: This will highlight the keyword outside of lambdas since tree-sitter
; does not allow us to check for arbitrary nestation
((simple_identifier) @variable.builtin
(#eq? @variable.builtin "it"))
; `field` keyword inside property getter/setter
; FIXME: This will highlight the keyword outside of getters and setters
; since tree-sitter does not allow us to check for arbitrary nestation
((simple_identifier) @variable.builtin
(#eq? @variable.builtin "field"))
; `this` this keyword inside classes
(this_expression) @variable.builtin
; `super` keyword inside classes
(super_expression) @variable.builtin
(class_parameter
(simple_identifier) @property)
(class_body
(property_declaration
(variable_declaration
(simple_identifier) @property)))
; id_1.id_2.id_3: `id_2` and `id_3` are assumed as object properties
(_
(navigation_suffix
(simple_identifier) @property))
(enum_entry
(simple_identifier) @constant)
(type_identifier) @type
((type_identifier) @type.builtin
(#any-of? @type.builtin
"Byte"
"Short"
"Int"
"Long"
"UByte"
"UShort"
"UInt"
"ULong"
"Float"
"Double"
"Boolean"
"Char"
"String"
"Array"
"ByteArray"
"ShortArray"
"IntArray"
"LongArray"
"UByteArray"
"UShortArray"
"UIntArray"
"ULongArray"
"FloatArray"
"DoubleArray"
"BooleanArray"
"CharArray"
"Map"
"Set"
"List"
"EmptyMap"
"EmptySet"
"EmptyList"
"MutableMap"
"MutableSet"
"MutableList"
))
(package_header
. (identifier)) @namespace
(import_header
"import" @include)
; TODO: Seperate labeled returns/breaks/continue/super/this
; Must be implemented in the parser first
(label) @label
;;; Function definitions
(function_declaration
. (simple_identifier) @function)
(getter
("get") @function.builtin)
(setter
("set") @function.builtin)
(primary_constructor) @constructor
(secondary_constructor
("constructor") @constructor)
(constructor_invocation
(user_type
(type_identifier) @constructor))
(anonymous_initializer
("init") @constructor)
(parameter
(simple_identifier) @parameter)
(parameter_with_optional_type
(simple_identifier) @parameter)
; lambda parameters
(lambda_literal
(lambda_parameters
(variable_declaration
(simple_identifier) @parameter)))
;;; Function calls
; function()
(call_expression
. (simple_identifier) @function)
; object.function() or object.property.function()
(call_expression
(navigation_expression
(navigation_suffix
(simple_identifier) @function) . ))
(call_expression
. (simple_identifier) @function.builtin
(#any-of? @function.builtin
"arrayOf"
"arrayOfNulls"
"byteArrayOf"
"shortArrayOf"
"intArrayOf"
"longArrayOf"
"ubyteArrayOf"
"ushortArrayOf"
"uintArrayOf"
"ulongArrayOf"
"floatArrayOf"
"doubleArrayOf"
"booleanArrayOf"
"charArrayOf"
"emptyArray"
"mapOf"
"setOf"
"listOf"
"emptyMap"
"emptySet"
"emptyList"
"mutableMapOf"
"mutableSetOf"
"mutableListOf"
"print"
"println"
"error"
"TODO"
"run"
"runCatching"
"repeat"
"lazy"
"lazyOf"
"enumValues"
"enumValueOf"
"assert"
"check"
"checkNotNull"
"require"
"requireNotNull"
"with"
"suspend"
"synchronized"
))
;;; Literals
[
(line_comment)
(multiline_comment)
(shebang_line)
] @comment
(real_literal) @float
[
(integer_literal)
(long_literal)
(hex_literal)
(bin_literal)
(unsigned_literal)
] @number
[
"null" ; should be highlighted the same as booleans
(boolean_literal)
] @boolean
(character_literal) @character
(string_literal) @string
(character_escape_seq) @string.escape
; There are 3 ways to define a regex
; - "[abc]?".toRegex()
(call_expression
(navigation_expression
((string_literal) @string.regex)
(navigation_suffix
((simple_identifier) @_function
(#eq? @_function "toRegex")))))
; - Regex("[abc]?")
(call_expression
((simple_identifier) @_function
(#eq? @_function "Regex"))
(call_suffix
(value_arguments
(value_argument
(string_literal) @string.regex))))
; - Regex.fromLiteral("[abc]?")
(call_expression
(navigation_expression
((simple_identifier) @_class
(#eq? @_class "Regex"))
(navigation_suffix
((simple_identifier) @_function
(#eq? @_function "fromLiteral"))))
(call_suffix
(value_arguments
(value_argument
(string_literal) @string.regex))))
;;; Keywords
(type_alias "typealias" @keyword)
[
(class_modifier)
(member_modifier)
(function_modifier)
(property_modifier)
(platform_modifier)
(variance_modifier)
(parameter_modifier)
(visibility_modifier)
(reification_modifier)
(inheritance_modifier)
]@keyword
[
"val"
"var"
"enum"
"class"
"object"
"interface"
; "typeof" ; NOTE: It is reserved for future use
] @keyword
("fun") @keyword.function
(jump_expression) @keyword.return
[
"if"
"else"
"when"
] @conditional
[
"for"
"do"
"while"
] @repeat
[
"try"
"catch"
"throw"
"finally"
] @exception
(annotation
"@" @attribute (use_site_target)? @attribute)
(annotation
(user_type
(type_identifier) @attribute))
(annotation
(constructor_invocation
(user_type
(type_identifier) @attribute)))
(file_annotation
"@" @attribute "file" @attribute ":" @attribute)
(file_annotation
(user_type
(type_identifier) @attribute))
(file_annotation
(constructor_invocation
(user_type
(type_identifier) @attribute)))
;;; Operators & Punctuation
[
"!"
"!="
"!=="
"="
"=="
"==="
">"
">="
"<"
"<="
"||"
"&&"
"+"
"++"
"+="
"-"
"--"
"-="
"*"
"*="
"/"
"/="
"%"
"%="
"?."
"?:"
"!!"
"is"
"!is"
"in"
"!in"
"as"
"as?"
".."
"->"
] @operator
[
"(" ")"
"[" "]"
"{" "}"
] @punctuation.bracket
[
"."
","
";"
":"
"::"
] @punctuation.delimiter
; NOTE: `interpolated_identifier`s can be highlighted in any way
(string_literal
"$" @punctuation.special
(interpolated_identifier) @none)
(string_literal
"${" @punctuation.special
(interpolated_expression) @none
"}" @punctuation.special)

View File

@ -0,0 +1,51 @@
; (identifier) @variable FIXME this overrides function call pattern
(string) @string
(number) @number
(comment) @comment
(function_call
function: (identifier) @function)
[
(NULL)
(TRUE)
(FALSE)
] @constant.builtin
[
"<"
"<="
"<>"
"="
">"
">="
"::"
] @operator
[
"("
")"
"["
"]"
] @punctuation.bracket
[
(type)
(array_type)
] @type
[
"CREATE TABLE"
"CREATE TYPE"
"CREATE DOMAIN"
"CREATE"
"INDEX"
"UNIQUE"
"SELECT"
"WHERE"
"FROM"
"AS"
"GROUP BY"
"ORDER BY"
] @keyword

380
core/src/doc.rs Normal file
View File

@ -0,0 +1,380 @@
use serde::{Serialize, Deserialize};
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum LineKind {
Markdown,
Cordial,
Eval,
Comment,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ClassifiedLine {
pub index: usize,
pub kind: LineKind,
pub content: String,
}
pub fn classify_line(index: usize, raw: &str) -> ClassifiedLine {
let trimmed = raw.trim();
let kind = if trimmed.starts_with("/=") {
LineKind::Eval
} else if trimmed.starts_with("//") {
LineKind::Comment
} else if is_cordial(trimmed) {
LineKind::Cordial
} else {
LineKind::Markdown
};
ClassifiedLine {
index,
kind,
content: raw.to_string(),
}
}
fn is_cordial(line: &str) -> bool {
if line.starts_with("let ") {
let rest = &line[4..];
if let Some(colon_pos) = rest.find(':') {
let before_colon = rest[..colon_pos].trim();
if is_ident(before_colon) {
let after_colon = &rest[colon_pos + 1..];
if after_colon.contains('=') {
return true;
}
}
}
if let Some(eq_pos) = rest.find('=') {
let after_eq = rest.as_bytes().get(eq_pos + 1);
if after_eq != Some(&b'=') {
let name = rest[..eq_pos].trim();
// Plain binding: `let x = …`. Covers every RHS — plain
// expressions, struct/macro-looking constructions like
// `let lfreq = solve!(l, f0)`, and the function-inversion
// math form `let f(a, b) = expr where …` (where the LHS
// is a function-def-shaped name+params, same as Cordial's
// existing top-level `f(x) = …` short form).
if is_ident(name) || is_assignment_target(name) {
return true;
}
}
}
return false;
}
if line.starts_with("while ") || line.starts_with("while(") { return true; }
if line.starts_with("fn ") { return true; }
if line.starts_with("if ") || line.starts_with("if(") { return true; }
if line.starts_with("else ") || line == "else" || line.starts_with("else{") { return true; }
if line.starts_with("for ") { return true; }
if line.starts_with("return ") || line == "return" { return true; }
if line.starts_with("use ") {
let rest = line[4..].trim();
if is_ident(rest.split("::").next().unwrap_or("")) {
return true;
}
}
if line == "}" || line.starts_with("} ") { return true; }
if let Some(eq_pos) = line.find('=') {
if eq_pos > 0 {
let before = &line[..eq_pos];
let after_eq = line.as_bytes().get(eq_pos + 1);
if after_eq != Some(&b'=') && !before.ends_with('!') && !before.ends_with('<') && !before.ends_with('>') {
let candidate = before.trim();
if is_assignment_target(candidate) {
return true;
}
}
}
}
false
}
fn is_assignment_target(s: &str) -> bool {
// simple variable: `x`
if is_ident(s) {
return true;
}
// function def: `f(x)` or `f(x, y)`
if let Some(paren) = s.find('(') {
let name = &s[..paren];
if is_ident(name) && s.ends_with(')') {
return true;
}
}
// cell-ref target: `@Table:A1`, `@Block::Table:A1`, or even bare
// `@Table` / `@Table:A1:B2`. The interpreter's parser surfaces
// whole-table / range mis-assignments as errors, so the classifier
// only needs to recognize the `@name…` shape here.
if let Some(rest) = s.strip_prefix('@') {
if let Some(first) = rest.chars().next() {
if first.is_alphabetic() || first == '_' {
return true;
}
}
}
false
}
fn is_ident(s: &str) -> bool {
if s.is_empty() { return false; }
let mut chars = s.chars();
let first = chars.next().unwrap();
if !first.is_alphabetic() && first != '_' { return false; }
chars.all(|c| c.is_alphanumeric() || c == '_')
}
pub fn classify_document(text: &str) -> Vec<ClassifiedLine> {
let mut result = Vec::new();
let mut comment_depth: usize = 0;
let mut brace_depth: i32 = 0;
for (i, line) in text.lines().enumerate() {
let was_in_comment = comment_depth > 0;
comment_depth = scan_comment_depth(line, comment_depth);
if was_in_comment || line.trim().starts_with("/*") {
result.push(ClassifiedLine { index: i, kind: LineKind::Comment, content: line.to_string() });
} else if brace_depth > 0 {
let trimmed = line.trim();
let opens = trimmed.matches('{').count() as i32;
let closes = trimmed.matches('}').count() as i32;
brace_depth += opens - closes;
if brace_depth < 0 { brace_depth = 0; }
result.push(ClassifiedLine { index: i, kind: LineKind::Cordial, content: line.to_string() });
} else {
let cl = classify_line(i, line);
if cl.kind == LineKind::Cordial {
let trimmed = line.trim();
let opens = trimmed.matches('{').count() as i32;
let closes = trimmed.matches('}').count() as i32;
brace_depth += opens - closes;
if brace_depth < 0 { brace_depth = 0; }
}
result.push(cl);
}
}
result
}
fn scan_comment_depth(line: &str, mut depth: usize) -> usize {
let bytes = line.as_bytes();
let len = bytes.len();
let mut i = 0;
while i < len.saturating_sub(1) {
if bytes[i] == b'/' && bytes[i + 1] == b'*' {
depth += 1;
i += 2;
} else if bytes[i] == b'*' && bytes[i + 1] == b'/' {
depth = depth.saturating_sub(1);
i += 2;
} else {
i += 1;
}
}
depth
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn markdown_line() {
let c = classify_line(0, "# Hello World");
assert_eq!(c.kind, LineKind::Markdown);
}
#[test]
fn eval_line() {
let c = classify_line(0, "/= 2 + 3");
assert_eq!(c.kind, LineKind::Eval);
}
#[test]
fn comment_line() {
let c = classify_line(0, "// this is a comment");
assert_eq!(c.kind, LineKind::Comment);
}
#[test]
fn let_binding() {
let c = classify_line(0, "let x = 5");
assert_eq!(c.kind, LineKind::Cordial);
}
#[test]
fn variable_assignment() {
let c = classify_line(0, "x = 5");
assert_eq!(c.kind, LineKind::Cordial);
}
#[test]
fn function_def() {
let c = classify_line(0, "f(x) = x^2");
assert_eq!(c.kind, LineKind::Cordial);
}
#[test]
fn plain_text() {
let c = classify_line(0, "Some notes about the project");
assert_eq!(c.kind, LineKind::Markdown);
}
#[test]
fn let_prose_not_cordial() {
let c = classify_line(0, "let us consider something");
assert_eq!(c.kind, LineKind::Markdown);
}
#[test]
fn let_without_equals_not_cordial() {
let c = classify_line(0, "let me explain");
assert_eq!(c.kind, LineKind::Markdown);
}
#[test]
fn single_line_block_comment() {
let lines = classify_document("/* hello */");
assert_eq!(lines.len(), 1);
assert_eq!(lines[0].kind, LineKind::Comment);
}
#[test]
fn multiline_block_comment() {
let lines = classify_document("/* start\nmiddle\nend */\nlet x = 5");
assert_eq!(lines.len(), 4);
assert_eq!(lines[0].kind, LineKind::Comment);
assert_eq!(lines[1].kind, LineKind::Comment);
assert_eq!(lines[2].kind, LineKind::Comment);
assert_eq!(lines[3].kind, LineKind::Cordial);
}
#[test]
fn block_comment_then_code() {
let lines = classify_document("/* comment */\n/= 2 + 3");
assert_eq!(lines[0].kind, LineKind::Comment);
assert_eq!(lines[1].kind, LineKind::Eval);
}
#[test]
fn nested_block_comments() {
let lines = classify_document("/* outer /* inner */ still comment */\nlet x = 5");
assert_eq!(lines[0].kind, LineKind::Comment);
assert_eq!(lines[1].kind, LineKind::Cordial);
}
#[test]
fn nested_multiline_block_comments() {
let doc = "/* outer\n/* inner */\nstill in outer\n*/\nlet x = 5";
let lines = classify_document(doc);
assert_eq!(lines[0].kind, LineKind::Comment);
assert_eq!(lines[1].kind, LineKind::Comment);
assert_eq!(lines[2].kind, LineKind::Comment);
assert_eq!(lines[3].kind, LineKind::Comment);
assert_eq!(lines[4].kind, LineKind::Cordial);
}
#[test]
fn while_line() {
let c = classify_line(0, "while (i < 10) {");
assert_eq!(c.kind, LineKind::Cordial);
}
#[test]
fn fn_line() {
let c = classify_line(0, "fn add(a, b) {");
assert_eq!(c.kind, LineKind::Cordial);
}
#[test]
fn closing_brace() {
let c = classify_line(0, "}");
assert_eq!(c.kind, LineKind::Cordial);
}
#[test]
fn while_block_body_classified() {
let doc = "while (x > 0) {\n x = x - 1\n}";
let lines = classify_document(doc);
assert_eq!(lines[0].kind, LineKind::Cordial);
assert_eq!(lines[1].kind, LineKind::Cordial);
assert_eq!(lines[2].kind, LineKind::Cordial);
}
#[test]
fn fn_block_body_classified() {
let doc = "fn add(a, b) {\n a + b\n}";
let lines = classify_document(doc);
assert_eq!(lines[0].kind, LineKind::Cordial);
assert_eq!(lines[1].kind, LineKind::Cordial);
assert_eq!(lines[2].kind, LineKind::Cordial);
}
#[test]
fn let_with_type_annotation() {
let c = classify_line(0, "let x: int = 5");
assert_eq!(c.kind, LineKind::Cordial);
}
#[test]
fn let_with_bool_type() {
let c = classify_line(0, "let flag: bool = 1");
assert_eq!(c.kind, LineKind::Cordial);
}
#[test]
fn if_line() {
let c = classify_line(0, "if (x > 5) {");
assert_eq!(c.kind, LineKind::Cordial);
}
#[test]
fn else_line() {
let c = classify_line(0, "} else {");
assert_eq!(c.kind, LineKind::Cordial);
}
#[test]
fn for_line() {
let c = classify_line(0, "for i in arr {");
assert_eq!(c.kind, LineKind::Cordial);
}
#[test]
fn return_line() {
let c = classify_line(0, "return x");
assert_eq!(c.kind, LineKind::Cordial);
}
#[test]
fn use_line() {
let c = classify_line(0, "use calculations");
assert_eq!(c.kind, LineKind::Cordial);
}
#[test]
fn use_with_item() {
let c = classify_line(0, "use budget::ramp");
assert_eq!(c.kind, LineKind::Cordial);
}
#[test]
fn use_prose_not_cordial() {
let c = classify_line(0, "use a fork to eat");
assert_eq!(c.kind, LineKind::Markdown);
}
#[test]
fn if_block_body_classified() {
let doc = "if (x > 5) {\n x = 1\n} else {\n x = 0\n}";
let lines = classify_document(doc);
assert!(lines.iter().all(|l| l.kind == LineKind::Cordial));
}
}

39
core/src/document.rs Normal file
View File

@ -0,0 +1,39 @@
use crate::doc::{classify_document, ClassifiedLine};
use crate::eval::{evaluate_document, DocumentResult};
pub struct AcordDoc {
pub text: String,
pub uuid: String,
lines: Vec<ClassifiedLine>,
}
impl AcordDoc {
pub fn new() -> Self {
AcordDoc {
text: String::new(),
uuid: uuid::Uuid::new_v4().to_string(),
lines: Vec::new(),
}
}
pub fn with_uuid(uuid: String) -> Self {
AcordDoc {
text: String::new(),
uuid,
lines: Vec::new(),
}
}
pub fn set_text(&mut self, text: &str) {
self.text = text.to_string();
self.lines = classify_document(text);
}
pub fn classified_lines(&self) -> &[ClassifiedLine] {
&self.lines
}
pub fn evaluate(&self) -> DocumentResult {
evaluate_document(&self.text)
}
}

618
core/src/eval.rs Normal file
View File

@ -0,0 +1,618 @@
use serde::Serialize;
use crate::doc::{classify_document, LineKind};
use crate::interp;
#[derive(Debug, Clone, Serialize)]
pub struct EvalResult {
pub line: usize,
pub result: String,
pub format: String,
}
#[derive(Debug, Clone, Serialize)]
pub struct EvalError {
pub line: usize,
pub error: String,
}
#[derive(Debug, Clone, Serialize)]
pub struct DocumentResult {
pub results: Vec<EvalResult>,
pub errors: Vec<EvalError>,
}
pub fn evaluate_document(text: &str) -> DocumentResult {
let classified = classify_document(text);
let mut results = Vec::new();
let mut errors = Vec::new();
let mut lines: Vec<(usize, &str, bool)> = Vec::new();
for cl in &classified {
match cl.kind {
LineKind::Cordial => lines.push((cl.index, &cl.content, false)),
LineKind::Eval => lines.push((cl.index, &cl.content, true)),
LineKind::Comment | LineKind::Markdown => {}
}
}
let interp_results = interp::interpret_document(&lines);
for ir in interp_results {
let fmt = match ir.format {
interp::EvalFormat::Inline => "inline",
interp::EvalFormat::Table => "table",
interp::EvalFormat::Tree => "tree",
};
match ir.value {
Some(interp::Value::Error(e)) => {
errors.push(EvalError { line: ir.line, error: e });
}
Some(v) => {
let s = match ir.format {
interp::EvalFormat::Table => value_to_table_json(&v),
interp::EvalFormat::Tree => value_to_tree_json(&v),
interp::EvalFormat::Inline => v.display(),
};
if !s.is_empty() {
results.push(EvalResult { line: ir.line, result: s, format: fmt.to_string() });
}
}
None => {}
}
}
DocumentResult { results, errors }
}
fn value_to_table_json(val: &interp::Value) -> String {
match val {
interp::Value::Array(rows) => {
let table: Vec<Vec<String>> = rows.iter().map(|row| {
match row {
interp::Value::Array(cols) => cols.iter().map(|c| c.display()).collect(),
other => vec![other.display()],
}
}).collect();
serde_json::to_string(&table).unwrap_or_else(|_| val.display())
}
_ => val.display(),
}
}
fn value_to_tree_json(val: &interp::Value) -> String {
fn to_json(v: &interp::Value) -> serde_json::Value {
match v {
interp::Value::Array(items) => {
serde_json::Value::Array(items.iter().map(|i| to_json(i)).collect())
}
interp::Value::Number(n) => {
serde_json::Value::Number(
serde_json::Number::from_f64(*n)
.unwrap_or_else(|| serde_json::Number::from(0))
)
}
interp::Value::Bool(b) => serde_json::Value::Bool(*b),
interp::Value::Str(s) => serde_json::Value::String(s.clone()),
other => serde_json::Value::String(other.display()),
}
}
serde_json::to_string(&to_json(val)).unwrap_or_else(|_| val.display())
}
pub fn evaluate_line(text: &str) -> Result<String, String> {
let mut interp = interp::Interpreter::new();
match interp.eval_expr_str(text) {
Ok(v) => Ok(v.display()),
Err(_) => {
// fall back to cord-expr/cord-trig for trig and CORDIC expressions
let graph = cord_expr::parse_expr(text)?;
let val = cord_trig::eval::evaluate(&graph, 0.0, 0.0, 0.0);
Ok(format_value(val))
}
}
}
fn format_value(val: f64) -> String {
if val == val.trunc() && val.abs() < 1e15 {
format!("{}", val as i64)
} else {
let s = format!("{:.10}", val);
let s = s.trim_end_matches('0');
let s = s.trim_end_matches('.');
s.to_string()
}
}
// --- Module evaluation pipeline ---
/// Source material for a single module (block).
pub struct ModuleSource {
/// Module name (from heading text, normalized).
pub name: String,
/// Raw text content of all text blocks in this module, joined.
pub text: String,
/// True for the root module (H1 section). Its exports are auto-imported
/// into every other module.
pub is_root: bool,
}
/// Per-module evaluation result.
pub struct ModuleResult {
pub name: String,
pub doc_result: DocumentResult,
pub exports: interp::ModuleExports,
}
/// Evaluate modules in dependency order. Root module is evaluated first
/// and its exports are auto-imported into every other module. `use`
/// declarations are resolved via topological sort. Failed `use` (module
/// name doesn't match any source) is silently dropped.
pub fn evaluate_modules(sources: &[ModuleSource]) -> Vec<ModuleResult> {
use std::collections::HashMap;
// Index modules by name
let name_to_idx: HashMap<&str, usize> = sources.iter().enumerate()
.map(|(i, s)| (s.name.as_str(), i))
.collect();
// Extract use declarations from each module
let use_decls: Vec<Vec<interp::UseDecl>> = sources.iter()
.map(|s| interp::extract_use_declarations(&s.text))
.collect();
// Build adjacency list for topo sort (dependency edges: module -> modules it depends on)
let n = sources.len();
let mut in_degree = vec![0usize; n];
let mut dependents: Vec<Vec<usize>> = vec![Vec::new(); n]; // dep -> modules that depend on it
for (i, decls) in use_decls.iter().enumerate() {
for decl in decls {
if let Some(&dep_idx) = name_to_idx.get(decl.module.as_str()) {
if dep_idx != i {
dependents[dep_idx].push(i);
in_degree[i] += 1;
}
}
// Unknown module names are silently ignored (failed use = prose)
}
}
// Kahn's algorithm for topological sort. Root modules get priority
// (pushed to front of queue).
let mut queue: std::collections::VecDeque<usize> = std::collections::VecDeque::new();
for (i, s) in sources.iter().enumerate() {
if in_degree[i] == 0 {
if s.is_root {
queue.push_front(i);
} else {
queue.push_back(i);
}
}
}
let mut order: Vec<usize> = Vec::with_capacity(n);
while let Some(idx) = queue.pop_front() {
order.push(idx);
for &dep in &dependents[idx] {
in_degree[dep] -= 1;
if in_degree[dep] == 0 {
queue.push_back(dep);
}
}
}
// Any modules not in `order` are part of a cycle. Append them at
// the end — they'll evaluate without their cyclic dependencies
// (which means their `use`d bindings won't be available, producing
// natural "undefined variable" errors downstream).
for i in 0..n {
if !order.contains(&i) {
order.push(i);
}
}
// Evaluate in topological order
let mut exports_by_name: HashMap<String, interp::ModuleExports> = HashMap::new();
let mut root_exports: Option<interp::ModuleExports> = None;
let mut results: Vec<Option<ModuleResult>> = (0..n).map(|_| None).collect();
for &idx in &order {
let source = &sources[idx];
// Create interpreter with imported scope
let mut interp = interp::Interpreter::new();
// Auto-import root module exports (unless this IS the root)
if !source.is_root {
if let Some(ref root_exp) = root_exports {
interp.import_all(root_exp);
}
}
// Import use'd modules' exports
for decl in &use_decls[idx] {
if let Some(module_exports) = exports_by_name.get(&decl.module) {
match &decl.item {
Some(s) if s == "*" => {
interp.import_all(module_exports);
}
None => {
interp.import_all(module_exports);
}
Some(item) => {
interp.import_item(module_exports, item);
}
}
}
}
// Evaluate this module's text
let doc_result = evaluate_document_with_interp(&mut interp, &source.text);
let module_exports = interp.exports();
if source.is_root {
root_exports = Some(module_exports.clone());
}
exports_by_name.insert(source.name.clone(), module_exports.clone());
results[idx] = Some(ModuleResult {
name: source.name.clone(),
doc_result,
exports: module_exports,
});
}
results.into_iter().flatten().collect()
}
/// Evaluate a document's text using an existing (pre-populated) interpreter.
pub fn evaluate_document_with_interp(interp: &mut interp::Interpreter, text: &str) -> DocumentResult {
let classified = classify_document(text);
let mut results = Vec::new();
let mut errors = Vec::new();
let mut lines: Vec<(usize, &str, bool)> = Vec::new();
for cl in &classified {
match cl.kind {
LineKind::Cordial => lines.push((cl.index, &cl.content, false)),
LineKind::Eval => lines.push((cl.index, &cl.content, true)),
LineKind::Comment | LineKind::Markdown => {}
}
}
let interp_results = interp::interpret_document_with(interp, &lines);
for ir in interp_results {
let fmt = match ir.format {
interp::EvalFormat::Inline => "inline",
interp::EvalFormat::Table => "table",
interp::EvalFormat::Tree => "tree",
};
match ir.value {
Some(interp::Value::Error(e)) => {
errors.push(EvalError { line: ir.line, error: e });
}
Some(v) => {
let s = match ir.format {
interp::EvalFormat::Table => value_to_table_json(&v),
interp::EvalFormat::Tree => value_to_tree_json(&v),
interp::EvalFormat::Inline => v.display(),
};
if !s.is_empty() {
results.push(EvalResult { line: ir.line, result: s, format: fmt.to_string() });
}
}
None => {}
}
}
DocumentResult { results, errors }
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn simple_eval() {
let result = evaluate_line("2 + 3").unwrap();
assert_eq!(result, "5");
}
#[test]
fn eval_with_variables() {
let doc = "let a = 5\nlet b = 3\n/= a + b";
let result = evaluate_document(doc);
assert_eq!(result.results.len(), 1);
assert_eq!(result.results[0].result, "8");
assert_eq!(result.results[0].line, 2);
}
#[test]
fn eval_with_markdown() {
let doc = "# Title\nlet val = 10\nSome text\n/= val * 2";
let result = evaluate_document(doc);
assert_eq!(result.results.len(), 1);
assert_eq!(result.results[0].result, "20");
}
#[test]
fn eval_trig() {
let result = evaluate_line("sin(0)").unwrap();
assert_eq!(result, "0");
}
#[test]
fn eval_function_def() {
let doc = "f(a) = a * a\n/= f(5)";
let result = evaluate_document(doc);
assert_eq!(result.results.len(), 1);
assert_eq!(result.results[0].result, "25");
}
#[test]
fn multiple_evals() {
let doc = "let a = 3\n/= a\nlet b = 7\n/= a + b";
let result = evaluate_document(doc);
assert_eq!(result.results.len(), 2);
assert_eq!(result.results[0].result, "3");
assert_eq!(result.results[1].result, "10");
}
#[test]
fn format_integer() {
assert_eq!(format_value(42.0), "42");
}
#[test]
fn format_float() {
let s = format_value(3.14);
assert!(s.starts_with("3.14"));
}
#[test]
fn eval_x_plus_5() {
let doc = "let x = 10\n/= x + 5";
let result = evaluate_document(doc);
assert_eq!(result.results.len(), 1);
assert_eq!(result.results[0].result, "15");
}
#[test]
fn eval_string_concat() {
let doc = "let x = \"hello\"\nlet y = \"world\"\n/= x + \" \" + y";
let result = evaluate_document(doc);
assert_eq!(result.results.len(), 1);
assert_eq!(result.results[0].result, "hello world");
}
#[test]
fn eval_booleans() {
let doc = "let x = true\n/= x\n/= 1 > 0";
let result = evaluate_document(doc);
assert_eq!(result.results.len(), 2);
assert_eq!(result.results[0].result, "true");
assert_eq!(result.results[1].result, "true");
}
#[test]
fn eval_while_loop() {
let doc = "let i = 0\nlet sum = 0\nwhile (i < 10) {\n sum = sum + i\n i = i + 1\n}\n/= sum";
let result = evaluate_document(doc);
assert_eq!(result.results.len(), 1);
assert_eq!(result.results[0].result, "45");
}
#[test]
fn eval_fn_block() {
let doc = "fn add(a, b) {\n a + b\n}\n/= add(3, 4)";
let result = evaluate_document(doc);
assert_eq!(result.results.len(), 1);
assert_eq!(result.results[0].result, "7");
}
#[test]
fn eval_type_annotation_int_lossy_rejected() {
// Round-trip rule: lossy coercion is rejected.
let doc = "let x: int = 3.7\n/= x";
let result = evaluate_document(doc);
assert!(result.errors.len() >= 1, "should error on lossy int");
}
#[test]
fn eval_type_annotation_int_exact_accepted() {
let doc = "let x: int = 3.0\n/= x";
let result = evaluate_document(doc);
assert_eq!(result.results.len(), 1);
assert_eq!(result.results[0].result, "3");
}
#[test]
fn eval_type_annotation_bool_error() {
let doc = "let x: bool = 2\n/= x";
let result = evaluate_document(doc);
assert!(result.errors.len() >= 1);
let msg = &result.errors[0].error;
assert!(
msg.contains("clean conversion") || msg.contains("cannot bind"),
"expected clean-conversion error, got: {}", msg
);
}
#[test]
fn eval_array() {
let doc = "let arr = [1, \"two\", true]\n/= arr";
let result = evaluate_document(doc);
assert_eq!(result.results.len(), 1);
assert_eq!(result.results[0].result, "[1, \"two\", true]");
}
#[test]
fn eval_error_recovery() {
let doc = "let x = undefined_var\nlet y = 5\n/= y";
let result = evaluate_document(doc);
assert_eq!(result.results.len(), 1);
assert_eq!(result.results[0].result, "5");
assert!(result.errors.len() >= 1);
}
#[test]
fn eval_mixed_markdown_and_code() {
let doc = "# Notes\nlet x = 10\nSome text here\nwhile (x > 0) {\n x = x - 1\n}\n/= x";
let result = evaluate_document(doc);
assert_eq!(result.results.len(), 1);
assert_eq!(result.results[0].result, "0");
}
#[test]
fn eval_if_else() {
let doc = "let x = 10\nif (x > 5) {\n x = 1\n} else {\n x = 0\n}\n/= x";
let result = evaluate_document(doc);
assert_eq!(result.results.len(), 1);
assert_eq!(result.results[0].result, "1");
}
#[test]
fn eval_for_loop() {
let doc = "let sum = 0\nfor i in [1, 2, 3] {\n sum = sum + i\n}\n/= sum";
let result = evaluate_document(doc);
assert_eq!(result.results.len(), 1);
assert_eq!(result.results[0].result, "6");
}
#[test]
fn eval_array_index() {
let doc = "let arr = [10, 20, 30]\n/= arr[1]";
let result = evaluate_document(doc);
assert_eq!(result.results.len(), 1);
assert_eq!(result.results[0].result, "20");
}
#[test]
fn eval_fn_return() {
let doc = "fn max(a, b) {\n if (a > b) {\n return a\n }\n return b\n}\n/= max(3, 7)";
let result = evaluate_document(doc);
assert_eq!(result.results.len(), 1);
assert_eq!(result.results[0].result, "7");
}
#[test]
fn eval_table_format() {
let doc = "let data = [[\"Name\", \"Age\"], [\"Alice\", 30], [\"Bob\", 25]]\n/=| data";
let result = evaluate_document(doc);
assert_eq!(result.results.len(), 1);
assert_eq!(result.results[0].format, "table");
let parsed: Vec<Vec<String>> = serde_json::from_str(&result.results[0].result).unwrap();
assert_eq!(parsed.len(), 3);
assert_eq!(parsed[0], vec!["Name", "Age"]);
}
#[test]
fn eval_tree_format() {
let doc = "let tree = [1, [2, 3], [4, [5]]]\n/=\\ tree";
let result = evaluate_document(doc);
assert_eq!(result.results.len(), 1);
assert_eq!(result.results[0].format, "tree");
let parsed: serde_json::Value = serde_json::from_str(&result.results[0].result).unwrap();
assert!(parsed.is_array());
}
#[test]
fn eval_inline_format_default() {
let doc = "let x = 42\n/= x";
let result = evaluate_document(doc);
assert_eq!(result.results[0].format, "inline");
}
#[test]
fn eval_table_flat_array() {
let doc = "/=| [1, 2, 3]";
let result = evaluate_document(doc);
assert_eq!(result.results.len(), 1);
assert_eq!(result.results[0].format, "table");
}
#[test]
fn eval_document_json_has_format() {
let doc = "let x = 42\n/= x\n/=| [[1, 2], [3, 4]]";
let result = evaluate_document(doc);
let json = serde_json::to_string(&result).unwrap();
assert!(json.contains("\"format\":\"inline\""));
assert!(json.contains("\"format\":\"table\""));
}
#[test]
fn module_eval_use_imports_binding() {
let sources = vec![
ModuleSource { name: "root".into(), text: "let pi = 3.14".into(), is_root: true },
ModuleSource { name: "math".into(), text: "fn double(x) {\n x * 2\n}".into(), is_root: false },
ModuleSource { name: "main".into(), text: "use math\n/= double(pi)".into(), is_root: false },
];
let results = evaluate_modules(&sources);
assert_eq!(results.len(), 3);
// "main" should see both root's `pi` (auto-import) and math's `double` (via use)
let main_result = results.iter().find(|r| r.name == "main").unwrap();
assert_eq!(main_result.doc_result.results.len(), 1);
assert_eq!(main_result.doc_result.results[0].result, "6.28");
}
#[test]
fn module_eval_root_auto_imported() {
let sources = vec![
ModuleSource { name: "root".into(), text: "let x = 5".into(), is_root: true },
ModuleSource { name: "child".into(), text: "/= x".into(), is_root: false },
];
let results = evaluate_modules(&sources);
let child = results.iter().find(|r| r.name == "child").unwrap();
assert_eq!(child.doc_result.results.len(), 1);
assert_eq!(child.doc_result.results[0].result, "5");
}
#[test]
fn module_eval_without_use_no_access() {
let sources = vec![
ModuleSource { name: "root".into(), text: "".into(), is_root: true },
ModuleSource { name: "a".into(), text: "let secret = 42".into(), is_root: false },
ModuleSource { name: "b".into(), text: "/= secret".into(), is_root: false },
];
let results = evaluate_modules(&sources);
let b = results.iter().find(|r| r.name == "b").unwrap();
assert_eq!(b.doc_result.errors.len(), 1);
assert!(b.doc_result.errors[0].error.contains("undefined"));
}
#[test]
fn module_eval_use_specific_item() {
let sources = vec![
ModuleSource { name: "root".into(), text: "".into(), is_root: true },
ModuleSource { name: "math".into(), text: "let a = 1\nlet b = 2".into(), is_root: false },
ModuleSource { name: "main".into(), text: "use math::a\n/= a".into(), is_root: false },
];
let results = evaluate_modules(&sources);
let main = results.iter().find(|r| r.name == "main").unwrap();
assert_eq!(main.doc_result.results.len(), 1);
assert_eq!(main.doc_result.results[0].result, "1");
}
#[test]
fn module_eval_failed_use_no_error() {
let sources = vec![
ModuleSource { name: "root".into(), text: "".into(), is_root: true },
ModuleSource { name: "main".into(), text: "use nonexistent\nlet x = 1\n/= x".into(), is_root: false },
];
let results = evaluate_modules(&sources);
let main = results.iter().find(|r| r.name == "main").unwrap();
assert!(main.doc_result.errors.is_empty());
assert_eq!(main.doc_result.results[0].result, "1");
}
#[test]
fn module_eval_cycle_handled() {
let sources = vec![
ModuleSource { name: "root".into(), text: "".into(), is_root: true },
ModuleSource { name: "a".into(), text: "use b\nlet x = 1".into(), is_root: false },
ModuleSource { name: "b".into(), text: "use a\nlet y = 2".into(), is_root: false },
];
// Shouldn't panic. One of them evaluates without the other's exports.
let results = evaluate_modules(&sources);
assert_eq!(results.len(), 3);
}
}

159
core/src/ffi.rs Normal file
View File

@ -0,0 +1,159 @@
use std::ffi::{CStr, CString};
use std::os::raw::c_char;
use std::path::Path;
use crate::document::AcordDoc;
use crate::eval;
use crate::highlight;
use crate::persist;
fn cstr_to_str<'a>(ptr: *const c_char) -> Option<&'a str> {
if ptr.is_null() { return None; }
unsafe { CStr::from_ptr(ptr).to_str().ok() }
}
fn str_to_cstr(s: &str) -> *mut c_char {
CString::new(s).unwrap_or_default().into_raw()
}
#[unsafe(no_mangle)]
pub extern "C" fn acord_doc_new() -> *mut AcordDoc {
Box::into_raw(Box::new(AcordDoc::new()))
}
#[unsafe(no_mangle)]
pub extern "C" fn acord_doc_free(doc: *mut AcordDoc) {
if doc.is_null() { return; }
unsafe { drop(Box::from_raw(doc)); }
}
#[unsafe(no_mangle)]
pub extern "C" fn acord_doc_set_text(doc: *mut AcordDoc, text: *const c_char) {
let doc = match unsafe { doc.as_mut() } {
Some(d) => d,
None => return,
};
let text = match cstr_to_str(text) {
Some(s) => s,
None => return,
};
doc.set_text(text);
}
#[unsafe(no_mangle)]
pub extern "C" fn acord_doc_get_text(doc: *const AcordDoc) -> *mut c_char {
let doc = match unsafe { doc.as_ref() } {
Some(d) => d,
None => return std::ptr::null_mut(),
};
str_to_cstr(&doc.text)
}
#[unsafe(no_mangle)]
pub extern "C" fn acord_doc_evaluate(doc: *mut AcordDoc) -> *mut c_char {
let doc = match unsafe { doc.as_mut() } {
Some(d) => d,
None => return str_to_cstr("[]"),
};
let result = doc.evaluate();
let json = serde_json::to_string(&result).unwrap_or_else(|_| "[]".into());
str_to_cstr(&json)
}
#[unsafe(no_mangle)]
pub extern "C" fn acord_eval_line(text: *const c_char) -> *mut c_char {
let text = match cstr_to_str(text) {
Some(s) => s,
None => return str_to_cstr(""),
};
match eval::evaluate_line(text) {
Ok(result) => str_to_cstr(&result),
Err(e) => str_to_cstr(&format!("error: {}", e)),
}
}
#[unsafe(no_mangle)]
pub extern "C" fn acord_doc_save(doc: *const AcordDoc, path: *const c_char) -> bool {
let doc = match unsafe { doc.as_ref() } {
Some(d) => d,
None => return false,
};
let path = match cstr_to_str(path) {
Some(s) => s,
None => return false,
};
persist::save_to_file(&doc.text, Path::new(path)).is_ok()
}
#[unsafe(no_mangle)]
pub extern "C" fn acord_doc_load(path: *const c_char) -> *mut AcordDoc {
let path = match cstr_to_str(path) {
Some(s) => s,
None => return std::ptr::null_mut(),
};
match persist::load_from_file(Path::new(path)) {
Ok(text) => {
let mut doc = AcordDoc::new();
doc.set_text(&text);
Box::into_raw(Box::new(doc))
}
Err(_) => std::ptr::null_mut(),
}
}
#[unsafe(no_mangle)]
pub extern "C" fn acord_cache_save(doc: *const AcordDoc) -> *mut c_char {
let doc = match unsafe { doc.as_ref() } {
Some(d) => d,
None => return std::ptr::null_mut(),
};
let uuid = doc.uuid.clone();
match persist::cache_save(&uuid, &doc.text) {
Ok(_) => str_to_cstr(&uuid),
Err(_) => std::ptr::null_mut(),
}
}
#[unsafe(no_mangle)]
pub extern "C" fn acord_cache_load(uuid: *const c_char) -> *mut AcordDoc {
let uuid = match cstr_to_str(uuid) {
Some(s) => s,
None => return std::ptr::null_mut(),
};
match persist::cache_load(uuid) {
Ok(text) => {
let mut doc = AcordDoc::with_uuid(uuid.to_string());
doc.set_text(&text);
Box::into_raw(Box::new(doc))
}
Err(_) => std::ptr::null_mut(),
}
}
#[unsafe(no_mangle)]
pub extern "C" fn acord_list_notes() -> *mut c_char {
let notes = persist::list_notes();
let json = serde_json::to_string(&notes).unwrap_or_else(|_| "[]".into());
str_to_cstr(&json)
}
#[unsafe(no_mangle)]
pub extern "C" fn acord_highlight(source: *const c_char, lang: *const c_char) -> *mut c_char {
let source = match cstr_to_str(source) {
Some(s) => s,
None => return str_to_cstr("[]"),
};
let lang = match cstr_to_str(lang) {
Some(s) => s,
None => return str_to_cstr("[]"),
};
let spans = highlight::highlight_source(source, lang);
let json = serde_json::to_string(&spans).unwrap_or_else(|_| "[]".into());
str_to_cstr(&json)
}
#[unsafe(no_mangle)]
pub extern "C" fn acord_free_string(s: *mut c_char) {
if s.is_null() { return; }
unsafe { drop(CString::from_raw(s)); }
}

234
core/src/highlight.rs Normal file
View File

@ -0,0 +1,234 @@
use tree_sitter::Language;
use tree_sitter_highlight::{HighlightConfiguration, HighlightEvent, Highlighter};
const HIGHLIGHT_NAMES: &[&str] = &[
"keyword",
"function",
"function.builtin",
"type",
"type.builtin",
"constructor",
"constant",
"constant.builtin",
"string",
"number",
"comment",
"variable",
"variable.builtin",
"variable.parameter",
"operator",
"punctuation",
"punctuation.bracket",
"punctuation.delimiter",
"property",
"tag",
"attribute",
"label",
"escape",
"embedded",
];
#[derive(serde::Serialize)]
pub struct HighlightSpan {
pub start: usize,
pub end: usize,
pub kind: u8,
}
struct LangDef {
language: Language,
highlights: &'static str,
injections: &'static str,
locals: &'static str,
}
fn lang_def(lang_id: &str) -> Option<LangDef> {
let ld = match lang_id {
"rust" => LangDef {
language: tree_sitter_rust::LANGUAGE.into(),
highlights: tree_sitter_rust::HIGHLIGHTS_QUERY,
injections: tree_sitter_rust::INJECTIONS_QUERY,
locals: "",
},
"c" => LangDef {
language: tree_sitter_c::LANGUAGE.into(),
highlights: tree_sitter_c::HIGHLIGHT_QUERY,
injections: "",
locals: "",
},
"cpp" => LangDef {
language: tree_sitter_cpp::LANGUAGE.into(),
highlights: tree_sitter_cpp::HIGHLIGHT_QUERY,
injections: "",
locals: "",
},
"javascript" | "jsx" => LangDef {
language: tree_sitter_javascript::LANGUAGE.into(),
highlights: tree_sitter_javascript::HIGHLIGHT_QUERY,
injections: tree_sitter_javascript::INJECTIONS_QUERY,
locals: tree_sitter_javascript::LOCALS_QUERY,
},
"typescript" => LangDef {
language: tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into(),
highlights: tree_sitter_typescript::HIGHLIGHTS_QUERY,
injections: "",
locals: tree_sitter_typescript::LOCALS_QUERY,
},
"tsx" => LangDef {
language: tree_sitter_typescript::LANGUAGE_TSX.into(),
highlights: tree_sitter_typescript::HIGHLIGHTS_QUERY,
injections: "",
locals: tree_sitter_typescript::LOCALS_QUERY,
},
"python" => LangDef {
language: tree_sitter_python::LANGUAGE.into(),
highlights: tree_sitter_python::HIGHLIGHTS_QUERY,
injections: "",
locals: "",
},
"go" => LangDef {
language: tree_sitter_go::LANGUAGE.into(),
highlights: tree_sitter_go::HIGHLIGHTS_QUERY,
injections: "",
locals: "",
},
"ruby" => LangDef {
language: tree_sitter_ruby::LANGUAGE.into(),
highlights: tree_sitter_ruby::HIGHLIGHTS_QUERY,
injections: "",
locals: tree_sitter_ruby::LOCALS_QUERY,
},
"bash" | "shell" => LangDef {
language: tree_sitter_bash::LANGUAGE.into(),
highlights: tree_sitter_bash::HIGHLIGHT_QUERY,
injections: "",
locals: "",
},
"java" => LangDef {
language: tree_sitter_java::LANGUAGE.into(),
highlights: tree_sitter_java::HIGHLIGHTS_QUERY,
injections: "",
locals: "",
},
"html" => LangDef {
language: tree_sitter_html::LANGUAGE.into(),
highlights: tree_sitter_html::HIGHLIGHTS_QUERY,
injections: tree_sitter_html::INJECTIONS_QUERY,
locals: "",
},
"css" | "scss" | "less" => LangDef {
language: tree_sitter_css::LANGUAGE.into(),
highlights: tree_sitter_css::HIGHLIGHTS_QUERY,
injections: "",
locals: "",
},
"json" => LangDef {
language: tree_sitter_json::LANGUAGE.into(),
highlights: tree_sitter_json::HIGHLIGHTS_QUERY,
injections: "",
locals: "",
},
"lua" => LangDef {
language: tree_sitter_lua::LANGUAGE.into(),
highlights: tree_sitter_lua::HIGHLIGHTS_QUERY,
injections: tree_sitter_lua::INJECTIONS_QUERY,
locals: tree_sitter_lua::LOCALS_QUERY,
},
"php" => LangDef {
language: tree_sitter_php::LANGUAGE_PHP.into(),
highlights: tree_sitter_php::HIGHLIGHTS_QUERY,
injections: tree_sitter_php::INJECTIONS_QUERY,
locals: "",
},
"toml" => LangDef {
language: tree_sitter_toml_ng::LANGUAGE.into(),
highlights: tree_sitter_toml_ng::HIGHLIGHTS_QUERY,
injections: "",
locals: "",
},
"yaml" => LangDef {
language: tree_sitter_yaml::language(),
highlights: tree_sitter_yaml::HIGHLIGHTS_QUERY,
injections: "",
locals: "",
},
"swift" => LangDef {
language: tree_sitter_swift::LANGUAGE.into(),
highlights: tree_sitter_swift::HIGHLIGHTS_QUERY,
injections: "",
locals: "",
},
"zig" => LangDef {
language: tree_sitter_zig::LANGUAGE.into(),
highlights: tree_sitter_zig::HIGHLIGHTS_QUERY,
injections: tree_sitter_zig::INJECTIONS_QUERY,
locals: "",
},
"sql" => LangDef {
language: tree_sitter_sequel::LANGUAGE.into(),
highlights: tree_sitter_sequel::HIGHLIGHTS_QUERY,
injections: "",
locals: "",
},
"make" | "makefile" => LangDef {
language: tree_sitter_make::LANGUAGE.into(),
highlights: tree_sitter_make::HIGHLIGHTS_QUERY,
injections: "",
locals: "",
},
_ => return None,
};
Some(ld)
}
fn make_config(def: LangDef, name: &str) -> Option<HighlightConfiguration> {
let mut config = HighlightConfiguration::new(
def.language,
name,
def.highlights,
def.injections,
def.locals,
).ok()?;
config.configure(HIGHLIGHT_NAMES);
Some(config)
}
pub fn highlight_source(source: &str, lang_id: &str) -> Vec<HighlightSpan> {
let def = match lang_def(lang_id) {
Some(d) => d,
None => return Vec::new(),
};
let config = match make_config(def, lang_id) {
Some(c) => c,
None => return Vec::new(),
};
let mut highlighter = Highlighter::new();
let events = match highlighter.highlight(&config, source.as_bytes(), None, |_| None) {
Ok(e) => e,
Err(_) => return Vec::new(),
};
let mut spans = Vec::new();
let mut stack: Vec<u8> = Vec::new();
for event in events {
match event {
Ok(HighlightEvent::Source { start, end }) => {
if let Some(&kind) = stack.last() {
spans.push(HighlightSpan { start, end, kind });
}
}
Ok(HighlightEvent::HighlightStart(h)) => {
stack.push(h.0 as u8);
}
Ok(HighlightEvent::HighlightEnd) => {
stack.pop();
}
Err(_) => break,
}
}
spans
}

4604
core/src/interp.rs Normal file

File diff suppressed because it is too large Load Diff

7
core/src/lib.rs Normal file
View File

@ -0,0 +1,7 @@
pub mod doc;
pub mod document;
pub mod eval;
pub mod highlight;
pub mod interp;
pub mod persist;
pub mod ffi;

168
core/src/persist.rs Normal file
View File

@ -0,0 +1,168 @@
use serde::{Serialize, Deserialize};
use std::collections::HashMap;
use std::fs;
use std::io;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NoteMeta {
pub uuid: String,
pub title: String,
pub path: String,
pub modified: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StateIndex {
pub notes: HashMap<String, NoteMeta>,
}
impl StateIndex {
pub fn new() -> Self {
StateIndex {
notes: HashMap::new(),
}
}
pub fn load() -> io::Result<Self> {
let path = state_path();
if !path.exists() {
return Ok(Self::new());
}
let data = fs::read_to_string(&path)?;
serde_json::from_str(&data).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))
}
pub fn save(&self) -> io::Result<()> {
let path = state_path();
ensure_dir(path.parent().unwrap())?;
let data = serde_json::to_string_pretty(self)?;
fs::write(&path, data)
}
pub fn upsert(&mut self, meta: NoteMeta) {
self.notes.insert(meta.uuid.clone(), meta);
}
pub fn remove(&mut self, uuid: &str) {
self.notes.remove(uuid);
}
pub fn list(&self) -> Vec<&NoteMeta> {
let mut notes: Vec<&NoteMeta> = self.notes.values().collect();
notes.sort_by(|a, b| b.modified.cmp(&a.modified));
notes
}
}
fn acord_dir() -> PathBuf {
let home = std::env::var("HOME").unwrap_or_else(|_| ".".into());
PathBuf::from(home).join(".acord")
}
fn cache_dir() -> PathBuf {
acord_dir().join("cache")
}
fn state_path() -> PathBuf {
acord_dir().join("state.json")
}
fn ensure_dir(dir: &Path) -> io::Result<()> {
if !dir.exists() {
fs::create_dir_all(dir)?;
}
Ok(())
}
fn now_epoch() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
}
fn title_from_text(text: &str) -> String {
for line in text.lines() {
let trimmed = line.trim();
if trimmed.is_empty() { continue; }
let title = trimmed.trim_start_matches('#').trim();
if !title.is_empty() {
return title.chars().take(80).collect();
}
}
"Untitled".into()
}
pub fn save_to_file(text: &str, path: &Path) -> io::Result<()> {
if let Some(parent) = path.parent() {
ensure_dir(parent)?;
}
fs::write(path, text)
}
pub fn load_from_file(path: &Path) -> io::Result<String> {
fs::read_to_string(path)
}
pub fn cache_save(uuid: &str, text: &str) -> io::Result<PathBuf> {
let dir = cache_dir();
ensure_dir(&dir)?;
let filename = format!("{}.sw", uuid);
let path = dir.join(&filename);
fs::write(&path, text)?;
let mut index = StateIndex::load().unwrap_or_else(|_| StateIndex::new());
index.upsert(NoteMeta {
uuid: uuid.to_string(),
title: title_from_text(text),
path: path.to_string_lossy().into_owned(),
modified: now_epoch(),
});
index.save()?;
Ok(path)
}
pub fn cache_load(uuid: &str) -> io::Result<String> {
let filename = format!("{}.sw", uuid);
let path = cache_dir().join(filename);
fs::read_to_string(path)
}
pub fn list_notes() -> Vec<NoteMeta> {
StateIndex::load()
.unwrap_or_else(|_| StateIndex::new())
.list()
.into_iter()
.cloned()
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn title_extraction() {
assert_eq!(title_from_text("# My Note\nSome content"), "My Note");
assert_eq!(title_from_text("Hello world"), "Hello world");
assert_eq!(title_from_text(""), "Untitled");
assert_eq!(title_from_text("\n\n## Section\nstuff"), "Section");
}
#[test]
fn state_index_round_trip() {
let mut idx = StateIndex::new();
idx.upsert(NoteMeta {
uuid: "abc".into(),
title: "Test".into(),
path: "/tmp/test.sw".into(),
modified: 1000,
});
assert_eq!(idx.list().len(), 1);
idx.remove("abc");
assert_eq!(idx.list().len(), 0);
}
}

733
src/AppDelegate.swift Normal file
View File

@ -0,0 +1,733 @@
import Cocoa
import Combine
import SwiftUI
import UniformTypeIdentifiers
extension Notification.Name {
static let focusEditor = Notification.Name("focusEditor")
static let focusTitle = Notification.Name("focusTitle")
}
class WindowController {
let window: NSWindow
let appState: AppState
init(window: NSWindow, appState: AppState) {
self.window = window
self.appState = appState
}
}
class AppDelegate: NSObject, NSApplicationDelegate, NSMenuItemValidation {
var window: NSWindow!
var appState: AppState!
private var titleCancellable: AnyCancellable?
private var textCancellable: AnyCancellable?
private var titleBarView: TitleBarView?
private var focusTitleObserver: NSObjectProtocol?
private var windowControllers: [WindowController] = []
/// Writes the viewport's current text to the notes directory on a
/// tight interval. Deliberately bypasses `appState.documentText` the
/// Combine sink on that property pushes text back into the viewport
/// via `vp.setText`, which rebuilds viewport state and clears the
/// eval overlay. By writing straight to disk, autosave can't disturb
/// what the user sees.
private var autosaveTimer: Timer?
private var viewport: IcedViewportView? {
window?.contentView as? IcedViewportView
}
func applicationDidFinishLaunching(_ notification: Notification) {
_ = ConfigManager.shared
appState = AppState()
let viewport = IcedViewportView(frame: NSRect(x: 0, y: 0, width: 1200, height: 800))
viewport.autoresizingMask = [.width, .height]
window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 1200, height: 800),
styleMask: [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView],
backing: .buffered,
defer: false
)
window.isReleasedWhenClosed = false
window.titlebarAppearsTransparent = true
window.titleVisibility = .hidden
window.backgroundColor = Theme.current.base
window.title = "Acord"
window.contentView = viewport
window.center()
window.setFrameAutosaveName("AcordMainWindow")
window.makeKeyAndOrderFront(nil)
applyThemeAppearance()
setupTitleBar()
setupMenuBar()
observeDocumentTitle()
observeDocumentText()
syncThemeToViewport()
startAutosaveTimer()
DocumentBrowserController.shared = DocumentBrowserController(appState: appState)
NotificationCenter.default.addObserver(
self, selector: #selector(settingsDidChange),
name: .settingsChanged, object: nil
)
if let url = pendingOpenURLs.first {
pendingOpenURLs = []
appState.loadNoteFromFile(url)
}
}
private var pendingOpenURLs: [URL] = []
func application(_ application: NSApplication, open urls: [URL]) {
guard let url = urls.first else { return }
if appState != nil {
appState.loadNoteFromFile(url)
} else {
pendingOpenURLs = [url]
}
}
func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool {
return true
}
// Runs before AppKit tears the window down. We must front-run the window
// teardown so the Rust-backed viewport releases its wgpu/Metal resources
// while the NSView + CAMetalLayer it holds raw pointers to are still
// alive. `applicationWillTerminate` is too late: by the time that fires,
// AppKit has already started deallocating the window/contentView graph
// and the delegate can no longer safely read `self.window`.
func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply {
// Pull out any unsaved text before tearing down. `getText` refreshes
// the viewport's own `cachedText`, so later reads during teardown
// can fall back to it if the handle is already gone.
syncTextFromViewport()
appState.saveNote()
// Explicit, ordered teardown of every viewport we own, while the
// views + window graph are still fully alive.
if let vp = viewport {
vp.teardown()
}
for controller in windowControllers {
if let vp = controller.window.contentView as? IcedViewportView {
vp.teardown()
}
}
// Drop strong refs so AppKit doesn't try to replay anything through
// the delegate during its own terminate phases.
titleCancellable = nil
textCancellable = nil
if let observer = focusTitleObserver {
NotificationCenter.default.removeObserver(observer)
focusTitleObserver = nil
}
NotificationCenter.default.removeObserver(self)
return .terminateNow
}
// MARK: - Menu bar
private func setupMenuBar() {
let mainMenu = NSMenu()
mainMenu.addItem(buildAppMenu())
mainMenu.addItem(buildFileMenu())
mainMenu.addItem(buildEditMenu())
mainMenu.addItem(buildRenderMenu())
mainMenu.addItem(buildViewMenu())
mainMenu.addItem(buildWindowMenu())
NSApp.mainMenu = mainMenu
}
private func buildAppMenu() -> NSMenuItem {
let item = NSMenuItem()
let menu = NSMenu()
menu.addItem(withTitle: "About Acord", action: #selector(NSApplication.orderFrontStandardAboutPanel(_:)), keyEquivalent: "")
menu.addItem(.separator())
let settingsItem = NSMenuItem(title: "Settings...", action: #selector(openSettings), keyEquivalent: ",")
settingsItem.target = self
menu.addItem(settingsItem)
menu.addItem(.separator())
menu.addItem(withTitle: "Quit Acord", action: #selector(NSApplication.terminate(_:)), keyEquivalent: "q")
item.submenu = menu
return item
}
private func buildFileMenu() -> NSMenuItem {
let item = NSMenuItem()
let menu = NSMenu(title: "File")
let newWindowItem = NSMenuItem(title: "New Window", action: #selector(newWindow), keyEquivalent: "n")
newWindowItem.target = self
menu.addItem(newWindowItem)
let newNoteItem = NSMenuItem(title: "New Note", action: #selector(newNote), keyEquivalent: "N")
newNoteItem.keyEquivalentModifierMask = [.command, .shift]
newNoteItem.target = self
menu.addItem(newNoteItem)
let openItem = NSMenuItem(title: "Open...", action: #selector(openNote), keyEquivalent: "o")
openItem.target = self
menu.addItem(openItem)
menu.addItem(.separator())
let saveItem = NSMenuItem(title: "Save", action: #selector(saveNote), keyEquivalent: "s")
saveItem.target = self
menu.addItem(saveItem)
let saveAsItem = NSMenuItem(title: "Save As...", action: #selector(saveNoteAs), keyEquivalent: "S")
saveAsItem.target = self
menu.addItem(saveAsItem)
menu.addItem(.separator())
let exportCrateItem = NSMenuItem(title: "Export as Rust Library...", action: #selector(exportCrate), keyEquivalent: "E")
exportCrateItem.keyEquivalentModifierMask = [.command, .shift]
exportCrateItem.target = self
menu.addItem(exportCrateItem)
menu.addItem(.separator())
let openStorageItem = NSMenuItem(title: "Open Storage Directory", action: #selector(openStorageDirectory), keyEquivalent: "")
openStorageItem.target = self
menu.addItem(openStorageItem)
item.submenu = menu
return item
}
private func buildEditMenu() -> NSMenuItem {
let item = NSMenuItem()
let menu = NSMenu(title: "Edit")
menu.addItem(withTitle: "Undo", action: Selector(("undo:")), keyEquivalent: "z")
menu.addItem(withTitle: "Redo", action: Selector(("redo:")), keyEquivalent: "Z")
menu.addItem(.separator())
menu.addItem(withTitle: "Cut", action: #selector(NSText.cut(_:)), keyEquivalent: "x")
menu.addItem(withTitle: "Copy", action: #selector(NSText.copy(_:)), keyEquivalent: "c")
menu.addItem(withTitle: "Paste", action: #selector(NSText.paste(_:)), keyEquivalent: "v")
menu.addItem(withTitle: "Select All", action: #selector(NSText.selectAll(_:)), keyEquivalent: "a")
menu.addItem(.separator())
let boldItem = NSMenuItem(title: "Bold", action: #selector(boldSelection), keyEquivalent: "b")
boldItem.target = self
menu.addItem(boldItem)
let italicItem = NSMenuItem(title: "Italic", action: #selector(italicizeSelection), keyEquivalent: "i")
italicItem.target = self
menu.addItem(italicItem)
menu.addItem(.separator())
let tableItem = NSMenuItem(title: "Insert Table", action: #selector(insertTable), keyEquivalent: "t")
tableItem.target = self
menu.addItem(tableItem)
let evalItem = NSMenuItem(title: "Smart Eval", action: #selector(smartEval), keyEquivalent: "e")
evalItem.target = self
menu.addItem(evalItem)
menu.addItem(.separator())
let findItem = NSMenuItem(title: "Find...", action: #selector(NSTextView.performFindPanelAction(_:)), keyEquivalent: "f")
findItem.tag = Int(NSTextFinder.Action.showFindInterface.rawValue)
menu.addItem(findItem)
menu.addItem(.separator())
let formatItem = NSMenuItem(title: "Format Document", action: #selector(formatDocument), keyEquivalent: "F")
formatItem.keyEquivalentModifierMask = [.command, .shift]
formatItem.target = self
menu.addItem(formatItem)
item.submenu = menu
return item
}
private func buildRenderMenu() -> NSMenuItem {
let item = NSMenuItem()
let menu = NSMenu(title: "Render")
let modesHeader = NSMenuItem(title: "Modes", action: nil, keyEquivalent: "")
modesHeader.isEnabled = false
menu.addItem(modesHeader)
let liveItem = NSMenuItem(title: "Live", action: #selector(setLiveMode), keyEquivalent: "")
liveItem.target = self
menu.addItem(liveItem)
let editorItem = NSMenuItem(title: "Editor", action: #selector(setEditorMode), keyEquivalent: "")
editorItem.target = self
menu.addItem(editorItem)
let viewItem = NSMenuItem(title: "View", action: #selector(setViewMode), keyEquivalent: "")
viewItem.target = self
menu.addItem(viewItem)
item.submenu = menu
return item
}
private func buildViewMenu() -> NSMenuItem {
let item = NSMenuItem()
let menu = NSMenu(title: "View")
let toggleItem = NSMenuItem(title: "Document Browser", action: #selector(toggleBrowser), keyEquivalent: "b")
toggleItem.keyEquivalentModifierMask = .control
toggleItem.target = self
menu.addItem(toggleItem)
menu.addItem(.separator())
let zoomInItem = NSMenuItem(title: "Zoom In", action: #selector(zoomIn), keyEquivalent: "=")
zoomInItem.target = self
menu.addItem(zoomInItem)
let zoomOutItem = NSMenuItem(title: "Zoom Out", action: #selector(zoomOut), keyEquivalent: "-")
zoomOutItem.target = self
menu.addItem(zoomOutItem)
let actualSizeItem = NSMenuItem(title: "Actual Size", action: #selector(zoomReset), keyEquivalent: "0")
actualSizeItem.target = self
menu.addItem(actualSizeItem)
item.submenu = menu
return item
}
private func buildWindowMenu() -> NSMenuItem {
let item = NSMenuItem()
let menu = NSMenu(title: "Window")
menu.addItem(withTitle: "Minimize", action: #selector(NSWindow.miniaturize(_:)), keyEquivalent: "m")
menu.addItem(withTitle: "Zoom", action: #selector(NSWindow.zoom(_:)), keyEquivalent: "")
item.submenu = menu
NSApp.windowsMenu = menu
return item
}
// MARK: - Actions
@objc private func newNote() {
appState.newNote()
}
@objc private func newWindow() {
let state = AppState()
let viewport = IcedViewportView(frame: NSRect(x: 0, y: 0, width: 1200, height: 800))
viewport.autoresizingMask = [.width, .height]
let win = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 1200, height: 800),
styleMask: [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView],
backing: .buffered,
defer: false
)
win.isReleasedWhenClosed = false
win.titlebarAppearsTransparent = true
win.titleVisibility = .hidden
win.backgroundColor = Theme.current.base
win.title = "Acord"
win.contentView = viewport
win.center()
win.makeKeyAndOrderFront(nil)
let controller = WindowController(window: win, appState: state)
windowControllers.append(controller)
}
@objc private func openStorageDirectory() {
let dir = ConfigManager.shared.autoSaveDirectory
let url = URL(fileURLWithPath: dir, isDirectory: true)
NSWorkspace.shared.open(url)
}
@objc private func boldSelection() {
viewport?.sendCommand(1)
}
@objc private func italicizeSelection() {
viewport?.sendCommand(2)
}
@objc private func insertTable() {
viewport?.sendCommand(3)
}
@objc private func smartEval() {
viewport?.sendCommand(4)
}
@objc private func openNote() {
let panel = NSOpenPanel()
panel.allowedContentTypes = Self.supportedContentTypes
panel.canChooseFiles = true
panel.canChooseDirectories = false
panel.allowsMultipleSelection = false
panel.beginSheetModal(for: window) { [weak self] response in
guard response == .OK, let url = panel.url else { return }
self?.appState.loadNoteFromFile(url)
}
}
@objc private func saveNote() {
syncTextFromViewport()
if appState.currentFileURL != nil {
appState.saveNote()
} else {
saveNoteAs()
}
}
@objc private func saveNoteAs() {
syncTextFromViewport()
let panel = NSSavePanel()
panel.allowedContentTypes = Self.supportedContentTypes
panel.nameFieldStringValue = defaultFilename()
if let url = appState.currentFileURL {
panel.directoryURL = url.deletingLastPathComponent()
panel.nameFieldStringValue = url.lastPathComponent
}
panel.beginSheetModal(for: window) { [weak self] response in
guard response == .OK, let url = panel.url else { return }
self?.appState.saveNoteToFile(url)
}
}
@objc private func exportCrate() {
syncTextFromViewport()
guard let w = window, let vp = w.contentView as? IcedViewportView,
let handle = vp.viewportHandle else { return }
let panel = NSSavePanel()
panel.title = "Export as Rust Library"
panel.message = "Choose a location and name for your exported crate"
panel.prompt = "Export"
panel.nameFieldLabel = "Crate name:"
panel.nameFieldStringValue = defaultCrateName()
panel.canCreateDirectories = true
panel.beginSheetModal(for: w) { response in
guard response == .OK, let url = panel.url else { return }
let parentDir = url.deletingLastPathComponent().path
let name = url.lastPathComponent
parentDir.withCString { pd in
name.withCString { n in
if let cstr = viewport_export_crate(handle, pd, n) {
let resultPath = String(cString: cstr)
viewport_free_string(cstr)
self.notifyExportComplete(at: resultPath)
} else {
self.notifyExportFailed()
}
}
}
}
}
private func defaultCrateName() -> String {
let firstLine = appState.documentText
.components(separatedBy: "\n").first?
.trimmingCharacters(in: .whitespaces) ?? ""
let stripped = firstLine.replacingOccurrences(
of: "^#+\\s*", with: "", options: .regularExpression
)
let words = stripped.split(separator: " ").prefix(2).joined(separator: "-")
let sanitized = words.lowercased()
.map { $0.isLetter || $0.isNumber || $0 == "-" ? String($0) : "" }.joined()
return sanitized.isEmpty ? "my-note" : sanitized
}
private func notifyExportComplete(at path: String) {
let alert = NSAlert()
alert.messageText = "Export complete"
alert.informativeText = "Crate written to:\n\(path)\n\nCheck the README for build and install instructions."
alert.addButton(withTitle: "Reveal in Finder")
alert.addButton(withTitle: "OK")
if alert.runModal() == .alertFirstButtonReturn {
NSWorkspace.shared.activateFileViewerSelecting([URL(fileURLWithPath: path)])
}
}
private func notifyExportFailed() {
let alert = NSAlert()
alert.messageText = "Export failed"
alert.informativeText = "Could not export the note. Check the folder permissions and that the crate name doesn't collide with an existing folder."
alert.addButton(withTitle: "OK")
alert.runModal()
}
private func defaultFilename() -> String {
if let url = appState.currentFileURL {
return url.lastPathComponent
}
let firstLine = appState.documentText
.components(separatedBy: "\n").first?
.trimmingCharacters(in: .whitespaces) ?? ""
let stripped = firstLine.replacingOccurrences(
of: "^#+\\s*", with: "", options: .regularExpression
)
let trimmed = stripped.trimmingCharacters(in: .whitespaces)
let ext = extensionForFormat(appState.currentFileFormat)
guard !trimmed.isEmpty, trimmed != "Untitled" else { return "note.\(ext)" }
let sanitized = trimmed.map { "/:\\\\".contains($0) ? "-" : String($0) }.joined()
return sanitized.prefix(80) + ".\(ext)"
}
private func extensionForFormat(_ format: FileFormat) -> String {
switch format {
case .markdown: return "md"
case .csv: return "csv"
case .json: return "json"
case .toml: return "toml"
case .yaml: return "yaml"
case .xml: return "xml"
case .svg: return "svg"
case .rust: return "rs"
case .c: return "c"
case .cpp: return "cpp"
case .objc: return "m"
case .javascript: return "js"
case .typescript: return "ts"
case .jsx: return "jsx"
case .tsx: return "tsx"
case .html: return "html"
case .css: return "css"
case .scss: return "scss"
case .less: return "less"
case .python: return "py"
case .go: return "go"
case .ruby: return "rb"
case .php: return "php"
case .lua: return "lua"
case .shell: return "sh"
case .java: return "java"
case .kotlin: return "kt"
case .swift: return "swift"
case .zig: return "zig"
case .sql: return "sql"
case .makefile: return "mk"
case .dockerfile: return "Dockerfile"
case .config: return "conf"
case .lock: return "lock"
case .plainText, .unknown: return "txt"
}
}
private static let supportedContentTypes: [UTType] = {
let extensions = [
"md", "markdown", "mdown",
"csv", "json", "toml", "yaml", "yml", "xml", "svg",
"rs", "c", "cpp", "cc", "cxx", "h", "hpp", "hxx",
"js", "jsx", "ts", "tsx",
"html", "htm", "css", "scss", "less",
"py", "go", "rb", "php", "lua",
"sh", "bash", "zsh", "fish",
"java", "kt", "kts", "swift", "zig", "sql",
"mk", "ini", "cfg", "conf", "env",
"lock", "txt", "text", "log"
]
var types: [UTType] = [.plainText]
for ext in extensions {
if let t = UTType(filenameExtension: ext) {
types.append(t)
}
}
return Array(Set(types))
}()
func validateMenuItem(_ menuItem: NSMenuItem) -> Bool {
let mode = viewport?.renderMode() ?? 0
switch menuItem.action {
case #selector(setLiveMode):
menuItem.state = mode == 0 ? .on : .off
case #selector(setEditorMode):
menuItem.state = mode == 1 ? .on : .off
case #selector(setViewMode):
menuItem.state = mode == 2 ? .on : .off
default:
break
}
return true
}
@objc private func setLiveMode() {
viewport?.sendCommand(11)
}
@objc private func setEditorMode() {
viewport?.sendCommand(12)
}
@objc private func setViewMode() {
viewport?.sendCommand(13)
}
@objc private func formatDocument() {
viewport?.sendCommand(10)
}
@objc private func openSettings() {
SettingsWindowController.show()
}
@objc private func settingsDidChange() {
window.backgroundColor = Theme.current.base
syncThemeToViewport()
window.contentView?.needsDisplay = true
}
private func syncThemeToViewport() {
let mode = ConfigManager.shared.themeMode
let name: String
switch mode {
case "dark": name = "mocha"
case "light": name = "latte"
default:
let appearance = NSApp.effectiveAppearance
let isDark = appearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua
name = isDark ? "mocha" : "latte"
}
viewport?.setTheme(name)
}
@objc private func toggleBrowser() {
DocumentBrowserController.shared?.toggle()
}
@objc private func zoomIn() {
if let browser = DocumentBrowserController.shared, browser.window.isKeyWindow {
browser.browserState.scaleUp()
return
}
ConfigManager.shared.zoomLevel += 1
NotificationCenter.default.post(name: .settingsChanged, object: nil)
}
@objc private func zoomOut() {
if let browser = DocumentBrowserController.shared, browser.window.isKeyWindow {
browser.browserState.scaleDown()
return
}
let current = ConfigManager.shared.zoomLevel
if 11 + current > 8 {
ConfigManager.shared.zoomLevel -= 1
NotificationCenter.default.post(name: .settingsChanged, object: nil)
}
}
@objc private func zoomReset() {
ConfigManager.shared.zoomLevel = 0
NotificationCenter.default.post(name: .settingsChanged, object: nil)
}
private func setupTitleBar() {
let accessory = TitleBarAccessoryController()
window.addTitlebarAccessoryViewController(accessory)
let tbv = accessory.titleView
tbv.onCommit = { [weak self] rawTitle in
guard let self = self else { return }
// Only drop the document's first line if it actually IS a title
// (starts with `#`). Normalize whatever the user typed in the
// title bar to a `# ` prefix so the saved markdown is valid.
let trimmed = rawTitle.trimmingCharacters(in: .whitespaces)
let normalizedTitle: String
if trimmed.isEmpty {
normalizedTitle = ""
} else if trimmed.hasPrefix("#") {
normalizedTitle = trimmed
} else {
normalizedTitle = "# " + trimmed
}
let lines = self.appState.documentText.components(separatedBy: "\n")
let firstIsTitle = lines.first
.map { $0.trimmingCharacters(in: .whitespaces).hasPrefix("#") }
?? false
let body: [String] = firstIsTitle ? Array(lines.dropFirst()) : lines
let newLines: [String]
if normalizedTitle.isEmpty {
newLines = body
} else {
newLines = [normalizedTitle] + body
}
self.appState.documentText = newLines.joined(separator: "\n")
}
titleBarView = tbv
focusTitleObserver = NotificationCenter.default.addObserver(
forName: .focusTitle, object: nil, queue: .main
) { [weak self] _ in
self?.titleBarView?.beginEditing()
}
}
private func observeDocumentText() {
textCancellable = appState.$documentText
.receive(on: RunLoop.main)
.sink { [weak self] text in
guard let self = self, let vp = self.viewport else { return }
// Idempotent: when the sync timer pulls text FROM the
// viewport and assigns it to `documentText`, this sink
// fires again and would push the identical text back in
// and `vp.setText` rebuilds viewport state, clearing eval
// results. Skip the round-trip when vp already has it.
if vp.getText() == text { return }
vp.setText(text)
}
}
private func syncTextFromViewport() {
guard let w = window, let vp = w.contentView as? IcedViewportView else { return }
let text = vp.getText()
if !text.isEmpty || appState.documentText.isEmpty {
appState.documentText = text
}
}
/// 100ms autosave loop. Reads straight from the viewport and writes a
/// file in the notes directory no Combine publishers, no `setText`,
/// no viewport-state rebuilds. The existing explicit flows (Cmd+S,
/// note switch, quit) still route through `syncTextFromViewport` so
/// `appState.documentText` stays current when Swift actually needs it.
private func startAutosaveTimer() {
autosaveTimer?.invalidate()
autosaveTimer = Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { [weak self] _ in
self?.persistViewportToNotesDir()
}
}
private func persistViewportToNotesDir() {
guard let w = window, let vp = w.contentView as? IcedViewportView else { return }
let text = vp.getText()
guard !AppState.isEffectivelyBlank(text) else { return }
appState.writeAutosavedCopy(text: text)
}
private func observeDocumentTitle() {
titleCancellable = appState.$documentText
.receive(on: RunLoop.main)
.sink { [weak self] text in
guard let self = self else { return }
let firstLine = text.components(separatedBy: "\n").first?
.trimmingCharacters(in: .whitespaces) ?? ""
let clean = firstLine.replacingOccurrences(
of: "^#+\\s*", with: "", options: .regularExpression
)
let displayTitle = clean.isEmpty ? "Acord" : String(clean.prefix(60))
self.window.title = displayTitle
self.titleBarView?.title = firstLine
}
}
}

537
src/AppState.swift Normal file
View File

@ -0,0 +1,537 @@
import Foundation
import Combine
enum FileFormat: String, CaseIterable {
case markdown, csv, json, toml, yaml, xml, svg
case rust, c, cpp, objc
case javascript, typescript, jsx, tsx
case html, css, scss, less
case python, go, ruby, php, lua
case shell, java, kotlin, swift, zig, sql
case makefile, dockerfile
case config, lock, plainText
case unknown
static func from(extension ext: String) -> FileFormat {
switch ext.lowercased() {
case "md", "markdown", "mdown": return .markdown
case "csv": return .csv
case "json": return .json
case "toml": return .toml
case "yaml", "yml": return .yaml
case "xml": return .xml
case "svg": return .svg
case "rs": return .rust
case "c": return .c
case "cpp", "cc", "cxx": return .cpp
case "h", "hpp", "hxx": return .cpp
case "m": return .objc
case "js": return .javascript
case "jsx": return .jsx
case "ts": return .typescript
case "tsx": return .tsx
case "html", "htm": return .html
case "css": return .css
case "scss": return .scss
case "less": return .less
case "py": return .python
case "go": return .go
case "rb": return .ruby
case "php": return .php
case "lua": return .lua
case "sh", "bash", "zsh", "fish": return .shell
case "java": return .java
case "kt", "kts": return .kotlin
case "swift": return .swift
case "zig": return .zig
case "sql": return .sql
case "mk": return .makefile
case "ini", "cfg", "conf", "env": return .config
case "lock": return .lock
case "txt", "text", "log": return .plainText
default: return .unknown
}
}
static func from(filename: String) -> FileFormat {
let lower = filename.lowercased()
if lower == "makefile" { return .makefile }
if lower == "dockerfile" { return .dockerfile }
let ext = (filename as NSString).pathExtension
if ext.isEmpty { return .unknown }
return from(extension: ext)
}
var isCode: Bool {
switch self {
case .rust, .c, .cpp, .objc, .javascript, .typescript, .jsx, .tsx,
.html, .css, .scss, .less, .python, .go, .ruby, .php, .lua,
.shell, .java, .kotlin, .swift, .zig, .sql, .makefile, .dockerfile,
.json, .toml, .yaml, .xml, .svg:
return true
default:
return false
}
}
var isMarkdown: Bool { self == .markdown }
var isCSV: Bool { self == .csv }
var treeSitterLang: String? {
switch self {
case .rust: return "rust"
case .c: return "c"
case .cpp: return "cpp"
case .javascript: return "javascript"
case .jsx: return "jsx"
case .typescript: return "typescript"
case .tsx: return "tsx"
case .python: return "python"
case .go: return "go"
case .ruby: return "ruby"
case .php: return "php"
case .lua: return "lua"
case .shell: return "bash"
case .java: return "java"
case .kotlin: return "kotlin"
case .swift: return "swift"
case .zig: return "zig"
case .sql: return "sql"
case .html: return "html"
case .css, .scss, .less: return "css"
case .json: return "json"
case .toml: return "toml"
case .yaml: return "yaml"
case .makefile: return "make"
case .dockerfile: return "dockerfile"
default: return nil
}
}
}
class AppState: ObservableObject {
@Published var documentText: String = "" {
didSet {
if documentText != oldValue {
modified = true
bridge.setText(currentNoteID, text: documentText)
scheduleAutoSave()
}
}
}
@Published var evalResults: [Int: EvalEntry] = [:]
@Published var noteList: [NoteInfo] = []
@Published var currentNoteID: UUID
@Published var selectedNoteIDs: Set<UUID> = []
@Published var modified: Bool = false
@Published var currentFileURL: URL? = nil
@Published var currentFileFormat: FileFormat = .markdown
private let bridge = RustBridge.shared
private var autoSaveTimer: DispatchSourceTimer?
private var autoSaveDirty = false
private var autoSaveCoolingDown = false
private let autoSaveQueue = DispatchQueue(label: "com.acord.autosave")
init() {
let id = bridge.newDocument()
self.currentNoteID = id
self.selectedNoteIDs = [id]
refreshNoteList()
}
// MARK: - Auto-save
private func scheduleAutoSave() {
if autoSaveCoolingDown {
autoSaveDirty = true
return
}
performAutoSave()
}
private func performAutoSave() {
guard shouldAutoSave() else { return }
autoSaveCoolingDown = true
autoSaveDirty = false
let text = documentText
let noteID = currentNoteID
let title = extractTitle(from: text)
autoSaveQueue.async { [weak self] in
self?.writeAutoSaveFile(noteID: noteID, title: title, text: text)
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in
guard let self = self else { return }
self.autoSaveCoolingDown = false
if self.autoSaveDirty {
self.autoSaveDirty = false
self.performAutoSave()
}
}
}
bridge.setText(currentNoteID, text: documentText)
let _ = bridge.cacheSave(currentNoteID)
modified = false
refreshNoteList()
}
private func shouldAutoSave() -> Bool {
// Autosave only when the note has real user content. A freshly-
// created doc that picked up the default `Header 1 | Header 2 |
// Header 3` table from Cmd+T without the user typing anything
// still reads as "blank" by this check that's what stops the
// ~/.acord/notes directory from accumulating `{uuid}.md` phantoms.
//
// Explicit saves (Cmd+S `saveNote`) skip this gate, so a user
// who genuinely wants to keep a note with only an empty table
// can still force it.
!AppState.isEffectivelyBlank(documentText)
}
/// Shared blank-detection used by both the autosave gate and (via its
/// `static` form) the browser's `(empty note)` preview label. A note
/// is "blank" when, after the `<!-- acord-archive -->` sidecar is
/// stripped, nothing remains except whitespace or default empty-table
/// scaffolding (all-empty cells or the `Header N` placeholder row).
static func isEffectivelyBlank(_ text: String) -> Bool {
let body = stripSidecarArchive(text)
let trimmed = body.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.isEmpty { return true }
let meaningful = trimmed.components(separatedBy: "\n").filter { line in
let t = line.trimmingCharacters(in: .whitespaces)
if t.isEmpty { return false }
if !t.hasPrefix("|") { return true }
let cells = t
.trimmingCharacters(in: CharacterSet(charactersIn: "|"))
.components(separatedBy: "|")
.map { $0.trimmingCharacters(in: .whitespaces) }
if cells.allSatisfy({ !$0.isEmpty && $0.allSatisfy { "-:".contains($0) } }) {
return false
}
let isDefaultHeader = cells.enumerated().allSatisfy { (i, cell) in
cell == "Header \(i + 1)"
}
if cells.allSatisfy({ $0.isEmpty }) || isDefaultHeader {
return false
}
return true
}
return meaningful.isEmpty
}
private static func stripSidecarArchive(_ text: String) -> String {
guard let marker = text.range(of: "<!-- acord-archive") else { return text }
return String(text[..<marker.lowerBound])
}
private func extractTitle(from text: String) -> String {
let firstLine = text.components(separatedBy: "\n").first?
.trimmingCharacters(in: .whitespaces) ?? ""
let clean = firstLine.replacingOccurrences(
of: "^#+\\s*", with: "", options: .regularExpression
)
return clean.isEmpty ? "Untitled" : String(clean.prefix(60))
}
private func sanitizeFilename(_ name: String) -> String {
let illegal = CharacterSet(charactersIn: "/\\:*?\"<>|")
let parts = name.unicodeScalars.filter { !illegal.contains($0) }
let cleaned = String(String.UnicodeScalarView(parts))
.trimmingCharacters(in: .whitespaces)
return cleaned.isEmpty ? UUID().uuidString : cleaned
}
private func writeAutoSaveFile(noteID: UUID, title: String, text: String) {
let dir = ConfigManager.shared.autoSaveDirectory
let dirURL = URL(fileURLWithPath: dir)
try? FileManager.default.createDirectory(at: dirURL, withIntermediateDirectories: true)
let filename: String
if title == "Untitled" {
filename = noteID.uuidString.lowercased()
} else {
filename = sanitizeFilename(title)
}
let fileURL = dirURL.appendingPathComponent(filename + ".md")
try? text.write(to: fileURL, atomically: true, encoding: .utf8)
}
// MARK: - Note operations
func newNote() {
saveCurrentIfNeeded()
cleanupBlankNote(currentNoteID)
let id = bridge.newDocument()
currentNoteID = id
selectedNoteIDs = [id]
documentText = ""
evalResults = [:]
modified = false
currentFileURL = nil
currentFileFormat = .markdown
refreshNoteList()
}
func selectNote(_ id: UUID, extend: Bool = false, range: Bool = false) {
if range, let anchor = selectedNoteIDs.first {
guard let anchorIdx = noteList.firstIndex(where: { $0.id == anchor }),
let targetIdx = noteList.firstIndex(where: { $0.id == id }) else {
selectedNoteIDs = [id]
return
}
let lo = min(anchorIdx, targetIdx)
let hi = max(anchorIdx, targetIdx)
selectedNoteIDs = Set(noteList[lo...hi].map(\.id))
} else if extend {
if selectedNoteIDs.contains(id) {
selectedNoteIDs.remove(id)
} else {
selectedNoteIDs.insert(id)
}
} else {
selectedNoteIDs = [id]
}
}
func openNote(_ id: UUID) {
saveCurrentIfNeeded()
cleanupBlankNote(currentNoteID)
if bridge.cacheLoad(id) {
currentNoteID = id
selectedNoteIDs = [id]
documentText = bridge.getText(id)
modified = false
evaluate()
}
}
func loadNote(_ id: UUID) {
openNote(id)
}
func saveNote() {
let textToSave: String
if currentFileFormat.isCSV {
textToSave = markdownTableToCSV(documentText)
} else {
textToSave = documentText
}
bridge.setText(currentNoteID, text: textToSave)
if let url = currentFileURL {
try? textToSave.write(to: url, atomically: true, encoding: .utf8)
}
let _ = bridge.cacheSave(currentNoteID)
modified = false
refreshNoteList()
}
func saveNoteToFile(_ url: URL) {
let format = FileFormat.from(filename: url.lastPathComponent)
let textToSave: String
if format.isCSV {
textToSave = markdownTableToCSV(documentText)
} else {
textToSave = documentText
}
try? textToSave.write(to: url, atomically: true, encoding: .utf8)
currentFileURL = url
currentFileFormat = format
modified = false
}
func loadNoteFromFile(_ url: URL) {
let format = FileFormat.from(filename: url.lastPathComponent)
if let (id, text) = bridge.loadNote(path: url.path) {
currentNoteID = id
currentFileURL = url
currentFileFormat = format
if format.isCSV {
documentText = csvToMarkdownTable(text)
} else {
documentText = text
}
modified = false
let _ = bridge.cacheSave(id)
evaluate()
refreshNoteList()
}
}
// MARK: - CSV conversion
private func csvToMarkdownTable(_ csv: String) -> String {
let rows = parseCSVRows(csv)
guard let header = rows.first, !header.isEmpty else { return csv }
var lines: [String] = []
lines.append("| " + header.joined(separator: " | ") + " |")
lines.append("| " + header.map { _ in "---" }.joined(separator: " | ") + " |")
for row in rows.dropFirst() {
var cells = row
while cells.count < header.count { cells.append("") }
lines.append("| " + cells.prefix(header.count).joined(separator: " | ") + " |")
}
return lines.joined(separator: "\n")
}
private func markdownTableToCSV(_ markdown: String) -> String {
let lines = markdown.components(separatedBy: "\n").filter { !$0.isEmpty }
var csvRows: [String] = []
for line in lines {
let trimmed = line.trimmingCharacters(in: .whitespaces)
guard trimmed.hasPrefix("|") else { continue }
if isTableSeparatorLine(trimmed) { continue }
let cells = extractTableCells(trimmed)
csvRows.append(cells.map { escapeCSVField($0) }.joined(separator: ","))
}
return csvRows.joined(separator: "\n") + "\n"
}
private func parseCSVRows(_ csv: String) -> [[String]] {
var rows: [[String]] = []
var current: [String] = []
var field = ""
var inQuotes = false
let chars = Array(csv)
var i = 0
while i < chars.count {
let ch = chars[i]
if inQuotes {
if ch == "\"" {
if i + 1 < chars.count && chars[i + 1] == "\"" {
field.append("\"")
i += 2
continue
}
inQuotes = false
} else {
field.append(ch)
}
} else {
if ch == "\"" {
inQuotes = true
} else if ch == "," {
current.append(field.trimmingCharacters(in: .whitespaces))
field = ""
} else if ch == "\n" || ch == "\r" {
current.append(field.trimmingCharacters(in: .whitespaces))
field = ""
if !current.isEmpty {
rows.append(current)
}
current = []
if ch == "\r" && i + 1 < chars.count && chars[i + 1] == "\n" {
i += 1
}
} else {
field.append(ch)
}
}
i += 1
}
if !field.isEmpty || !current.isEmpty {
current.append(field.trimmingCharacters(in: .whitespaces))
rows.append(current)
}
return rows
}
private func isTableSeparatorLine(_ line: String) -> Bool {
let stripped = line.replacingOccurrences(of: " ", with: "")
return stripped.allSatisfy { "|:-".contains($0) } && stripped.contains("-")
}
private func extractTableCells(_ line: String) -> [String] {
var trimmed = line.trimmingCharacters(in: .whitespaces)
if trimmed.hasPrefix("|") { trimmed = String(trimmed.dropFirst()) }
if trimmed.hasSuffix("|") { trimmed = String(trimmed.dropLast()) }
return trimmed.components(separatedBy: "|").map { $0.trimmingCharacters(in: .whitespaces) }
}
private func escapeCSVField(_ field: String) -> String {
if field.contains(",") || field.contains("\"") || field.contains("\n") {
return "\"" + field.replacingOccurrences(of: "\"", with: "\"\"") + "\""
}
return field
}
func deleteNote(_ id: UUID) {
bridge.deleteNote(id)
if id == currentNoteID {
newNote()
}
refreshNoteList()
}
func deleteNotes(_ ids: Set<UUID>) {
for id in ids {
bridge.deleteNote(id)
}
if ids.contains(currentNoteID) {
let remaining = noteList.first { !ids.contains($0.id) }
if let next = remaining {
currentNoteID = next.id
if bridge.cacheLoad(next.id) {
documentText = bridge.getText(next.id)
}
} else {
let id = bridge.newDocument()
currentNoteID = id
documentText = ""
}
evalResults = [:]
modified = false
}
refreshNoteList()
}
func evaluate() {
evalResults = bridge.evaluate(currentNoteID)
}
/// Write a caller-provided text snapshot to the notes directory,
/// bypassing the `documentText` pipeline entirely. Used by the
/// AppDelegate's 100ms autosave timer, which reads text directly
/// from the viewport routing through `documentText.didSet` would
/// trip the Combine `vp.setText` round-trip and wipe viewport
/// state (including visible eval results).
func writeAutosavedCopy(text: String) {
let noteID = currentNoteID
let title = extractTitle(from: text)
autoSaveQueue.async { [weak self] in
self?.writeAutoSaveFile(noteID: noteID, title: title, text: text)
}
}
func refreshNoteList() {
var notes = bridge.listNotes()
notes.removeAll { note in
let trimmed = note.title.trimmingCharacters(in: .whitespacesAndNewlines)
let isBlank = trimmed.isEmpty || trimmed == "Untitled"
return isBlank && note.id != currentNoteID
}
noteList = notes
}
private func saveCurrentIfNeeded() {
if modified {
saveNote()
}
}
private func cleanupBlankNote(_ id: UUID) {
let text = bridge.getText(id)
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.isEmpty {
bridge.deleteNote(id)
}
}
}

60
src/ConfigManager.swift Normal file
View File

@ -0,0 +1,60 @@
import Foundation
class ConfigManager {
static let shared = ConfigManager()
private let configDir: URL
private let configFile: URL
private let defaultNotesDir: URL
private var config: [String: String]
private init() {
let home = FileManager.default.homeDirectoryForCurrentUser
configDir = home.appendingPathComponent(".acord")
configFile = configDir.appendingPathComponent("config.json")
defaultNotesDir = configDir.appendingPathComponent("notes")
config = [:]
ensureDirectories()
load()
}
private func ensureDirectories() {
let fm = FileManager.default
try? fm.createDirectory(at: configDir, withIntermediateDirectories: true)
try? fm.createDirectory(at: defaultNotesDir, withIntermediateDirectories: true)
}
private func load() {
guard let data = try? Data(contentsOf: configFile),
let dict = try? JSONSerialization.jsonObject(with: data) as? [String: String]
else { return }
config = dict
}
private func save() {
guard let data = try? JSONSerialization.data(
withJSONObject: config, options: [.prettyPrinted, .sortedKeys]
) else { return }
try? data.write(to: configFile, options: .atomic)
}
var autoSaveDirectory: String {
get { config["autoSaveDirectory"] ?? defaultNotesDir.path }
set { config["autoSaveDirectory"] = newValue; save() }
}
var themeMode: String {
get { config["themeMode"] ?? "auto" }
set { config["themeMode"] = newValue; save() }
}
var lineIndicatorMode: String {
get { config["lineIndicatorMode"] ?? "on" }
set { config["lineIndicatorMode"] = newValue; save() }
}
var zoomLevel: CGFloat {
get { CGFloat(Double(config["zoomLevel"] ?? "0") ?? 0) }
set { config["zoomLevel"] = String(Double(newValue)); save() }
}
}

View File

@ -0,0 +1,520 @@
import Cocoa
import SwiftUI
import Combine
import UniformTypeIdentifiers
// MARK: - Model
enum BrowserItemKind {
case file
case folder
}
struct BrowserItem: Identifiable, Hashable {
let id: String
let url: URL
let name: String
let kind: BrowserItemKind
let modified: Date
var preview: String
static func == (lhs: BrowserItem, rhs: BrowserItem) -> Bool {
lhs.url == rhs.url
}
func hash(into hasher: inout Hasher) {
hasher.combine(url)
}
}
// MARK: - Controller
class DocumentBrowserController {
static var shared: DocumentBrowserController?
let window: NSWindow
let browserState: BrowserState
private let hostingView: NSHostingView<DocumentBrowserView>
init(appState: AppState) {
browserState = BrowserState(appState: appState)
let view = DocumentBrowserView(state: browserState)
hostingView = NSHostingView(rootView: view)
window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 800, height: 600),
styleMask: [.titled, .closable, .miniaturizable, .resizable],
backing: .buffered,
defer: false
)
window.title = "Documents"
window.backgroundColor = Theme.current.base
window.contentView = hostingView
window.setFrameAutosaveName("AcordBrowser")
window.center()
window.isReleasedWhenClosed = false
}
func toggle() {
if window.isVisible {
window.orderOut(nil)
} else {
browserState.refresh()
window.makeKeyAndOrderFront(nil)
}
}
}
// MARK: - State
class BrowserState: ObservableObject {
@Published var items: [BrowserItem] = []
@Published var cardScale: CGFloat = 1.0
@Published var selectedURL: URL?
@Published var currentPath: URL
let appState: AppState
private let fm = FileManager.default
private static let supportedExtensions: Set<String> = ["md", "txt", "markdown", "mdown"]
var rootPath: URL {
URL(fileURLWithPath: ConfigManager.shared.autoSaveDirectory)
}
var pathSegments: [(name: String, url: URL)] {
var segments: [(String, URL)] = []
var path = currentPath.standardizedFileURL
let root = rootPath.standardizedFileURL
while path != root && path.path.hasPrefix(root.path) {
segments.insert((path.lastPathComponent, path), at: 0)
path = path.deletingLastPathComponent().standardizedFileURL
}
segments.insert(("Documents", root), at: 0)
return segments
}
init(appState: AppState) {
self.appState = appState
self.currentPath = URL(fileURLWithPath: ConfigManager.shared.autoSaveDirectory)
refresh()
}
func refresh() {
items = scanDirectory(currentPath)
}
func navigate(to url: URL) {
currentPath = url
selectedURL = nil
refresh()
}
private func scanDirectory(_ dir: URL) -> [BrowserItem] {
guard let contents = try? fm.contentsOfDirectory(
at: dir,
includingPropertiesForKeys: [.contentModificationDateKey, .isDirectoryKey],
options: [.skipsHiddenFiles]
) else { return [] }
var folders: [BrowserItem] = []
var files: [BrowserItem] = []
for url in contents {
guard let values = try? url.resourceValues(forKeys: [.isDirectoryKey, .contentModificationDateKey]) else { continue }
let mtime = values.contentModificationDate ?? .distantPast
if values.isDirectory == true {
folders.append(BrowserItem(
id: url.path,
url: url,
name: url.lastPathComponent,
kind: .folder,
modified: mtime,
preview: folderSummary(url)
))
} else {
let ext = url.pathExtension.lowercased()
guard Self.supportedExtensions.contains(ext) else { continue }
files.append(BrowserItem(
id: url.path,
url: url,
name: url.deletingPathExtension().lastPathComponent,
kind: .file,
modified: mtime,
preview: filePreview(url)
))
}
}
folders.sort { $0.modified > $1.modified }
files.sort { $0.modified > $1.modified }
return folders + files
}
private func filePreview(_ url: URL) -> String {
guard let data = try? Data(contentsOf: url, options: .mappedIfSafe),
let text = String(data: data, encoding: .utf8) else { return "" }
let body = Self.stripSidecarArchive(text)
if Self.bodyLooksBlank(body) {
return "(empty note)"
}
let lines = body.components(separatedBy: "\n")
return lines.prefix(20).joined(separator: "\n")
}
/// Remove the `<!-- acord-archive -->` base64 sidecar comment before
/// previewing. Without this, phantom notes that were saved with only
/// an empty default table render their archive blob as tile text.
private static func stripSidecarArchive(_ text: String) -> String {
guard let marker = text.range(of: "<!-- acord-archive") else { return text }
return String(text[..<marker.lowerBound])
}
/// `true` when the body contains no real content either all whitespace
/// or nothing but an empty default-header table with no user data. These
/// show up for notes the user opened but never filled in; calling them
/// out as `(empty note)` beats rendering three rows of `| | |`.
private static func bodyLooksBlank(_ body: String) -> Bool {
let trimmed = body.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.isEmpty { return true }
let meaningful = trimmed.components(separatedBy: "\n").filter { line in
let t = line.trimmingCharacters(in: .whitespaces)
if t.isEmpty { return false }
if !t.hasPrefix("|") { return true }
let cells = t
.trimmingCharacters(in: CharacterSet(charactersIn: "|"))
.components(separatedBy: "|")
.map { $0.trimmingCharacters(in: .whitespaces) }
// Separator row: cells are all dashes/colons.
if cells.allSatisfy({ !$0.isEmpty && $0.allSatisfy { "-:".contains($0) } }) {
return false
}
// All cells empty or the default `Header N` placeholder.
let isDefaultHeader = cells.enumerated().allSatisfy { (i, cell) in
cell == "Header \(i + 1)"
}
if cells.allSatisfy({ $0.isEmpty }) || isDefaultHeader {
return false
}
return true
}
return meaningful.isEmpty
}
private func folderSummary(_ url: URL) -> String {
let contents = (try? fm.contentsOfDirectory(
at: url,
includingPropertiesForKeys: [.isDirectoryKey],
options: [.skipsHiddenFiles]
)) ?? []
let fileCount = contents.filter {
Self.supportedExtensions.contains($0.pathExtension.lowercased())
}.count
let folderCount = contents.filter {
(try? $0.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) == true
}.count
var parts: [String] = []
if fileCount > 0 { parts.append("\(fileCount) file\(fileCount == 1 ? "" : "s")") }
if folderCount > 0 { parts.append("\(folderCount) folder\(folderCount == 1 ? "" : "s")") }
return parts.isEmpty ? "Empty" : parts.joined(separator: ", ")
}
// MARK: - Actions
func openFile(_ item: BrowserItem) {
guard item.kind == .file else { return }
appState.loadNoteFromFile(item.url)
DocumentBrowserController.shared?.window.orderOut(nil)
}
func renameItem(_ item: BrowserItem, to newName: String) {
let trimmed = newName.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return }
let ext = item.kind == .file ? "." + item.url.pathExtension : ""
let dest = item.url.deletingLastPathComponent().appendingPathComponent(trimmed + ext)
guard !fm.fileExists(atPath: dest.path) else { return }
try? fm.moveItem(at: item.url, to: dest)
refresh()
}
func duplicateItem(_ item: BrowserItem) {
guard item.kind == .file else { return }
let dir = item.url.deletingLastPathComponent()
let base = item.url.deletingPathExtension().lastPathComponent
let ext = item.url.pathExtension
var n = 1
var dest: URL
repeat {
dest = dir.appendingPathComponent("\(base) \(n).\(ext)")
n += 1
} while fm.fileExists(atPath: dest.path)
try? fm.copyItem(at: item.url, to: dest)
refresh()
}
func trashItem(_ item: BrowserItem) {
try? fm.trashItem(at: item.url, resultingItemURL: nil)
if selectedURL == item.url { selectedURL = nil }
refresh()
}
func revealInFinder(_ item: BrowserItem) {
NSWorkspace.shared.activateFileViewerSelecting([item.url])
}
func createFolder() {
var name = "New Folder"
var n = 1
while fm.fileExists(atPath: currentPath.appendingPathComponent(name).path) {
n += 1
name = "New Folder \(n)"
}
let url = currentPath.appendingPathComponent(name)
try? fm.createDirectory(at: url, withIntermediateDirectories: false)
refresh()
}
func moveItem(_ item: BrowserItem, into folder: BrowserItem) {
guard folder.kind == .folder else { return }
let dest = folder.url.appendingPathComponent(item.url.lastPathComponent)
guard !fm.fileExists(atPath: dest.path) else { return }
try? fm.moveItem(at: item.url, to: dest)
refresh()
}
func scaleUp() {
cardScale = min(cardScale + 0.1, 3.0)
}
func scaleDown() {
cardScale = max(cardScale - 0.1, 0.4)
}
}
// MARK: - Browser View
struct DocumentBrowserView: View {
@ObservedObject var state: BrowserState
var body: some View {
VStack(spacing: 0) {
BreadcrumbBar(state: state)
Divider().background(Color(ns: Theme.current.surface1))
ScrollView {
if state.items.isEmpty {
emptyState
} else {
LazyVGrid(
columns: [GridItem(.adaptive(
minimum: 200 * state.cardScale,
maximum: 400 * state.cardScale
))],
spacing: 16 * state.cardScale
) {
ForEach(state.items) { item in
BrowserCardView(item: item, state: state)
.onDrag {
NSItemProvider(object: item.url as NSURL)
}
}
}
.padding(16 * state.cardScale)
}
}
.background(Color(ns: Theme.current.base))
.contextMenu {
Button("New Folder") { state.createFolder() }
Divider()
Button("Reveal in Finder") {
NSWorkspace.shared.open(state.currentPath)
}
}
}
.background(Color(ns: Theme.current.base))
.frame(minWidth: 400, minHeight: 300)
}
private var emptyState: some View {
VStack(spacing: 8) {
Text("No documents")
.font(.system(size: 16, weight: .medium))
.foregroundColor(Color(ns: Theme.current.subtext0))
Text("Create a new note or add files to this folder")
.font(.system(size: 12))
.foregroundColor(Color(ns: Theme.current.overlay0))
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.padding(.top, 100)
}
}
// MARK: - Breadcrumb Bar
struct BreadcrumbBar: View {
@ObservedObject var state: BrowserState
var body: some View {
HStack(spacing: 4) {
ForEach(Array(state.pathSegments.enumerated()), id: \.offset) { index, segment in
if index > 0 {
Image(systemName: "chevron.right")
.font(.system(size: 9, weight: .semibold))
.foregroundColor(Color(ns: Theme.current.overlay0))
}
Button(action: { state.navigate(to: segment.url) }) {
Text(segment.name)
.font(.system(size: 12, weight: isLast(index) ? .semibold : .regular))
.foregroundColor(Color(ns: isLast(index) ? Theme.current.text : Theme.current.subtext0))
}
.buttonStyle(.plain)
}
Spacer()
}
.padding(.horizontal, 16)
.padding(.vertical, 8)
.background(Color(ns: Theme.current.mantle))
}
private func isLast(_ index: Int) -> Bool {
index == state.pathSegments.count - 1
}
}
// MARK: - Card View
struct BrowserCardView: View {
let item: BrowserItem
@ObservedObject var state: BrowserState
@State private var isRenaming = false
@State private var renameText = ""
@State private var isDropTarget = false
private var isSelected: Bool {
state.selectedURL == item.url
}
var body: some View {
VStack(alignment: .leading, spacing: 6 * state.cardScale) {
previewArea
titleArea
}
.padding(10 * state.cardScale)
.background(
RoundedRectangle(cornerRadius: 8 * state.cardScale)
.fill(Color(ns: isSelected ? Theme.current.surface1 : Theme.current.surface0))
)
.overlay(
RoundedRectangle(cornerRadius: 8 * state.cardScale)
.stroke(
isDropTarget ? Color(ns: Theme.current.green) :
isSelected ? Color(ns: Theme.current.blue) : Color.clear,
lineWidth: 2
)
)
.contentShape(Rectangle())
.onTapGesture(count: 2) {
switch item.kind {
case .folder: state.navigate(to: item.url)
case .file: state.openFile(item)
}
}
.onTapGesture(count: 1) {
state.selectedURL = item.url
}
.contextMenu { contextMenuItems }
.onDrop(of: [.fileURL], isTargeted: item.kind == .folder ? $isDropTarget : .constant(false)) { providers in
guard item.kind == .folder else { return false }
for provider in providers {
provider.loadItem(forTypeIdentifier: UTType.fileURL.identifier, options: nil) { data, _ in
guard let urlData = data as? Data,
let sourceURL = URL(dataRepresentation: urlData, relativeTo: nil) else { return }
DispatchQueue.main.async {
let source = BrowserItem(
id: sourceURL.path, url: sourceURL,
name: sourceURL.lastPathComponent,
kind: .file, modified: .now, preview: ""
)
state.moveItem(source, into: item)
}
}
}
return true
}
}
@ViewBuilder
private var previewArea: some View {
if item.kind == .folder {
HStack(spacing: 8 * state.cardScale) {
Image(systemName: "folder.fill")
.font(.system(size: 28 * state.cardScale))
.foregroundColor(Color(ns: Theme.current.blue))
Text(item.preview)
.font(.system(size: 10 * state.cardScale))
.foregroundColor(Color(ns: Theme.current.subtext0))
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(8 * state.cardScale)
.background(Color(ns: Theme.current.mantle))
.cornerRadius(4 * state.cardScale)
} else {
Text(item.preview)
.font(.system(size: 10 * state.cardScale, design: .monospaced))
.foregroundColor(Color(ns: Theme.current.subtext0))
.lineLimit(nil)
.frame(maxWidth: .infinity, alignment: .topLeading)
.padding(8 * state.cardScale)
.background(Color(ns: Theme.current.mantle))
.cornerRadius(4 * state.cardScale)
}
}
@ViewBuilder
private var titleArea: some View {
if isRenaming {
TextField("Name", text: $renameText, onCommit: {
state.renameItem(item, to: renameText)
isRenaming = false
})
.textFieldStyle(.plain)
.font(.system(size: 12 * state.cardScale, weight: .semibold))
.foregroundColor(Color(ns: Theme.current.text))
.padding(.horizontal, 4)
} else {
Text(item.name)
.font(.system(size: 12 * state.cardScale, weight: .semibold))
.foregroundColor(Color(ns: Theme.current.text))
.lineLimit(2)
.padding(.horizontal, 4)
}
}
@ViewBuilder
private var contextMenuItems: some View {
switch item.kind {
case .file:
Button("Open") { state.openFile(item) }
Button("Rename") {
renameText = item.name
isRenaming = true
}
Button("Duplicate") { state.duplicateItem(item) }
Divider()
Button("Move to Trash") { state.trashItem(item) }
Divider()
Button("Reveal in Finder") { state.revealInFinder(item) }
case .folder:
Button("Open") { state.navigate(to: item.url) }
Button("Rename") {
renameText = item.name
isRenaming = true
}
Divider()
Button("Move to Trash") { state.trashItem(item) }
Divider()
Button("Reveal in Finder") { state.revealInFinder(item) }
}
}
}

261
src/IcedViewportView.swift Normal file
View File

@ -0,0 +1,261 @@
import AppKit
import SwiftUI
class IcedViewportView: NSView {
private(set) var viewportHandle: OpaquePointer?
private var displayLink: CVDisplayLink?
private var isTornDown = false
// Last text pulled out of Rust. Refreshed on every edit tick via the
// render loop, so terminate/save paths can read a current-enough value
// without touching the viewport once teardown has begun.
private var cachedText: String = ""
override init(frame frameRect: NSRect) {
super.init(frame: frameRect)
wantsLayer = true
}
required init?(coder: NSCoder) {
super.init(coder: coder)
wantsLayer = true
}
override var isFlipped: Bool { true }
override var wantsUpdateLayer: Bool { true }
override var acceptsFirstResponder: Bool { true }
// MARK: - Lifecycle
override func viewDidMoveToWindow() {
super.viewDidMoveToWindow()
if window != nil && viewportHandle == nil && !isTornDown {
createViewport()
startDisplayLink()
window?.makeFirstResponder(self)
} else if window == nil {
teardown()
}
}
private func createViewport() {
let scale = Float(window?.backingScaleFactor ?? 2.0)
let w = Float(bounds.width)
let h = Float(bounds.height)
let nsviewPtr = Unmanaged.passUnretained(self).toOpaque()
viewportHandle = viewport_create(nsviewPtr, w, h, scale)
}
private func destroyViewport() {
guard let handle = viewportHandle else { return }
viewportHandle = nil
viewport_destroy(handle)
}
/// Ordered shutdown: stop the display link first (joins in-flight CV
/// callbacks), then snapshot the text into `cachedText`, then drop the
/// Rust handle. Idempotent safe to call from both the terminate hook
/// and `deinit`.
func teardown() {
if isTornDown { return }
isTornDown = true
stopDisplayLink()
if let h = viewportHandle, let cstr = viewport_get_text(h) {
cachedText = String(cString: cstr)
viewport_free_string(cstr)
}
destroyViewport()
}
deinit {
teardown()
}
// MARK: - Display Link
private func startDisplayLink() {
guard displayLink == nil else { return }
var link: CVDisplayLink?
CVDisplayLinkCreateWithActiveCGDisplays(&link)
guard let link = link else { return }
let selfPtr = UnsafeMutableRawPointer(Unmanaged.passUnretained(self).toOpaque())
CVDisplayLinkSetOutputCallback(link, { _, _, _, _, _, userInfo -> CVReturn in
guard let userInfo = userInfo else { return kCVReturnSuccess }
let view = Unmanaged<IcedViewportView>.fromOpaque(userInfo).takeUnretainedValue()
DispatchQueue.main.async {
view.renderFrame()
}
return kCVReturnSuccess
}, selfPtr)
CVDisplayLinkStart(link)
displayLink = link
}
private func stopDisplayLink() {
guard let link = displayLink else { return }
CVDisplayLinkStop(link)
displayLink = nil
}
private func renderFrame() {
if isTornDown { return }
guard let handle = viewportHandle else { return }
viewport_render(handle)
}
// MARK: - Resize
override func setFrameSize(_ newSize: NSSize) {
super.setFrameSize(newSize)
resizeViewport()
}
override func setBoundsSize(_ newSize: NSSize) {
super.setBoundsSize(newSize)
resizeViewport()
}
private func resizeViewport() {
guard let handle = viewportHandle else { return }
let scale = Float(window?.backingScaleFactor ?? 2.0)
viewport_resize(handle, Float(bounds.width), Float(bounds.height), scale)
}
// MARK: - Mouse Events
override func mouseDown(with event: NSEvent) {
window?.makeFirstResponder(self)
guard let h = viewportHandle else { return }
let pt = convert(event.locationInWindow, from: nil)
viewport_mouse_event(h, Float(pt.x), Float(pt.y), 0, true)
}
override func mouseUp(with event: NSEvent) {
guard let h = viewportHandle else { return }
let pt = convert(event.locationInWindow, from: nil)
viewport_mouse_event(h, Float(pt.x), Float(pt.y), 0, false)
}
override func mouseMoved(with event: NSEvent) {
guard let h = viewportHandle else { return }
let pt = convert(event.locationInWindow, from: nil)
viewport_mouse_event(h, Float(pt.x), Float(pt.y), 255, false)
}
override func mouseDragged(with event: NSEvent) {
guard let h = viewportHandle else { return }
let pt = convert(event.locationInWindow, from: nil)
// Use the 255 sentinel pointer move only, no button event. mouseDown
// already fired ButtonPressed; sending another one per drag tick would
// restart iced's selection on every frame and make click+drag twitch.
viewport_mouse_event(h, Float(pt.x), Float(pt.y), 255, false)
}
override func rightMouseDown(with event: NSEvent) {
guard let h = viewportHandle else { return }
let pt = convert(event.locationInWindow, from: nil)
viewport_mouse_event(h, Float(pt.x), Float(pt.y), 1, true)
}
override func rightMouseUp(with event: NSEvent) {
guard let h = viewportHandle else { return }
let pt = convert(event.locationInWindow, from: nil)
viewport_mouse_event(h, Float(pt.x), Float(pt.y), 1, false)
}
override func scrollWheel(with event: NSEvent) {
guard let h = viewportHandle else { return }
let pt = convert(event.locationInWindow, from: nil)
viewport_scroll_event(h, Float(pt.x), Float(pt.y), Float(event.scrollingDeltaX), Float(event.scrollingDeltaY))
}
// MARK: - Key Events
override func performKeyEquivalent(with event: NSEvent) -> Bool {
guard viewportHandle != nil else { return super.performKeyEquivalent(with: event) }
let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask)
let cmd = flags.contains(.command)
let shift = flags.contains(.shift)
let chars = event.charactersIgnoringModifiers ?? ""
if cmd && !shift {
switch chars {
case "a", "b", "c", "e", "f", "g", "i", "v", "x", "z", "p", "t",
"=", "+", "-", "0":
keyDown(with: event)
return true
default: break
}
}
if cmd && shift {
switch chars {
case "g", "z":
keyDown(with: event)
return true
default: break
}
}
return super.performKeyEquivalent(with: event)
}
override func keyDown(with event: NSEvent) {
guard let h = viewportHandle else { return }
let text = event.characters ?? ""
text.withCString { cstr in
viewport_key_event(h, UInt32(event.keyCode), UInt32(event.modifierFlags.rawValue), true, cstr)
}
}
override func keyUp(with event: NSEvent) {
guard let h = viewportHandle else { return }
let text = event.characters ?? ""
text.withCString { cstr in
viewport_key_event(h, UInt32(event.keyCode), UInt32(event.modifierFlags.rawValue), false, cstr)
}
}
override func flagsChanged(with event: NSEvent) {
guard let h = viewportHandle else { return }
viewport_key_event(h, UInt32(event.keyCode), UInt32(event.modifierFlags.rawValue), true, nil)
}
// MARK: - Text Bridge
func setText(_ text: String) {
if isTornDown { return }
guard let h = viewportHandle else { return }
cachedText = text
text.withCString { cstr in
viewport_set_text(h, cstr)
}
}
func getText() -> String {
if isTornDown { return cachedText }
guard let h = viewportHandle else { return cachedText }
guard let cstr = viewport_get_text(h) else { return cachedText }
let result = String(cString: cstr)
viewport_free_string(cstr)
cachedText = result
return result
}
func sendCommand(_ command: UInt32) {
guard let h = viewportHandle else { return }
viewport_send_command(h, command)
}
func setTheme(_ name: String) {
guard let h = viewportHandle else { return }
name.withCString { cstr in
viewport_set_theme(h, cstr)
}
}
/// Returns 0 = Live, 1 = Editor, 2 = View.
func renderMode() -> UInt32 {
guard let h = viewportHandle else { return 0 }
return viewport_render_mode(h)
}
}

190
src/RustBridge.swift Normal file
View File

@ -0,0 +1,190 @@
import Foundation
struct NoteInfo: Identifiable {
let id: UUID
var title: String
var lastModified: Date
}
enum EvalFormat: String {
case inline
case table
case tree
}
struct EvalEntry {
let result: String
let format: EvalFormat
}
class RustBridge {
static let shared = RustBridge()
private var docs: [UUID: OpaquePointer] = [:]
private init() {}
func newDocument() -> UUID {
let ptr = acord_doc_new()!
let uuidStr = cacheSaveRaw(ptr)
let id = UUID(uuidString: uuidStr) ?? UUID()
docs[id] = ptr
return id
}
func freeDocument(_ id: UUID) {
guard let ptr = docs.removeValue(forKey: id) else { return }
acord_doc_free(ptr)
}
func setText(_ id: UUID, text: String) {
guard let ptr = docs[id] else { return }
text.withCString { cstr in
acord_doc_set_text(ptr, cstr)
}
}
func getText(_ id: UUID) -> String {
guard let ptr = docs[id] else { return "" }
guard let cstr = acord_doc_get_text(ptr) else { return "" }
let str = String(cString: cstr)
acord_free_string(cstr)
return str
}
func evaluate(_ id: UUID) -> [Int: EvalEntry] {
guard let ptr = docs[id] else { return [:] }
guard let cstr = acord_doc_evaluate(ptr) else { return [:] }
let json = String(cString: cstr)
acord_free_string(cstr)
return parseEvalJSON(json)
}
func evaluateLine(_ line: String) -> String {
guard let cstr = line.withCString({ acord_eval_line($0) }) else { return "" }
let str = String(cString: cstr)
acord_free_string(cstr)
return str
}
func saveNote(_ id: UUID, path: String) -> Bool {
guard let ptr = docs[id] else { return false }
return path.withCString { cstr in
acord_doc_save(ptr, cstr)
}
}
func loadNote(path: String) -> (UUID, String)? {
guard let ptr = path.withCString({ acord_doc_load($0) }) else { return nil }
let uuidStr = cacheSaveRaw(ptr)
guard let id = UUID(uuidString: uuidStr) else {
acord_doc_free(ptr)
return nil
}
if let old = docs[id] { acord_doc_free(old) }
docs[id] = ptr
guard let cstr = acord_doc_get_text(ptr) else { return (id, "") }
let text = String(cString: cstr)
acord_free_string(cstr)
return (id, text)
}
func cacheSave(_ id: UUID) -> Bool {
guard let ptr = docs[id] else { return false }
guard let cstr = acord_cache_save(ptr) else { return false }
acord_free_string(cstr)
return true
}
func cacheLoad(_ id: UUID) -> Bool {
let uuidStr = id.uuidString.lowercased()
guard let ptr = uuidStr.withCString({ acord_cache_load($0) }) else { return false }
if let old = docs[id] { acord_doc_free(old) }
docs[id] = ptr
return true
}
func listNotes() -> [NoteInfo] {
guard let cstr = acord_list_notes() else { return [] }
let json = String(cString: cstr)
acord_free_string(cstr)
return parseNoteListJSON(json)
}
struct HighlightSpan {
let start: Int
let end: Int
let kind: Int
}
func highlight(source: String, lang: String) -> [HighlightSpan] {
guard let cstr = source.withCString({ src in
lang.withCString({ lng in
acord_highlight(src, lng)
})
}) else { return [] }
let json = String(cString: cstr)
acord_free_string(cstr)
return parseHighlightJSON(json)
}
private func parseHighlightJSON(_ json: String) -> [HighlightSpan] {
guard let data = json.data(using: .utf8) else { return [] }
guard let arr = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]] else { return [] }
var spans: [HighlightSpan] = []
for item in arr {
guard let start = item["start"] as? Int,
let end = item["end"] as? Int,
let kind = item["kind"] as? Int else { continue }
spans.append(HighlightSpan(start: start, end: end, kind: kind))
}
return spans
}
func deleteNote(_ id: UUID) {
freeDocument(id)
let cacheDir = FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent(".acord/cache")
let cacheFile = cacheDir.appendingPathComponent("\(id.uuidString.lowercased()).sw")
try? FileManager.default.removeItem(at: cacheFile)
}
// MARK: - Internal
private func cacheSaveRaw(_ ptr: OpaquePointer) -> String {
guard let cstr = acord_cache_save(ptr) else { return UUID().uuidString }
let str = String(cString: cstr)
acord_free_string(cstr)
return str
}
private func parseEvalJSON(_ json: String) -> [Int: EvalEntry] {
guard let data = json.data(using: .utf8) else { return [:] }
guard let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return [:] }
guard let results = obj["results"] as? [[String: Any]] else { return [:] }
var dict: [Int: EvalEntry] = [:]
for item in results {
if let line = item["line"] as? Int, let result = item["result"] as? String {
let fmt = EvalFormat(rawValue: item["format"] as? String ?? "inline") ?? .inline
dict[line] = EvalEntry(result: result, format: fmt)
}
}
return dict
}
private func parseNoteListJSON(_ json: String) -> [NoteInfo] {
guard let data = json.data(using: .utf8) else { return [] }
guard let arr = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]] else { return [] }
var notes: [NoteInfo] = []
for item in arr {
guard let uuidStr = item["uuid"] as? String,
let uuid = UUID(uuidString: uuidStr),
let title = item["title"] as? String else { continue }
let modified = item["modified"] as? Double ?? 0
let date = Date(timeIntervalSince1970: modified)
notes.append(NoteInfo(id: uuid, title: title, lastModified: date))
}
return notes.sorted { $0.lastModified > $1.lastModified }
}
}

134
src/SettingsView.swift Normal file
View File

@ -0,0 +1,134 @@
import SwiftUI
import Cocoa
enum ThemeMode: String, CaseIterable {
case auto = "auto"
case dark = "dark"
case light = "light"
var label: String {
switch self {
case .auto: return "Auto"
case .dark: return "Dark"
case .light: return "Light"
}
}
}
enum LineIndicatorMode: String, CaseIterable {
case on = "on"
case off = "off"
case vim = "vim"
var label: String {
switch self {
case .on: return "On"
case .off: return "Off"
case .vim: return "Vim"
}
}
}
struct SettingsView: View {
@State private var themeMode: String = ConfigManager.shared.themeMode
@State private var lineIndicatorMode: String = ConfigManager.shared.lineIndicatorMode
@State private var autoSaveDir: String = ConfigManager.shared.autoSaveDirectory
var body: some View {
let palette = Theme.current
Form {
Section("Theme") {
Picker("Mode", selection: $themeMode) {
ForEach(ThemeMode.allCases, id: \.rawValue) { mode in
Text(mode.label).tag(mode.rawValue)
}
}
.pickerStyle(.segmented)
}
Section("Line Numbers") {
Picker("Mode", selection: $lineIndicatorMode) {
ForEach(LineIndicatorMode.allCases, id: \.rawValue) { mode in
Text(mode.label).tag(mode.rawValue)
}
}
.pickerStyle(.segmented)
}
Section("Auto-Save") {
HStack {
TextField("Directory", text: $autoSaveDir)
.textFieldStyle(.roundedBorder)
Button("Choose...") {
let panel = NSOpenPanel()
panel.canChooseFiles = false
panel.canChooseDirectories = true
panel.allowsMultipleSelection = false
if panel.runModal() == .OK, let url = panel.url {
autoSaveDir = url.path
}
}
}
}
}
.formStyle(.grouped)
.frame(width: 400, height: 260)
.background(Color(ns: palette.base))
.onChange(of: themeMode) {
ConfigManager.shared.themeMode = themeMode
applyThemeAppearance()
NotificationCenter.default.post(name: .settingsChanged, object: nil)
}
.onChange(of: lineIndicatorMode) {
ConfigManager.shared.lineIndicatorMode = lineIndicatorMode
NotificationCenter.default.post(name: .settingsChanged, object: nil)
}
.onChange(of: autoSaveDir) {
ConfigManager.shared.autoSaveDirectory = autoSaveDir
}
}
}
func applyThemeAppearance() {
let mode = ConfigManager.shared.themeMode
switch mode {
case "dark":
NSApp.appearance = NSAppearance(named: .darkAqua)
case "light":
NSApp.appearance = NSAppearance(named: .aqua)
default:
NSApp.appearance = nil
}
}
extension Notification.Name {
static let settingsChanged = Notification.Name("settingsChanged")
}
class SettingsWindowController {
private static var window: NSWindow?
static func show() {
if let existing = window, existing.isVisible {
existing.makeKeyAndOrderFront(nil)
return
}
let settingsView = SettingsView()
let hostingView = NSHostingView(rootView: settingsView)
hostingView.frame = NSRect(x: 0, y: 0, width: 400, height: 280)
let w = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 400, height: 280),
styleMask: [.titled, .closable],
backing: .buffered,
defer: false
)
w.title = "Settings"
w.contentView = hostingView
w.center()
w.isReleasedWhenClosed = false
w.makeKeyAndOrderFront(nil)
window = w
}
}

149
src/Theme.swift Normal file
View File

@ -0,0 +1,149 @@
import Cocoa
import SwiftUI
struct CatppuccinPalette {
let base: NSColor
let mantle: NSColor
let crust: NSColor
let surface0: NSColor
let surface1: NSColor
let surface2: NSColor
let overlay0: NSColor
let overlay1: NSColor
let overlay2: NSColor
let text: NSColor
let subtext0: NSColor
let subtext1: NSColor
let red: NSColor
let maroon: NSColor
let peach: NSColor
let yellow: NSColor
let green: NSColor
let teal: NSColor
let sky: NSColor
let sapphire: NSColor
let blue: NSColor
let lavender: NSColor
let mauve: NSColor
let pink: NSColor
let flamingo: NSColor
let rosewater: NSColor
}
struct Theme {
static let mocha = CatppuccinPalette(
base: NSColor(red: 0.118, green: 0.118, blue: 0.180, alpha: 1),
mantle: NSColor(red: 0.094, green: 0.094, blue: 0.149, alpha: 1),
crust: NSColor(red: 0.071, green: 0.071, blue: 0.118, alpha: 1),
surface0: NSColor(red: 0.188, green: 0.188, blue: 0.259, alpha: 1),
surface1: NSColor(red: 0.271, green: 0.271, blue: 0.353, alpha: 1),
surface2: NSColor(red: 0.353, green: 0.353, blue: 0.439, alpha: 1),
overlay0: NSColor(red: 0.427, green: 0.427, blue: 0.522, alpha: 1),
overlay1: NSColor(red: 0.506, green: 0.506, blue: 0.600, alpha: 1),
overlay2: NSColor(red: 0.584, green: 0.584, blue: 0.682, alpha: 1),
text: NSColor(red: 0.804, green: 0.839, blue: 0.957, alpha: 1),
subtext0: NSColor(red: 0.651, green: 0.686, blue: 0.820, alpha: 1),
subtext1: NSColor(red: 0.725, green: 0.761, blue: 0.886, alpha: 1),
red: NSColor(red: 0.953, green: 0.545, blue: 0.659, alpha: 1),
maroon: NSColor(red: 0.922, green: 0.600, blue: 0.659, alpha: 1),
peach: NSColor(red: 0.980, green: 0.702, blue: 0.529, alpha: 1),
yellow: NSColor(red: 0.976, green: 0.886, blue: 0.686, alpha: 1),
green: NSColor(red: 0.651, green: 0.890, blue: 0.631, alpha: 1),
teal: NSColor(red: 0.596, green: 0.878, blue: 0.816, alpha: 1),
sky: NSColor(red: 0.537, green: 0.863, blue: 0.922, alpha: 1),
sapphire: NSColor(red: 0.455, green: 0.784, blue: 0.890, alpha: 1),
blue: NSColor(red: 0.537, green: 0.706, blue: 0.980, alpha: 1),
lavender: NSColor(red: 0.710, green: 0.745, blue: 0.996, alpha: 1),
mauve: NSColor(red: 0.796, green: 0.651, blue: 0.969, alpha: 1),
pink: NSColor(red: 0.961, green: 0.710, blue: 0.898, alpha: 1),
flamingo: NSColor(red: 0.949, green: 0.710, blue: 0.765, alpha: 1),
rosewater: NSColor(red: 0.961, green: 0.761, blue: 0.765, alpha: 1)
)
static let latte = CatppuccinPalette(
base: NSColor(red: 0.937, green: 0.929, blue: 0.961, alpha: 1),
mantle: NSColor(red: 0.906, green: 0.898, blue: 0.941, alpha: 1),
crust: NSColor(red: 0.863, green: 0.855, blue: 0.910, alpha: 1),
surface0: NSColor(red: 0.800, green: 0.796, blue: 0.863, alpha: 1),
surface1: NSColor(red: 0.737, green: 0.733, blue: 0.816, alpha: 1),
surface2: NSColor(red: 0.667, green: 0.663, blue: 0.757, alpha: 1),
overlay0: NSColor(red: 0.604, green: 0.596, blue: 0.706, alpha: 1),
overlay1: NSColor(red: 0.533, green: 0.529, blue: 0.647, alpha: 1),
overlay2: NSColor(red: 0.467, green: 0.463, blue: 0.592, alpha: 1),
text: NSColor(red: 0.298, green: 0.286, blue: 0.416, alpha: 1),
subtext0: NSColor(red: 0.376, green: 0.365, blue: 0.494, alpha: 1),
subtext1: NSColor(red: 0.337, green: 0.325, blue: 0.455, alpha: 1),
red: NSColor(red: 0.822, green: 0.294, blue: 0.345, alpha: 1),
maroon: NSColor(red: 0.906, green: 0.345, blue: 0.388, alpha: 1),
peach: NSColor(red: 0.996, green: 0.541, blue: 0.243, alpha: 1),
yellow: NSColor(red: 0.875, green: 0.627, blue: 0.086, alpha: 1),
green: NSColor(red: 0.251, green: 0.624, blue: 0.247, alpha: 1),
teal: NSColor(red: 0.090, green: 0.604, blue: 0.502, alpha: 1),
sky: NSColor(red: 0.016, green: 0.639, blue: 0.757, alpha: 1),
sapphire: NSColor(red: 0.125, green: 0.561, blue: 0.737, alpha: 1),
blue: NSColor(red: 0.118, green: 0.404, blue: 0.878, alpha: 1),
lavender: NSColor(red: 0.451, green: 0.420, blue: 0.878, alpha: 1),
mauve: NSColor(red: 0.529, green: 0.329, blue: 0.890, alpha: 1),
pink: NSColor(red: 0.918, green: 0.341, blue: 0.604, alpha: 1),
flamingo: NSColor(red: 0.867, green: 0.369, blue: 0.424, alpha: 1),
rosewater: NSColor(red: 0.863, green: 0.443, blue: 0.439, alpha: 1)
)
static var current: CatppuccinPalette {
let mode = ConfigManager.shared.themeMode
switch mode {
case "dark": return mocha
case "light": return latte
default:
let appearance = NSApp.effectiveAppearance
let isDark = appearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua
return isDark ? mocha : latte
}
}
static var editorFont: NSFont {
NSFont.monospacedSystemFont(ofSize: max(8, 13 + ConfigManager.shared.zoomLevel), weight: .regular)
}
static var gutterFont: NSFont {
NSFont.monospacedSystemFont(ofSize: max(8, 11 + ConfigManager.shared.zoomLevel), weight: .regular)
}
static var sidebarFont: NSFont {
NSFont.systemFont(ofSize: max(8, 13 + ConfigManager.shared.zoomLevel), weight: .regular)
}
static var sidebarDateFont: NSFont {
NSFont.systemFont(ofSize: max(8, 11 + ConfigManager.shared.zoomLevel), weight: .regular)
}
struct SyntaxColors {
let keyword: NSColor
let number: NSColor
let string: NSColor
let comment: NSColor
let `operator`: NSColor
let function: NSColor
let result: NSColor
let type: NSColor
let boolean: NSColor
}
static var syntax: SyntaxColors {
let p = current
return SyntaxColors(
keyword: p.mauve,
number: p.peach,
string: p.green,
comment: p.overlay1,
operator: p.sky,
function: p.blue,
result: p.teal,
type: p.yellow,
boolean: p.peach
)
}
}
extension Color {
init(ns: NSColor) {
self.init(nsColor: ns)
}
}

164
src/TitleBarView.swift Normal file
View File

@ -0,0 +1,164 @@
import Cocoa
class TitleBarAccessoryController: NSTitlebarAccessoryViewController {
let titleView = TitleBarView()
override func loadView() {
let container = NSView(frame: NSRect(x: 0, y: 0, width: 400, height: 28))
titleView.translatesAutoresizingMaskIntoConstraints = false
container.addSubview(titleView)
NSLayoutConstraint.activate([
titleView.leadingAnchor.constraint(equalTo: container.leadingAnchor),
titleView.trailingAnchor.constraint(equalTo: container.trailingAnchor),
titleView.topAnchor.constraint(equalTo: container.topAnchor),
titleView.bottomAnchor.constraint(equalTo: container.bottomAnchor),
])
self.view = container
}
override func viewDidLoad() {
super.viewDidLoad()
layoutAttribute = .top
fullScreenMinHeight = 28
}
}
class TitleBarView: NSView {
private let label = NSTextField(labelWithString: "")
private let editor = NSTextField()
private(set) var isEditing = false
var title: String = "" {
didSet {
if !isEditing {
let dt = displayTitle
label.stringValue = dt.isEmpty ? "Untitled" : dt
label.textColor = dt.isEmpty ? Theme.current.overlay0 : Theme.current.text
}
}
}
var onCommit: ((String) -> Void)?
private var displayTitle: String {
let trimmed = title.trimmingCharacters(in: .whitespaces)
if trimmed.hasPrefix("# ") {
return String(trimmed.dropFirst(2))
}
return trimmed
}
override init(frame: NSRect) {
super.init(frame: frame)
setup()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
setup()
}
private func setup() {
label.font = .systemFont(ofSize: 13, weight: .semibold)
label.textColor = Theme.current.overlay0
label.backgroundColor = .clear
label.isBezeled = false
label.isEditable = false
label.isSelectable = false
label.alignment = .center
label.lineBreakMode = .byTruncatingTail
label.cell?.truncatesLastVisibleLine = true
label.translatesAutoresizingMaskIntoConstraints = false
label.stringValue = "Untitled"
editor.font = .systemFont(ofSize: 13, weight: .semibold)
editor.textColor = Theme.current.text
editor.backgroundColor = Theme.current.surface0
editor.isBezeled = false
editor.isEditable = true
editor.isSelectable = true
editor.alignment = .center
editor.focusRingType = .none
editor.cell?.lineBreakMode = .byTruncatingTail
editor.translatesAutoresizingMaskIntoConstraints = false
editor.isHidden = true
editor.delegate = self
addSubview(label)
addSubview(editor)
NSLayoutConstraint.activate([
label.centerXAnchor.constraint(equalTo: centerXAnchor),
label.centerYAnchor.constraint(equalTo: centerYAnchor),
label.widthAnchor.constraint(lessThanOrEqualTo: widthAnchor, multiplier: 0.5),
editor.centerXAnchor.constraint(equalTo: centerXAnchor),
editor.centerYAnchor.constraint(equalTo: centerYAnchor),
editor.widthAnchor.constraint(equalTo: widthAnchor, multiplier: 0.5),
])
let dblClick = NSClickGestureRecognizer(target: self, action: #selector(handleDoubleClick(_:)))
dblClick.numberOfClicksRequired = 2
dblClick.delaysPrimaryMouseButtonEvents = false
addGestureRecognizer(dblClick)
}
@objc private func handleDoubleClick(_ sender: NSClickGestureRecognizer) {
if !isEditing { beginEditing() }
}
override func mouseDown(with event: NSEvent) {
if event.clickCount == 2 && !isEditing {
beginEditing()
return
}
super.mouseDown(with: event)
}
func beginEditing() {
isEditing = true
editor.stringValue = title
label.isHidden = true
editor.isHidden = false
window?.makeFirstResponder(editor)
editor.currentEditor()?.selectAll(nil)
}
func endEditing() {
guard isEditing else { return }
isEditing = false
let raw = editor.stringValue
onCommit?(raw)
let dt = displayTitle
label.stringValue = dt.isEmpty ? "Untitled" : dt
label.textColor = dt.isEmpty ? Theme.current.overlay0 : Theme.current.text
editor.isHidden = true
label.isHidden = false
}
func updateColors() {
let dt = displayTitle
label.textColor = dt.isEmpty ? Theme.current.overlay0 : Theme.current.text
editor.textColor = Theme.current.text
editor.backgroundColor = Theme.current.surface0
}
}
extension TitleBarView: NSTextFieldDelegate {
func controlTextDidEndEditing(_ obj: Notification) {
endEditing()
}
func control(_ control: NSControl, textView: NSTextView, doCommandBy sel: Selector) -> Bool {
if sel == #selector(NSResponder.insertNewline(_:)) {
endEditing()
NotificationCenter.default.post(name: .focusEditor, object: nil)
return true
}
if sel == #selector(NSResponder.cancelOperation(_:)) {
endEditing()
return true
}
return false
}
}

16
src/main.swift Normal file
View File

@ -0,0 +1,16 @@
import Cocoa
// The delegate is stored in a static so it outlives `app.run()`. NSApplication
// holds its delegate weakly; if the compiler decides the local `delegate`
// binding isn't needed past `app.delegate = ...` it can be released early,
// tearing down state mid-run. A static keeps a concrete strong reference.
enum AcordAppMain {
static let delegate = AppDelegate()
}
let app = NSApplication.shared
app.delegate = AcordAppMain.delegate
app.setActivationPolicy(.regular)
app.activate(ignoringOtherApps: true)
app.run()
_ = AcordAppMain.delegate // keep alive past app.run()

27
viewport/Cargo.toml Normal file
View File

@ -0,0 +1,27 @@
[package]
name = "acord-viewport"
version = "0.1.0"
edition = "2024"
[lib]
crate-type = ["staticlib", "rlib"]
[dependencies]
acord-core = { path = "../core" }
iced_wgpu = "0.14"
iced_graphics = "0.14"
iced_runtime = "0.14"
iced_widget = { version = "0.14", features = ["wgpu", "markdown", "canvas"] }
wgpu = "27"
raw-window-handle = "0.6"
pollster = "0.4"
smol_str = "0.2"
cosmic-text = "0.15"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
toml = "0.8"
zip = { version = "2", default-features = false, features = ["deflate"] }
base64 = "0.22"
[build-dependencies]
cbindgen = "0.29"

6
viewport/build.rs Normal file
View File

@ -0,0 +1,6 @@
fn main() {
let crate_dir = std::env::var("CARGO_MANIFEST_DIR").unwrap();
cbindgen::generate(&crate_dir)
.expect("cbindgen failed")
.write_to_file(format!("{}/include/acord.h", crate_dir));
}

12
viewport/cbindgen.toml Normal file
View File

@ -0,0 +1,12 @@
language = "C"
include_guard = "ACORD_VIEWPORT_H"
header = """
#include <stdarg.h>
#include <stdbool.h>
#include <stdint.h>
#include <stdlib.h>
#include "../../core/include/acord.h"
"""
[parse]
parse_deps = false

74
viewport/include/acord.h Normal file
View File

@ -0,0 +1,74 @@
#include <stdarg.h>
#include <stdbool.h>
#include <stdint.h>
#include <stdlib.h>
#include "../../core/include/acord.h"
#ifndef ACORD_VIEWPORT_H
#define ACORD_VIEWPORT_H
#include <stdarg.h>
#include <stdbool.h>
#include <stdint.h>
#include <stdlib.h>
#define EVAL_RESULT_KIND 24
#define EVAL_ERROR_KIND 25
typedef struct TextPos TextPos;
typedef struct ViewportHandle ViewportHandle;
struct ViewportHandle *viewport_create(void *nsview, float width, float height, float scale);
void viewport_destroy(struct ViewportHandle *handle);
void viewport_render(struct ViewportHandle *handle);
void viewport_resize(struct ViewportHandle *handle, float width, float height, float scale);
void viewport_mouse_event(struct ViewportHandle *handle,
float x,
float y,
uint8_t button,
bool pressed);
void viewport_key_event(struct ViewportHandle *handle,
uint32_t key,
uint32_t modifiers,
bool pressed,
const char *text);
void viewport_scroll_event(struct ViewportHandle *handle,
float x,
float y,
float delta_x,
float delta_y);
void viewport_set_text(struct ViewportHandle *handle, const char *text);
void viewport_set_lang(struct ViewportHandle *handle, const char *ext);
char *viewport_get_text(struct ViewportHandle *handle);
void viewport_free_string(char *s);
void viewport_set_theme(struct ViewportHandle *handle, const char *name);
void viewport_send_command(struct ViewportHandle *handle, uint32_t command);
/**
* Export the note as a standalone Rust crate at `out_dir/name/`. Returns
* a heap-allocated C string on success (the absolute path of the created
* folder), or null on failure. Free the returned string with
* `viewport_free_string`.
*/
char *viewport_export_crate(struct ViewportHandle *handle, const char *out_dir, const char *name);
uint32_t viewport_render_mode(struct ViewportHandle *handle);
#endif /* ACORD_VIEWPORT_H */

173
viewport/src/block.rs Normal file
View File

@ -0,0 +1,173 @@
//! The `Block` trait that all block kinds implement.
//!
//! Each concrete struct (TextBlock, TableBlock, HeadingBlock, HrBlock,
//! TreeBlock) keeps its own internal data but exposes a common interface for
//! iteration, dispatch, selection participation, hit-testing, and serialization.
//!
//! Two rules apply throughout the trait:
//!
//! 1. **Iteration over recursion.** `selectable_paths` returns an iterator and
//! must be implemented without self-recursion. When nesting (cells containing
//! blocks) lands, this protects deep documents from stack overflow.
//! 2. **Layered draw order.** `view` returns a `LayeredView`, not a single
//! element. The document compositor merges overlays from every block into
//! shared layers, so cursorline (Below) and selection borders (Above) end up
//! in the right z-order regardless of where they were declared.
use iced_wgpu::core::{Element, Point, Theme};
use crate::text_widget;
use crate::selection::{BlockId, InnerPath, NodePath, Selection};
use crate::table_block::TableMessage;
/// Z-ordering for the document compositor. Explicit draw order decoupled from
/// the structural list, so overlays render in the right place regardless of
/// where they were declared.
#[repr(u8)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum Layer {
/// Drawn behind block content. Cursorline highlights, selection background tints.
Below = 0,
/// The block's structural body. Most blocks emit their main element here.
Content = 1,
/// Drawn in front of content. Selection borders, focus rings, eval result tags.
Above = 2,
/// The very top. Drag previews, ghosted reorder anchors.
Floating = 3,
}
/// What a block returns from `view`. The compositor merges layered output across
/// all blocks, drawing in layer order rather than source order.
pub struct LayeredView<'a, Message> {
/// The block's main structural element. Always rendered in document order
/// in a column; conceptually at `Layer::Content`.
pub base: Element<'a, Message, Theme, iced_wgpu::Renderer>,
/// Decorative overlays tagged with their target layer.
pub overlays: Vec<(Layer, Element<'a, Message, Theme, iced_wgpu::Renderer>)>,
}
impl<'a, Message> LayeredView<'a, Message> {
/// Convenience for blocks with no overlays (HR, headings without cursorline).
pub fn just(base: Element<'a, Message, Theme, iced_wgpu::Renderer>) -> Self {
Self { base, overlays: Vec::new() }
}
}
/// Shared rendering context passed by reference into every `Block::view` call.
/// Instead of embedding selection or focus state on every block, blocks query a
/// shared context.
///
/// `on_text_action` and `on_table_msg` are plain function pointers, not boxed
/// closures: the editor only needs message constructors that wrap an index, no
/// captured state. This makes `ViewCtx` cheap to copy and avoids the
/// invariant-lifetime trap that capturing closures would impose on the trait
/// `view` signature.
pub struct ViewCtx<'a, Message: 'a> {
pub block_index: usize,
pub selection: &'a Selection,
pub focus: Option<&'a NodePath>,
pub editing: Option<&'a NodePath>,
pub font_size: f32,
pub is_dark: bool,
pub on_text_action: fn(usize, text_widget::Action) -> Message,
pub on_table_msg: fn(usize, TableMessage) -> Message,
/// Computed values for cells whose raw text is a `/=...` formula.
/// Keyed by (table block id, col, row). A `Some(Value)` here means:
/// show the computed display form when not editing; a missing entry
/// means render the cell's raw text.
pub computed_cells: &'a std::collections::HashMap<
(BlockId, u32, u32),
acord_core::interp::Value,
>,
}
/// Structural commands that mutate a block. The editor routes a `BlockCommand`
/// to a specific block based on the active focus / selection rather than
/// dispatching kind-specific messages directly.
#[derive(Debug, Clone)]
pub enum BlockCommand {
// Table commands
InsertRowAbove(usize),
InsertRowBelow(usize),
DeleteRow(usize),
InsertColLeft(usize),
InsertColRight(usize),
DeleteCol(usize),
SetCellValue { row: usize, col: usize, value: String },
ResizeCol { col: usize, width: f32 },
ResizeRow { row: usize, height: f32 },
MoveCol { from: usize, to: usize },
MoveRow { from: usize, to: usize },
// Heading commands
SetHeadingLevel(u8),
SetHeadingText(String),
// Generic
SelectAll,
Clear,
}
/// The protocol every block kind implements.
///
/// Generic over `Message` so concrete impls can plug in the editor's message
/// type without `block.rs` taking a hard dependency on `editor.rs`. The trait
/// stays dyn-compatible because the generic is at the trait level (not on
/// individual methods) — `Box<dyn Block<crate::editor::Message>>` works.
pub trait Block<Message> {
fn id(&self) -> BlockId;
fn kind_tag(&self) -> &'static str;
/// Document-relative line where this block begins. Maintained by
/// `recount_lines` after any structural mutation.
fn start_line(&self) -> usize;
fn set_start_line(&mut self, line: usize);
/// Line count this block contributes to the document. For text blocks
/// this is the editor `Content::line_count()`; for tables it's
/// `rows.len() + 1` (header + separator + data); fixed at 1 for
/// headings, HRs, and trees.
fn line_count(&self) -> usize;
/// True if this block was produced by an eval `/=` table result rather
/// than parsed from markdown. Drives read-only behaviour and skips
/// markdown serialization. Defaults to false; only `TableBlock` overrides.
fn is_eval_result(&self) -> bool {
false
}
/// Downcast hooks for the editor to access kind-specific fields
/// (`TextBlock::content`, `TableBlock::rows`, ...) from a `Box<dyn Block>`.
/// Every concrete impl just returns `self` — these are required because
/// `Block` is generic over `Message` so we can't use Rust's trait
/// upcasting to `Any` directly.
fn as_any(&self) -> &dyn std::any::Any;
fn as_any_mut(&mut self) -> &mut dyn std::any::Any;
/// Render the block. `ctx` carries selection / focus / theme / font as
/// shared state. Returns layered output rather than a single element so
/// the document compositor can merge overlays from multiple blocks into
/// shared layers.
///
/// `ctx`'s lifetime is independent from the returned `LayeredView`'s `'a`
/// (which is tied to `&self`). Implementations must read what they need
/// from `ctx` eagerly into Copy locals — they may NOT capture `ctx` into
/// the returned element. This is what lets the editor's `view_blocks` build
/// a fresh per-iteration `ViewCtx` on the stack and return elements that
/// outlive the loop.
fn view<'a>(&'a self, ctx: &ViewCtx<'_, Message>) -> LayeredView<'a, Message>;
/// Markdown serialization. Rich side-channel state (col widths, row
/// heights, cell formulas) is serialized separately into the embedded
/// sidecar archive — this method returns plain markdown only.
fn to_md(&self) -> String;
/// Cursor coordinate (in this block's local space) -> innermost selectable
/// path. Returns `None` if the point doesn't hit anything selectable.
fn hit_test(&self, point: Point) -> Option<InnerPath>;
/// Apply a structural mutation. Unsupported commands are silently ignored.
fn apply(&mut self, cmd: BlockCommand);
/// Iterate (NOT recurse) over all selectable inner paths in this block.
/// MUST be iterative — when nesting lands this gets exercised on deep trees.
fn selectable_paths(&self) -> Box<dyn Iterator<Item = InnerPath> + '_>;
}

368
viewport/src/blocks.rs Normal file
View File

@ -0,0 +1,368 @@
//! Document parsing and block-list utilities.
//!
//! Owns the markdown -> `Vec<BoxedBlock>` parser plus the round-trip helpers
//! (serialize, recount lines, locate by line). Every block kind lives in its
//! own module behind the `Block` trait; this file deals with document-wide
//! concerns only.
use std::sync::atomic::{AtomicU64, Ordering};
use crate::block::Block;
use crate::editor::Message;
use crate::heading_block::{HeadingBlock, HeadingLevel};
use crate::hr_block::HrBlock;
use crate::table_block::TableBlock;
use crate::text_block::TextBlock;
pub type BoxedBlock = Box<dyn Block<Message>>;
static NEXT_BLOCK_ID: AtomicU64 = AtomicU64::new(1);
pub fn next_id() -> u64 {
NEXT_BLOCK_ID.fetch_add(1, Ordering::Relaxed)
}
/// Split text into lines, preserving trailing empty lines that `str::lines()` drops.
fn split_lines(text: &str) -> Vec<&str> {
let mut lines: Vec<&str> = text.lines().collect();
if text.ends_with('\n') {
lines.push("");
}
lines
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum SpanKind {
Text,
Table,
HorizontalRule,
Heading,
}
fn is_hr_line(line: &str) -> bool {
let trimmed = line.trim();
trimmed.len() >= 3 && trimmed.chars().all(|c| c == '-')
}
fn heading_prefix(line: &str) -> Option<(u8, &str)> {
let trimmed = line.trim_start();
let bytes = trimmed.as_bytes();
if bytes.is_empty() || bytes[0] != b'#' {
return None;
}
let mut level = 0u8;
while (level as usize) < bytes.len() && bytes[level as usize] == b'#' {
level += 1;
}
if level > 4 || (level as usize) >= bytes.len() || bytes[level as usize] != b' ' {
return None;
}
Some((level, &trimmed[level as usize + 1..]))
}
fn is_table_start(lines: &[&str], idx: usize) -> bool {
if idx + 1 >= lines.len() {
return false;
}
let line = lines[idx].trim();
let next = lines[idx + 1].trim();
if !line.starts_with('|') || !next.starts_with('|') {
return false;
}
let inner = next.strip_prefix('|').unwrap_or(next);
let inner = inner.strip_suffix('|').unwrap_or(inner);
inner.split('|').all(|seg| {
let s = seg.trim();
!s.is_empty() && s.chars().all(|c| c == '-' || c == ':')
})
}
fn consume_table(lines: &[&str], start: usize) -> (Vec<Vec<String>>, usize) {
let parse_row = |line: &str| -> Vec<String> {
let trimmed = line.trim();
let inner = trimmed.strip_prefix('|').unwrap_or(trimmed);
let inner = inner.strip_suffix('|').unwrap_or(inner);
inner.split('|').map(|c| c.trim().to_string()).collect()
};
let mut rows = vec![parse_row(lines[start])];
let mut end = start + 2; // skip header + separator
while end < lines.len() {
let trimmed = lines[end].trim();
if trimmed.is_empty() || !trimmed.contains('|') {
break;
}
rows.push(parse_row(lines[end]));
end += 1;
}
(rows, end)
}
struct BlockSpan {
kind: SpanKind,
start: usize,
end: usize, // exclusive
heading_level: u8,
heading_text: String,
table_rows: Vec<Vec<String>>,
}
impl BlockSpan {
fn line_count(&self) -> usize {
self.end - self.start
}
}
fn classify_spans(lines: &[&str]) -> Vec<BlockSpan> {
let mut spans = Vec::new();
let mut i = 0;
let mut text_start: Option<usize> = None;
let flush_text = |text_start: &mut Option<usize>, i: usize, spans: &mut Vec<BlockSpan>| {
if let Some(s) = text_start.take() {
if s < i {
spans.push(BlockSpan {
kind: SpanKind::Text,
start: s,
end: i,
heading_level: 0,
heading_text: String::new(),
table_rows: Vec::new(),
});
}
}
};
while i < lines.len() {
if is_hr_line(lines[i]) {
flush_text(&mut text_start, i, &mut spans);
spans.push(BlockSpan {
kind: SpanKind::HorizontalRule,
start: i,
end: i + 1,
heading_level: 0,
heading_text: String::new(),
table_rows: Vec::new(),
});
i += 1;
} else if let Some((level, text)) = heading_prefix(lines[i]) {
flush_text(&mut text_start, i, &mut spans);
spans.push(BlockSpan {
kind: SpanKind::Heading,
start: i,
end: i + 1,
heading_level: level,
heading_text: text.to_string(),
table_rows: Vec::new(),
});
i += 1;
} else if is_table_start(lines, i) {
flush_text(&mut text_start, i, &mut spans);
let (rows, end) = consume_table(lines, i);
spans.push(BlockSpan {
kind: SpanKind::Table,
start: i,
end,
heading_level: 0,
heading_text: String::new(),
table_rows: rows,
});
i = end;
} else {
if text_start.is_none() {
text_start = Some(i);
}
i += 1;
}
}
flush_text(&mut text_start, lines.len(), &mut spans);
if spans.is_empty() {
spans.push(BlockSpan {
kind: SpanKind::Text,
start: 0,
end: 0,
heading_level: 0,
heading_text: String::new(),
table_rows: Vec::new(),
});
}
spans
}
fn build_block(span: &BlockSpan, lines: &[&str], lang: &str) -> BoxedBlock {
match span.kind {
SpanKind::Text => {
let block_text = lines[span.start..span.end].join("\n");
Box::new(TextBlock::new(next_id(), &block_text, span.start, lang.to_string()))
}
SpanKind::HorizontalRule => Box::new(HrBlock::new(next_id(), span.start)),
SpanKind::Heading => Box::new(HeadingBlock::new(
next_id(),
HeadingLevel::from_u8(span.heading_level),
span.heading_text.clone(),
span.start,
)),
SpanKind::Table => {
Box::new(TableBlock::new(
next_id(),
span.table_rows.clone(),
span.start,
))
}
}
}
pub fn parse_blocks(text: &str, lang: &str) -> Vec<BoxedBlock> {
if text.is_empty() {
return vec![Box::new(TextBlock::new(next_id(), "", 0, lang.to_string()))];
}
let lines: Vec<&str> = split_lines(text);
let spans = classify_spans(&lines);
let mut blocks: Vec<BoxedBlock> = Vec::with_capacity(spans.len());
for span in &spans {
blocks.push(build_block(span, &lines, lang));
}
if blocks.is_empty() {
blocks.push(Box::new(TextBlock::new(next_id(), "", 0, lang.to_string())));
}
blocks
}
/// Incremental reparse: compare existing block kinds + spans to the new
/// classification, reuse boxed instances where the slot matches, rebuild the
/// rest. Preserves cursor state for unchanged text blocks because the
/// `text_editor::Content` instance is moved (not recreated) when both the
/// kind tag and the line span match.
pub fn reparse_incremental(old_blocks: &mut Vec<BoxedBlock>, text: &str, lang: &str) {
let lines: Vec<&str> = if text.is_empty() {
Vec::new()
} else {
split_lines(text)
};
let spans = classify_spans(&lines);
let old_descriptors: Vec<(&'static str, usize, usize)> = old_blocks
.iter()
.map(|b| (b.kind_tag(), b.start_line(), b.line_count()))
.collect();
let new_descriptors: Vec<(&'static str, usize, usize)> = spans
.iter()
.map(|s| (span_kind_tag(s.kind), s.start, s.line_count()))
.collect();
if old_descriptors == new_descriptors {
// Same structure: update text content in place for text blocks; align
// start_line for all kinds. Cursor preserved (Content not recreated
// unless serialized text actually changed).
for (block, span) in old_blocks.iter_mut().zip(spans.iter()) {
block.set_start_line(span.start);
if matches!(span.kind, SpanKind::Text) {
if let Some(tb) = block.as_any_mut().downcast_mut::<TextBlock>() {
let block_text = lines[span.start..span.end].join("\n");
let current = tb.content.text();
if current != block_text {
tb.content =
crate::text_widget::Content::with_text(&block_text);
}
}
}
}
return;
}
// Structure changed: rebuild, reusing blocks at matching positions. Match
// is by (kind_tag, start_line); the boxed instance is `mem::replace`'d so
// any preserved state (text editor cursor, table focus, drag) survives.
let placeholder = || -> BoxedBlock {
Box::new(TextBlock::new(0, "", 0, String::new()))
};
let mut new_blocks: Vec<BoxedBlock> = Vec::with_capacity(spans.len());
for (i, span) in spans.iter().enumerate() {
let span_tag = span_kind_tag(span.kind);
let reuse = i < old_blocks.len()
&& old_blocks[i].kind_tag() == span_tag
&& old_blocks[i].start_line() == span.start;
if reuse {
let mut b = std::mem::replace(&mut old_blocks[i], placeholder());
b.set_start_line(span.start);
if matches!(span.kind, SpanKind::Text) {
if let Some(tb) = b.as_any_mut().downcast_mut::<TextBlock>() {
let block_text = lines[span.start..span.end].join("\n");
if tb.content.text() != block_text {
tb.content =
crate::text_widget::Content::with_text(&block_text);
}
}
}
new_blocks.push(b);
} else {
new_blocks.push(build_block(span, &lines, lang));
}
}
if new_blocks.is_empty() {
new_blocks.push(Box::new(TextBlock::new(next_id(), "", 0, lang.to_string())));
}
*old_blocks = new_blocks;
}
fn span_kind_tag(kind: SpanKind) -> &'static str {
match kind {
SpanKind::Text => "text",
SpanKind::Table => "table",
SpanKind::HorizontalRule => "hr",
SpanKind::Heading => "heading",
}
}
/// Serialize blocks back to document text. Eval-result tables and trees are
/// skipped — they're regenerated from source on every load.
pub fn serialize_blocks(blocks: &[BoxedBlock]) -> String {
let mut parts = Vec::new();
for block in blocks {
let tag = block.kind_tag();
if tag == "tree" {
continue;
}
if tag == "table" && block.is_eval_result() {
continue;
}
let md = block.to_md();
// For text blocks, push even empty strings — they preserve the empty
// line gap between adjacent non-text blocks.
if tag == "text" || !md.is_empty() {
parts.push(md);
}
}
parts.join("\n")
}
/// Update document-relative start_line on every block based on its line_count.
pub fn recount_lines(blocks: &mut [BoxedBlock]) {
let mut line = 0;
for block in blocks.iter_mut() {
block.set_start_line(line);
line += block.line_count();
}
}
/// Find the block index containing a given global line number.
pub fn block_at_line(blocks: &[BoxedBlock], global_line: usize) -> Option<usize> {
for (i, block) in blocks.iter().enumerate() {
let start = block.start_line();
let end = start + block.line_count();
if global_line >= start && global_line < end {
return Some(i);
}
}
None
}
pub fn line_count_with_trailing(text: &str) -> usize {
split_lines(text).len().max(1)
}

150
viewport/src/bridge.rs Normal file
View File

@ -0,0 +1,150 @@
use iced_wgpu::core::keyboard::{self, key};
use iced_wgpu::core::mouse;
use iced_wgpu::core::{Event, Point};
use smol_str::SmolStr;
use crate::ViewportHandle;
pub fn push_mouse_event(handle: &mut ViewportHandle, x: f32, y: f32, button: u8, pressed: bool) {
let position = Point::new(x, y);
handle.cursor = mouse::Cursor::Available(position);
handle.events.push(Event::Mouse(mouse::Event::CursorMoved { position }));
// Sentinel: button == 255 means "pointer move only — do not fire any
// ButtonPressed/Released event." Used by mouseMoved and mouseDragged in
// the Swift shell. Without this, every drag tick would re-fire
// ButtonPressed(Left) and iced's text_editor would interpret each tick as
// a new click, restarting the active selection on every frame and making
// click+drag selection twitch / over-highlight.
if button == 255 {
return;
}
let btn = match button {
0 => mouse::Button::Left,
1 => mouse::Button::Right,
2 => mouse::Button::Middle,
n => mouse::Button::Other(n as u16),
};
if pressed {
handle.events.push(Event::Mouse(mouse::Event::ButtonPressed(btn)));
} else {
handle.events.push(Event::Mouse(mouse::Event::ButtonReleased(btn)));
}
}
pub fn push_key_event(
handle: &mut ViewportHandle,
keycode: u32,
modifier_flags: u32,
pressed: bool,
text: Option<&str>,
) {
let modifiers = decode_modifiers(modifier_flags);
// Always emit a ModifiersChanged BEFORE the key event so handle.rs's
// state.mods stays current. Without this, holding Cmd silently and
// then clicking would leave state.mods stale (the click event carries
// no modifier info), and click handlers reading self.mods would see
// the wrong state. Idempotent — handle.rs only stores the latest.
handle.events.push(Event::Keyboard(keyboard::Event::ModifiersChanged(modifiers)));
let physical = key::Physical::Unidentified(key::NativeCode::MacOS(keycode as u16));
let named = keycode_to_named(keycode);
let logical = if let Some(n) = named {
keyboard::Key::Named(n)
} else {
text.filter(|s| !s.is_empty())
.map(|s| keyboard::Key::Character(SmolStr::new(s)))
.unwrap_or(keyboard::Key::Unidentified)
};
let has_action_modifier = modifiers.logo() || modifiers.control();
let insert_text = if named.is_some() || has_action_modifier {
None
} else {
text.filter(|s| !s.is_empty()).map(SmolStr::new)
};
if pressed {
handle.events.push(Event::Keyboard(keyboard::Event::KeyPressed {
key: logical.clone(),
modified_key: logical,
physical_key: physical,
location: keyboard::Location::Standard,
modifiers,
text: insert_text,
repeat: false,
}));
} else {
handle.events.push(Event::Keyboard(keyboard::Event::KeyReleased {
key: logical.clone(),
modified_key: logical,
physical_key: physical,
location: keyboard::Location::Standard,
modifiers,
}));
}
}
fn keycode_to_named(keycode: u32) -> Option<keyboard::key::Named> {
use keyboard::key::Named;
match keycode {
36 => Some(Named::Enter),
48 => Some(Named::Tab),
51 => Some(Named::Backspace),
53 => Some(Named::Escape),
117 => Some(Named::Delete),
123 => Some(Named::ArrowLeft),
124 => Some(Named::ArrowRight),
125 => Some(Named::ArrowDown),
126 => Some(Named::ArrowUp),
115 => Some(Named::Home),
119 => Some(Named::End),
116 => Some(Named::PageUp),
121 => Some(Named::PageDown),
122 => Some(Named::F1),
120 => Some(Named::F2),
99 => Some(Named::F3),
118 => Some(Named::F4),
96 => Some(Named::F5),
97 => Some(Named::F6),
98 => Some(Named::F7),
100 => Some(Named::F8),
101 => Some(Named::F9),
109 => Some(Named::F10),
103 => Some(Named::F11),
111 => Some(Named::F12),
_ => None,
}
}
pub fn push_scroll_event(
handle: &mut ViewportHandle,
x: f32,
y: f32,
delta_x: f32,
delta_y: f32,
) {
let position = Point::new(x, y);
handle.cursor = mouse::Cursor::Available(position);
handle.events.push(Event::Mouse(mouse::Event::WheelScrolled {
delta: mouse::ScrollDelta::Pixels {
x: delta_x,
y: delta_y,
},
}));
}
fn decode_modifiers(flags: u32) -> keyboard::Modifiers {
let mut m = keyboard::Modifiers::empty();
// NSEvent modifier flags
if flags & (1 << 17) != 0 { m |= keyboard::Modifiers::SHIFT; }
if flags & (1 << 18) != 0 { m |= keyboard::Modifiers::CTRL; }
if flags & (1 << 19) != 0 { m |= keyboard::Modifiers::ALT; }
if flags & (1 << 20) != 0 { m |= keyboard::Modifiers::LOGO; }
m
}

4035
viewport/src/editor.rs Normal file

File diff suppressed because it is too large Load Diff

383
viewport/src/export.rs Normal file
View File

@ -0,0 +1,383 @@
//! Export a note as a standalone Rust crate. The crate mirrors the sidecar
//! ZIP's structure (src/blocks/*.cord + config.toml) but is written to a
//! user-chosen folder on disk with the full Cargo scaffolding (Cargo.toml,
//! build.sh, install.sh, README.md, src/main.rs, src/lib.rs).
//!
//! The main module (src/main.rs) runs a REPL using acord-core's interpreter.
//! Each `.cord` block is a submodule loaded into the REPL's scope at startup.
//! AOT codegen (Cordial → Rust source) is planned separately — build.sh is a
//! stub that currently just does `cargo build --release` of the REPL binary.
use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};
use crate::editor::EditorState;
use crate::heading_block::HeadingBlock;
use crate::text_block::TextBlock;
/// Convert a free-form string to hyphen-form for use as a crate/folder name.
/// Lowercase, spaces and underscores become `-`, non-alphanumeric stripped.
pub fn to_hyphen_name(s: &str) -> String {
s.trim()
.to_lowercase()
.chars()
.map(|c| if c == ' ' || c == '_' { '-' } else { c })
.filter(|c| c.is_alphanumeric() || *c == '-')
.collect::<String>()
.trim_matches('-')
.to_string()
}
/// Export the current note as a standalone Rust crate at `out_dir`. The
/// folder name is the crate name; spaces/underscores in the user-supplied
/// name get converted to hyphens. Returns Ok(path) on success.
pub fn export_crate(state: &EditorState, out_dir: &Path, name: &str) -> Result<PathBuf, String> {
let crate_name = to_hyphen_name(name);
if crate_name.is_empty() {
return Err("crate name is empty after normalization".into());
}
let crate_dir = out_dir.join(&crate_name);
let src_dir = crate_dir.join("src");
let blocks_dir = src_dir.join("blocks");
fs::create_dir_all(&blocks_dir)
.map_err(|e| format!("create {}: {}", blocks_dir.display(), e))?;
// Write per-block .cord files (reuses the same format as the sidecar)
let block_files = state.build_block_files();
for file in &block_files {
let path = blocks_dir.join(&file.filename);
write_file(&path, &file.content)?;
}
// Write the three scaffolding files: Cargo.toml, main.rs, lib.rs
write_file(&crate_dir.join("Cargo.toml"), &cargo_toml(&crate_name))?;
write_file(&src_dir.join("main.rs"), &main_rs(&crate_name, &block_files))?;
write_file(&src_dir.join("lib.rs"), &lib_rs(&block_files))?;
// Scripts + README + gitignore
let build_path = crate_dir.join("build.sh");
write_file(&build_path, &build_sh(&crate_name))?;
make_executable(&build_path)?;
let install_path = crate_dir.join("install.sh");
write_file(&install_path, &install_sh(&crate_name))?;
make_executable(&install_path)?;
write_file(&crate_dir.join("README.md"), &readme_md(state, &crate_name))?;
write_file(&crate_dir.join(".gitignore"), "target/\nCargo.lock\n")?;
Ok(crate_dir)
}
fn write_file(path: &Path, content: &str) -> Result<(), String> {
let mut f = fs::File::create(path)
.map_err(|e| format!("create {}: {}", path.display(), e))?;
f.write_all(content.as_bytes())
.map_err(|e| format!("write {}: {}", path.display(), e))?;
Ok(())
}
#[cfg(unix)]
fn make_executable(path: &Path) -> Result<(), String> {
use std::os::unix::fs::PermissionsExt;
let mut perms = fs::metadata(path)
.map_err(|e| format!("metadata {}: {}", path.display(), e))?
.permissions();
perms.set_mode(0o755);
fs::set_permissions(path, perms)
.map_err(|e| format!("chmod {}: {}", path.display(), e))
}
#[cfg(not(unix))]
fn make_executable(_path: &Path) -> Result<(), String> { Ok(()) }
fn cargo_toml(name: &str) -> String {
format!(
r#"[package]
name = "{name}"
version = "0.1.0"
edition = "2024"
[lib]
path = "src/lib.rs"
[[bin]]
name = "{name}"
path = "src/main.rs"
[dependencies]
acord-core = {{ git = "https://git.else-if.org/jess/Acord.git", package = "acord-core" }}
"#,
)
}
fn main_rs(name: &str, block_files: &[crate::sidecar::BlockFile]) -> String {
let mut includes = String::new();
let mut init_lines = String::new();
for file in block_files {
let var = ident_from_filename(&file.filename);
includes.push_str(&format!(
"const {}: &str = include_str!(\"blocks/{}\");\n",
var.to_uppercase(),
file.filename
));
init_lines.push_str(&format!(
" load_block(&mut interp, {});\n",
var.to_uppercase()
));
}
format!(
r#"//! {name} — exported Acord note running as a REPL.
//!
//! `cargo run` drops you into an interactive Cordial prompt with every block
//! from the note pre-loaded. It's the notepad experience, minus the UI.
//!
//! Type expressions, call functions, reference tables. `:list` to inspect,
//! `:q` to quit.
use acord_core::interp::Interpreter;
use std::io::{{self, BufRead, Write}};
{includes}
fn main() {{
let mut interp = Interpreter::new();
{init_lines}
println!("{{}} REPL — :list to show bindings, :q to quit", env!("CARGO_PKG_NAME"));
let stdin = io::stdin();
let mut out = io::stdout().lock();
let mut line = String::new();
loop {{
write!(out, "> ").ok();
out.flush().ok();
line.clear();
if stdin.lock().read_line(&mut line).unwrap_or(0) == 0 {{ break; }}
let trimmed = line.trim();
if trimmed.is_empty() {{ continue; }}
if trimmed == ":q" || trimmed == ":quit" {{ break; }}
if trimmed == ":list" {{
list_bindings(&interp);
continue;
}}
match interp.exec_line(trimmed) {{
Ok(Some(v)) => println!("{{}}", v.display()),
Ok(None) => {{}}
Err(e) => eprintln!("error: {{}}", e),
}}
}}
}}
/// Strip the `.cord` front-matter (everything up to and including the first
/// standalone `---`) and evaluate the remaining source into `interp`.
/// Lines that fail to parse are silently skipped — same as in the notepad.
fn load_block(interp: &mut Interpreter, source: &str) {{
let body = strip_front_matter(source);
for line in body.lines() {{
let _ = interp.exec_line(line);
}}
}}
fn strip_front_matter(src: &str) -> &str {{
if !src.starts_with("---") {{ return src; }}
let mut lines = src.split_inclusive('\n');
// skip opening ---
lines.next();
// skip through closing ---
let mut consumed = 0;
for line in &mut lines {{
consumed += line.len();
if line.trim_end_matches('\n').trim() == "---" {{ break; }}
}}
&src[3 + consumed..]
}}
fn list_bindings(_interp: &Interpreter) {{
// TODO: when acord-core exposes a public bindings iterator, enumerate here.
// For now, users discover bindings by referencing them.
println!("(binding enumeration not yet implemented)");
}}
"#,
)
}
fn lib_rs(block_files: &[crate::sidecar::BlockFile]) -> String {
let mut includes = String::new();
let mut init_lines = String::new();
for file in block_files {
let var = ident_from_filename(&file.filename);
includes.push_str(&format!(
"const {}: &str = include_str!(\"blocks/{}\");\n",
var.to_uppercase(),
file.filename
));
init_lines.push_str(&format!(
" load_block(&mut interp, {});\n",
var.to_uppercase()
));
}
format!(
r#"//! Exposes this note's loaded interpreter for use from other Rust projects.
//!
//! Example:
//! ```no_run
//! let mut interp = my_note::load();
//! let v = interp.exec_line("my_fn(1, 2, 3)").unwrap();
//! ```
use acord_core::interp::Interpreter;
{includes}
pub fn load() -> Interpreter {{
let mut interp = Interpreter::new();
{init_lines}
interp
}}
fn load_block(interp: &mut Interpreter, source: &str) {{
let body = strip_front_matter(source);
for line in body.lines() {{
let _ = interp.exec_line(line);
}}
}}
fn strip_front_matter(src: &str) -> &str {{
if !src.starts_with("---") {{ return src; }}
let mut lines = src.split_inclusive('\n');
lines.next();
let mut consumed = 0;
for line in &mut lines {{
consumed += line.len();
if line.trim_end_matches('\n').trim() == "---" {{ break; }}
}}
&src[3 + consumed..]
}}
"#,
)
}
fn build_sh(_name: &str) -> String {
r#"#!/usr/bin/env bash
set -e
# TODO: AOT codegen compile .cord sources to Rust source, produce a static
# binary with zero interpreter overhead. See the cordial-to-rust-codegen plan
# for the design. Until then, this builds the REPL binary in release mode.
cargo build --release
echo "Built target/release/$(basename "$PWD")"
"#
.into()
}
fn install_sh(name: &str) -> String {
format!(
r#"#!/usr/bin/env bash
set -e
NAME="{name}"
DEST="${{HOME}}/.acord/bin"
if [ ! -f "target/release/${{NAME}}" ]; then
echo "No release binary found. Running ./build.sh first..."
./build.sh
fi
mkdir -p "$DEST"
cp "target/release/${{NAME}}" "$DEST/${{NAME}}"
chmod +x "$DEST/${{NAME}}"
echo "Installed: ${{DEST}}/${{NAME}}"
echo ""
echo "Add ~/.acord/bin to your PATH if you haven't already:"
echo ""
echo " # zsh:"
echo " echo 'export PATH=\"\$HOME/.acord/bin:\$PATH\"' >> ~/.zshrc && source ~/.zshrc"
echo ""
echo " # bash:"
echo " echo 'export PATH=\"\$HOME/.acord/bin:\$PATH\"' >> ~/.bashrc && source ~/.bashrc"
"#,
)
}
fn readme_md(state: &EditorState, name: &str) -> String {
let mut inventory = String::new();
for block_id in state.layout.iter() {
let Some(block) = state.registry.get(block_id) else { continue };
let kind = block.kind_tag();
if let Some(hb) = block.as_any().downcast_ref::<HeadingBlock>() {
inventory.push_str(&format!(
"- **{kind}** (level {}) — `{}`\n",
hb.level as u8 + 1,
hb.text.trim()
));
} else if let Some(tb) = block.as_any().downcast_ref::<TextBlock>() {
let first_line = tb.content.text();
let preview = first_line.lines().next().unwrap_or("").trim();
if !preview.is_empty() {
inventory.push_str(&format!("- **{kind}** — {}\n", truncate(preview, 60)));
} else {
inventory.push_str(&format!("- **{kind}**\n"));
}
} else {
inventory.push_str(&format!("- **{kind}**\n"));
}
}
format!(
r#"# {name}
This is your Acord note, exported as a standalone Rust crate.
## Run
- `cargo run` interactive Cordial REPL with every binding from your note pre-loaded. Call functions, reference tables, mutate variables exactly like opening a new block below the existing ones in the notepad.
- `./build.sh` build the release binary.
- `./install.sh` install the binary to `~/.acord/bin` and print PATH setup instructions.
## Blocks
{inventory}
## Use from another Rust project
Add this crate to your `Cargo.toml` as a path dependency, then:
```rust
use {name}::load;
let mut interp = load();
let v = interp.exec_line("my_fn(1, 2, 3)").unwrap();
println!("{{}}", v.unwrap().display());
```
## Notes
- Future versions will AOT-compile your `.cord` sources into native Rust via `./build.sh`, producing binaries with zero interpreter overhead. Today the binary uses the interpreter at runtime.
- This crate depends on `acord-core` via git. Make sure the host has network access on first build, or switch to a path/vendored dep for offline environments.
"#,
)
}
fn truncate(s: &str, max: usize) -> String {
if s.chars().count() <= max { s.to_string() } else {
let mut out: String = s.chars().take(max).collect();
out.push_str("...");
out
}
}
fn ident_from_filename(filename: &str) -> String {
let stem = filename.trim_end_matches(".cord");
stem.chars()
.map(|c| if c.is_alphanumeric() { c } else { '_' })
.collect::<String>()
.trim_start_matches(|c: char| !c.is_alphabetic() && c != '_')
.to_string()
}
#[allow(dead_code)]
fn derive_name_from_first_line(text: &str) -> String {
let first = text
.lines()
.find(|l| !l.trim().is_empty())
.unwrap_or("");
let cleaned = first.trim_start_matches('#').trim();
let first_two: Vec<&str> = cleaned.split_whitespace().take(2).collect();
to_hyphen_name(&first_two.join(" "))
}

696
viewport/src/handle.rs Normal file
View File

@ -0,0 +1,696 @@
use std::ffi::c_void;
use std::ptr::NonNull;
use iced_graphics::{Shell, Viewport};
use iced_runtime::user_interface::{self, UserInterface};
use iced_wgpu::core::renderer::Style;
use iced_wgpu::core::time::Instant;
use iced_wgpu::core::{clipboard, keyboard, mouse, window, Color, Event, Font, Pixels, Point, Size, Theme};
use iced_wgpu::Engine;
use raw_window_handle::{
AppKitDisplayHandle, AppKitWindowHandle, RawDisplayHandle, RawWindowHandle,
};
use crate::editor::{EditorState, Message, RenderMode};
use crate::palette;
use crate::table_block::TableMessage;
use crate::ViewportHandle;
struct MacClipboard;
impl clipboard::Clipboard for MacClipboard {
fn read(&self, _kind: clipboard::Kind) -> Option<String> {
std::process::Command::new("pbpaste")
.output()
.ok()
.and_then(|o| String::from_utf8(o.stdout).ok())
}
fn write(&mut self, _kind: clipboard::Kind, contents: String) {
use std::io::Write;
if let Ok(mut child) = std::process::Command::new("pbcopy")
.stdin(std::process::Stdio::piped())
.spawn()
{
if let Some(stdin) = child.stdin.as_mut() {
let _ = stdin.write_all(contents.as_bytes());
}
let _ = child.wait();
}
}
}
pub fn create(
nsview: *mut c_void,
width: f32,
height: f32,
scale: f32,
) -> Option<ViewportHandle> {
let ptr = NonNull::new(nsview)?;
let instance = wgpu::Instance::new(&wgpu::InstanceDescriptor {
backends: wgpu::Backends::METAL,
..Default::default()
});
let raw_window = RawWindowHandle::AppKit(AppKitWindowHandle::new(ptr));
let raw_display = RawDisplayHandle::AppKit(AppKitDisplayHandle::new());
let target = wgpu::SurfaceTargetUnsafe::RawHandle {
raw_display_handle: raw_display,
raw_window_handle: raw_window,
};
let surface = unsafe { instance.create_surface_unsafe(target).ok()? };
let adapter = pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions {
power_preference: wgpu::PowerPreference::HighPerformance,
compatible_surface: Some(&surface),
force_fallback_adapter: false,
}))
.ok()?;
let (device, queue) =
pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor::default())).ok()?;
let phys_w = (width * scale) as u32;
let phys_h = (height * scale) as u32;
let caps = surface.get_capabilities(&adapter);
let format = caps.formats.first().copied()?;
surface.configure(
&device,
&wgpu::SurfaceConfiguration {
usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
format,
width: phys_w.max(1),
height: phys_h.max(1),
present_mode: wgpu::PresentMode::AutoVsync,
alpha_mode: caps
.alpha_modes
.first()
.copied()
.unwrap_or(wgpu::CompositeAlphaMode::Auto),
view_formats: vec![],
desired_maximum_frame_latency: 2,
},
);
let engine = Engine::new(
&adapter,
device.clone(),
queue.clone(),
format,
None,
Shell::headless(),
);
let renderer = iced_wgpu::Renderer::new(engine, Font::DEFAULT, Pixels(16.0));
let viewport =
Viewport::with_physical_size(Size::new(phys_w.max(1), phys_h.max(1)), scale);
let focus_point = Point::new(width / 2.0, height / 2.0);
let initial_events = vec![
Event::Mouse(mouse::Event::CursorMoved { position: focus_point }),
Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)),
Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)),
];
Some(ViewportHandle {
surface,
device,
queue,
format,
width: phys_w,
height: phys_h,
scale,
renderer,
viewport,
cache: user_interface::Cache::new(),
state: EditorState::new(),
events: initial_events,
cursor: mouse::Cursor::Available(focus_point),
// First frame must paint.
needs_redraw: true,
})
}
pub fn render(handle: &mut ViewportHandle) {
// Idle-frame short circuit. The Swift CVDisplayLink ticks viewport_render() at
// vsync regardless of activity. Without this gate we'd rebuild the entire widget
// tree, run update + draw, and present a frame ~60 times per second forever.
// We still wake up while `eval_dirty` is set so the eval debounce in
// EditorState::tick() can fire after typing stops.
let pending_events = !handle.events.is_empty();
if !handle.needs_redraw && !handle.state.has_pending_eval() && !pending_events {
return;
}
let frame = match handle.surface.get_current_texture() {
Ok(f) => f,
Err(_) => return,
};
let view = frame.texture.create_view(&Default::default());
let logical_size = handle.viewport.logical_size();
handle
.events
.push(Event::Window(window::Event::RedrawRequested(Instant::now())));
let cache = std::mem::take(&mut handle.cache);
let mut ui = UserInterface::build(
handle.state.view(),
Size::new(logical_size.width, logical_size.height),
cache,
&mut handle.renderer,
);
let mut clipboard = MacClipboard;
let mut messages: Vec<Message> = Vec::new();
let mut consumed: Vec<usize> = Vec::new();
// Captured during the event scan, applied to `handle.state.mods` AFTER
// `ui` is released — the UI build above borrows `&handle.state` so we
// can't mutate any field of state while it's alive.
let mut latest_mods: Option<keyboard::Modifiers> = None;
// Cmd+A escalation: armed by the first press, escalates on the second.
// Some(true) = arm for next press, Some(false) = disarm. None = unchanged.
// Events are scanned in order so the LAST write wins — a Cmd+A followed
// by a mouse click in the same frame correctly disarms.
let mut new_cmd_a_armed: Option<bool> = None;
for (ev_idx, event) in handle.events.iter().enumerate() {
// Default-disarm Cmd+A for any user input. The Cmd+A arm below
// overwrites with Some(true) when it actually wants to arm. Events
// are scanned in order so the LAST write wins for the frame.
let is_user_input = matches!(
event,
Event::Keyboard(keyboard::Event::KeyPressed { .. })
| Event::Mouse(mouse::Event::ButtonPressed(_))
);
if is_user_input {
new_cmd_a_armed = Some(false);
}
// View mode: consume all events except mode-switch keys.
// Ctrl+I, Ctrl+/, Ctrl+Esc, `i`, `/` are handled by their own
// match arms below. Everything else is swallowed.
if handle.state.render_mode == RenderMode::View {
let is_mode_switch = match event {
Event::Keyboard(keyboard::Event::KeyPressed {
key: keyboard::Key::Character(c), modifiers, ..
}) => {
(modifiers.control() && (c.as_str() == "i" || c.as_str() == "/"))
|| (!modifiers.logo() && !modifiers.control() && !modifiers.alt()
&& (c.as_str() == "i" || c.as_str() == "/"))
}
Event::Keyboard(keyboard::Event::KeyPressed {
key: keyboard::Key::Named(keyboard::key::Named::Escape), modifiers, ..
}) => modifiers.control(),
_ => false,
};
if !is_mode_switch {
consumed.push(ev_idx);
continue;
}
}
match event {
Event::Keyboard(keyboard::Event::KeyPressed {
key: keyboard::Key::Character(c),
modifiers,
..
}) if modifiers.logo() => {
match c.as_str() {
"p" => {
messages.push(Message::TogglePreview);
consumed.push(ev_idx);
}
"t" => {
messages.push(Message::InsertTable);
consumed.push(ev_idx);
}
// Cmd+A — first press lets the focused block do its
// local select-all (text_editor selects its text;
// table cells in select mode upgrade to whole-table
// selection). Second press while still armed escalates
// to whole-document selection.
"a" => {
if handle.state.cmd_a_armed {
messages.push(Message::SelectAllBlocks);
new_cmd_a_armed = Some(false);
consumed.push(ev_idx);
} else {
// First press path. Decide what "local select all"
// means for the focused block.
if handle.state.table_is_focused_block()
&& !handle.state.focused_table_is_select_all()
&& handle.state.editing.is_none()
{
// Cell-selected table → escalate to whole-table.
messages.push(Message::FocusedTableOp(
TableMessage::SelectAll,
));
consumed.push(ev_idx);
}
// For text blocks (and table cells in edit mode),
// do NOT consume — let iced's text_editor /
// text_input handle Cmd+A natively.
new_cmd_a_armed = Some(true);
}
}
"b" => {
messages.push(Message::ToggleBold);
consumed.push(ev_idx);
}
"i" => {
messages.push(Message::ToggleItalic);
consumed.push(ev_idx);
}
"e" => {
messages.push(Message::SmartEval);
consumed.push(ev_idx);
}
"z" if modifiers.shift() => {
messages.push(Message::Redo);
consumed.push(ev_idx);
}
"z" => {
messages.push(Message::Undo);
consumed.push(ev_idx);
}
"f" => {
messages.push(Message::ToggleFind);
consumed.push(ev_idx);
}
"g" if modifiers.shift() => {
messages.push(Message::FindPrev);
consumed.push(ev_idx);
}
"g" => {
messages.push(Message::FindNext);
consumed.push(ev_idx);
}
_ => {}
}
}
// Ctrl+I → Editor mode, Ctrl+/ → Live mode
Event::Keyboard(keyboard::Event::KeyPressed {
key: keyboard::Key::Character(c),
modifiers,
..
}) if modifiers.control() && !modifiers.logo() => {
match c.as_str() {
"i" => {
messages.push(Message::SetRenderMode(RenderMode::Editor));
consumed.push(ev_idx);
}
"/" => {
messages.push(Message::SetRenderMode(RenderMode::Live));
consumed.push(ev_idx);
}
_ => {}
}
}
// Ctrl+Escape → View mode
Event::Keyboard(keyboard::Event::KeyPressed {
key: keyboard::Key::Named(keyboard::key::Named::Escape),
modifiers,
..
}) if modifiers.control() => {
messages.push(Message::SetRenderMode(RenderMode::View));
consumed.push(ev_idx);
}
// View mode: `i` → Editor, `/` → Live
Event::Keyboard(keyboard::Event::KeyPressed {
key: keyboard::Key::Character(c),
modifiers,
..
}) if handle.state.render_mode == RenderMode::View
&& !modifiers.logo() && !modifiers.control() && !modifiers.alt() => {
match c.as_str() {
"i" => {
messages.push(Message::SetRenderMode(RenderMode::Editor));
consumed.push(ev_idx);
}
"/" => {
messages.push(Message::SetRenderMode(RenderMode::Live));
consumed.push(ev_idx);
}
_ => {}
}
}
Event::Keyboard(keyboard::Event::KeyPressed {
key: keyboard::Key::Named(named),
modifiers,
..
}) if modifiers.logo() && modifiers.alt() => {
use keyboard::key::Named;
let op = match named {
Named::ArrowUp => Some(TableMessage::InsertRowAbove),
Named::ArrowDown => Some(TableMessage::InsertRowBelow),
Named::ArrowLeft => Some(TableMessage::InsertColLeft),
Named::ArrowRight => Some(TableMessage::InsertColRight),
Named::Backspace if modifiers.shift() => Some(TableMessage::DeleteCol),
Named::Backspace => Some(TableMessage::DeleteRow),
_ => None,
};
if let Some(tmsg) = op {
messages.push(Message::FocusedTableOp(tmsg));
consumed.push(ev_idx);
}
}
Event::Keyboard(keyboard::Event::KeyPressed {
key: keyboard::Key::Named(named),
modifiers,
..
}) if !modifiers.logo() && !modifiers.alt() && !modifiers.control()
&& handle.state.table_is_focused_block() =>
{
use keyboard::key::Named;
match named {
Named::Tab if modifiers.shift() => {
messages.push(Message::TableShiftTab);
consumed.push(ev_idx);
}
Named::Tab => {
messages.push(Message::TableTab);
consumed.push(ev_idx);
}
Named::Enter => {
messages.push(Message::TableEnter);
consumed.push(ev_idx);
}
// Up arrow inside a table cell. If we're on a non-top row,
// move the cell focus up. If we're on row 0, escape upward
// into the previous text block (synthesize one if none).
Named::ArrowUp => {
if let Some((block_idx, row, _)) =
handle.state.active_table_focused_row()
{
if row == 0 {
messages.push(Message::EscapeTableUp(block_idx));
} else {
messages.push(Message::TableMoveUp);
}
consumed.push(ev_idx);
}
}
// Mirror of Up — row navigation with edge-escape.
Named::ArrowDown => {
if let Some((block_idx, row, total)) =
handle.state.active_table_focused_row()
{
if row + 1 >= total {
messages.push(Message::EscapeTableDown(block_idx));
} else {
messages.push(Message::TableMoveDown);
}
consumed.push(ev_idx);
}
}
// Left/Right walk the row. No edge-escape — at column 0 or
// the last column the move just no-ops; cell stays put.
Named::ArrowLeft => {
messages.push(Message::TableMoveLeft);
consumed.push(ev_idx);
}
Named::ArrowRight => {
messages.push(Message::TableMoveRight);
consumed.push(ev_idx);
}
// Backspace/Delete behavior depends on selection scope:
// - whole table selected → clear every cell's content
// - single cell selected (not editing) → clear that cell
// Edit mode is handled by text_input's own backspace.
Named::Backspace | Named::Delete
if handle.state.focused_table_is_select_all() =>
{
messages.push(Message::FocusedTableOp(TableMessage::ClearAll));
consumed.push(ev_idx);
}
Named::Backspace | Named::Delete
if handle.state.has_selected_cell_not_editing() =>
{
messages.push(Message::ClearSelectedCell);
consumed.push(ev_idx);
}
_ => {}
}
}
// Cmd+Backspace with the whole document selected → wipe all
// blocks down to one empty text block.
Event::Keyboard(keyboard::Event::KeyPressed {
key: keyboard::Key::Named(keyboard::key::Named::Backspace),
modifiers,
..
}) if modifiers.logo() && !modifiers.alt() && !modifiers.control()
&& handle.state.all_blocks_selected =>
{
messages.push(Message::DeleteAllBlocks);
consumed.push(ev_idx);
}
// Cmd+Backspace with the whole table selected → delete the table.
// Mirrors the user's "Cmd+Delete deletes whatever's selected" rule
// applied at table scope. Single-cell selection has its own
// Cmd+Alt+Backspace = delete row binding below.
Event::Keyboard(keyboard::Event::KeyPressed {
key: keyboard::Key::Named(keyboard::key::Named::Backspace),
modifiers,
..
}) if modifiers.logo() && !modifiers.alt() && !modifiers.control()
&& handle.state.focused_table_is_select_all() =>
{
messages.push(Message::DeleteCurrentTable);
consumed.push(ev_idx);
}
// Plain Backspace/Delete with whole document selected → clear all
// block content but keep structure.
Event::Keyboard(keyboard::Event::KeyPressed {
key: keyboard::Key::Named(named),
modifiers,
..
}) if (matches!(named, keyboard::key::Named::Backspace | keyboard::key::Named::Delete))
&& !modifiers.logo() && !modifiers.alt() && !modifiers.control()
&& handle.state.all_blocks_selected =>
{
messages.push(Message::ClearAllBlocks);
consumed.push(ev_idx);
}
Event::Keyboard(keyboard::Event::KeyPressed {
key: keyboard::Key::Named(keyboard::key::Named::Escape),
modifiers,
..
}) if !modifiers.control() => {
if handle.state.context_menu.is_some() {
messages.push(Message::HideContextMenu);
consumed.push(ev_idx);
} else if handle.state.find.visible {
messages.push(Message::HideFind);
consumed.push(ev_idx);
} else if handle.state.editing.is_some() {
messages.push(Message::ExitCellEdit);
consumed.push(ev_idx);
} else {
// Nothing to dismiss — chain mode switch.
// Live → Editor, Editor → View
match handle.state.render_mode {
RenderMode::Live => {
messages.push(Message::SetRenderMode(RenderMode::Editor));
consumed.push(ev_idx);
}
RenderMode::Editor => {
messages.push(Message::SetRenderMode(RenderMode::View));
consumed.push(ev_idx);
}
RenderMode::View => {}
}
}
}
// Printable-key entry into a selected cell. When a table cell is
// selected (highlighted) but not yet in edit mode, hitting any
// printable character should overwrite the cell with that
// character and enter edit mode in one step.
Event::Keyboard(keyboard::Event::KeyPressed {
key: keyboard::Key::Character(c),
modifiers,
..
}) if !modifiers.logo() && !modifiers.alt() && !modifiers.control()
&& handle.state.has_selected_cell_not_editing() =>
{
if let Some(first) = c.chars().next() {
if !first.is_control() {
messages.push(Message::EnterCellEditWithChar(first));
consumed.push(ev_idx);
}
}
}
Event::Keyboard(keyboard::Event::ModifiersChanged(mods)) => {
latest_mods = Some(*mods);
}
_ => {}
}
}
// Strip keyboard events we've already routed into Messages, so iced's
// text_input/text_editor doesn't also process them and corrupt cell content.
if !consumed.is_empty() {
let consumed_set: std::collections::HashSet<usize> = consumed.into_iter().collect();
let mut filtered: Vec<Event> = Vec::with_capacity(handle.events.len());
for (i, e) in handle.events.drain(..).enumerate() {
if !consumed_set.contains(&i) {
filtered.push(e);
}
}
handle.events = filtered;
}
let _ = ui.update(
&handle.events,
handle.cursor,
&mut handle.renderer,
&mut clipboard,
&mut messages,
);
handle.events.clear();
// Snapshot which cell (if any) is currently focused in any table, so that
// subsequent structural edit shortcuts (insert row, delete col, ...) can
// target the right block without a separate focus-tracking field.
let focused_id = {
use iced_wgpu::core::widget::operation::{Focusable, Operation};
use iced_wgpu::core::widget::Id as CoreId;
use iced_wgpu::core::Rectangle;
struct FindFocusedId {
focused: Option<CoreId>,
}
impl Operation<()> for FindFocusedId {
fn focusable(
&mut self,
id: Option<&CoreId>,
_bounds: Rectangle,
state: &mut dyn Focusable,
) {
if state.is_focused() && id.is_some() && self.focused.is_none() {
self.focused = id.cloned();
}
}
fn traverse(
&mut self,
operate: &mut dyn FnMut(&mut dyn Operation<()>),
) {
operate(self);
}
fn container(&mut self, _id: Option<&CoreId>, _bounds: Rectangle) {}
}
let mut op = FindFocusedId { focused: None };
ui.operate(&handle.renderer, &mut op);
op.focused
};
let cache = ui.into_cache();
if let Some(mods) = latest_mods {
handle.state.mods = mods;
}
if let Some(armed) = new_cmd_a_armed {
handle.state.cmd_a_armed = armed;
}
// Update cursor pos BEFORE draining messages so right-click handlers can
// anchor the context menu at the current position in the same frame.
if let Some(pt) = handle.cursor.position() {
handle.state.cursor_pos = pt;
}
handle.state.sync_focused_cell(focused_id.as_ref());
for msg in messages.drain(..) {
handle.state.update(msg);
}
handle.state.tick();
let pending_focus = handle.state.take_pending_focus();
// Drain BEFORE the second `ui` is built — `view()` re-borrows state and
// would block any subsequent mutable take.
let pending_scroll = handle.state.take_pending_scroll();
let theme = Theme::Dark;
let style = Style {
text_color: Color::WHITE,
};
let mut ui = UserInterface::build(
handle.state.view(),
Size::new(logical_size.width, logical_size.height),
cache,
&mut handle.renderer,
);
if let Some(focus_id) = pending_focus {
use iced_wgpu::core::widget::operation::focusable;
let mut op = focusable::focus(focus_id);
ui.operate(&handle.renderer, &mut op);
}
// Forward any wheel-scroll delta that an inner text_editor swallowed
// (Action::Scroll) to the outer document scrollable. text_editor captures
// WheelScrolled when the cursor is over its bounds, which would otherwise
// leave the page stuck. The editor.rs Action::Scroll handler accumulates
// pixel deltas into pending_scroll; here we drain and apply them.
if let Some(delta_y) = pending_scroll {
use iced_wgpu::core::widget::operation::scrollable::{scroll_by, AbsoluteOffset};
use iced_wgpu::core::widget::Id as CoreId;
let mut op = scroll_by::<()>(
CoreId::new(crate::editor::DOC_SCROLLABLE_ID),
AbsoluteOffset { x: 0.0, y: delta_y },
);
ui.operate(&handle.renderer, &mut op);
}
ui.draw(&mut handle.renderer, &theme, &style, handle.cursor);
handle.cache = ui.into_cache();
handle
.renderer
.present(Some(palette::current().base), handle.format, &view, &handle.viewport);
frame.present();
// Frame is on screen. Clear dirty so the next vsync tick is a free no-op
// unless something genuinely changed (input event, eval debounce, etc.).
handle.needs_redraw = false;
}
pub fn resize(handle: &mut ViewportHandle, width: f32, height: f32, scale: f32) {
let phys_w = (width * scale) as u32;
let phys_h = (height * scale) as u32;
if phys_w == 0 || phys_h == 0 {
return;
}
handle.width = phys_w;
handle.height = phys_h;
handle.scale = scale;
handle.viewport = Viewport::with_physical_size(Size::new(phys_w, phys_h), scale);
handle.surface.configure(
&handle.device,
&wgpu::SurfaceConfiguration {
usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
format: handle.format,
width: phys_w,
height: phys_h,
present_mode: wgpu::PresentMode::AutoVsync,
alpha_mode: wgpu::CompositeAlphaMode::Auto,
view_formats: vec![],
desired_maximum_frame_latency: 2,
},
);
}

View File

@ -0,0 +1,190 @@
use iced_wgpu::core::alignment;
use iced_wgpu::core::text::LineHeight;
use iced_wgpu::core::{
mouse, Element, Font, Length, Pixels, Point, Rectangle, Theme,
};
use iced_wgpu::core::font::Weight;
use iced_widget::canvas;
use crate::block::{Block, BlockCommand, LayeredView, ViewCtx};
use crate::palette;
use crate::selection::{BlockId, InnerPath};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum HeadingLevel {
H1,
H2,
H3,
H4,
}
impl HeadingLevel {
pub fn scale(&self) -> f32 {
match self {
HeadingLevel::H1 => 2.0,
HeadingLevel::H2 => 1.5,
HeadingLevel::H3 => 1.17,
HeadingLevel::H4 => 1.0,
}
}
pub fn weight(&self) -> Weight {
match self {
HeadingLevel::H1 => Weight::Black,
HeadingLevel::H2 => Weight::Bold,
HeadingLevel::H3 => Weight::Semibold,
HeadingLevel::H4 => Weight::Medium,
}
}
pub fn as_u8(&self) -> u8 {
match self {
HeadingLevel::H1 => 1,
HeadingLevel::H2 => 2,
HeadingLevel::H3 => 3,
HeadingLevel::H4 => 4,
}
}
pub fn from_u8(level: u8) -> HeadingLevel {
match level {
1 => HeadingLevel::H1,
2 => HeadingLevel::H2,
3 => HeadingLevel::H3,
_ => HeadingLevel::H4,
}
}
}
struct HeadingProgram {
level: HeadingLevel,
text: String,
font_size: f32,
}
impl<Message: Clone> canvas::Program<Message, Theme, iced_wgpu::Renderer> for HeadingProgram {
type State = ();
fn draw(
&self,
_state: &(),
renderer: &iced_wgpu::Renderer,
_theme: &Theme,
bounds: Rectangle,
_cursor: mouse::Cursor,
) -> Vec<canvas::Geometry<iced_wgpu::Renderer>> {
let mut frame = canvas::Frame::new(renderer, bounds.size());
let p = palette::current();
let color = match self.level {
HeadingLevel::H1 => p.rosewater,
HeadingLevel::H2 => p.peach,
HeadingLevel::H3 => p.yellow,
HeadingLevel::H4 => p.green,
};
frame.fill_text(canvas::Text {
content: self.text.clone(),
position: Point::new(8.0, 4.0),
max_width: bounds.width - 16.0,
color,
size: Pixels(self.font_size),
line_height: LineHeight::Relative(1.4),
font: Font { weight: self.level.weight(), ..Font::DEFAULT },
align_x: iced_wgpu::core::text::Alignment::Left,
align_y: alignment::Vertical::Top,
shaping: iced_wgpu::core::text::Shaping::Basic,
});
vec![frame.into_geometry()]
}
}
fn build<Message: Clone + 'static>(
level: HeadingLevel,
text: &str,
base_font_size: f32,
) -> Element<'static, Message, Theme, iced_wgpu::Renderer> {
let font_size = base_font_size * level.scale();
let height = font_size * 1.4 + 8.0;
canvas::Canvas::new(HeadingProgram {
level,
text: text.to_string(),
font_size,
})
.width(Length::Fill)
.height(Length::Fixed(height))
.into()
}
pub struct HeadingBlock {
pub id: BlockId,
pub level: HeadingLevel,
pub text: String,
pub start_line: usize,
}
impl HeadingBlock {
pub fn new(id: BlockId, level: HeadingLevel, text: String, start_line: usize) -> Self {
Self { id, level, text, start_line }
}
}
impl<Message: Clone + 'static> Block<Message> for HeadingBlock {
fn id(&self) -> BlockId {
self.id
}
fn kind_tag(&self) -> &'static str {
"heading"
}
fn start_line(&self) -> usize {
self.start_line
}
fn set_start_line(&mut self, line: usize) {
self.start_line = line;
}
fn line_count(&self) -> usize {
1
}
fn as_any(&self) -> &dyn std::any::Any {
self
}
fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
self
}
fn view<'a>(&'a self, ctx: &ViewCtx<'_, Message>) -> LayeredView<'a, Message> {
LayeredView::just(build(self.level, &self.text, ctx.font_size))
}
fn to_md(&self) -> String {
let prefix = "#".repeat(self.level.as_u8() as usize);
format!("{prefix} {}", self.text)
}
fn hit_test(&self, _point: Point) -> Option<InnerPath> {
Some(InnerPath::Whole)
}
fn apply(&mut self, cmd: BlockCommand) {
match cmd {
BlockCommand::SetHeadingLevel(level) => {
self.level = HeadingLevel::from_u8(level);
}
BlockCommand::SetHeadingText(text) => {
self.text = text;
}
_ => {}
}
}
fn selectable_paths(&self) -> Box<dyn Iterator<Item = InnerPath> + '_> {
Box::new(std::iter::once(InnerPath::Whole))
}
}

105
viewport/src/hr_block.rs Normal file
View File

@ -0,0 +1,105 @@
use iced_wgpu::core::{mouse, Element, Length, Point, Rectangle, Theme};
use iced_widget::canvas;
use crate::block::{Block, BlockCommand, LayeredView, ViewCtx};
use crate::palette;
use crate::selection::{BlockId, InnerPath};
struct HRProgram;
impl<Message: Clone> canvas::Program<Message, Theme, iced_wgpu::Renderer> for HRProgram {
type State = ();
fn draw(
&self,
_state: &(),
renderer: &iced_wgpu::Renderer,
_theme: &Theme,
bounds: Rectangle,
_cursor: mouse::Cursor,
) -> Vec<canvas::Geometry<iced_wgpu::Renderer>> {
let mut frame = canvas::Frame::new(renderer, bounds.size());
let p = palette::current();
let y = bounds.height / 2.0;
let margin = 8.0;
let path = canvas::Path::line(
Point::new(margin, y),
Point::new(bounds.width - margin, y),
);
frame.stroke(
&path,
canvas::Stroke::default()
.with_width(1.0)
.with_color(p.overlay0),
);
vec![frame.into_geometry()]
}
}
fn build<Message: Clone + 'static>() -> Element<'static, Message, Theme, iced_wgpu::Renderer> {
canvas::Canvas::new(HRProgram)
.width(Length::Fill)
.height(Length::Fixed(20.0))
.into()
}
pub struct HrBlock {
pub id: BlockId,
pub start_line: usize,
}
impl HrBlock {
pub fn new(id: BlockId, start_line: usize) -> Self {
Self { id, start_line }
}
}
impl<Message: Clone + 'static> Block<Message> for HrBlock {
fn id(&self) -> BlockId {
self.id
}
fn kind_tag(&self) -> &'static str {
"hr"
}
fn start_line(&self) -> usize {
self.start_line
}
fn set_start_line(&mut self, line: usize) {
self.start_line = line;
}
fn line_count(&self) -> usize {
1
}
fn as_any(&self) -> &dyn std::any::Any {
self
}
fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
self
}
fn view<'a>(&'a self, _ctx: &ViewCtx<'_, Message>) -> LayeredView<'a, Message> {
LayeredView::just(build())
}
fn to_md(&self) -> String {
"---".to_string()
}
fn hit_test(&self, _point: Point) -> Option<InnerPath> {
Some(InnerPath::Whole)
}
fn apply(&mut self, _cmd: BlockCommand) {
// HRs have no structural state to mutate.
}
fn selectable_paths(&self) -> Box<dyn Iterator<Item = InnerPath> + '_> {
Box::new(std::iter::once(InnerPath::Whole))
}
}

286
viewport/src/lib.rs Normal file
View File

@ -0,0 +1,286 @@
use std::ffi::{c_char, c_void, CStr, CString};
pub mod block;
pub mod blocks;
mod bridge;
mod editor;
pub mod export;
mod handle;
pub mod heading_block;
pub mod hr_block;
pub mod module;
pub mod palette;
pub mod selection;
pub mod sidecar;
mod syntax;
pub mod table_block;
pub mod text_block;
pub mod text_widget;
pub mod tree_block;
pub use acord_core::*;
use editor::EditorState;
use iced_graphics::Viewport;
use iced_runtime::user_interface;
use iced_wgpu::core::Event;
#[allow(dead_code)]
pub struct ViewportHandle {
surface: wgpu::Surface<'static>,
device: wgpu::Device,
queue: wgpu::Queue,
format: wgpu::TextureFormat,
width: u32,
height: u32,
scale: f32,
renderer: iced_wgpu::Renderer,
viewport: Viewport,
cache: user_interface::Cache,
state: EditorState,
events: Vec<Event>,
cursor: iced_wgpu::core::mouse::Cursor,
/// Set true on any FFI input or state-change call. handle::render() early-returns
/// when this is false AND no pending eval debounce, so the vsync display link
/// becomes a microsecond no-op while the editor is idle.
pub needs_redraw: bool,
}
#[unsafe(no_mangle)]
pub extern "C" fn viewport_create(
nsview: *mut c_void,
width: f32,
height: f32,
scale: f32,
) -> *mut ViewportHandle {
if nsview.is_null() {
return std::ptr::null_mut();
}
match handle::create(nsview, width, height, scale) {
Some(h) => Box::into_raw(Box::new(h)),
None => std::ptr::null_mut(),
}
}
#[unsafe(no_mangle)]
pub extern "C" fn viewport_destroy(handle: *mut ViewportHandle) {
if handle.is_null() {
return;
}
unsafe {
drop(Box::from_raw(handle));
}
}
#[unsafe(no_mangle)]
pub extern "C" fn viewport_render(handle: *mut ViewportHandle) {
let h = match unsafe { handle.as_mut() } {
Some(h) => h,
None => return,
};
handle::render(h);
}
#[unsafe(no_mangle)]
pub extern "C" fn viewport_resize(
handle: *mut ViewportHandle,
width: f32,
height: f32,
scale: f32,
) {
let h = match unsafe { handle.as_mut() } {
Some(h) => h,
None => return,
};
handle::resize(h, width, height, scale);
h.needs_redraw = true;
}
#[unsafe(no_mangle)]
pub extern "C" fn viewport_mouse_event(
handle: *mut ViewportHandle,
x: f32,
y: f32,
button: u8,
pressed: bool,
) {
let h = match unsafe { handle.as_mut() } {
Some(h) => h,
None => return,
};
bridge::push_mouse_event(h, x, y, button, pressed);
h.needs_redraw = true;
}
#[unsafe(no_mangle)]
pub extern "C" fn viewport_key_event(
handle: *mut ViewportHandle,
key: u32,
modifiers: u32,
pressed: bool,
text: *const c_char,
) {
let h = match unsafe { handle.as_mut() } {
Some(h) => h,
None => return,
};
let text_str = if text.is_null() {
None
} else {
Some(unsafe { std::ffi::CStr::from_ptr(text) }.to_string_lossy())
};
bridge::push_key_event(h, key, modifiers, pressed, text_str.as_deref());
h.needs_redraw = true;
}
#[unsafe(no_mangle)]
pub extern "C" fn viewport_scroll_event(
handle: *mut ViewportHandle,
x: f32,
y: f32,
delta_x: f32,
delta_y: f32,
) {
let h = match unsafe { handle.as_mut() } {
Some(h) => h,
None => return,
};
bridge::push_scroll_event(h, x, y, delta_x, delta_y);
h.needs_redraw = true;
}
#[unsafe(no_mangle)]
pub extern "C" fn viewport_set_text(handle: *mut ViewportHandle, text: *const c_char) {
let h = match unsafe { handle.as_mut() } {
Some(h) => h,
None => return,
};
let s = if text.is_null() {
""
} else {
unsafe { CStr::from_ptr(text) }.to_str().unwrap_or("")
};
// Goes through `load_doc` so any embedded sidecar archive comment is
// pulled out before the markdown body reaches the parser.
h.state.load_doc(s);
h.needs_redraw = true;
}
#[unsafe(no_mangle)]
pub extern "C" fn viewport_set_lang(handle: *mut ViewportHandle, ext: *const c_char) {
let h = match unsafe { handle.as_mut() } {
Some(h) => h,
None => return,
};
if ext.is_null() {
h.state.lang = None;
} else {
let s = unsafe { CStr::from_ptr(ext) }.to_str().unwrap_or("");
h.state.set_lang_from_ext(s);
}
h.needs_redraw = true;
}
#[unsafe(no_mangle)]
pub extern "C" fn viewport_get_text(handle: *mut ViewportHandle) -> *mut c_char {
let h = match unsafe { handle.as_mut() } {
Some(h) => h,
None => return std::ptr::null_mut(),
};
// Goes through `save_doc` so any tables with persistent metadata get
// their data round-tripped via the embedded sidecar archive comment.
let text = h.state.save_doc();
CString::new(text).unwrap_or_default().into_raw()
}
#[unsafe(no_mangle)]
pub extern "C" fn viewport_free_string(s: *mut c_char) {
if s.is_null() { return; }
unsafe { drop(CString::from_raw(s)); }
}
#[unsafe(no_mangle)]
pub extern "C" fn viewport_set_theme(handle: *mut ViewportHandle, name: *const c_char) {
let s = if name.is_null() {
"mocha"
} else {
unsafe { CStr::from_ptr(name) }.to_str().unwrap_or("mocha")
};
palette::set_theme(s);
if let Some(h) = unsafe { handle.as_mut() } {
h.needs_redraw = true;
}
}
#[unsafe(no_mangle)]
pub extern "C" fn viewport_send_command(handle: *mut ViewportHandle, command: u32) {
let h = match unsafe { handle.as_mut() } {
Some(h) => h,
None => return,
};
match command {
1 => h.state.update(editor::Message::ToggleBold),
2 => h.state.update(editor::Message::ToggleItalic),
3 => h.state.update(editor::Message::InsertTable),
4 => h.state.update(editor::Message::SmartEval),
5 => h.state.update(editor::Message::Evaluate),
6 => h.state.update(editor::Message::TogglePreview),
7 => h.state.update(editor::Message::ZoomIn),
8 => h.state.update(editor::Message::ZoomOut),
9 => h.state.update(editor::Message::ZoomReset),
// 11 = live, 12 = editor, 13 = view
11 => h.state.exit_editor_mode(),
12 => h.state.enter_editor_mode(),
13 => h.state.enter_view_mode(),
_ => return,
};
h.needs_redraw = true;
}
/// Export the note as a standalone Rust crate at `out_dir/name/`. Returns
/// a heap-allocated C string on success (the absolute path of the created
/// folder), or null on failure. Free the returned string with
/// `viewport_free_string`.
#[unsafe(no_mangle)]
pub extern "C" fn viewport_export_crate(
handle: *mut ViewportHandle,
out_dir: *const c_char,
name: *const c_char,
) -> *mut c_char {
let h = match unsafe { handle.as_ref() } {
Some(h) => h,
None => return std::ptr::null_mut(),
};
if out_dir.is_null() || name.is_null() {
return std::ptr::null_mut();
}
let out_dir_str = match unsafe { CStr::from_ptr(out_dir) }.to_str() {
Ok(s) => s,
Err(_) => return std::ptr::null_mut(),
};
let name_str = match unsafe { CStr::from_ptr(name) }.to_str() {
Ok(s) => s,
Err(_) => return std::ptr::null_mut(),
};
match export::export_crate(&h.state, std::path::Path::new(out_dir_str), name_str) {
Ok(path) => {
let s = path.to_string_lossy().into_owned();
CString::new(s).map(|c| c.into_raw()).unwrap_or(std::ptr::null_mut())
}
Err(_) => std::ptr::null_mut(),
}
}
#[unsafe(no_mangle)]
pub extern "C" fn viewport_render_mode(handle: *mut ViewportHandle) -> u32 {
let h = match unsafe { handle.as_mut() } {
Some(h) => h,
None => return 0,
};
match h.state.render_mode {
editor::RenderMode::Live => 0,
editor::RenderMode::Editor => 1,
editor::RenderMode::View => 2,
}
}

414
viewport/src/module.rs Normal file
View File

@ -0,0 +1,414 @@
use crate::selection::BlockId;
/// A module groups consecutive blocks that share a scope.
/// H2 headings start named modules; HRs close the current module
/// and start an unnamed one; H1 marks the root module.
#[derive(Debug, Clone)]
pub struct Module {
pub name: String,
pub heading_block: Option<BlockId>,
pub block_ids: Vec<BlockId>,
pub is_root: bool,
}
/// Lightweight descriptor used by compute_modules so it doesn't need
/// access to the full Block trait (which is generic over Message).
pub struct BlockInfo {
pub id: BlockId,
pub kind_tag: &'static str,
/// For heading blocks: the level (1, 2, 3). Zero for non-headings.
pub heading_level: u8,
/// For heading blocks: the heading text. Empty for non-headings.
pub heading_text: String,
/// For text blocks: the raw markdown content (used to auto-name
/// unnamed modules from first `fn`/`let`). Empty for non-text blocks.
pub text_content: String,
}
/// Walk blocks in layout order and group them into modules based on
/// heading/HR boundaries.
///
/// Rules:
/// - H1 -> root module (is_root = true)
/// - H2 -> close current, start named module
/// - HR -> close current, start unnamed module
/// - Everything else -> append to current module
///
/// Unnamed modules are auto-named from their first `fn` or `let`
/// declaration, falling back to `_unnamed_N`.
pub fn compute_modules(infos: &[BlockInfo]) -> Vec<Module> {
let mut modules: Vec<Module> = Vec::new();
let mut current = Module {
name: String::new(),
heading_block: None,
block_ids: Vec::new(),
is_root: false,
};
let mut unnamed_counter: usize = 0;
let mut seen_any = false;
for info in infos {
match (info.kind_tag, info.heading_level) {
("heading", 1) => {
if seen_any || !current.block_ids.is_empty() {
finalize_unnamed(&mut current, &mut unnamed_counter, infos);
modules.push(current);
}
current = Module {
name: normalize_name(&info.heading_text),
heading_block: Some(info.id),
block_ids: vec![info.id],
is_root: true,
};
seen_any = true;
}
("heading", 2) => {
if seen_any || !current.block_ids.is_empty() {
finalize_unnamed(&mut current, &mut unnamed_counter, infos);
modules.push(current);
}
current = Module {
name: normalize_name(&info.heading_text),
heading_block: Some(info.id),
block_ids: vec![info.id],
is_root: false,
};
seen_any = true;
}
("hr", _) => {
if seen_any || !current.block_ids.is_empty() {
finalize_unnamed(&mut current, &mut unnamed_counter, infos);
modules.push(current);
}
current = Module {
name: String::new(),
heading_block: None,
block_ids: vec![info.id],
is_root: false,
};
seen_any = true;
}
_ => {
current.block_ids.push(info.id);
}
}
}
if !current.block_ids.is_empty() || seen_any {
finalize_unnamed(&mut current, &mut unnamed_counter, infos);
modules.push(current);
}
modules
}
/// If a module has no name, derive one from its first `fn`/`let` declaration.
fn finalize_unnamed(module: &mut Module, counter: &mut usize, infos: &[BlockInfo]) {
if !module.name.is_empty() {
return;
}
for &id in &module.block_ids {
let Some(info) = infos.iter().find(|i| i.id == id) else { continue };
if info.kind_tag != "text" { continue; }
for line in info.text_content.lines() {
let trimmed = line.trim_start();
if let Some(rest) = trimmed.strip_prefix("fn ") {
if let Some(name) = extract_ident(rest) {
module.name = name;
return;
}
}
if let Some(rest) = trimmed.strip_prefix("let ") {
if let Some(name) = extract_ident(rest) {
module.name = name;
return;
}
}
}
}
*counter += 1;
module.name = format!("_unnamed_{counter}");
}
fn extract_ident(s: &str) -> Option<String> {
let s = s.trim_start();
let ident: String = s.chars()
.take_while(|c| c.is_alphanumeric() || *c == '_')
.collect();
if ident.is_empty() { None } else { Some(ident) }
}
/// Scope of a table name assigned by a heading above it.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TableNameScope {
/// H3 heading — name is globally visible across all modules.
Global,
/// H4 heading — name is only visible within the owning module.
BlockScoped,
}
/// Result of scanning for heading-named tables.
#[derive(Debug, Clone)]
pub struct TableNameAssignment {
pub table_id: BlockId,
pub name: String,
pub scope: TableNameScope,
}
/// Scan the layout for H3/H4 headings directly above a table (only
/// whitespace/empty blocks between) and return name assignments.
pub fn detect_table_names(infos: &[BlockInfo]) -> Vec<TableNameAssignment> {
let mut assignments = Vec::new();
let len = infos.len();
for i in 0..len {
let level = infos[i].heading_level;
if infos[i].kind_tag != "heading" || (level != 3 && level != 4) {
continue;
}
// Look ahead for the next non-heading block. Skip whitespace-only
// text blocks between the heading and the table.
for j in (i + 1)..len {
match infos[j].kind_tag {
"table" => {
let scope = if level == 3 {
TableNameScope::Global
} else {
TableNameScope::BlockScoped
};
assignments.push(TableNameAssignment {
table_id: infos[j].id,
name: infos[i].heading_text.trim().to_string(),
scope,
});
break;
}
"text" if infos[j].text_content.trim().is_empty() => {
continue;
}
_ => break,
}
}
}
assignments
}
/// Lowercase, spaces to underscores, strip non-ident characters.
pub fn normalize_name(heading_text: &str) -> String {
heading_text
.trim()
.to_lowercase()
.chars()
.map(|c| if c == ' ' { '_' } else { c })
.filter(|c| c.is_alphanumeric() || *c == '_')
.collect()
}
/// Positional fallback names for every block and table in the document,
/// assigned globally in layout order (1-indexed: `block_1`, `table_1`, …).
/// Headings and HRs count as blocks; tables also get their own sequence.
/// Cross-block refs use `block_N::table_N`. Heading-derived names from
/// `detect_table_names` take precedence — positional names are always
/// available as an additional lookup key.
pub fn compute_positional_ids(infos: &[BlockInfo]) -> PositionalIds {
let mut blocks = Vec::new();
let mut tables = Vec::new();
let mut block_counter: usize = 0;
let mut table_counter: usize = 0;
for info in infos {
block_counter += 1;
blocks.push((info.id, format!("block_{}", block_counter)));
if info.kind_tag == "table" {
table_counter += 1;
tables.push((info.id, format!("table_{}", table_counter), block_counter));
}
}
PositionalIds { blocks, tables }
}
/// Output of `compute_positional_ids`. `tables` entries also carry the
/// 1-indexed block position the table appears in, so the caller can build
/// the cross-block alias `block_N::table_M`.
pub struct PositionalIds {
pub blocks: Vec<(BlockId, String)>,
pub tables: Vec<(BlockId, String, usize)>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn normalize_heading_names() {
assert_eq!(normalize_name("Budget"), "budget");
assert_eq!(normalize_name("My Calculations"), "my_calculations");
assert_eq!(normalize_name(" Phase 2 — Design "), "phase_2__design");
}
#[test]
fn extract_ident_basic() {
assert_eq!(extract_ident("ramp(s, d)"), Some("ramp".into()));
assert_eq!(extract_ident("x = 5"), Some("x".into()));
assert_eq!(extract_ident(" my_var: int = 3"), Some("my_var".into()));
assert_eq!(extract_ident(""), None);
}
fn info(id: BlockId, kind: &'static str, level: u8, heading: &str, text: &str) -> BlockInfo {
BlockInfo {
id,
kind_tag: kind,
heading_level: level,
heading_text: heading.to_string(),
text_content: text.to_string(),
}
}
#[test]
fn basic_module_structure() {
let infos = vec![
info(1, "heading", 1, "Title", ""),
info(2, "text", 0, "", "let pi = 3.14"),
info(3, "heading", 2, "Calculations", ""),
info(4, "text", 0, "", "fn ramp(s, d) {\n s * d\n}"),
info(5, "hr", 0, "", ""),
info(6, "text", 0, "", "some prose"),
];
let modules = compute_modules(&infos);
assert_eq!(modules.len(), 3);
assert_eq!(modules[0].name, "title");
assert!(modules[0].is_root);
assert_eq!(modules[0].block_ids, vec![1, 2]);
assert_eq!(modules[1].name, "calculations");
assert!(!modules[1].is_root);
assert_eq!(modules[1].block_ids, vec![3, 4]);
// HR starts an unnamed module; subsequent text joins it
assert_eq!(modules[2].name, "_unnamed_1");
assert_eq!(modules[2].block_ids, vec![5, 6]);
}
#[test]
fn unnamed_module_gets_fn_name() {
let infos = vec![
info(1, "hr", 0, "", ""),
info(2, "text", 0, "", "fn helper(x) = x * 2\nlet y = 3"),
];
let modules = compute_modules(&infos);
assert_eq!(modules.len(), 1);
assert_eq!(modules[0].name, "helper");
}
#[test]
fn unnamed_module_gets_let_name() {
let infos = vec![
info(1, "hr", 0, "", ""),
info(2, "text", 0, "", "Just prose\nlet total = 100"),
];
let modules = compute_modules(&infos);
assert_eq!(modules.len(), 1);
assert_eq!(modules[0].name, "total");
}
#[test]
fn h3_does_not_split_module() {
let infos = vec![
info(1, "heading", 2, "Budget", ""),
info(2, "heading", 3, "Details", ""),
info(3, "text", 0, "", "content"),
];
let modules = compute_modules(&infos);
assert_eq!(modules.len(), 1);
assert_eq!(modules[0].name, "budget");
assert_eq!(modules[0].block_ids, vec![1, 2, 3]);
}
#[test]
fn empty_input() {
let modules = compute_modules(&[]);
assert!(modules.is_empty());
}
#[test]
fn text_before_any_heading() {
let infos = vec![
info(1, "text", 0, "", "some preamble"),
info(2, "heading", 1, "Title", ""),
];
let modules = compute_modules(&infos);
assert_eq!(modules.len(), 2);
assert_eq!(modules[0].name, "_unnamed_1");
assert!(!modules[0].is_root);
assert_eq!(modules[1].name, "title");
assert!(modules[1].is_root);
}
#[test]
fn h3_names_table_globally() {
let infos = vec![
info(1, "heading", 3, "Revenue", ""),
info(2, "table", 0, "", ""),
];
let names = detect_table_names(&infos);
assert_eq!(names.len(), 1);
assert_eq!(names[0].table_id, 2);
assert_eq!(names[0].name, "Revenue");
assert_eq!(names[0].scope, TableNameScope::Global);
}
#[test]
fn h4_names_table_block_scoped() {
let infos = vec![
info(1, "heading", 4, "Internal", ""),
info(2, "table", 0, "", ""),
];
let names = detect_table_names(&infos);
assert_eq!(names.len(), 1);
assert_eq!(names[0].scope, TableNameScope::BlockScoped);
}
#[test]
fn h3_without_table_below_is_ignored() {
let infos = vec![
info(1, "heading", 3, "Just a heading", ""),
info(2, "text", 0, "", "no table here"),
];
let names = detect_table_names(&infos);
assert!(names.is_empty());
}
#[test]
fn h3_with_whitespace_gap_names_table() {
let infos = vec![
info(1, "heading", 3, "Revenue", ""),
info(2, "text", 0, "", " \n "),
info(3, "table", 0, "", ""),
];
let names = detect_table_names(&infos);
assert_eq!(names.len(), 1);
assert_eq!(names[0].table_id, 3);
}
#[test]
fn positional_ids_global_ordering() {
let infos = vec![
info(10, "heading", 1, "Doc", ""), // block_1
info(11, "table", 0, "", ""), // block_2, table_1
info(12, "heading", 2, "Section", ""),// block_3
info(13, "text", 0, "", "prose"), // block_4
info(14, "table", 0, "", ""), // block_5, table_2
];
let ids = compute_positional_ids(&infos);
assert_eq!(ids.blocks.len(), 5);
assert_eq!(ids.blocks[0], (10, "block_1".into()));
assert_eq!(ids.blocks[4], (14, "block_5".into()));
assert_eq!(ids.tables.len(), 2);
assert_eq!(ids.tables[0], (11, "table_1".into(), 2));
assert_eq!(ids.tables[1], (14, "table_2".into(), 5));
}
}

134
viewport/src/palette.rs Normal file
View File

@ -0,0 +1,134 @@
use iced_wgpu::core::Color;
use std::cell::RefCell;
#[derive(Clone, Copy)]
pub struct Palette {
pub rosewater: Color,
pub flamingo: Color,
pub pink: Color,
pub mauve: Color,
pub red: Color,
pub maroon: Color,
pub peach: Color,
pub yellow: Color,
pub green: Color,
pub teal: Color,
pub sky: Color,
pub sapphire: Color,
pub blue: Color,
pub lavender: Color,
pub text: Color,
pub subtext1: Color,
pub subtext0: Color,
pub overlay2: Color,
pub overlay1: Color,
pub overlay0: Color,
pub surface2: Color,
pub surface1: Color,
pub surface0: Color,
pub base: Color,
pub mantle: Color,
pub crust: Color,
}
pub static MOCHA: Palette = Palette {
rosewater: Color::from_rgb(0.961, 0.878, 0.863),
flamingo: Color::from_rgb(0.949, 0.804, 0.804),
pink: Color::from_rgb(0.961, 0.761, 0.906),
mauve: Color::from_rgb(0.796, 0.651, 0.969),
red: Color::from_rgb(0.953, 0.545, 0.659),
maroon: Color::from_rgb(0.922, 0.627, 0.675),
peach: Color::from_rgb(0.980, 0.702, 0.529),
yellow: Color::from_rgb(0.976, 0.886, 0.686),
green: Color::from_rgb(0.651, 0.890, 0.631),
teal: Color::from_rgb(0.580, 0.886, 0.835),
sky: Color::from_rgb(0.537, 0.863, 0.922),
sapphire: Color::from_rgb(0.455, 0.780, 0.925),
blue: Color::from_rgb(0.537, 0.706, 0.980),
lavender: Color::from_rgb(0.706, 0.745, 0.996),
text: Color::from_rgb(0.804, 0.839, 0.957),
subtext1: Color::from_rgb(0.729, 0.761, 0.871),
subtext0: Color::from_rgb(0.651, 0.678, 0.784),
overlay2: Color::from_rgb(0.576, 0.600, 0.698),
overlay1: Color::from_rgb(0.498, 0.518, 0.612),
overlay0: Color::from_rgb(0.424, 0.439, 0.525),
surface2: Color::from_rgb(0.345, 0.357, 0.439),
surface1: Color::from_rgb(0.271, 0.278, 0.353),
surface0: Color::from_rgb(0.192, 0.196, 0.267),
base: Color::from_rgb(0.118, 0.118, 0.180),
mantle: Color::from_rgb(0.094, 0.094, 0.145),
crust: Color::from_rgb(0.067, 0.067, 0.106),
};
pub static LATTE: Palette = Palette {
rosewater: Color::from_rgb(0.863, 0.541, 0.471),
flamingo: Color::from_rgb(0.867, 0.471, 0.471),
pink: Color::from_rgb(0.918, 0.463, 0.796),
mauve: Color::from_rgb(0.533, 0.224, 0.937),
red: Color::from_rgb(0.824, 0.059, 0.224),
maroon: Color::from_rgb(0.902, 0.271, 0.325),
peach: Color::from_rgb(0.996, 0.392, 0.043),
yellow: Color::from_rgb(0.875, 0.557, 0.114),
green: Color::from_rgb(0.251, 0.627, 0.169),
teal: Color::from_rgb(0.090, 0.573, 0.600),
sky: Color::from_rgb(0.016, 0.647, 0.898),
sapphire: Color::from_rgb(0.125, 0.624, 0.710),
blue: Color::from_rgb(0.118, 0.400, 0.961),
lavender: Color::from_rgb(0.447, 0.529, 0.992),
text: Color::from_rgb(0.298, 0.310, 0.412),
subtext1: Color::from_rgb(0.361, 0.373, 0.467),
subtext0: Color::from_rgb(0.424, 0.435, 0.522),
overlay2: Color::from_rgb(0.486, 0.498, 0.576),
overlay1: Color::from_rgb(0.549, 0.561, 0.631),
overlay0: Color::from_rgb(0.612, 0.627, 0.690),
surface2: Color::from_rgb(0.675, 0.690, 0.745),
surface1: Color::from_rgb(0.737, 0.753, 0.800),
surface0: Color::from_rgb(0.800, 0.816, 0.855),
base: Color::from_rgb(0.937, 0.945, 0.961),
mantle: Color::from_rgb(0.902, 0.914, 0.937),
crust: Color::from_rgb(0.863, 0.878, 0.910),
};
thread_local! {
static CURRENT: RefCell<&'static Palette> = const { RefCell::new(&MOCHA) };
static IS_DARK: RefCell<bool> = const { RefCell::new(true) };
}
pub fn current() -> &'static Palette {
CURRENT.with(|c| *c.borrow())
}
pub fn is_dark() -> bool {
IS_DARK.with(|d| *d.borrow())
}
pub fn set_theme(name: &str) {
let (pal, dark) = match name {
"latte" | "light" => (&LATTE, false),
_ => (&MOCHA, true),
};
CURRENT.with(|c| *c.borrow_mut() = pal);
IS_DARK.with(|d| *d.borrow_mut() = dark);
}
/// Colors for bordered inline widgets (tables, trees). Shared so both
/// widget types render identical frosted-card surfaces in both themes.
pub struct WidgetSurface {
pub fill: Color,
pub border: Color,
pub header_accent: Color,
pub body_text: Color,
}
pub fn widget_surface() -> WidgetSurface {
let p = current();
// Dark: fill lifts above base (surface0) for a frosted-lighter card.
// Light: fill recedes below base (mantle) for a frosted-cooler card.
let fill = if is_dark() { p.surface0 } else { p.mantle };
WidgetSurface {
fill,
border: p.surface2,
header_accent: p.teal,
body_text: p.text,
}
}

107
viewport/src/selection.rs Normal file
View File

@ -0,0 +1,107 @@
//! Central selection model.
//!
//! Every selectable element in the document — a cell, a row, a column, a line,
//! a character range, or a whole block — is addressed by a `NodePath`. The
//! `Selection` enum holds whatever the user has currently selected and is the
//! single source of truth (no per-block selection state).
//!
//! Cursorline highlight, table cell selection, multi-line picks, and cross-block
//! ranges are all the same primitive at different scopes.
pub type BlockId = u64;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct TextPos {
pub line: usize,
pub col: usize,
}
impl TextPos {
pub const ZERO: Self = TextPos { line: 0, col: 0 };
}
/// Address of any selectable element inside a block. Which variants are valid
/// depends on the block kind: a heading only meaningfully has `Whole`; a table
/// has `Cell`/`CellRect`/`Row`/`Col`; a text block has `Line`/`LineRange`.
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum InnerPath {
/// The whole block (heading, HR, tree, or "this entire table/text block").
Whole,
/// A specific line in a text-bearing block (cursorline target).
Line(usize),
/// A character range within a text-bearing block.
LineRange { start: TextPos, end: TextPos },
/// A cell at (row, col) in a table.
Cell { row: usize, col: usize },
/// A rectangular range of cells.
CellRect { r0: usize, c0: usize, r1: usize, c1: usize },
/// An entire row of a table.
Row(usize),
/// An entire column of a table.
Col(usize),
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct NodePath {
pub block_id: BlockId,
pub inner: InnerPath,
}
impl NodePath {
pub fn block(block_id: BlockId) -> Self {
Self { block_id, inner: InnerPath::Whole }
}
pub fn line(block_id: BlockId, line: usize) -> Self {
Self { block_id, inner: InnerPath::Line(line) }
}
pub fn cell(block_id: BlockId, row: usize, col: usize) -> Self {
Self { block_id, inner: InnerPath::Cell { row, col } }
}
}
/// The single selection state for the entire document.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub enum Selection {
/// Nothing selected.
#[default]
None,
/// Cursor anchor at a single path. No range. Cursorline target.
Caret(NodePath),
/// A range from anchor to head. The two paths can live in different blocks.
Range { anchor: NodePath, head: NodePath },
/// An independent set (Cmd-click multi-cell, multi-line picks).
Set(Vec<NodePath>),
}
impl Selection {
pub fn is_empty(&self) -> bool {
matches!(self, Selection::None)
}
/// True if the given path is a member of this selection. Iterative — no
/// recursion. Range membership beyond exact endpoints (e.g. "is cell
/// (2,3) inside the rect from (1,1) to (3,5)?") is the consumer block's
/// responsibility; this only does point-equality.
pub fn contains_path(&self, path: &NodePath) -> bool {
match self {
Selection::None => false,
Selection::Caret(p) => p == path,
Selection::Range { anchor, head } => anchor == path || head == path,
Selection::Set(paths) => paths.iter().any(|p| p == path),
}
}
/// True if any path in the selection lives in the given block.
pub fn touches_block(&self, block_id: BlockId) -> bool {
match self {
Selection::None => false,
Selection::Caret(p) => p.block_id == block_id,
Selection::Range { anchor, head } => {
anchor.block_id == block_id || head.block_id == block_id
}
Selection::Set(paths) => paths.iter().any(|p| p.block_id == block_id),
}
}
}

456
viewport/src/sidecar.rs Normal file
View File

@ -0,0 +1,456 @@
//! Embedded sidecar archive.
//!
//! Markdown is the floor — `.md` files stay readable in vim and on GitHub —
//! but Numbers-class table features (positional metadata, per-cell formatting,
//! formulas, references) don't fit in markdown.
//!
//! Acord stores rich metadata in the SAME `.md` file as a base64-encoded zip
//! wrapped in an HTML comment appended to the end of the document:
//!
//! ```text
//! ...the user's markdown content...
//!
//! <!-- acord-archive
//! UEsDBBQAAAAIA...base64...AAAA
//! -->
//! ```
//!
//! Why this shape:
//! - HTML comments are valid markdown — every renderer (GitHub, Bear, Obsidian)
//! treats them as invisible. Vim shows them as a single comment block, not
//! as binary garbage.
//! - Base64 stays text-clean — no `\0` bytes, vim won't flag the file as
//! binary, `git diff` is still legible (modulo a wide line at the bottom).
//! - The zip's central directory makes it trivial to add more entries later
//! (per-block scratch state, formula caches, embedded images) without
//! changing the framing.
//!
//! Per-table linking is positional: the Nth non-eval table in document layout
//! order is sidecar key "N". No proprietary tags appear in the markdown body.
//! Identity is runtime state derived from the document, never written to disk.
//!
//! The archive is structured like a Rust crate — each block is a submodule
//! file under `src/`, and `config.toml` holds display-only metadata (col
//! widths, row heights, cell styles). Save direction only: the markdown is
//! always the source of truth; the archive is regenerated fresh on every save.
//! On load, only `config.toml` is read for display metadata. If missing or
//! malformed, start fresh — next save overwrites.
//!
//! Eval result tables are explicitly NOT persisted. Only the source `/= expr`
//! line goes into markdown; the result table re-renders fresh on load.
use std::collections::HashMap;
use std::io::{Cursor, Read, Write};
use base64::engine::general_purpose::STANDARD as B64;
use base64::Engine;
use serde::{Deserialize, Serialize};
use zip::write::SimpleFileOptions;
use zip::{CompressionMethod, ZipArchive, ZipWriter};
/// Sentinel that opens the embedded archive comment. Anything from this string
/// to the matching `-->` is the archive payload (base64-encoded zip).
const ARCHIVE_OPEN: &str = "<!-- acord-archive";
const ARCHIVE_CLOSE: &str = "-->";
/// Root-level display metadata file inside the zip. Holds col widths, row
/// heights, cell styles, formulas — things that don't affect evaluation.
const CONFIG_ENTRY: &str = "config.toml";
/// Directory inside the zip holding one `.cord` file per block. Each file
/// contains TOML front-matter + source, structured like a crate submodule.
const SRC_DIR: &str = "src/";
/// Top-level schema of a `<file>.acord.toml` companion. Versioned so we can
/// migrate later as the Numbers-class table feature set grows.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct Sidecar {
/// Schema version. Bump on incompatible changes.
#[serde(default = "default_version")]
pub version: u32,
/// Table metadata indexed by `[#id]` markers in the markdown.
#[serde(default)]
pub tables: HashMap<String, TableSidecar>,
}
fn default_version() -> u32 {
1
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct TableSidecar {
/// Per-column widths in pixels. Same length as the table's column count
/// (or shorter; missing entries fall back to the editor's default width).
#[serde(default)]
pub col_widths: Vec<f32>,
/// Sparse per-row explicit heights. Keys are row indices serialized as
/// strings (TOML's native key type); convert with `parse::<usize>()` at
/// the boundary. A table with a few resized rows doesn't carry the
/// default for every other row.
#[serde(default)]
pub row_heights: HashMap<String, f32>,
/// Per-cell metadata indexed by spreadsheet-style address ("A1", "D2", ...).
#[serde(default)]
pub cells: HashMap<String, CellSidecar>,
/// Cell formulas indexed by spreadsheet address.
#[serde(default)]
pub formulas: HashMap<String, String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct CellSidecar {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub background: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub foreground: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub font_weight: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub align: Option<String>,
}
/// Reads sidecar TOML. Returns `Default` on parse error so a corrupt sidecar
/// never blocks opening a markdown file — the user just loses the rich metadata
/// until they re-save.
pub struct SidecarReader {
inner: Sidecar,
}
impl SidecarReader {
pub fn from_toml(text: &str) -> Self {
let inner: Sidecar = toml::from_str(text).unwrap_or_default();
Self { inner }
}
pub fn empty() -> Self {
Self { inner: Sidecar::default() }
}
pub fn table(&self, id: &str) -> Option<&TableSidecar> {
self.inner.tables.get(id)
}
}
/// Accumulates sidecar entries during a save pass. Each block's `to_md` writes
/// its side-channel state into the writer; after the pass, `flush` produces the
/// TOML text to write to disk (or `None` if there's nothing to write — empty
/// sidecars should be deleted from disk to avoid littering).
pub struct SidecarWriter {
inner: Sidecar,
}
impl SidecarWriter {
pub fn new() -> Self {
Self {
inner: Sidecar {
version: 1,
tables: HashMap::new(),
},
}
}
pub fn put_table(&mut self, id: String, data: TableSidecar) {
self.inner.tables.insert(id, data);
}
/// Returns the serialized TOML, or `None` if the sidecar has no entries.
pub fn flush(self) -> Option<String> {
if self.inner.tables.is_empty() {
return None;
}
toml::to_string_pretty(&self.inner).ok()
}
}
impl Default for SidecarWriter {
fn default() -> Self {
Self::new()
}
}
// ----------------------------------------------------------------------------
// Embedded archive: split markdown text into (body, optional sidecar)
// ----------------------------------------------------------------------------
/// Result of pulling an archive out of an `.md` file. `markdown` is the user
/// content with the archive comment stripped; `sidecar` is the parsed config
/// (or `None` if the file had no archive).
pub struct LoadedDoc {
pub markdown: String,
pub sidecar: Option<Sidecar>,
}
/// Pull an embedded archive out of a markdown file. If the file has no
/// `<!-- acord-archive ... -->` comment, returns the text unchanged with
/// `sidecar = None`. Failure modes (truncated comment, bad base64, malformed
/// zip, malformed TOML) all degrade gracefully to "no sidecar" — the user
/// never loses access to their markdown content because of corrupted metadata.
pub fn extract_archive(text: &str) -> LoadedDoc {
let Some(open_idx) = text.rfind(ARCHIVE_OPEN) else {
return LoadedDoc {
markdown: text.to_string(),
sidecar: None,
};
};
// The closing `-->` must come AFTER the opener.
let after_open = open_idx + ARCHIVE_OPEN.len();
let Some(rel_close) = text[after_open..].find(ARCHIVE_CLOSE) else {
return LoadedDoc {
markdown: text.to_string(),
sidecar: None,
};
};
let close_idx = after_open + rel_close;
let payload = text[after_open..close_idx].trim();
let body = strip_trailing_blank_lines(text[..open_idx].trim_end_matches('\n'));
let parsed = decode_archive_payload(payload);
LoadedDoc {
markdown: body,
sidecar: parsed,
}
}
/// A single block's source file for the archive. Written to `src/<filename>`
/// inside the zip. Content is TOML front-matter + `---` separator + raw source.
pub struct BlockFile {
pub filename: String,
pub content: String,
}
/// Append an archive comment to the markdown body. If there's nothing to store
/// (no block files AND no sidecar entries), returns the body unchanged.
pub fn embed_archive(markdown: &str, sidecar: &Sidecar, block_files: &[BlockFile]) -> String {
if sidecar.tables.is_empty() && block_files.is_empty() {
return markdown.to_string();
}
let toml_text = match toml::to_string_pretty(sidecar) {
Ok(t) => t,
Err(_) => return markdown.to_string(),
};
let zip_bytes = match write_zip(&toml_text, block_files) {
Ok(b) => b,
Err(_) => return markdown.to_string(),
};
let encoded = B64.encode(&zip_bytes);
// Wrap base64 to ~76 cols so the comment doesn't blow out git diffs and
// terminal viewers. The decoder ignores whitespace.
let wrapped = wrap_base64(&encoded, 76);
let mut out = markdown.trim_end_matches('\n').to_string();
out.push_str("\n\n");
out.push_str(ARCHIVE_OPEN);
out.push('\n');
out.push_str(&wrapped);
out.push('\n');
out.push_str(ARCHIVE_CLOSE);
out.push('\n');
out
}
fn strip_trailing_blank_lines(s: &str) -> String {
// Walk back over consecutive trailing newlines / whitespace lines so that
// round-tripping a doc with an archive doesn't accumulate blank lines.
let mut end = s.len();
let bytes = s.as_bytes();
while end > 0 {
let line_end = end;
let mut line_start = end;
while line_start > 0 && bytes[line_start - 1] != b'\n' {
line_start -= 1;
}
let line = &s[line_start..line_end];
if line.trim().is_empty() {
end = if line_start == 0 { 0 } else { line_start - 1 };
} else {
break;
}
}
s[..end].to_string()
}
fn decode_archive_payload(payload: &str) -> Option<Sidecar> {
// Strip whitespace inside the comment so the wrapping is invisible to the
// decoder.
let cleaned: String = payload.chars().filter(|c| !c.is_whitespace()).collect();
let zip_bytes = B64.decode(cleaned.as_bytes()).ok()?;
let toml_text = read_zip(&zip_bytes)?;
toml::from_str::<Sidecar>(&toml_text).ok()
}
fn write_zip(toml_text: &str, block_files: &[BlockFile]) -> Result<Vec<u8>, String> {
let total_bytes = toml_text.len()
+ block_files.iter().map(|f| f.filename.len() + f.content.len()).sum::<usize>();
let mut buf: Vec<u8> = Vec::with_capacity(total_bytes + 512);
{
let cursor = Cursor::new(&mut buf);
let mut zip = ZipWriter::new(cursor);
let opts: SimpleFileOptions =
SimpleFileOptions::default().compression_method(CompressionMethod::Deflated);
if !toml_text.is_empty() {
zip.start_file(CONFIG_ENTRY, opts)
.map_err(|e| format!("zip start_file config: {}", e))?;
zip.write_all(toml_text.as_bytes())
.map_err(|e| format!("zip write config: {}", e))?;
}
for file in block_files {
let path = format!("{}{}", SRC_DIR, file.filename);
zip.start_file(path, opts)
.map_err(|e| format!("zip start_file {}: {}", file.filename, e))?;
zip.write_all(file.content.as_bytes())
.map_err(|e| format!("zip write {}: {}", file.filename, e))?;
}
zip.finish()
.map_err(|e| format!("zip finish: {}", e))?;
}
Ok(buf)
}
fn read_zip(bytes: &[u8]) -> Option<String> {
let cursor = Cursor::new(bytes);
let mut zip = ZipArchive::new(cursor).ok()?;
let mut entry = zip.by_name(CONFIG_ENTRY).ok()?;
let mut text = String::new();
entry.read_to_string(&mut text).ok()?;
Some(text)
}
fn wrap_base64(s: &str, width: usize) -> String {
if width == 0 || s.len() <= width {
return s.to_string();
}
let mut out = String::with_capacity(s.len() + s.len() / width);
let bytes = s.as_bytes();
let mut i = 0;
while i < bytes.len() {
let end = (i + width).min(bytes.len());
// Base64 is ASCII, slicing by byte == slicing by char.
out.push_str(&s[i..end]);
if end < bytes.len() {
out.push('\n');
}
i = end;
}
out
}
#[cfg(test)]
mod tests {
use super::*;
fn sample_sidecar() -> Sidecar {
let mut tables = HashMap::new();
tables.insert(
"t1".to_string(),
TableSidecar {
col_widths: vec![100.0, 200.0, 150.0],
row_heights: HashMap::new(),
cells: HashMap::new(),
formulas: HashMap::new(),
},
);
Sidecar { version: 1, tables }
}
#[test]
fn round_trip_embed_extract() {
let body = "# Hello\n\nSome text.\n\n| a | b |\n|---|---|\n| 1 | 2 |\n";
let sidecar = sample_sidecar();
let with_archive = embed_archive(body, &sidecar, &[]);
assert!(with_archive.contains(ARCHIVE_OPEN));
assert!(with_archive.contains(ARCHIVE_CLOSE));
let loaded = extract_archive(&with_archive);
assert_eq!(loaded.markdown.trim_end(), body.trim_end());
let parsed = loaded.sidecar.expect("sidecar should round-trip");
assert_eq!(parsed.tables.len(), 1);
let t1 = &parsed.tables["t1"];
assert_eq!(t1.col_widths, vec![100.0, 200.0, 150.0]);
}
#[test]
fn empty_sidecar_skips_embed() {
let body = "Just some markdown.\n";
let empty = Sidecar::default();
let out = embed_archive(body, &empty, &[]);
assert_eq!(out, body);
assert!(!out.contains("acord-archive"));
}
#[test]
fn extract_with_no_archive() {
let body = "# Plain doc\n\nNo archive here.";
let loaded = extract_archive(body);
assert_eq!(loaded.markdown, body);
assert!(loaded.sidecar.is_none());
}
#[test]
fn extract_with_corrupt_payload_recovers_markdown() {
// Garbage in the comment body must NOT eat the user's markdown — they
// get the body back, sidecar None.
let doc = "# Body\n\nstuff\n\n<!-- acord-archive\nnot-actually-base64!!!\n-->\n";
let loaded = extract_archive(doc);
assert!(loaded.markdown.contains("# Body"));
assert!(loaded.markdown.contains("stuff"));
assert!(loaded.sidecar.is_none());
}
#[test]
fn round_trip_preserves_complex_metadata() {
let mut tables = HashMap::new();
let mut cells = HashMap::new();
cells.insert(
"A1".to_string(),
CellSidecar {
background: Some("#ff0000".into()),
foreground: Some("#ffffff".into()),
font_weight: Some("bold".into()),
align: Some("center".into()),
},
);
let mut row_heights = HashMap::new();
row_heights.insert("2".to_string(), 48.0);
let mut formulas = HashMap::new();
formulas.insert("B3".to_string(), "=SUM(A1:A10)".to_string());
tables.insert(
"t1".to_string(),
TableSidecar {
col_widths: vec![80.0, 120.0],
row_heights,
cells,
formulas,
},
);
let sc = Sidecar { version: 1, tables };
let body = "# Doc\n";
let embedded = embed_archive(body, &sc, &[]);
let loaded = extract_archive(&embedded);
let parsed = loaded.sidecar.unwrap();
let t = &parsed.tables["t1"];
assert_eq!(t.col_widths, vec![80.0, 120.0]);
assert_eq!(t.row_heights["2"], 48.0);
assert_eq!(t.cells["A1"].background.as_deref(), Some("#ff0000"));
assert_eq!(t.formulas["B3"], "=SUM(A1:A10)");
}
#[test]
fn embed_does_not_double_blank_line() {
// Body that already ends with newlines should round-trip cleanly.
let body = "Line\n\n\n";
let sc = sample_sidecar();
let embedded = embed_archive(body, &sc, &[]);
let loaded = extract_archive(&embedded);
// Trailing blank lines around the archive should not accumulate.
assert_eq!(loaded.markdown.trim_end(), "Line");
}
}

891
viewport/src/syntax.rs Normal file
View File

@ -0,0 +1,891 @@
use std::ops::Range;
use iced_wgpu::core::text::highlighter;
use iced_wgpu::core::{Color, Font};
use iced_wgpu::core::font::{Weight, Style as FontStyle};
use acord_core::highlight::{highlight_source, HighlightSpan};
use acord_core::doc::{classify_document, LineKind};
use crate::editor::{RESULT_PREFIX, ERROR_PREFIX};
use crate::palette;
pub const EVAL_RESULT_KIND: u8 = 24;
pub const EVAL_ERROR_KIND: u8 = 25;
// --- Cordial (eval-line) tokens. Start at 50 to leave room above the
// markdown range. A single hand-rolled scanner (`highlight_cordial`) dispatches
// on these so every Cordial visual element — the `/=` sigil, the `@` ref
// prefix, `::`, table/block names, cell addresses, keywords, builtins,
// numbers, strings, comments — gets its own color.
const COR_EVAL_SIGIL: u8 = 50;
const COR_AT_SIGIL: u8 = 51;
const COR_COLON_COLON: u8 = 52;
const COR_REF_COLON: u8 = 53;
const COR_TABLE_NAME: u8 = 54;
const COR_BLOCK_NAME: u8 = 55;
const COR_CELL_ADDR: u8 = 56;
const COR_KEYWORD: u8 = 57;
const COR_BUILTIN_FN: u8 = 58;
const COR_NUMBER: u8 = 59;
const COR_STRING: u8 = 60;
const COR_COMMENT: u8 = 61;
const COR_OPERATOR: u8 = 62;
const COR_BRACKET: u8 = 63;
const COR_TYPE_ANN: u8 = 64;
const MD_HEADING_MARKER: u8 = 26;
const MD_H1: u8 = 27;
const MD_H2: u8 = 28;
const MD_H3: u8 = 29;
const MD_BOLD: u8 = 30;
const MD_ITALIC: u8 = 31;
const MD_INLINE_CODE: u8 = 32;
const MD_FORMAT_MARKER: u8 = 33;
const MD_LINK_TEXT: u8 = 34;
const MD_LINK_URL: u8 = 35;
const MD_BLOCKQUOTE_MARKER: u8 = 36;
const MD_BLOCKQUOTE: u8 = 37;
const MD_LIST_MARKER: u8 = 38;
const MD_FENCE_MARKER: u8 = 39;
const MD_CODE_BLOCK: u8 = 40;
const MD_HR: u8 = 41;
const MD_TASK_OPEN: u8 = 42;
const MD_TASK_DONE: u8 = 43;
const MD_BOLD_ITALIC: u8 = 44;
/// The monospace family used for the editor body and every inline highlight
/// span. Naming the family explicitly (rather than `Family::Monospace`) forces
/// cosmic-text / fontdb to resolve real Bold, Italic and BoldItalic faces,
/// which the generic monospace fallback does not reliably do on macOS because
/// cosmic-text hardcodes its default monospace family to "Noto Sans Mono".
#[cfg(target_os = "macos")]
pub const EDITOR_FONT: Font = Font::with_name("Menlo");
#[cfg(target_os = "windows")]
pub const EDITOR_FONT: Font = Font::with_name("Consolas");
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
pub const EDITOR_FONT: Font = Font::with_name("DejaVu Sans Mono");
#[derive(Clone, PartialEq)]
pub struct SyntaxSettings {
pub lang: String,
pub source: String,
}
#[derive(Clone, Copy, Debug)]
pub struct SyntaxHighlight {
pub kind: u8,
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum LineDecor {
None,
CodeBlock,
Blockquote,
HorizontalRule,
FenceMarker,
}
pub struct SyntaxHighlighter {
lang: String,
spans: Vec<HighlightSpan>,
line_offsets: Vec<usize>,
line_kinds: Vec<LineKind>,
in_fenced_code: bool,
current_line: usize,
line_decors: Vec<LineDecor>,
}
impl SyntaxHighlighter {
fn rebuild(&mut self, source: &str) {
self.spans = highlight_source(source, &self.lang);
self.line_offsets.clear();
let mut offset = 0;
for line in source.split('\n') {
self.line_offsets.push(offset);
offset += line.len() + 1;
}
let classified = classify_document(source);
self.line_kinds = classified.into_iter().map(|cl| cl.kind).collect();
self.line_decors.clear();
let mut in_fence = false;
for (i, raw_line) in source.split('\n').enumerate() {
let is_md = i < self.line_kinds.len() && self.line_kinds[i] == LineKind::Markdown;
if is_md {
let trimmed = raw_line.trim_start();
if trimmed.starts_with("```") {
in_fence = !in_fence;
self.line_decors.push(LineDecor::FenceMarker);
} else if in_fence {
self.line_decors.push(LineDecor::CodeBlock);
} else if is_horizontal_rule(trimmed) {
self.line_decors.push(LineDecor::HorizontalRule);
} else if trimmed.starts_with("> ") || trimmed == ">" {
self.line_decors.push(LineDecor::Blockquote);
} else {
self.line_decors.push(LineDecor::None);
}
} else {
if in_fence { in_fence = false; }
self.line_decors.push(LineDecor::None);
}
}
self.in_fenced_code = false;
self.current_line = 0;
}
fn highlight_markdown(&self, line: &str) -> Vec<(Range<usize>, SyntaxHighlight)> {
let trimmed = line.trim_start();
let leading = line.len() - trimmed.len();
if is_horizontal_rule(trimmed) {
return vec![(0..line.len(), SyntaxHighlight { kind: MD_HR })];
}
if let Some(level) = heading_level(trimmed) {
let marker_end = leading + level + 1;
let kind = match level {
1 => MD_H1,
2 => MD_H2,
_ => MD_H3,
};
let mut spans = vec![
(0..marker_end, SyntaxHighlight { kind: MD_HEADING_MARKER }),
];
if marker_end < line.len() {
spans.push((marker_end..line.len(), SyntaxHighlight { kind }));
}
return spans;
}
if trimmed.starts_with("> ") || trimmed == ">" {
let marker_end = leading + if trimmed.len() > 1 { 2 } else { 1 };
let mut spans = vec![
(0..marker_end, SyntaxHighlight { kind: MD_BLOCKQUOTE_MARKER }),
];
if marker_end < line.len() {
let content = &line[marker_end..];
let inner = parse_inline(content, marker_end);
if inner.is_empty() {
spans.push((marker_end..line.len(), SyntaxHighlight { kind: MD_BLOCKQUOTE }));
} else {
spans.extend(inner);
}
}
return spans;
}
if let Some(list_info) = list_marker_info(trimmed) {
let (marker_len, marker_kind) = match list_info {
ListKind::TaskOpen(n) => (n, MD_TASK_OPEN),
ListKind::TaskDone(n) => (n, MD_TASK_DONE),
ListKind::Plain(n) => (n, MD_LIST_MARKER),
};
let marker_end = leading + marker_len;
let mut spans = vec![
(0..marker_end, SyntaxHighlight { kind: marker_kind }),
];
if marker_end < line.len() {
let content = &line[marker_end..];
let inner = parse_inline(content, marker_end);
if inner.is_empty() {
return spans;
}
spans.extend(inner);
}
return spans;
}
parse_inline(line, 0)
}
}
/// Scan a Cordial line (or an eval line) and emit per-token highlight
/// spans. Idempotent, single-pass; each branch either consumes a whole
/// token or advances one byte. Unknown bytes get no highlight (they fall
/// through to the editor's default text color).
fn highlight_cordial(line: &str) -> Vec<(Range<usize>, SyntaxHighlight)> {
let bytes = line.as_bytes();
let len = bytes.len();
let mut spans: Vec<(Range<usize>, SyntaxHighlight)> = Vec::new();
let mut i = 0;
// Opening `/=`, `/=|`, `/=\` sigil (with optional leading whitespace).
let leading = line.len() - line.trim_start().len();
if leading + 2 <= len && &bytes[leading..leading + 2] == b"/=" {
let sigil_end = if leading + 3 <= len
&& (bytes[leading + 2] == b'|' || bytes[leading + 2] == b'\\')
{
leading + 3
} else {
leading + 2
};
spans.push((leading..sigil_end, SyntaxHighlight { kind: COR_EVAL_SIGIL }));
i = sigil_end;
}
while i < len {
let c = bytes[i];
// Whitespace: skip.
if c == b' ' || c == b'\t' || c == b'\r' {
i += 1;
continue;
}
// Line comment: `// …` — rest of line.
if c == b'/' && i + 1 < len && bytes[i + 1] == b'/' {
spans.push((i..len, SyntaxHighlight { kind: COR_COMMENT }));
break;
}
// String literal.
if c == b'"' {
let start = i;
i += 1;
while i < len && bytes[i] != b'"' {
if bytes[i] == b'\\' && i + 1 < len { i += 2; } else { i += 1; }
}
if i < len { i += 1; }
spans.push((start..i, SyntaxHighlight { kind: COR_STRING }));
continue;
}
// `@` cell reference: @[Block::]Table[:A1[:B4]] or @T[A1:B4].
if c == b'@' {
spans.push((i..i + 1, SyntaxHighlight { kind: COR_AT_SIGIL }));
i += 1;
// First ident.
let n1_start = i;
while i < len && is_ident_byte(bytes[i]) { i += 1; }
let n1_end = i;
// Is it a block qualifier? Look for `::` after.
if i + 1 < len && bytes[i] == b':' && bytes[i + 1] == b':' {
if n1_end > n1_start {
spans.push((n1_start..n1_end, SyntaxHighlight { kind: COR_BLOCK_NAME }));
}
spans.push((i..i + 2, SyntaxHighlight { kind: COR_COLON_COLON }));
i += 2;
let t_start = i;
while i < len && is_ident_byte(bytes[i]) { i += 1; }
if i > t_start {
spans.push((t_start..i, SyntaxHighlight { kind: COR_TABLE_NAME }));
}
} else if n1_end > n1_start {
spans.push((n1_start..n1_end, SyntaxHighlight { kind: COR_TABLE_NAME }));
}
// Optional `:A1` or `:A1:B2` cell/range target.
if i < len && bytes[i] == b':' {
spans.push((i..i + 1, SyntaxHighlight { kind: COR_REF_COLON }));
i += 1;
i = consume_cell_addr(bytes, i, &mut spans);
if i < len && bytes[i] == b':' {
spans.push((i..i + 1, SyntaxHighlight { kind: COR_REF_COLON }));
i += 1;
i = consume_cell_addr(bytes, i, &mut spans);
}
} else if i < len && bytes[i] == b'[' {
// Bracket range: `[A1:B2]`.
spans.push((i..i + 1, SyntaxHighlight { kind: COR_BRACKET }));
i += 1;
i = consume_cell_addr(bytes, i, &mut spans);
if i < len && bytes[i] == b':' {
spans.push((i..i + 1, SyntaxHighlight { kind: COR_REF_COLON }));
i += 1;
i = consume_cell_addr(bytes, i, &mut spans);
}
if i < len && bytes[i] == b']' {
spans.push((i..i + 1, SyntaxHighlight { kind: COR_BRACKET }));
i += 1;
}
}
continue;
}
// Numeric literal (integer or decimal, with optional leading `-`
// in operator-valid position — keep it simple: only recognise as
// a number when we're right after an operator or at the start of
// whitespace, otherwise leave `-` to the operator scanner).
if c.is_ascii_digit()
|| (c == b'.' && i + 1 < len && bytes[i + 1].is_ascii_digit())
{
let start = i;
while i < len && (bytes[i].is_ascii_digit() || bytes[i] == b'.') {
i += 1;
}
spans.push((start..i, SyntaxHighlight { kind: COR_NUMBER }));
continue;
}
// Identifier → keyword / builtin / plain. Plain idents get no
// highlight so user-defined names stay in the default editor
// color — keeps the document from looking like confetti.
if is_ident_byte(c) && !c.is_ascii_digit() {
let start = i;
while i < len && is_ident_byte(bytes[i]) { i += 1; }
let word = &line[start..i];
if is_cordial_keyword(word) {
spans.push((start..i, SyntaxHighlight { kind: COR_KEYWORD }));
} else if is_cordial_builtin(word) {
spans.push((start..i, SyntaxHighlight { kind: COR_BUILTIN_FN }));
} else if is_cordial_type_annotation(word) && last_token_is_colon(&spans) {
// Type annotation immediately after `:` in a `let x: T = …`
// reads the type name; give it the yellow/type color.
spans.push((start..i, SyntaxHighlight { kind: COR_TYPE_ANN }));
}
continue;
}
// `::` as a namespace separator outside of a ref (e.g. `use mod::item`).
if c == b':' && i + 1 < len && bytes[i + 1] == b':' {
spans.push((i..i + 2, SyntaxHighlight { kind: COR_COLON_COLON }));
i += 2;
continue;
}
// Plain `:` — likely a type annotation colon in `let x: T = …`.
if c == b':' {
spans.push((i..i + 1, SyntaxHighlight { kind: COR_REF_COLON }));
i += 1;
continue;
}
// Bracket / brace / paren — separate color from operators.
if matches!(c, b'(' | b')' | b'{' | b'}' | b'[' | b']' | b',') {
spans.push((i..i + 1, SyntaxHighlight { kind: COR_BRACKET }));
i += 1;
continue;
}
// Operator run: consume a contiguous block of operator bytes.
if is_operator_byte(c) {
let start = i;
while i < len && is_operator_byte(bytes[i]) { i += 1; }
spans.push((start..i, SyntaxHighlight { kind: COR_OPERATOR }));
continue;
}
i += 1;
}
spans
}
fn consume_cell_addr(
bytes: &[u8],
start: usize,
spans: &mut Vec<(Range<usize>, SyntaxHighlight)>,
) -> usize {
let mut i = start;
while i < bytes.len() && bytes[i].is_ascii_alphabetic() { i += 1; }
let letters_end = i;
while i < bytes.len() && bytes[i].is_ascii_digit() { i += 1; }
// Only tag as a cell address when we matched BOTH letters AND digits —
// otherwise we're looking at a bare identifier or a digit run that
// some other branch should have handled.
if i > start && letters_end > start && i > letters_end {
spans.push((start..i, SyntaxHighlight { kind: COR_CELL_ADDR }));
}
i
}
fn is_ident_byte(b: u8) -> bool {
b.is_ascii_alphanumeric() || b == b'_'
}
fn is_operator_byte(b: u8) -> bool {
matches!(b, b'+' | b'-' | b'*' | b'/' | b'%' | b'^'
| b'=' | b'<' | b'>' | b'!' | b'~' | b'&' | b'|' | b'.')
}
fn is_cordial_keyword(w: &str) -> bool {
matches!(w, "let" | "fn" | "if" | "else" | "while" | "for" | "in"
| "return" | "use" | "is" | "true" | "false" | "and" | "or" | "not"
// Function-inversion DSL — two forms:
// programmer: let lfreq = solve!(l, f0) // or `solve!(l from f0)`
// math: let lfreq(freq, c) = l where f0(l, c) = freq
| "solve" | "where" | "from")
}
fn is_cordial_builtin(w: &str) -> bool {
matches!(w,
// math
"sin" | "cos" | "tan" | "asin" | "acos" | "atan"
| "sqrt" | "abs" | "floor" | "ceil" | "round" | "ln" | "log"
// collections
| "len" | "range" | "push"
// aggregates
| "sum" | "avg" | "min" | "max" | "count" | "std_devp" | "std_devs"
// constants
| "pi"
)
}
fn is_cordial_type_annotation(w: &str) -> bool {
matches!(w, "int" | "float" | "bool" | "str" | "number" | "array" | "vec")
}
/// Did the scanner just emit a `:` span? Used so a type name following a
/// `:` picks up the type-annotation color only in the `let x: T = …` shape,
/// never when it happens to sit elsewhere on the line.
fn last_token_is_colon(spans: &[(Range<usize>, SyntaxHighlight)]) -> bool {
matches!(spans.last(), Some((_, h)) if h.kind == COR_REF_COLON)
}
fn heading_level(trimmed: &str) -> Option<usize> {
let bytes = trimmed.as_bytes();
if bytes.is_empty() || bytes[0] != b'#' { return None; }
let mut level = 0;
while level < bytes.len() && bytes[level] == b'#' { level += 1; }
if level > 3 { return None; }
if level < bytes.len() && bytes[level] == b' ' {
Some(level)
} else {
None
}
}
fn is_horizontal_rule(trimmed: &str) -> bool {
if trimmed.len() < 3 { return false; }
let first = trimmed.as_bytes()[0];
if !matches!(first, b'-' | b'*' | b'_') { return false; }
trimmed.bytes().all(|b| b == first || b == b' ')
}
#[derive(Clone, Copy, PartialEq)]
enum ListKind {
Plain(usize),
TaskOpen(usize),
TaskDone(usize),
}
fn list_marker_info(trimmed: &str) -> Option<ListKind> {
let bytes = trimmed.as_bytes();
if bytes.is_empty() { return None; }
if matches!(bytes[0], b'-' | b'*' | b'+') && bytes.get(1) == Some(&b' ') {
if trimmed.starts_with("- [ ] ") {
return Some(ListKind::TaskOpen(6));
}
if trimmed.starts_with("- [x] ") || trimmed.starts_with("- [X] ") {
return Some(ListKind::TaskDone(6));
}
return Some(ListKind::Plain(2));
}
let mut i = 0;
while i < bytes.len() && bytes[i].is_ascii_digit() { i += 1; }
if i > 0 && i < bytes.len() && matches!(bytes[i], b'.' | b')') {
if bytes.get(i + 1) == Some(&b' ') {
return Some(ListKind::Plain(i + 2));
}
}
None
}
fn parse_inline(text: &str, base: usize) -> Vec<(Range<usize>, SyntaxHighlight)> {
let bytes = text.as_bytes();
let len = bytes.len();
let mut spans = Vec::new();
let mut i = 0;
while i < len {
if bytes[i] == b'\\' && i + 1 < len && is_md_punctuation(bytes[i + 1]) {
i += 2;
continue;
}
// cosmic-text's partial reshape (called by iced's text_editor after
// add_span) drops the new run's attrs on the FIRST glyph of the new
// attribute run, so `*hello*` would render with "h" plain and "ello"
// italic. Workaround: emit the bold/italic span covering the opening
// marker bytes too — the marker becomes the "lost first glyph" and
// the first letter of the inner text gets the style. The marker span
// pushed first is overridden by the bold/italic span that follows
// because cosmic-text uses the LAST add_span to win on overlap.
// Markers (`*`, `**`, `***`) end up italic/bold themselves, which is
// imperceptible at typical font sizes.
if i + 2 < len && bytes[i] == b'*' && bytes[i + 1] == b'*' && bytes[i + 2] == b'*' {
if let Some(end) = find_triple_star(bytes, i + 3) {
spans.push((base + i..base + i + 3, SyntaxHighlight { kind: MD_FORMAT_MARKER }));
spans.push((base + end..base + end + 3, SyntaxHighlight { kind: MD_FORMAT_MARKER }));
if i + 3 < end {
spans.push((base + i..base + end + 3, SyntaxHighlight { kind: MD_BOLD_ITALIC }));
}
i = end + 3;
continue;
}
}
if i + 1 < len && bytes[i] == b'*' && bytes[i + 1] == b'*' {
if let Some(end) = find_closing(bytes, i + 2, b'*', b'*') {
spans.push((base + i..base + i + 2, SyntaxHighlight { kind: MD_FORMAT_MARKER }));
spans.push((base + end..base + end + 2, SyntaxHighlight { kind: MD_FORMAT_MARKER }));
if i + 2 < end {
let inner = parse_inline(&text[i + 2..end], base + i + 2);
if inner.is_empty() {
spans.push((base + i..base + end + 2, SyntaxHighlight { kind: MD_BOLD }));
} else {
spans.push((base + i..base + i + 2, SyntaxHighlight { kind: MD_BOLD }));
for (r, h) in inner {
let kind = if h.kind == MD_ITALIC { MD_BOLD_ITALIC } else { h.kind };
spans.push((r, SyntaxHighlight { kind }));
}
// Extend bold over closing marker for visual consistency.
spans.push((base + end..base + end + 2, SyntaxHighlight { kind: MD_BOLD }));
}
}
i = end + 2;
continue;
}
}
if bytes[i] == b'*' && (i + 1 >= len || bytes[i + 1] != b'*') {
if let Some(end) = find_single_closing(bytes, i + 1, b'*') {
if end > i + 1 && bytes[end - 1] != b'*' {
spans.push((base + i..base + i + 1, SyntaxHighlight { kind: MD_FORMAT_MARKER }));
spans.push((base + end..base + end + 1, SyntaxHighlight { kind: MD_FORMAT_MARKER }));
if i + 1 < end {
spans.push((base + i..base + end + 1, SyntaxHighlight { kind: MD_ITALIC }));
}
i = end + 1;
continue;
}
}
}
if bytes[i] == b'`' {
let tick_count = count_backticks(bytes, i);
if let Some(end) = find_backtick_close(bytes, i + tick_count, tick_count) {
spans.push((base + i..base + i + tick_count, SyntaxHighlight { kind: MD_FORMAT_MARKER }));
if i + tick_count < end {
spans.push((base + i + tick_count..base + end, SyntaxHighlight { kind: MD_INLINE_CODE }));
}
spans.push((base + end..base + end + tick_count, SyntaxHighlight { kind: MD_FORMAT_MARKER }));
i = end + tick_count;
continue;
}
}
if bytes[i] == b'[' {
if let Some((text_end, url_end)) = find_link(bytes, i) {
spans.push((base + i..base + i + 1, SyntaxHighlight { kind: MD_FORMAT_MARKER }));
if i + 1 < text_end {
spans.push((base + i + 1..base + text_end, SyntaxHighlight { kind: MD_LINK_TEXT }));
}
spans.push((base + text_end..base + text_end + 2, SyntaxHighlight { kind: MD_FORMAT_MARKER }));
if text_end + 2 < url_end {
spans.push((base + text_end + 2..base + url_end, SyntaxHighlight { kind: MD_LINK_URL }));
}
spans.push((base + url_end..base + url_end + 1, SyntaxHighlight { kind: MD_FORMAT_MARKER }));
i = url_end + 1;
continue;
}
}
i += 1;
}
spans
}
fn is_md_punctuation(b: u8) -> bool {
matches!(b, b'\\' | b'`' | b'*' | b'_' | b'{' | b'}' | b'[' | b']'
| b'(' | b')' | b'#' | b'+' | b'-' | b'.' | b'!' | b'|')
}
fn find_triple_star(bytes: &[u8], start: usize) -> Option<usize> {
let mut i = start;
while i + 2 < bytes.len() {
if bytes[i] == b'*' && bytes[i + 1] == b'*' && bytes[i + 2] == b'*' {
return Some(i);
}
i += 1;
}
None
}
fn count_backticks(bytes: &[u8], start: usize) -> usize {
let mut n = 0;
while start + n < bytes.len() && bytes[start + n] == b'`' { n += 1; }
n
}
fn find_backtick_close(bytes: &[u8], start: usize, count: usize) -> Option<usize> {
if count == 0 { return None; }
let mut i = start;
while i + count <= bytes.len() {
if count_backticks(bytes, i) == count {
return Some(i);
}
i += 1;
}
None
}
fn find_closing(bytes: &[u8], start: usize, c1: u8, c2: u8) -> Option<usize> {
let mut i = start;
while i + 1 < bytes.len() {
if bytes[i] == c1 && bytes[i + 1] == c2 {
return Some(i);
}
i += 1;
}
None
}
fn find_single_closing(bytes: &[u8], start: usize, ch: u8) -> Option<usize> {
let mut i = start;
while i < bytes.len() {
if bytes[i] == ch {
return Some(i);
}
i += 1;
}
None
}
fn find_link(bytes: &[u8], open: usize) -> Option<(usize, usize)> {
let mut i = open + 1;
while i < bytes.len() {
if bytes[i] == b']' {
if i + 1 < bytes.len() && bytes[i + 1] == b'(' {
let text_end = i;
let mut j = i + 2;
while j < bytes.len() {
if bytes[j] == b')' {
return Some((text_end, j));
}
j += 1;
}
}
return None;
}
if bytes[i] == b'\n' { return None; }
i += 1;
}
None
}
impl highlighter::Highlighter for SyntaxHighlighter {
type Settings = SyntaxSettings;
type Highlight = SyntaxHighlight;
type Iterator<'a> = std::vec::IntoIter<(Range<usize>, SyntaxHighlight)>;
fn new(settings: &Self::Settings) -> Self {
let mut h = SyntaxHighlighter {
lang: settings.lang.clone(),
spans: Vec::new(),
line_offsets: Vec::new(),
line_kinds: Vec::new(),
in_fenced_code: false,
current_line: 0,
line_decors: Vec::new(),
};
h.rebuild(&settings.source);
h
}
fn update(&mut self, new_settings: &Self::Settings) {
self.lang = new_settings.lang.clone();
self.rebuild(&new_settings.source);
}
fn change_line(&mut self, line: usize) {
self.current_line = self.current_line.min(line);
if line == 0 {
self.in_fenced_code = false;
}
}
fn highlight_line(&mut self, line: &str) -> Self::Iterator<'_> {
let ln = self.current_line;
self.current_line += 1;
let trimmed = line.trim_start();
if trimmed.starts_with(RESULT_PREFIX) {
return vec![(0..line.len(), SyntaxHighlight { kind: EVAL_RESULT_KIND })].into_iter();
}
if trimmed.starts_with(ERROR_PREFIX) {
return vec![(0..line.len(), SyntaxHighlight { kind: EVAL_ERROR_KIND })].into_iter();
}
let is_markdown = ln < self.line_kinds.len()
&& self.line_kinds[ln] == LineKind::Markdown;
if is_markdown {
if trimmed.starts_with("```") {
self.in_fenced_code = !self.in_fenced_code;
return vec![(0..line.len(), SyntaxHighlight { kind: MD_FENCE_MARKER })].into_iter();
}
if self.in_fenced_code {
return vec![(0..line.len(), SyntaxHighlight { kind: MD_CODE_BLOCK })].into_iter();
}
// Markdown lines always return md_spans, even when empty —
// falling through to the code path would let plain prose pick up
// Rust keyword highlighting on words like "let", "type", "return".
return self.highlight_markdown(line).into_iter();
} else if self.in_fenced_code {
self.in_fenced_code = false;
}
// Non-markdown lines are Cordial / Eval / Comment — hand-rolled
// Cordial scanner, not the generic tree-sitter path (which uses
// the configured `lang`, wrong for Cordial). Each token gets its
// own color: `/=`, `@`, `::`, table / block names, cell addresses,
// keywords, builtins, numbers, strings, comments.
if ln < self.line_kinds.len()
&& matches!(self.line_kinds[ln], LineKind::Cordial | LineKind::Eval | LineKind::Comment)
{
return highlight_cordial(line).into_iter();
}
if ln >= self.line_offsets.len() {
return Vec::new().into_iter();
}
let line_start = self.line_offsets[ln];
let line_end = if ln + 1 < self.line_offsets.len() {
self.line_offsets[ln + 1] - 1
} else {
line_start + line.len()
};
let mut result = Vec::new();
for span in &self.spans {
if span.end <= line_start || span.start >= line_end {
continue;
}
let start = span.start.max(line_start) - line_start;
let end = span.end.min(line_end) - line_start;
if start < end {
result.push((start..end, SyntaxHighlight { kind: span.kind }));
}
}
result.into_iter()
}
fn current_line(&self) -> usize {
self.current_line
}
}
pub fn highlight_color(kind: u8) -> Color {
let p = palette::current();
match kind {
0 => p.mauve,
1 => p.blue,
2 => p.teal,
3 => p.yellow,
4 => p.yellow,
5 => p.teal,
6 => p.peach,
7 => p.peach,
8 => p.green,
9 => p.peach,
10 => p.overlay0,
11 => p.text,
12 => p.red,
13 => p.flamingo,
14 => p.sky,
15 => p.overlay2,
16 => p.overlay2,
17 => p.overlay2,
18 => p.blue,
19 => p.mauve,
20 => p.yellow,
21 => p.teal,
22 => p.red,
23 => p.text,
24 => p.green,
25 => p.maroon,
COR_EVAL_SIGIL => p.teal,
COR_AT_SIGIL => p.mauve,
COR_COLON_COLON => p.flamingo,
COR_REF_COLON => p.flamingo,
COR_TABLE_NAME => p.blue,
COR_BLOCK_NAME => p.lavender,
COR_CELL_ADDR => p.yellow,
COR_KEYWORD => p.mauve,
COR_BUILTIN_FN => p.sky,
COR_NUMBER => p.peach,
COR_STRING => p.green,
COR_COMMENT => p.overlay1,
COR_OPERATOR => p.overlay2,
COR_BRACKET => p.overlay2,
COR_TYPE_ANN => p.yellow,
MD_HEADING_MARKER => p.overlay0,
MD_H1 => p.rosewater,
MD_H2 => p.peach,
MD_H3 => p.yellow,
MD_BOLD => p.text,
MD_ITALIC => p.text,
MD_INLINE_CODE => p.green,
MD_FORMAT_MARKER => p.overlay0,
MD_LINK_TEXT => p.blue,
MD_LINK_URL => p.overlay1,
MD_BLOCKQUOTE_MARKER => p.overlay0,
MD_BLOCKQUOTE => p.sky,
MD_LIST_MARKER => p.sky,
MD_FENCE_MARKER => p.overlay0,
MD_CODE_BLOCK => p.text,
MD_HR => p.overlay1,
MD_TASK_OPEN => p.overlay2,
MD_TASK_DONE => p.green,
MD_BOLD_ITALIC => p.text,
_ => p.text,
}
}
pub fn highlight_font(kind: u8) -> Option<Font> {
// Spans inherit the named family from EDITOR_FONT so fontdb can pick up
// the real Bold, Italic and BoldItalic faces of the system monospace.
match kind {
MD_HEADING_MARKER => Some(Font { weight: Weight::Bold, ..EDITOR_FONT }),
MD_H1 => Some(Font { weight: Weight::Black, ..EDITOR_FONT }),
MD_H2 => Some(Font { weight: Weight::Bold, ..EDITOR_FONT }),
MD_H3 => Some(Font { weight: Weight::Semibold, ..EDITOR_FONT }),
MD_BOLD => Some(Font { weight: Weight::Bold, ..EDITOR_FONT }),
MD_ITALIC => Some(Font { style: FontStyle::Italic, ..EDITOR_FONT }),
MD_BOLD_ITALIC => Some(Font { weight: Weight::Bold, style: FontStyle::Italic, ..EDITOR_FONT }),
MD_INLINE_CODE => Some(EDITOR_FONT),
MD_FORMAT_MARKER => Some(EDITOR_FONT),
MD_BLOCKQUOTE => Some(Font { style: FontStyle::Italic, ..EDITOR_FONT }),
MD_FENCE_MARKER => Some(EDITOR_FONT),
MD_CODE_BLOCK => Some(EDITOR_FONT),
MD_TASK_DONE => Some(Font { weight: Weight::Bold, ..EDITOR_FONT }),
_ => None,
}
}
pub fn compute_line_decors(source: &str) -> Vec<LineDecor> {
let classified = classify_document(source);
let line_kinds: Vec<LineKind> = classified.into_iter().map(|cl| cl.kind).collect();
let mut decors = Vec::new();
let mut in_fence = false;
for (i, raw_line) in source.split('\n').enumerate() {
let is_md = i < line_kinds.len() && line_kinds[i] == LineKind::Markdown;
if is_md {
let trimmed = raw_line.trim_start();
if trimmed.starts_with("```") {
in_fence = !in_fence;
decors.push(LineDecor::FenceMarker);
} else if in_fence {
decors.push(LineDecor::CodeBlock);
} else if is_horizontal_rule(trimmed) {
decors.push(LineDecor::HorizontalRule);
} else if trimmed.starts_with("> ") || trimmed == ">" {
decors.push(LineDecor::Blockquote);
} else {
decors.push(LineDecor::None);
}
} else {
if in_fence { in_fence = false; }
decors.push(LineDecor::None);
}
}
decors
}

1363
viewport/src/table_block.rs Normal file

File diff suppressed because it is too large Load Diff

128
viewport/src/text_block.rs Normal file
View File

@ -0,0 +1,128 @@
//! `TextBlock` — the trait-implementing wrapper around `text_widget::Content`.
//!
//! Owns the editor content and language hint for syntax highlighting. Lives in
//! `EditorState::blocks` as a `Box<dyn Block>`.
use iced_wgpu::core::text::Wrapping;
use iced_wgpu::core::text::highlighter::Format;
use iced_wgpu::core::{
Background, Border, Color, Element, Length, Padding, Point, Theme,
};
use crate::text_widget::{self, Style};
use crate::block::{Block, BlockCommand, LayeredView, ViewCtx};
use crate::palette;
use crate::selection::{BlockId, InnerPath};
use crate::syntax::{self, SyntaxHighlighter, SyntaxSettings};
pub struct TextBlock {
pub id: BlockId,
pub content: text_widget::Content,
/// Document-relative starting line. Maintained by `recount_lines`.
pub start_line: usize,
/// Language hint for syntax highlighting.
pub lang: String,
}
impl TextBlock {
pub fn new(id: BlockId, text: &str, start_line: usize, lang: String) -> Self {
Self {
id,
content: text_widget::Content::with_text(text),
start_line,
lang,
}
}
}
impl<Message: Clone + 'static> Block<Message> for TextBlock {
fn id(&self) -> BlockId {
self.id
}
fn kind_tag(&self) -> &'static str {
"text"
}
fn start_line(&self) -> usize {
self.start_line
}
fn set_start_line(&mut self, line: usize) {
self.start_line = line;
}
fn line_count(&self) -> usize {
self.content.line_count()
}
fn as_any(&self) -> &dyn std::any::Any {
self
}
fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
self
}
fn view<'a>(&'a self, ctx: &ViewCtx<'_, Message>) -> LayeredView<'a, Message> {
let block_idx = ctx.block_index;
let on_action = ctx.on_text_action;
let editor = text_widget::TextEditor::new(&self.content)
.on_action(move |action| on_action(block_idx, action))
.font(syntax::EDITOR_FONT)
.size(ctx.font_size)
.height(Length::Fill)
.padding(Padding {
top: 8.0,
right: 8.0,
bottom: 8.0,
left: 8.0,
})
.wrapping(Wrapping::Word)
.style(|_theme: &Theme, _status: text_widget::Status| {
let p = palette::current();
Style {
background: Background::Color(p.base),
border: Border::default(),
placeholder: p.overlay0,
value: p.text,
selection: Color { a: 0.4, ..p.blue },
}
});
let settings = SyntaxSettings {
lang: self.lang.clone(),
source: self.content.text(),
};
let editor_el: Element<'a, Message, Theme, iced_wgpu::Renderer> = editor
.highlight_with::<SyntaxHighlighter>(
settings,
|highlight, _theme| Format {
color: Some(syntax::highlight_color(highlight.kind)),
font: syntax::highlight_font(highlight.kind),
},
)
.into();
LayeredView::just(editor_el)
}
fn to_md(&self) -> String {
self.content.text()
}
fn hit_test(&self, _point: Point) -> Option<InnerPath> {
Some(InnerPath::Whole)
}
fn apply(&mut self, _cmd: BlockCommand) {
// Text mutations go through `text_editor::Action` routed via
// `Message::BlockAction` in the editor's update loop. BlockCommand
// on a text block is a no-op.
}
fn selectable_paths(&self) -> Box<dyn Iterator<Item = InnerPath> + '_> {
Box::new(std::iter::once(InnerPath::Whole))
}
}

1803
viewport/src/text_widget.rs Normal file

File diff suppressed because it is too large Load Diff

345
viewport/src/tree_block.rs Normal file
View File

@ -0,0 +1,345 @@
use iced_wgpu::core::text::LineHeight;
use iced_wgpu::core::{
alignment, Background, Border, Element, Font, Length, Padding, Pixels,
Point, Rectangle, Shadow, Theme,
};
use iced_widget::canvas;
use iced_widget::container;
use crate::block::{Block, BlockCommand, LayeredView, ViewCtx};
use crate::palette;
use crate::selection::{BlockId, InnerPath};
const BASE_FONT: f32 = 13.0;
fn node_height(font_size: f32) -> f32 { font_size * (20.0 / BASE_FONT) }
fn indent_px(font_size: f32) -> f32 { font_size * (20.0 / BASE_FONT) }
fn branch_inset(font_size: f32) -> f32 { font_size * (12.0 / BASE_FONT) }
fn glyph_width(font_size: f32) -> f32 { font_size * (7.2 / BASE_FONT) }
const WIDGET_INNER_PADDING: Padding = Padding {
top: 4.0,
right: 8.0,
bottom: 4.0,
left: 8.0,
};
const WIDGET_OUTER_PADDING: Padding = Padding {
top: 2.0,
right: 0.0,
bottom: 2.0,
left: 8.0,
};
#[derive(Debug, Clone)]
pub enum TreeMessage {}
struct TreeNode {
label: String,
depth: usize,
is_last: bool,
}
fn flatten_tree(val: &serde_json::Value, depth: usize, is_last: bool, out: &mut Vec<TreeNode>) {
match val {
serde_json::Value::Array(items) => {
if depth > 0 {
out.push(TreeNode {
label: "[array]".into(),
depth,
is_last,
});
}
let len = items.len();
for (i, item) in items.iter().enumerate() {
flatten_tree(item, depth + 1, i == len - 1, out);
}
}
serde_json::Value::Object(_) => {
out.push(TreeNode {
label: "{object}".into(),
depth,
is_last,
});
}
serde_json::Value::String(s) => {
out.push(TreeNode {
label: format!("\"{}\"", s),
depth,
is_last,
});
}
serde_json::Value::Number(n) => {
out.push(TreeNode {
label: n.to_string(),
depth,
is_last,
});
}
serde_json::Value::Bool(b) => {
out.push(TreeNode {
label: b.to_string(),
depth,
is_last,
});
}
serde_json::Value::Null => {
out.push(TreeNode {
label: "null".into(),
depth,
is_last,
});
}
}
}
pub struct TreeProgram {
nodes: Vec<TreeNode>,
total_height: f32,
content_width: f32,
font_size: f32,
}
impl TreeProgram {
pub fn from_json_scaled(val: &serde_json::Value, font_size: f32) -> Self {
let mut nodes = Vec::new();
match val {
serde_json::Value::Array(items) => {
let len = items.len();
for (i, item) in items.iter().enumerate() {
flatten_tree(item, 0, i == len - 1, &mut nodes);
}
}
_ => {
flatten_tree(val, 0, true, &mut nodes);
}
}
let nh = node_height(font_size);
let ind = indent_px(font_size);
let gw = glyph_width(font_size);
let total_height = (nodes.len() as f32 * nh).max(nh);
let content_width = nodes.iter()
.map(|n| {
let depth_px = n.depth as f32 * ind + 16.0;
let label_px = (n.label.chars().count() as f32 + 3.0) * gw;
depth_px + label_px
})
.fold(60.0_f32, f32::max);
Self { nodes, total_height, content_width, font_size }
}
pub fn height(&self) -> f32 {
self.total_height
}
pub fn width(&self) -> f32 {
self.content_width
}
}
impl<Message: Clone> canvas::Program<Message, Theme, iced_wgpu::Renderer> for TreeProgram {
type State = ();
fn draw(
&self,
_state: &(),
renderer: &iced_wgpu::Renderer,
_theme: &Theme,
bounds: Rectangle,
_cursor: iced_wgpu::core::mouse::Cursor,
) -> Vec<canvas::Geometry<iced_wgpu::Renderer>> {
let mut frame = canvas::Frame::new(renderer, bounds.size());
let p = palette::current();
let ws = palette::widget_surface();
let connector_color = p.overlay0;
let label_color = ws.body_text;
let array_color = p.overlay1;
let nh = node_height(self.font_size);
let ind = indent_px(self.font_size);
let bi = branch_inset(self.font_size);
for (i, node) in self.nodes.iter().enumerate() {
let y = i as f32 * nh;
let indent_x = node.depth as f32 * ind + 8.0;
if node.depth > 0 {
let parent_x = (node.depth - 1) as f32 * ind + 8.0;
let connector = canvas::Path::new(|b| {
b.move_to(Point::new(parent_x, y));
b.line_to(Point::new(parent_x, y + nh / 2.0));
b.line_to(Point::new(parent_x + bi, y + nh / 2.0));
});
frame.stroke(
&connector,
canvas::Stroke::default()
.with_width(1.0)
.with_color(connector_color),
);
if !node.is_last {
let vert = canvas::Path::line(
Point::new(indent_x - ind, y + nh / 2.0),
Point::new(indent_x - ind, y + nh),
);
frame.stroke(
&vert,
canvas::Stroke::default()
.with_width(1.0)
.with_color(connector_color),
);
}
}
let text_color = if node.label.starts_with('[') || node.label.starts_with('{') {
array_color
} else {
label_color
};
let branch_char = if node.depth == 0 {
String::new()
} else if node.is_last {
"\u{2514}\u{2500} ".into() // └─
} else {
"\u{251C}\u{2500} ".into() // ├─
};
let display = format!("{}{}", branch_char, node.label);
frame.fill_text(canvas::Text {
content: display,
position: Point::new(indent_x, y + 2.0),
max_width: bounds.width - indent_x,
color: text_color,
size: Pixels(self.font_size),
line_height: LineHeight::Relative(1.3),
font: Font::MONOSPACE,
align_x: alignment::Horizontal::Left.into(),
align_y: alignment::Vertical::Top,
shaping: iced_wgpu::core::text::Shaping::Basic,
});
}
vec![frame.into_geometry()]
}
}
/// Builds the framed canvas Element for a tree block. Returns `'static`
/// because `TreeProgram::from_json` clones the labels into an owned `Vec<TreeNode>` —
/// nothing in the returned widget tree borrows from `data`.
/// Total rendered height of a tree element including padding and border.
pub fn element_height(data: &serde_json::Value, font_size: f32) -> f32 {
let program = TreeProgram::from_json_scaled(data, font_size);
program.height()
+ WIDGET_INNER_PADDING.top + WIDGET_INNER_PADDING.bottom
+ WIDGET_OUTER_PADDING.top + WIDGET_OUTER_PADDING.bottom
}
pub fn build<Message: Clone + 'static>(
data: &serde_json::Value,
font_size: f32,
) -> Element<'static, Message, Theme, iced_wgpu::Renderer> {
let program = TreeProgram::from_json_scaled(data, font_size);
let h = program.height();
let w = program.width();
let canvas_el: Element<'static, Message, Theme, iced_wgpu::Renderer> =
canvas::Canvas::new(program)
.width(Length::Fixed(w))
.height(Length::Fixed(h))
.into();
let framed = container(canvas_el)
.padding(WIDGET_INNER_PADDING)
.style(|_theme: &Theme| {
let ws = palette::widget_surface();
container::Style {
background: Some(Background::Color(ws.fill)),
border: Border {
color: ws.border,
width: 1.0,
radius: 0.0.into(),
},
text_color: Some(ws.body_text),
shadow: Shadow::default(),
snap: false,
}
});
container(framed)
.padding(WIDGET_OUTER_PADDING)
.width(Length::Shrink)
.style(|_theme: &Theme| container::Style {
background: None,
border: Border::default(),
text_color: None,
shadow: Shadow::default(),
snap: false,
})
.into()
}
/// Trait-implementing struct for a tree block. Owns the JSON value; the
/// canvas program is rebuilt fresh on each `view` call (cheap — flatten_tree
/// is O(nodes) and the JSON is already parsed).
pub struct TreeBlock {
pub id: BlockId,
pub data: serde_json::Value,
pub start_line: usize,
}
impl TreeBlock {
pub fn new(id: BlockId, data: serde_json::Value, start_line: usize) -> Self {
Self { id, data, start_line }
}
}
impl<Message: Clone + 'static> Block<Message> for TreeBlock {
fn id(&self) -> BlockId {
self.id
}
fn kind_tag(&self) -> &'static str {
"tree"
}
fn start_line(&self) -> usize {
self.start_line
}
fn set_start_line(&mut self, line: usize) {
self.start_line = line;
}
fn line_count(&self) -> usize {
1
}
fn as_any(&self) -> &dyn std::any::Any {
self
}
fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
self
}
fn view<'a>(&'a self, ctx: &ViewCtx<'_, Message>) -> LayeredView<'a, Message> {
LayeredView::just(build(&self.data, ctx.font_size))
}
fn to_md(&self) -> String {
// Trees aren't currently round-tripped through markdown — they only
// appear as eval results.
String::new()
}
fn hit_test(&self, _point: Point) -> Option<InnerPath> {
Some(InnerPath::Whole)
}
fn apply(&mut self, _cmd: BlockCommand) {
// Trees are read-only.
}
fn selectable_paths(&self) -> Box<dyn Iterator<Item = InnerPath> + '_> {
Box::new(std::iter::once(InnerPath::Whole))
}
}