Initial commit
This commit is contained in:
commit
18ff0a0525
|
|
@ -0,0 +1,3 @@
|
|||
.DS_Store
|
||||
target/
|
||||
build/
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
[submodule "libraries/Cord"]
|
||||
path = libraries/Cord
|
||||
url = git@ssh-git.else-if.org:jess/Cord.git
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
[workspace]
|
||||
members = ["core", "viewport"]
|
||||
resolver = "2"
|
||||
|
||||
[profile.release]
|
||||
panic = "abort"
|
||||
|
|
@ -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>
|
||||
|
|
@ -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.
|
||||
|
|
@ -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("#gradient-1"); 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 |
|
|
@ -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"
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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 */
|
||||
|
|
@ -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]*$"))
|
||||
|
||||
|
||||
|
|
@ -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)
|
||||
|
|
@ -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
|
||||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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(¬es).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)); }
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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;
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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() }
|
||||
}
|
||||
}
|
||||
|
|
@ -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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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 }
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
@ -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"
|
||||
|
|
@ -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));
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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 */
|
||||
|
|
@ -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> + '_>;
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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(" "))
|
||||
}
|
||||
|
|
@ -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,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue