ive lost track. but this needs to happen. because ive lost track.

This commit is contained in:
jess 2026-05-09 22:08:49 -07:00
parent a35f46193b
commit 4e3dfd8e85
53 changed files with 1550 additions and 115 deletions

View File

@ -1,12 +1,12 @@
<?xml version="1.0" encoding="utf-8"?> <?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" xmlns:bx="https://boxy-svg.com"> <svg viewBox="28.548 28.642 432.523 433.752" version="1.1" id="svg29" width="489.61899" height="491.035" xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="none" xmlns:bx="https://boxy-svg.com">
<defs id="defs26"> <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="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-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"/> <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"/>
<filter id="drop-shadow-filter-0" bx:preset="drop-shadow 1 0 2 20 1 #fff" color-interpolation-filters="sRGB" x="-50%" y="-50%" width="200%" height="200%"> <filter id="drop-shadow-filter-0" bx:preset="drop-shadow 1 0 2 5 1 #fff" color-interpolation-filters="sRGB" x="-50%" y="-50%" width="200%" height="200%">
<title>Drop shadow</title> <title>Drop shadow</title>
<feGaussianBlur in="SourceAlpha" stdDeviation="20"/> <feGaussianBlur in="SourceAlpha" stdDeviation="5"/>
<feOffset dx="0" dy="2"/> <feOffset dx="0" dy="2"/>
<feComponentTransfer result="offsetblur"> <feComponentTransfer result="offsetblur">
<feFuncA id="spread-ctrl" type="linear" slope="2"/> <feFuncA id="spread-ctrl" type="linear" slope="2"/>
@ -22,7 +22,7 @@
<feGaussianBlur in="SourceAlpha" stdDeviation="4"/> <feGaussianBlur in="SourceAlpha" stdDeviation="4"/>
<feOffset dx="-3" dy="10"/> <feOffset dx="-3" dy="10"/>
<feComponentTransfer result="offsetblur"> <feComponentTransfer result="offsetblur">
<feFuncA id="spread-ctrl" type="linear" slope="1.74"/> <feFuncA id="spread-ctrl-2" type="linear" slope="1.74"/>
</feComponentTransfer> </feComponentTransfer>
<feFlood flood-color="#fff"/> <feFlood flood-color="#fff"/>
<feComposite in2="offsetblur" operator="in"/> <feComposite in2="offsetblur" operator="in"/>

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

View File

@ -7,16 +7,28 @@
objects = { objects = {
/* Begin PBXBuildFile section */ /* Begin PBXBuildFile section */
13355EBDA35C1D1A4C56C4B6 /* ViewportController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1D4361480B1086E42F1C44D /* ViewportController.swift */; };
451B55F9A56914484DE9F387 /* MenuBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1CC2AF06102C6AF52E50D50 /* MenuBar.swift */; };
52FAB9972C318EE0F234F494 /* IcedViewportView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4AF21EB9858D4F182A323DAA /* IcedViewportView.swift */; }; 52FAB9972C318EE0F234F494 /* IcedViewportView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4AF21EB9858D4F182A323DAA /* IcedViewportView.swift */; };
951153057A1C3373C77657DF /* AcordApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = E391F925E2727D13659EDA02 /* AcordApp.swift */; }; 951153057A1C3373C77657DF /* AcordApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = E391F925E2727D13659EDA02 /* AcordApp.swift */; };
A6236C78901D9A1CDF17D665 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 22A61FD61FB3606DC153A210 /* Assets.xcassets */; };
C138920C883B9BF2BF57BCFA /* PermissionsManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5FB74A74224F1D3571524FA8 /* PermissionsManager.swift */; };
F0306F25CA56D707AE2E27A0 /* IcedViewportRepresentable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 19D7F3B08824BF5E9E468EF2 /* IcedViewportRepresentable.swift */; }; F0306F25CA56D707AE2E27A0 /* IcedViewportRepresentable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 19D7F3B08824BF5E9E468EF2 /* IcedViewportRepresentable.swift */; };
F10B810345E36162B23064AF /* DocumentPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = F50B6D3A3481F01ECC66CDBD /* DocumentPicker.swift */; };
F6003A9958CB3CA65BAFE23F /* Debug.swift in Sources */ = {isa = PBXBuildFile; fileRef = CF09AEF6E0546502D3FB36BE /* Debug.swift */; };
/* End PBXBuildFile section */ /* End PBXBuildFile section */
/* Begin PBXFileReference section */ /* Begin PBXFileReference section */
19D7F3B08824BF5E9E468EF2 /* IcedViewportRepresentable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IcedViewportRepresentable.swift; sourceTree = "<group>"; }; 19D7F3B08824BF5E9E468EF2 /* IcedViewportRepresentable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IcedViewportRepresentable.swift; sourceTree = "<group>"; };
22A61FD61FB3606DC153A210 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
4AF21EB9858D4F182A323DAA /* IcedViewportView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IcedViewportView.swift; sourceTree = "<group>"; }; 4AF21EB9858D4F182A323DAA /* IcedViewportView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IcedViewportView.swift; sourceTree = "<group>"; };
5FB74A74224F1D3571524FA8 /* PermissionsManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PermissionsManager.swift; sourceTree = "<group>"; };
A1D4361480B1086E42F1C44D /* ViewportController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewportController.swift; sourceTree = "<group>"; };
C3417095D399AB8B216B5139 /* Acord.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = Acord.app; sourceTree = BUILT_PRODUCTS_DIR; }; C3417095D399AB8B216B5139 /* Acord.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = Acord.app; sourceTree = BUILT_PRODUCTS_DIR; };
CF09AEF6E0546502D3FB36BE /* Debug.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Debug.swift; sourceTree = "<group>"; };
D1CC2AF06102C6AF52E50D50 /* MenuBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuBar.swift; sourceTree = "<group>"; };
E391F925E2727D13659EDA02 /* AcordApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AcordApp.swift; sourceTree = "<group>"; }; E391F925E2727D13659EDA02 /* AcordApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AcordApp.swift; sourceTree = "<group>"; };
F50B6D3A3481F01ECC66CDBD /* DocumentPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DocumentPicker.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */ /* End PBXFileReference section */
/* Begin PBXGroup section */ /* Begin PBXGroup section */
@ -32,6 +44,7 @@
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
FD6FF6B76E15E1EA3D0E676B /* src */, FD6FF6B76E15E1EA3D0E676B /* src */,
22A61FD61FB3606DC153A210 /* Assets.xcassets */,
76C61BCA6D25861221CA6340 /* Products */, 76C61BCA6D25861221CA6340 /* Products */,
); );
sourceTree = "<group>"; sourceTree = "<group>";
@ -40,8 +53,13 @@
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
E391F925E2727D13659EDA02 /* AcordApp.swift */, E391F925E2727D13659EDA02 /* AcordApp.swift */,
CF09AEF6E0546502D3FB36BE /* Debug.swift */,
F50B6D3A3481F01ECC66CDBD /* DocumentPicker.swift */,
19D7F3B08824BF5E9E468EF2 /* IcedViewportRepresentable.swift */, 19D7F3B08824BF5E9E468EF2 /* IcedViewportRepresentable.swift */,
4AF21EB9858D4F182A323DAA /* IcedViewportView.swift */, 4AF21EB9858D4F182A323DAA /* IcedViewportView.swift */,
D1CC2AF06102C6AF52E50D50 /* MenuBar.swift */,
5FB74A74224F1D3571524FA8 /* PermissionsManager.swift */,
A1D4361480B1086E42F1C44D /* ViewportController.swift */,
); );
path = src; path = src;
sourceTree = "<group>"; sourceTree = "<group>";
@ -54,6 +72,7 @@
buildConfigurationList = 3E9D564CF91C62A230D65F8C /* Build configuration list for PBXNativeTarget "Acord" */; buildConfigurationList = 3E9D564CF91C62A230D65F8C /* Build configuration list for PBXNativeTarget "Acord" */;
buildPhases = ( buildPhases = (
BEED27C37617469B917539A1 /* Sources */, BEED27C37617469B917539A1 /* Sources */,
5C3B8F8816A1704304EF6942 /* Resources */,
); );
buildRules = ( buildRules = (
); );
@ -100,14 +119,30 @@
}; };
/* End PBXProject section */ /* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */
5C3B8F8816A1704304EF6942 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
A6236C78901D9A1CDF17D665 /* Assets.xcassets in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXSourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */
BEED27C37617469B917539A1 /* Sources */ = { BEED27C37617469B917539A1 /* Sources */ = {
isa = PBXSourcesBuildPhase; isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
951153057A1C3373C77657DF /* AcordApp.swift in Sources */, 951153057A1C3373C77657DF /* AcordApp.swift in Sources */,
F6003A9958CB3CA65BAFE23F /* Debug.swift in Sources */,
F10B810345E36162B23064AF /* DocumentPicker.swift in Sources */,
F0306F25CA56D707AE2E27A0 /* IcedViewportRepresentable.swift in Sources */, F0306F25CA56D707AE2E27A0 /* IcedViewportRepresentable.swift in Sources */,
52FAB9972C318EE0F234F494 /* IcedViewportView.swift in Sources */, 52FAB9972C318EE0F234F494 /* IcedViewportView.swift in Sources */,
451B55F9A56914484DE9F387 /* MenuBar.swift in Sources */,
C138920C883B9BF2BF57BCFA /* PermissionsManager.swift in Sources */,
13355EBDA35C1D1A4C56C4B6 /* ViewportController.swift in Sources */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
@ -187,9 +222,8 @@
GENERATE_INFOPLIST_FILE = NO; GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = Info.plist; INFOPLIST_FILE = Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 17.0; IPHONEOS_DEPLOYMENT_TARGET = 17.0;
LIBRARY_SEARCH_PATHS = ( "LIBRARY_SEARCH_PATHS[sdk=iphoneos*]" = "/tmp/acord/target/aarch64-apple-ios/release";
"$(PROJECT_DIR)/../target/$(SWIFT_PLATFORM_TARGET_PREFIX)/release", "LIBRARY_SEARCH_PATHS[sdk=iphonesimulator*]" = "/tmp/acord/target/aarch64-apple-ios-sim/release";
);
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES; MTL_FAST_MATH = YES;
ONLY_ACTIVE_ARCH = YES; ONLY_ACTIVE_ARCH = YES;
@ -258,9 +292,8 @@
GENERATE_INFOPLIST_FILE = NO; GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = Info.plist; INFOPLIST_FILE = Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 17.0; IPHONEOS_DEPLOYMENT_TARGET = 17.0;
LIBRARY_SEARCH_PATHS = ( "LIBRARY_SEARCH_PATHS[sdk=iphoneos*]" = "/tmp/acord/target/aarch64-apple-ios/release";
"$(PROJECT_DIR)/../target/$(SWIFT_PLATFORM_TARGET_PREFIX)/release", "LIBRARY_SEARCH_PATHS[sdk=iphonesimulator*]" = "/tmp/acord/target/aarch64-apple-ios-sim/release";
);
MTL_ENABLE_DEBUG_INFO = NO; MTL_ENABLE_DEBUG_INFO = NO;
MTL_FAST_MATH = YES; MTL_FAST_MATH = YES;
OTHER_LDFLAGS = ( OTHER_LDFLAGS = (

View File

@ -0,0 +1,14 @@
<?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>SchemeUserState</key>
<dict>
<key>Acord.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>0</integer>
</dict>
</dict>
</dict>
</plist>

View File

@ -0,0 +1,26 @@
{
"images" : [
{ "idiom" : "iphone", "size" : "20x20", "scale" : "2x", "filename" : "Icon-20@2x.png" },
{ "idiom" : "iphone", "size" : "20x20", "scale" : "3x", "filename" : "Icon-20@3x.png" },
{ "idiom" : "iphone", "size" : "29x29", "scale" : "2x", "filename" : "Icon-29@2x.png" },
{ "idiom" : "iphone", "size" : "29x29", "scale" : "3x", "filename" : "Icon-29@3x.png" },
{ "idiom" : "iphone", "size" : "40x40", "scale" : "2x", "filename" : "Icon-40@2x.png" },
{ "idiom" : "iphone", "size" : "40x40", "scale" : "3x", "filename" : "Icon-40@3x.png" },
{ "idiom" : "iphone", "size" : "60x60", "scale" : "2x", "filename" : "Icon-60@2x.png" },
{ "idiom" : "iphone", "size" : "60x60", "scale" : "3x", "filename" : "Icon-60@3x.png" },
{ "idiom" : "ipad", "size" : "20x20", "scale" : "1x", "filename" : "Icon-20.png" },
{ "idiom" : "ipad", "size" : "20x20", "scale" : "2x", "filename" : "Icon-20@2x.png" },
{ "idiom" : "ipad", "size" : "29x29", "scale" : "1x", "filename" : "Icon-29.png" },
{ "idiom" : "ipad", "size" : "29x29", "scale" : "2x", "filename" : "Icon-29@2x.png" },
{ "idiom" : "ipad", "size" : "40x40", "scale" : "1x", "filename" : "Icon-40.png" },
{ "idiom" : "ipad", "size" : "40x40", "scale" : "2x", "filename" : "Icon-40@2x.png" },
{ "idiom" : "ipad", "size" : "76x76", "scale" : "1x", "filename" : "Icon-76.png" },
{ "idiom" : "ipad", "size" : "76x76", "scale" : "2x", "filename" : "Icon-76@2x.png" },
{ "idiom" : "ipad", "size" : "83.5x83.5","scale" : "2x","filename" : "Icon-83.5@2x.png" },
{ "idiom" : "ios-marketing","size" : "1024x1024","scale" : "1x","filename" : "Icon-1024.png" }
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 258 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

View File

@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@ -60,5 +60,68 @@
<true/> <true/>
<key>UIFileSharingEnabled</key> <key>UIFileSharingEnabled</key>
<true/> <true/>
<key>UISupportsDocumentBrowser</key>
<true/>
<key>CFBundleDocumentTypes</key>
<array>
<dict>
<key>CFBundleTypeName</key>
<string>Markdown</string>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>LSHandlerRank</key>
<string>Default</string>
<key>LSItemContentTypes</key>
<array>
<string>net.daringfireball.markdown</string>
<string>public.plain-text</string>
</array>
<key>CFBundleTypeExtensions</key>
<array>
<string>md</string>
<string>markdown</string>
<string>mdown</string>
<string>txt</string>
</array>
</dict>
</array>
<key>NSPhotoLibraryUsageDescription</key>
<string>Acord uses your photo library to embed images into notes.</string>
<key>NSPhotoLibraryAddUsageDescription</key>
<string>Acord saves rendered notes and exported PDFs to your photo library.</string>
<key>NSCameraUsageDescription</key>
<string>Acord uses the camera to attach photos directly to notes.</string>
<key>NSMicrophoneUsageDescription</key>
<string>Acord uses the microphone to attach voice notes.</string>
<key>NSDocumentsFolderUsageDescription</key>
<string>Acord reads and writes notes from your chosen documents folder.</string>
<key>NSDownloadsFolderUsageDescription</key>
<string>Acord opens and saves notes in your Downloads folder.</string>
<key>UTExportedTypeDeclarations</key>
<array>
<dict>
<key>UTTypeIdentifier</key>
<string>net.daringfireball.markdown</string>
<key>UTTypeDescription</key>
<string>Markdown</string>
<key>UTTypeConformsTo</key>
<array>
<string>public.plain-text</string>
</array>
<key>UTTypeTagSpecification</key>
<dict>
<key>public.filename-extension</key>
<array>
<string>md</string>
<string>markdown</string>
<string>mdown</string>
</array>
<key>public.mime-type</key>
<array>
<string>text/markdown</string>
</array>
</dict>
</dict>
</array>
</dict> </dict>
</plist> </plist>

View File

@ -16,8 +16,10 @@ settings:
TARGETED_DEVICE_FAMILY: "2,1" TARGETED_DEVICE_FAMILY: "2,1"
IPHONEOS_DEPLOYMENT_TARGET: "17.0" IPHONEOS_DEPLOYMENT_TARGET: "17.0"
SWIFT_OBJC_BRIDGING_HEADER: ../viewport/include/acord.h SWIFT_OBJC_BRIDGING_HEADER: ../viewport/include/acord.h
LIBRARY_SEARCH_PATHS: # Cargo writes to /tmp/acord/target by default (see scripts/_build-dirs.sh).
- $(PROJECT_DIR)/../target/$(SWIFT_PLATFORM_TARGET_PREFIX)/release # xcodeproj.sh runs cargo with both targets and copies their output here.
"LIBRARY_SEARCH_PATHS[sdk=iphoneos*]": /tmp/acord/target/aarch64-apple-ios/release
"LIBRARY_SEARCH_PATHS[sdk=iphonesimulator*]": /tmp/acord/target/aarch64-apple-ios-sim/release
OTHER_LDFLAGS: OTHER_LDFLAGS:
- -lacord_viewport - -lacord_viewport
EXCLUDED_ARCHS[sdk=iphonesimulator*]: x86_64 EXCLUDED_ARCHS[sdk=iphonesimulator*]: x86_64
@ -30,8 +32,10 @@ targets:
platform: iOS platform: iOS
sources: sources:
- path: src - path: src
- path: Assets.xcassets
settings: settings:
base: base:
INFOPLIST_FILE: Info.plist INFOPLIST_FILE: Info.plist
GENERATE_INFOPLIST_FILE: NO GENERATE_INFOPLIST_FILE: NO
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
dependencies: [] dependencies: []

View File

@ -1,18 +1,65 @@
import SwiftUI import SwiftUI
import os.log
import Darwin
@main @main
struct AcordApp: App { struct AcordApp: App {
init() {
Self.captureStderr()
dlog("AcordApp.init")
}
/// Pipes Rust staticlib stderr into both NSLog (for Console.app) and the
/// real stdout (for `xcrun devicectl --console`, which only forwards
/// stdout/stderr). Without this, `eprintln!()` from Rust is silently dropped.
private static func captureStderr() {
let realStdout = dup(fileno(stdout))
guard realStdout != -1 else { return }
let outFile = fdopen(realStdout, "w")
guard outFile != nil else { close(realStdout); return }
setvbuf(outFile, nil, _IONBF, 0)
var fds: [Int32] = [0, 0]
guard pipe(&fds) == 0 else { return }
dup2(fds[1], fileno(stderr))
setvbuf(stderr, nil, _IONBF, 0)
DispatchQueue.global(qos: .utility).async {
guard let f = fdopen(fds[0], "r") else { return }
var line: UnsafeMutablePointer<CChar>?
var cap: Int = 0
while getline(&line, &cap, f) > 0 {
if let l = line {
fputs("[Rust] ", outFile)
fputs(l, outFile)
NSLog("[Rust] %s", l)
}
}
if let l = line { free(l) }
}
}
var body: some Scene { var body: some Scene {
WindowGroup { WindowGroup {
ContentView() ContentView()
.ignoresSafeArea(.keyboard) .onAppear { dlog("AcordApp scene WindowGroup appeared") }
} }
} }
} }
struct ContentView: View { struct ContentView: View {
@StateObject private var controller = ViewportController()
var body: some View { var body: some View {
IcedViewportRepresentable() VStack(spacing: 0) {
.ignoresSafeArea() MenuBar(controller: controller)
IcedViewportRepresentable(controller: controller)
.ignoresSafeArea(.container, edges: .bottom)
}
.ignoresSafeArea(.keyboard)
.onAppear {
dlog("ContentView.onAppear")
PermissionsManager.requestSystemPermissions()
}
} }
} }

13
ios/src/Debug.swift Normal file
View File

@ -0,0 +1,13 @@
import Foundation
/// Gated logging every diagnostic print in the iOS shell goes through here.
/// Release builds compile this to a no-op so no log lines leak into shipping.
/// Define DEBUG via `-D DEBUG` when invoking swiftc (debug.sh does this; the
/// release path used by install.sh does not).
@inline(__always)
func dlog(_ message: @autoclosure () -> String, file: StaticString = #file, line: UInt = #line) {
#if DEBUG
let stem = (("\(file)" as NSString).lastPathComponent as NSString).deletingPathExtension
print("[Acord] \(stem):\(line)\(message())")
#endif
}

View File

@ -0,0 +1,133 @@
import UIKit
import UniformTypeIdentifiers
/// Bridges UIDocumentPickerViewController into the Rust viewport.
/// Open and Save flows rely on iOS's per-file permission grant a
/// security-scoped URL is what the picker hands back, and we copy bytes in
/// or out under `startAccessingSecurityScopedResource` while it's in scope.
enum DocumentPicker {
private static var openDelegate: OpenDelegate?
private static var saveDelegate: SaveDelegate?
static func presentOpen(handle: OpaquePointer) {
dlog("presentOpen called")
guard let root = topViewController() else {
dlog("presentOpen: topViewController returned nil — picker NOT shown")
return
}
// .item is the broadest "any file" UTI without this, files whose UTI
// doesn't exactly match get rendered grey/unselectable in the picker.
// asCopy:true sidesteps the security-scoped-resource entitlement dance:
// iOS hands us a copy in our sandbox tmp dir we can just read.
var types: [UTType] = [.plainText, .utf8PlainText, .text, .sourceCode, .data, .item]
if let md = UTType(filenameExtension: "md") { types.insert(md, at: 0) }
if let md = UTType("net.daringfireball.markdown") { types.insert(md, at: 0) }
dlog("presentOpen: types=\(types.map(\.identifier))")
let picker = UIDocumentPickerViewController(forOpeningContentTypes: types, asCopy: true)
let delegate = OpenDelegate(handle: handle)
openDelegate = delegate
picker.delegate = delegate
picker.allowsMultipleSelection = false
root.present(picker, animated: true) {
dlog("presentOpen: picker presented from \(type(of: root))")
}
}
static func presentSave(handle: OpaquePointer, defaultName: String) {
dlog("presentSave called")
guard let root = topViewController() else {
dlog("presentSave: topViewController returned nil — picker NOT shown")
return
}
guard let cstr = viewport_get_text(handle) else {
dlog("presentSave: viewport_get_text returned null")
return
}
let text = String(cString: cstr)
viewport_free_string(cstr)
dlog("presentSave: serialized \(text.utf8.count) bytes from viewport")
let tmp = FileManager.default.temporaryDirectory.appendingPathComponent("\(defaultName).md")
do {
try text.data(using: .utf8)?.write(to: tmp)
dlog("presentSave: wrote tmp \(tmp.path)")
} catch {
dlog("presentSave: tmp write failed: \(error)")
return
}
let picker = UIDocumentPickerViewController(forExporting: [tmp], asCopy: true)
let delegate = SaveDelegate(handle: handle, source: tmp)
saveDelegate = delegate
picker.delegate = delegate
root.present(picker, animated: true) {
dlog("presentSave: picker presented")
}
}
private static func topViewController() -> UIViewController? {
guard let scene = UIApplication.shared.connectedScenes.first as? UIWindowScene else {
dlog("topViewController: no UIWindowScene")
return nil
}
guard let window = scene.windows.first(where: { $0.isKeyWindow }) ?? scene.windows.first else {
dlog("topViewController: no window in scene")
return nil
}
var top = window.rootViewController
while let presented = top?.presentedViewController { top = presented }
return top
}
}
private final class OpenDelegate: NSObject, UIDocumentPickerDelegate {
let handle: OpaquePointer
init(handle: OpaquePointer) { self.handle = handle }
func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) {
dlog("open delegate fired with \(urls.count) urls")
guard let url = urls.first else {
dlog("open: no url in selection")
return
}
dlog("open: url=\(url.path)")
// asCopy:true means url is already in our sandbox tmp dir no scoped access needed.
do {
let data = try Data(contentsOf: url)
guard let text = String(data: data, encoding: .utf8) else {
dlog("open: file at \(url.path) is not utf-8 (\(data.count) bytes)")
return
}
text.withCString { cstr in
viewport_set_text(handle, cstr)
}
dlog("open: loaded \(data.count) bytes (\(text.count) chars) from \(url.lastPathComponent)")
} catch {
dlog("open: read failed: \(error)")
}
}
func documentPickerWasCancelled(_ controller: UIDocumentPickerViewController) {
dlog("open: cancelled by user")
}
}
private final class SaveDelegate: NSObject, UIDocumentPickerDelegate {
let handle: OpaquePointer
let source: URL
init(handle: OpaquePointer, source: URL) {
self.handle = handle
self.source = source
}
func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) {
dlog("save: picker resolved with destinations=\(urls.map(\.path))")
try? FileManager.default.removeItem(at: source)
}
func documentPickerWasCancelled(_ controller: UIDocumentPickerViewController) {
dlog("save: cancelled by user")
try? FileManager.default.removeItem(at: source)
}
}

View File

@ -2,12 +2,20 @@ import SwiftUI
import UIKit import UIKit
/// SwiftUI wrapper around the UIView that hosts the Rust viewport. /// SwiftUI wrapper around the UIView that hosts the Rust viewport.
/// Stashes the underlying view into the shared ViewportController so the
/// menu bar can dispatch commands against the same handle.
struct IcedViewportRepresentable: UIViewRepresentable { struct IcedViewportRepresentable: UIViewRepresentable {
let controller: ViewportController
func makeUIView(context: Context) -> IcedViewportView { func makeUIView(context: Context) -> IcedViewportView {
IcedViewportView(frame: .zero) dlog("makeUIView")
let v = IcedViewportView(frame: .zero)
controller.view = v
return v
} }
func updateUIView(_ uiView: IcedViewportView, context: Context) { func updateUIView(_ uiView: IcedViewportView, context: Context) {
// size pushed via setFrameSize; nothing to refresh per SwiftUI tick. dlog("updateUIView: bounds=\(uiView.bounds.size) handle=\(uiView.viewportHandle != nil ? "live" : "nil")")
controller.view = uiView
} }
} }

View File

@ -2,8 +2,9 @@ import UIKit
import QuartzCore import QuartzCore
/// CAMetalLayer-backed UIView that owns the Rust viewport handle and pumps /// CAMetalLayer-backed UIView that owns the Rust viewport handle and pumps
/// CADisplayLink ticks into `viewport_render`. /// CADisplayLink ticks into `viewport_render`. UIKeyInput conformance is what
class IcedViewportView: UIView { /// makes the soft keyboard appear when the view becomes first responder.
class IcedViewportView: UIView, UIKeyInput {
override class var layerClass: AnyClass { CAMetalLayer.self } override class var layerClass: AnyClass { CAMetalLayer.self }
private(set) var viewportHandle: OpaquePointer? private(set) var viewportHandle: OpaquePointer?
@ -29,15 +30,22 @@ class IcedViewportView: UIView {
metalLayer.framebufferOnly = true metalLayer.framebufferOnly = true
metalLayer.pixelFormat = .bgra8Unorm metalLayer.pixelFormat = .bgra8Unorm
metalLayer.isOpaque = true metalLayer.isOpaque = true
dlog("commonInit: scale=\(UIScreen.main.scale) pixelFormat=bgra8Unorm")
} else {
dlog("commonInit: layer is NOT CAMetalLayer — got \(type(of: layer))")
} }
} }
override func didMoveToWindow() { override func didMoveToWindow() {
super.didMoveToWindow() super.didMoveToWindow()
dlog("didMoveToWindow: window=\(window != nil ? "set" : "nil") handle=\(viewportHandle != nil ? "live" : "nil") tornDown=\(isTornDown) bounds=\(bounds.size)")
if window != nil && viewportHandle == nil && !isTornDown { if window != nil && viewportHandle == nil && !isTornDown {
createViewport() createViewport()
startDisplayLink() startDisplayLink()
becomeFirstResponder() // intentionally NOT becoming first responder here claiming it on
// appear conflicts with SwiftUI Menu popovers (see the
// _UIReparentingView warning when clicking the menu strip).
// touchesBegan claims it instead, which is the natural moment.
} else if window == nil { } else if window == nil {
teardown() teardown()
} }
@ -45,27 +53,66 @@ class IcedViewportView: UIView {
override var canBecomeFirstResponder: Bool { true } override var canBecomeFirstResponder: Bool { true }
// MARK: - UIKeyInput (soft keyboard)
var hasText: Bool { true }
func insertText(_ text: String) {
guard let h = viewportHandle else {
dlog("insertText: no handle — dropped \(text.debugDescription)")
return
}
dlog("insertText: \(text.debugDescription) (\(text.utf8.count) bytes)")
text.withCString { cstr in
viewport_key_event(h, 0, 0, true, cstr)
viewport_key_event(h, 0, 0, false, cstr)
}
}
func deleteBackward() {
guard let h = viewportHandle else {
dlog("deleteBackward: no handle")
return
}
dlog("deleteBackward")
"\u{7F}".withCString { cstr in
viewport_key_event(h, 51, 0, true, cstr)
viewport_key_event(h, 51, 0, false, cstr)
}
}
private func createViewport() { private func createViewport() {
let scale = Float(window?.screen.scale ?? UIScreen.main.scale) let scale = Float(window?.screen.scale ?? UIScreen.main.scale)
let w = Float(bounds.width) let w = Float(bounds.width)
let h = Float(bounds.height) let h = Float(bounds.height)
if let metalLayer = layer as? CAMetalLayer, metalLayer.device == nil {
metalLayer.device = MTLCreateSystemDefaultDevice()
dlog("createViewport: assigned MTLDevice=\(String(describing: metalLayer.device?.name))")
}
let viewPtr = Unmanaged.passUnretained(self).toOpaque() let viewPtr = Unmanaged.passUnretained(self).toOpaque()
viewportHandle = viewport_create(viewPtr, w, h, scale) viewportHandle = viewport_create(viewPtr, w, h, scale)
dlog("createViewport: bounds=\(w)x\(h) scale=\(scale) handle=\(String(describing: viewportHandle))")
if w == 0 || h == 0 {
dlog("createViewport: WARNING bounds are zero — layoutSubviews must fire before paint is meaningful")
}
} }
private func destroyViewport() { private func destroyViewport() {
guard let handle = viewportHandle else { return } guard let handle = viewportHandle else { return }
dlog("destroyViewport")
viewportHandle = nil viewportHandle = nil
viewport_destroy(handle) viewport_destroy(handle)
} }
func teardown() { func teardown() {
if isTornDown { return } if isTornDown { return }
dlog("teardown")
isTornDown = true isTornDown = true
stopDisplayLink() stopDisplayLink()
if let h = viewportHandle, let cstr = viewport_get_text(h) { if let h = viewportHandle, let cstr = viewport_get_text(h) {
cachedText = String(cString: cstr) cachedText = String(cString: cstr)
viewport_free_string(cstr) viewport_free_string(cstr)
dlog("teardown: cached \(cachedText.count) chars")
} }
destroyViewport() destroyViewport()
} }
@ -79,6 +126,7 @@ class IcedViewportView: UIView {
let link = CADisplayLink(target: self, selector: #selector(renderFrame)) let link = CADisplayLink(target: self, selector: #selector(renderFrame))
link.add(to: .main, forMode: .common) link.add(to: .main, forMode: .common)
displayLink = link displayLink = link
dlog("startDisplayLink")
} }
private func stopDisplayLink() { private func stopDisplayLink() {
@ -86,10 +134,17 @@ class IcedViewportView: UIView {
displayLink = nil displayLink = nil
} }
private var renderCount: Int = 0
@objc private func renderFrame() { @objc private func renderFrame() {
if isTornDown { return } if isTornDown { return }
guard let handle = viewportHandle else { return } guard let handle = viewportHandle else { return }
viewport_render(handle) viewport_render(handle)
renderCount += 1
// first frame, then 60th (~1s), then every 600 (~10s) to confirm the loop is alive.
if renderCount == 1 || renderCount == 60 || renderCount % 600 == 0 {
let ml = layer as? CAMetalLayer
dlog("renderFrame #\(renderCount) bounds=\(bounds.width)x\(bounds.height) drawable=\(ml?.drawableSize ?? .zero) scale=\(ml?.contentsScale ?? 0)")
}
} }
// MARK: - Resize // MARK: - Resize
@ -103,8 +158,12 @@ class IcedViewportView: UIView {
width: bounds.width * CGFloat(scale), width: bounds.width * CGFloat(scale),
height: bounds.height * CGFloat(scale) height: bounds.height * CGFloat(scale)
) )
dlog("layoutSubviews: bounds=\(bounds.size) scale=\(scale) drawable=\(metalLayer.drawableSize)")
}
guard let handle = viewportHandle else {
dlog("layoutSubviews: no handle yet")
return
} }
guard let handle = viewportHandle else { return }
viewport_resize(handle, Float(bounds.width), Float(bounds.height), scale) viewport_resize(handle, Float(bounds.width), Float(bounds.height), scale)
} }
@ -113,8 +172,10 @@ class IcedViewportView: UIView {
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) { override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
guard let h = viewportHandle, let touch = touches.first else { return } guard let h = viewportHandle, let touch = touches.first else { return }
let p = touch.location(in: self) let p = touch.location(in: self)
dlog("touchesBegan: \(p)")
viewport_mouse_event(h, Float(p.x), Float(p.y), 0, true) viewport_mouse_event(h, Float(p.x), Float(p.y), 0, true)
becomeFirstResponder() becomeFirstResponder()
dlog("touchesBegan: firstResponder=\(isFirstResponder)")
} }
override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent?) { override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent?) {
@ -126,12 +187,14 @@ class IcedViewportView: UIView {
override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) { override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
guard let h = viewportHandle, let touch = touches.first else { return } guard let h = viewportHandle, let touch = touches.first else { return }
let p = touch.location(in: self) let p = touch.location(in: self)
dlog("touchesEnded: \(p)")
viewport_mouse_event(h, Float(p.x), Float(p.y), 0, false) viewport_mouse_event(h, Float(p.x), Float(p.y), 0, false)
} }
override func touchesCancelled(_ touches: Set<UITouch>, with event: UIEvent?) { override func touchesCancelled(_ touches: Set<UITouch>, with event: UIEvent?) {
guard let h = viewportHandle, let touch = touches.first else { return } guard let h = viewportHandle, let touch = touches.first else { return }
let p = touch.location(in: self) let p = touch.location(in: self)
dlog("touchesCancelled: \(p)")
viewport_mouse_event(h, Float(p.x), Float(p.y), 0, false) viewport_mouse_event(h, Float(p.x), Float(p.y), 0, false)
} }
@ -160,6 +223,7 @@ class IcedViewportView: UIView {
private func forwardKey(_ press: UIPress, pressed: Bool, handle: OpaquePointer) { private func forwardKey(_ press: UIPress, pressed: Bool, handle: OpaquePointer) {
guard let key = press.key else { return } guard let key = press.key else { return }
let chars = pressed ? key.characters : "" let chars = pressed ? key.characters : ""
dlog("forwardKey: keyCode=\(key.keyCode.rawValue) mods=\(key.modifierFlags.rawValue) pressed=\(pressed) chars=\(chars.debugDescription)")
chars.withCString { cstr in chars.withCString { cstr in
viewport_key_event(handle, UInt32(key.keyCode.rawValue), UInt32(key.modifierFlags.rawValue), pressed, cstr) viewport_key_event(handle, UInt32(key.keyCode.rawValue), UInt32(key.modifierFlags.rawValue), pressed, cstr)
} }

82
ios/src/MenuBar.swift Normal file
View File

@ -0,0 +1,82 @@
import SwiftUI
/// Top toolbar with File / Edit / Render / View menus, mirroring the
/// editor's MenuCategory layout. Uses SwiftUI Menu so each label opens a
/// dropdown of buttons; each button dispatches through ViewportController.
struct MenuBar: View {
@ObservedObject var controller: ViewportController
var body: some View {
HStack(spacing: 0) {
fileMenu
editMenu
renderMenu
viewMenu
Spacer()
}
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(Color(white: 0.12))
.foregroundColor(.white)
.font(.system(size: 13))
}
private var fileMenu: some View {
Menu("File") {
Button("New Note") { controller.newNote() }
Button("Open…") { controller.openDocument() }
Divider()
Button("Save") { controller.saveDocument() }
Button("Save As…") { controller.saveDocumentAs() }
Divider()
Button("Print…") { controller.printDocument() }
Divider()
Button("Settings…") { controller.toggleSettings() }
}
.menuLabel
}
private var editMenu: some View {
Menu("Edit") {
Button("Undo") { controller.undo() }
Button("Redo") { controller.redo() }
Divider()
Button("Bold") { controller.toggleBold() }
Button("Italic") { controller.toggleItalic() }
Button("Insert Table") { controller.insertTable() }
Divider()
Button("Find…") { controller.toggleFind() }
}
.menuLabel
}
private var renderMenu: some View {
Menu("Render") {
Button("Live") { controller.setLiveMode() }
Button("Editor") { controller.setEditorMode() }
Button("View") { controller.setViewMode() }
Divider()
Button("Evaluate") { controller.evaluate() }
}
.menuLabel
}
private var viewMenu: some View {
Menu("View") {
Button("Zoom In") { controller.zoomIn() }
Button("Zoom Out") { controller.zoomOut() }
Button("Reset Zoom") { controller.resetZoom() }
}
.menuLabel
}
}
private extension View {
/// Consistent Menu chrome slightly padded, light hit target.
var menuLabel: some View {
self
.padding(.horizontal, 10)
.padding(.vertical, 4)
.contentShape(Rectangle())
}
}

View File

@ -0,0 +1,79 @@
import UIKit
import Photos
import AVFoundation
/// Drives the iOS permission dialogs that fire on first launch.
/// Photos / Camera / Microphone trigger native system prompts when their
/// `requestAuthorization` is called, the Info.plist usage strings exist,
/// and the corresponding framework is linked.
/// File-system access on iOS isn't a global permission DocumentPicker's
/// per-file picker IS the consent moment for that, and asCopy:true means
/// no entitlements beyond the picker itself are required.
enum PermissionsManager {
/// Sequentially asks for Photos Camera Microphone access.
/// Each call shows the system prompt only when status is .notDetermined.
static func requestSystemPermissions() {
dlog("requestSystemPermissions called")
let photosStatus = PHPhotoLibrary.authorizationStatus(for: .readWrite)
dlog("photos status: \(authStatusName(photosStatus))")
if photosStatus == .notDetermined {
dlog("photos: requesting authorization (system dialog should appear)")
PHPhotoLibrary.requestAuthorization(for: .readWrite) { status in
dlog("photos: dialog returned \(authStatusName(status))")
requestCamera()
}
} else {
dlog("photos: already determined — skipping dialog")
requestCamera()
}
}
private static func requestCamera() {
let cameraStatus = AVCaptureDevice.authorizationStatus(for: .video)
dlog("camera status: \(avStatusName(cameraStatus))")
if cameraStatus == .notDetermined {
dlog("camera: requesting authorization (system dialog should appear)")
AVCaptureDevice.requestAccess(for: .video) { granted in
dlog("camera: dialog returned granted=\(granted)")
requestMicrophone()
}
} else {
dlog("camera: already determined — skipping dialog")
requestMicrophone()
}
}
private static func requestMicrophone() {
let micStatus = AVCaptureDevice.authorizationStatus(for: .audio)
dlog("microphone status: \(avStatusName(micStatus))")
if micStatus == .notDetermined {
dlog("microphone: requesting authorization (system dialog should appear)")
AVCaptureDevice.requestAccess(for: .audio) { granted in
dlog("microphone: dialog returned granted=\(granted)")
}
} else {
dlog("microphone: already determined — skipping dialog")
}
}
private static func authStatusName(_ s: PHAuthorizationStatus) -> String {
switch s {
case .notDetermined: return "notDetermined"
case .restricted: return "restricted"
case .denied: return "denied"
case .authorized: return "authorized"
case .limited: return "limited"
@unknown default: return "unknown(\(s.rawValue))"
}
}
private static func avStatusName(_ s: AVAuthorizationStatus) -> String {
switch s {
case .notDetermined: return "notDetermined"
case .restricted: return "restricted"
case .denied: return "denied"
case .authorized: return "authorized"
@unknown default: return "unknown(\(s.rawValue))"
}
}
}

View File

@ -0,0 +1,96 @@
import SwiftUI
/// Bridges SwiftUI menu buttons to the Rust viewport handle.
/// Holds a weak reference to the IcedViewportView so the menu can dispatch
/// commands without owning the rendering surface.
final class ViewportController: ObservableObject {
weak var view: IcedViewportView?
func send(_ code: UInt32) {
guard let h = view?.viewportHandle else {
dlog("send(\(code)): no handle")
return
}
dlog("send(\(code))")
viewport_send_command(h, code)
}
/// Editor commands (mirror viewport/src/lib.rs::viewport_send_command codes).
func toggleBold() { send(1) }
func toggleItalic() { send(2) }
func insertTable() { send(3) }
func evaluate() { send(5) }
func zoomIn() { send(7) }
func zoomOut() { send(8) }
func resetZoom() { send(9) }
func setLiveMode() { send(11) }
func setEditorMode() { send(12) }
func setViewMode() { send(13) }
func toggleSettings() { send(16) }
/// Hand-rolled key events for shortcuts that flow through iced's text bindings
/// rather than the cmd dispatcher (Find, Undo, Redo, etc.).
private func sendKey(keyCode: UInt32, modifiers: UInt32, character: String) {
guard let h = view?.viewportHandle else {
dlog("sendKey: no handle (key=\(character.debugDescription) mods=\(modifiers))")
return
}
dlog("sendKey: char=\(character.debugDescription) keyCode=\(keyCode) mods=\(modifiers)")
character.withCString { cstr in
viewport_key_event(h, keyCode, modifiers, true, cstr)
viewport_key_event(h, keyCode, modifiers, false, cstr)
}
}
/// `f` / cmd. The viewport reads .super_key as cmd via iced's modifier mapping.
/// keycode 3 is the macOS keycode for `f`; iced doesn't actually use the
/// platform keycode on macOS it pulls the Key from the characters string.
/// So we pass 0 and let the character drive it.
func toggleFind() { sendKey(keyCode: 0, modifiers: cmdMask, character: "f") }
func undo() { sendKey(keyCode: 0, modifiers: cmdMask, character: "z") }
func redo() { sendKey(keyCode: 0, modifiers: cmdMask | shiftMask, character: "Z") }
// UIKeyModifierFlags bits; copied here so the controller doesn't import UIKit.
private var cmdMask: UInt32 { 1 << 20 }
private var shiftMask: UInt32 { 1 << 17 }
/// File operations Open and Save go through UIDocumentPicker so iOS
/// prompts the user to grant per-file access.
func newNote() {
guard let h = view?.viewportHandle else {
dlog("newNote: no handle")
return
}
dlog("newNote")
let stub = "# "
stub.withCString { viewport_set_text(h, $0) }
}
func openDocument() {
guard let h = view?.viewportHandle else {
dlog("openDocument: no handle")
return
}
dlog("openDocument: dispatching to picker")
DocumentPicker.presentOpen(handle: h)
}
func saveDocument() {
dlog("saveDocument (routed to saveDocumentAs)")
saveDocumentAs()
}
func saveDocumentAs() {
guard let h = view?.viewportHandle else {
dlog("saveDocumentAs: no handle")
return
}
dlog("saveDocumentAs: dispatching to picker")
DocumentPicker.presentSave(handle: h, defaultName: "Acord")
}
func printDocument() {
dlog("printDocument: not implemented")
// TODO: call viewport_render_pdf, hand to UIPrintInteractionController
}
}

15
scripts/_build-dirs.sh Executable file
View File

@ -0,0 +1,15 @@
#!/usr/bin/env bash
# Sourced by build / install / debug scripts to redirect cargo's target dir
# to the boot SSD instead of the repo's external spinning disk.
# Scripts read compiled artifacts from $CARGO_TARGET_DIR and copy the final
# .app / .exe / binary into $ROOT/build/ as real files at the end.
#
# Override the SSD location with: export CARGO_TARGET_DIR=/some/other/path
if [ -n "${ACORD_BUILD_DIRS_DONE:-}" ]; then
return 0 2>/dev/null || exit 0
fi
export ACORD_BUILD_DIRS_DONE=1
export CARGO_TARGET_DIR="${CARGO_TARGET_DIR:-/tmp/acord/target}"
mkdir -p "$CARGO_TARGET_DIR"

17
scripts/android/select.sh Executable file
View File

@ -0,0 +1,17 @@
#!/usr/bin/env bash
set -euo pipefail
# Stub — the Android shell hasn't been started yet.
# When it lands, this should mirror scripts/ios/select.sh: list `adb devices`
# entries plus available emulators (`emulator -list-avds`), let the user
# pick one, and write the choice to $HOME/.acord/android-target.
cat <<'EOF' >&2
android select is a stub — the Android shell isn't built yet.
when it ships, this will:
- list connected devices (adb devices)
- list available emulators (emulator -list-avds)
- write the picked target to $HOME/.acord/android-target
EOF
exit 1

View File

@ -2,6 +2,7 @@
set -euo pipefail set -euo pipefail
ROOT="$(cd "$(dirname "$0")/../.." && pwd)" ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
source "$ROOT/scripts/_build-dirs.sh"
cd "$ROOT" cd "$ROOT"
case "$(uname -s)" in case "$(uname -s)" in
@ -29,9 +30,29 @@ case "$TARGET" in
;; ;;
esac esac
# debug.sh sets ACORD_IOS_CONFIG=debug to flip cargo to the dev profile and
# pass -D DEBUG / -Onone / -g to swiftc. install.sh leaves it unset → release.
CONFIG="${ACORD_IOS_CONFIG:-release}"
case "$CONFIG" in
release)
CARGO_FLAGS=(--release)
CARGO_PROFILE_DIR="release"
SWIFT_OPT_FLAGS=(-O)
;;
debug)
CARGO_FLAGS=()
CARGO_PROFILE_DIR="debug"
SWIFT_OPT_FLAGS=(-Onone -g -D DEBUG)
;;
*)
echo "ACORD_IOS_CONFIG must be release or debug (got '$CONFIG')" >&2
exit 2
;;
esac
BUILD="$ROOT/build" BUILD="$ROOT/build"
APP="$BUILD/ios/Acord.app" APP="$BUILD/ios/Acord.app"
RUST_LIB="$ROOT/target/$RUST_TARGET/release" RUST_LIB="$CARGO_TARGET_DIR/$RUST_TARGET/$CARGO_PROFILE_DIR"
SDK="$(xcrun --sdk "$SDK_NAME" --show-sdk-path)" SDK="$(xcrun --sdk "$SDK_NAME" --show-sdk-path)"
@ -40,8 +61,8 @@ export CC=/usr/bin/clang
export CXX=/usr/bin/clang++ export CXX=/usr/bin/clang++
export IPHONEOS_DEPLOYMENT_TARGET=17.0 export IPHONEOS_DEPLOYMENT_TARGET=17.0
echo "Building Rust workspace for $RUST_TARGET (release)..." echo "Building Rust workspace for $RUST_TARGET ($CONFIG)..."
cargo build --release --target "$RUST_TARGET" -p acord-viewport cargo build "${CARGO_FLAGS[@]}" --target "$RUST_TARGET" -p acord-viewport
if [ ! -f "$RUST_LIB/libacord_viewport.a" ]; then if [ ! -f "$RUST_LIB/libacord_viewport.a" ]; then
echo "ERROR: libacord_viewport.a not found at $RUST_LIB" >&2 echo "ERROR: libacord_viewport.a not found at $RUST_LIB" >&2
@ -53,18 +74,32 @@ rm -rf "$APP"
mkdir -p "$APP" mkdir -p "$APP"
cp "$ROOT/ios/Info.plist" "$APP/Info.plist" cp "$ROOT/ios/Info.plist" "$APP/Info.plist"
# generate icon (PNG variants required by iOS) — single 1024 master, scaled to bundle entries. # asset catalog → compiled Assets.car + partial Info.plist via actool.
SVG="$ROOT/assets/Acord.svg" # regenerate the source PNGs from the SVG so a fresh checkout doesn't need
if [ -f "$SVG" ] && command -v rsvg-convert >/dev/null 2>&1; then # a manual icon-rebuild step.
echo "Generating app icons..." bash "$ROOT/scripts/ios/generate-icons.sh"
for size in 20 29 40 58 60 76 80 87 120 152 167 180 1024; do
rsvg-convert --width="$size" --height="$size" "$SVG" -o "$APP/AppIcon-${size}.png" ACTOOL_PARTIAL="$BUILD/ios/actool-partial-info.plist"
done mkdir -p "$BUILD/ios"
echo "Compiling asset catalog..."
xcrun actool "$ROOT/ios/Assets.xcassets" \
--compile "$APP" \
--platform "$SDK_NAME" \
--minimum-deployment-target 17.0 \
--app-icon AppIcon \
--output-partial-info-plist "$ACTOOL_PARTIAL" \
--target-device ipad \
--target-device iphone \
>/dev/null
# merge the actool partial plist (icon name + variants) into Info.plist.
if [ -f "$ACTOOL_PARTIAL" ]; then
/usr/libexec/PlistBuddy -c "Merge $ACTOOL_PARTIAL" "$APP/Info.plist" 2>/dev/null || true
fi fi
RUST_FLAGS=(-import-objc-header "$ROOT/viewport/include/acord.h" -L "$RUST_LIB" -lacord_viewport) RUST_FLAGS=(-import-objc-header "$ROOT/viewport/include/acord.h" -L "$RUST_LIB" -lacord_viewport)
echo "Compiling Swift (release)..." echo "Compiling Swift ($CONFIG)..."
xcrun -sdk "$SDK_NAME" swiftc \ xcrun -sdk "$SDK_NAME" swiftc \
-target "$SWIFT_TARGET" \ -target "$SWIFT_TARGET" \
-sdk "$SDK" \ -sdk "$SDK" \
@ -76,7 +111,10 @@ xcrun -sdk "$SDK_NAME" swiftc \
-framework MetalKit \ -framework MetalKit \
-framework CoreGraphics \ -framework CoreGraphics \
-framework CoreFoundation \ -framework CoreFoundation \
-O \ -framework Photos \
-framework AVFoundation \
-framework UniformTypeIdentifiers \
"${SWIFT_OPT_FLAGS[@]}" \
-o "$APP/Acord" \ -o "$APP/Acord" \
"$ROOT"/ios/src/*.swift "$ROOT"/ios/src/*.swift

84
scripts/ios/debug.sh Executable file → Normal file
View File

@ -1,11 +1,85 @@
#!/usr/bin/env bash #!/usr/bin/env bash
set -euo pipefail set -euo pipefail
# Builds debug, installs to the chosen target, and launches with stdio attached
# so Swift print() AND Rust eprintln!() (via captureStderr in AcordApp) stream
# straight into this terminal.
ROOT="$(cd "$(dirname "$0")/../.." && pwd)" ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
source "$ROOT/scripts/_build-dirs.sh"
cd "$ROOT" cd "$ROOT"
# debug build: skip release flags by reusing build.sh but with the dev profile. export ACORD_IOS_CONFIG=debug
# build.sh always uses release; for now, point users to install.sh for normal use,
# and stream the simulator log for the bundle id. CONFIG_FILE="$HOME/.acord/ios-target"
bash "$ROOT/scripts/ios/install.sh" KIND="" ; ID=""
xcrun simctl spawn booted log stream --predicate 'subsystem == "org.else-if.acord" OR processImagePath CONTAINS "Acord"' --level debug if [ -n "${1:-}" ]; then
case "$1" in
sim|device) KIND="$1";;
esac
fi
if [ -z "$KIND" ] && [ -f "$CONFIG_FILE" ]; then
# shellcheck disable=SC1090
. "$CONFIG_FILE"
fi
if [ -z "$KIND" ]; then
if xcrun devicectl list devices 2>/dev/null | grep -q "available (paired)"; then
KIND="device"
else
KIND="sim"
fi
fi
if [ -z "$ID" ]; then
case "$KIND" in
device)
ID="$(xcrun devicectl list devices 2>/dev/null \
| awk '/available \(paired\)/ {for(i=1;i<=NF;i++) if($i ~ /^[A-F0-9-]{36}$/) {print $i; exit}}')"
;;
sim)
ID="$(xcrun simctl list devices booted | { grep -oE '[0-9A-F]{8}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{12}' || true; } | head -1)"
if [ -z "$ID" ]; then
ID="$(xcrun simctl list devices available | awk '/iPad/' | { grep -oE '[0-9A-F]{8}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{12}' || true; } | head -1)"
fi
;;
esac
fi
bash "$ROOT/scripts/ios/build.sh" "$KIND"
APP="$ROOT/build/ios/Acord.app"
BUNDLE_ID="org.else-if.acord"
case "$KIND" in
device)
if [ -z "$ID" ]; then
echo "no paired device found — connect via cable and trust this Mac" >&2
exit 1
fi
echo "Installing to device $ID..."
xcrun devicectl device install app --device "$ID" "$APP"
echo "Launching with live console (Ctrl+C to detach)..."
echo "----------------------------------------------------------"
exec xcrun devicectl device process launch \
--device "$ID" \
--console \
--terminate-existing \
"$BUNDLE_ID"
;;
sim)
if [ -z "$ID" ]; then
echo "no iPad simulator available — open Xcode → Window → Devices and Simulators to add one" >&2
exit 1
fi
xcrun simctl boot "$ID" 2>/dev/null || true
open -a Simulator
echo "Installing to simulator $ID..."
xcrun simctl install "$ID" "$APP"
echo "Launching with live console (Ctrl+C to detach)..."
echo "----------------------------------------------------------"
exec xcrun simctl launch --console-pty --terminate-running-process "$ID" "$BUNDLE_ID"
;;
*)
echo "unknown KIND='$KIND'" >&2
exit 1
;;
esac

90
scripts/ios/generate-icons.sh Executable file
View File

@ -0,0 +1,90 @@
#!/usr/bin/env bash
# Generates ios/Assets.xcassets/AppIcon.appiconset/ from assets/Acord.svg.
# Used by both the CLI build (build.sh) and the Xcode project path
# (xcodeproj.sh). Idempotent — re-running just overwrites.
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
SVG="$ROOT/assets/Acord.svg"
ASSETS="$ROOT/ios/Assets.xcassets"
APPICON="$ASSETS/AppIcon.appiconset"
if [ ! -f "$SVG" ]; then
echo "ERROR: $SVG not found" >&2
exit 1
fi
if ! command -v rsvg-convert >/dev/null 2>&1; then
echo "ERROR: rsvg-convert not on PATH (brew install librsvg or port install librsvg)" >&2
exit 1
fi
mkdir -p "$APPICON"
# (filename, pixel size) pairs covering iPhone + iPad icon slots through iOS 17.
# 1024 is the marketing icon; the rest are point-size@scale variants.
SIZES=(
"Icon-20.png 20"
"Icon-20@2x.png 40"
"Icon-20@3x.png 60"
"Icon-29.png 29"
"Icon-29@2x.png 58"
"Icon-29@3x.png 87"
"Icon-40.png 40"
"Icon-40@2x.png 80"
"Icon-40@3x.png 120"
"Icon-60@2x.png 120"
"Icon-60@3x.png 180"
"Icon-76.png 76"
"Icon-76@2x.png 152"
"Icon-83.5@2x.png 167"
"Icon-1024.png 1024"
)
for entry in "${SIZES[@]}"; do
name="${entry%% *}"
size="${entry##* }"
rsvg-convert --width="$size" --height="$size" "$SVG" -o "$APPICON/$name"
done
# Top-level Assets.xcassets/Contents.json (xcode requires it even if empty-ish).
cat > "$ASSETS/Contents.json" <<'EOF'
{
"info" : {
"author" : "xcode",
"version" : 1
}
}
EOF
# AppIcon.appiconset/Contents.json — maps every Icon-*.png to its slot.
cat > "$APPICON/Contents.json" <<'EOF'
{
"images" : [
{ "idiom" : "iphone", "size" : "20x20", "scale" : "2x", "filename" : "Icon-20@2x.png" },
{ "idiom" : "iphone", "size" : "20x20", "scale" : "3x", "filename" : "Icon-20@3x.png" },
{ "idiom" : "iphone", "size" : "29x29", "scale" : "2x", "filename" : "Icon-29@2x.png" },
{ "idiom" : "iphone", "size" : "29x29", "scale" : "3x", "filename" : "Icon-29@3x.png" },
{ "idiom" : "iphone", "size" : "40x40", "scale" : "2x", "filename" : "Icon-40@2x.png" },
{ "idiom" : "iphone", "size" : "40x40", "scale" : "3x", "filename" : "Icon-40@3x.png" },
{ "idiom" : "iphone", "size" : "60x60", "scale" : "2x", "filename" : "Icon-60@2x.png" },
{ "idiom" : "iphone", "size" : "60x60", "scale" : "3x", "filename" : "Icon-60@3x.png" },
{ "idiom" : "ipad", "size" : "20x20", "scale" : "1x", "filename" : "Icon-20.png" },
{ "idiom" : "ipad", "size" : "20x20", "scale" : "2x", "filename" : "Icon-20@2x.png" },
{ "idiom" : "ipad", "size" : "29x29", "scale" : "1x", "filename" : "Icon-29.png" },
{ "idiom" : "ipad", "size" : "29x29", "scale" : "2x", "filename" : "Icon-29@2x.png" },
{ "idiom" : "ipad", "size" : "40x40", "scale" : "1x", "filename" : "Icon-40.png" },
{ "idiom" : "ipad", "size" : "40x40", "scale" : "2x", "filename" : "Icon-40@2x.png" },
{ "idiom" : "ipad", "size" : "76x76", "scale" : "1x", "filename" : "Icon-76.png" },
{ "idiom" : "ipad", "size" : "76x76", "scale" : "2x", "filename" : "Icon-76@2x.png" },
{ "idiom" : "ipad", "size" : "83.5x83.5","scale" : "2x","filename" : "Icon-83.5@2x.png" },
{ "idiom" : "ios-marketing","size" : "1024x1024","scale" : "1x","filename" : "Icon-1024.png" }
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
EOF
echo "Wrote $APPICON ($(ls "$APPICON" | wc -l | tr -d ' ') files)"

View File

@ -2,10 +2,32 @@
set -euo pipefail set -euo pipefail
ROOT="$(cd "$(dirname "$0")/../.." && pwd)" ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
source "$ROOT/scripts/_build-dirs.sh"
cd "$ROOT" cd "$ROOT"
# pick the deploy target: explicit arg, or auto-detect (paired physical device wins). CONFIG_FILE="$HOME/.acord/ios-target"
TARGET="${1:-}"
# resolve target. priority:
# 1. explicit cli arg ("sim" / "device")
# 2. saved selection from `cargo xtask select-ios`
# 3. auto-detect (paired physical device wins)
TARGET=""
SELECTED_ID=""
SELECTED_LABEL=""
CLI_ARG="${1:-}"
if [ -n "$CLI_ARG" ]; then
TARGET="$CLI_ARG"
elif [ -f "$CONFIG_FILE" ]; then
# shellcheck disable=SC1090
. "$CONFIG_FILE"
case "${KIND:-}" in
device) TARGET="device"; SELECTED_ID="$ID"; SELECTED_LABEL="${LABEL:-}";;
sim) TARGET="sim"; SELECTED_ID="$ID"; SELECTED_LABEL="${LABEL:-}";;
*) echo "warning: $CONFIG_FILE has unknown KIND='${KIND:-}', falling back to auto-detect" >&2;;
esac
fi
if [ -z "$TARGET" ]; then if [ -z "$TARGET" ]; then
if xcrun devicectl list devices 2>/dev/null | grep -q "available (paired)"; then if xcrun devicectl list devices 2>/dev/null | grep -q "available (paired)"; then
TARGET="device" TARGET="device"
@ -14,34 +36,52 @@ if [ -z "$TARGET" ]; then
fi fi
fi fi
if [ -n "$SELECTED_LABEL" ]; then
echo "Target: $TARGET$SELECTED_LABEL ($SELECTED_ID)"
else
echo "Target: $TARGET (auto-detected)"
fi
case "$TARGET" in case "$TARGET" in
sim) sim)
bash "$ROOT/scripts/ios/build.sh" sim bash "$ROOT/scripts/ios/build.sh" sim
APP="$ROOT/build/ios/Acord.app" APP="$ROOT/build/ios/Acord.app"
BUNDLE_ID="org.else-if.acord" BUNDLE_ID="org.else-if.acord"
DEV="$(xcrun simctl list devices booted | awk '/Booted/ {print $NF}' | tr -d '()' | head -1 || true)" DEV="$SELECTED_ID"
if [ -z "$DEV" ]; then if [ -z "$DEV" ]; then
DEV="$(xcrun simctl list devices available | awk '/iPad/ && /\([A-F0-9\-]+\)/ {gsub(/[\(\)]/,"",$NF); print $NF; exit}')" DEV="$(xcrun simctl list devices booted | { grep -oE '[0-9A-F]{8}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{12}' || true; } | head -1)"
fi
if [ -z "$DEV" ]; then
DEV="$(xcrun simctl list devices available | awk '/iPad/' | { grep -oE '[0-9A-F]{8}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{12}' || true; } | head -1)"
if [ -z "$DEV" ]; then if [ -z "$DEV" ]; then
echo "no iPad simulator available — open Xcode → Window → Devices and Simulators to add one" >&2 echo "no iPad simulator available — open Xcode → Window → Devices and Simulators to add one" >&2
exit 1 exit 1
fi fi
fi
# boot it if it isn't already.
STATE="$(xcrun simctl list devices | awk -v id="$DEV" '$0 ~ id { for (i=1; i<=NF; i++) if ($i ~ /^\(/) state=$i } END { gsub(/[()]/, "", state); print state }')"
if [ "$STATE" != "Booted" ]; then
xcrun simctl boot "$DEV" 2>/dev/null || true xcrun simctl boot "$DEV" 2>/dev/null || true
open -a Simulator open -a Simulator
fi fi
echo "Installing to simulator $DEV..." echo "Installing to simulator $DEV..."
xcrun simctl install "$DEV" "$APP" xcrun simctl install "$DEV" "$APP"
echo "Launching..." if [ "${ACORD_IOS_NOLAUNCH:-0}" = "1" ]; then
xcrun simctl launch "$DEV" "$BUNDLE_ID" echo "Skipping launch (ACORD_IOS_NOLAUNCH=1)"
else
echo "Launching..."
xcrun simctl launch "$DEV" "$BUNDLE_ID"
fi
;; ;;
device) device)
bash "$ROOT/scripts/ios/build.sh" device bash "$ROOT/scripts/ios/build.sh" device
APP="$ROOT/build/ios/Acord.app" APP="$ROOT/build/ios/Acord.app"
DEVICE_ID="${ACORD_IOS_DEVICE:-}" DEVICE_ID="${SELECTED_ID:-${ACORD_IOS_DEVICE:-}}"
if [ -z "$DEVICE_ID" ]; then if [ -z "$DEVICE_ID" ]; then
DEVICE_ID="$(xcrun devicectl list devices 2>/dev/null \ DEVICE_ID="$(xcrun devicectl list devices 2>/dev/null \
| awk '/available \(paired\)/ {for(i=1;i<=NF;i++) if($i ~ /^[A-F0-9-]{36}$/) {print $i; exit}}')" | awk '/available \(paired\)/ {for(i=1;i<=NF;i++) if($i ~ /^[A-F0-9-]{36}$/) {print $i; exit}}')"
@ -49,14 +89,19 @@ case "$TARGET" in
if [ -z "$DEVICE_ID" ]; then if [ -z "$DEVICE_ID" ]; then
echo "no paired device found — connect via cable and trust this Mac on the device" >&2 echo "no paired device found — connect via cable and trust this Mac on the device" >&2
echo " or run: cargo xtask select-ios" >&2
exit 1 exit 1
fi fi
echo "Installing to device $DEVICE_ID..." echo "Installing to device $DEVICE_ID..."
xcrun devicectl device install app --device "$DEVICE_ID" "$APP" xcrun devicectl device install app --device "$DEVICE_ID" "$APP"
echo "Launching..." if [ "${ACORD_IOS_NOLAUNCH:-0}" = "1" ]; then
xcrun devicectl device process launch --device "$DEVICE_ID" org.else-if.acord || true echo "Skipping launch (ACORD_IOS_NOLAUNCH=1)"
else
echo "Launching..."
xcrun devicectl device process launch --device "$DEVICE_ID" org.else-if.acord || true
fi
;; ;;
*) *)

80
scripts/ios/select.sh Executable file
View File

@ -0,0 +1,80 @@
#!/usr/bin/env bash
set -euo pipefail
# Interactive picker for the iOS deploy target. Lists every paired physical
# device and every available iPad simulator, lets the user pick one, then
# stores the choice at $HOME/.acord/ios-target so install.sh / debug.sh can
# read it back without prompting again.
CONFIG_DIR="$HOME/.acord"
CONFIG_FILE="$CONFIG_DIR/ios-target"
mkdir -p "$CONFIG_DIR"
ALL_FILE="$(mktemp)"
trap 'rm -f "$ALL_FILE"' EXIT
echo "Scanning paired devices..."
# devicectl columns are aligned with 2+ spaces. fields: name | host | uuid | state | model
xcrun devicectl list devices 2>/dev/null \
| awk -F' +' '/available \(paired\)/ {
gsub(/^[[:space:]]+|[[:space:]]+$/, "", $1)
gsub(/^[[:space:]]+|[[:space:]]+$/, "", $3)
gsub(/^[[:space:]]+|[[:space:]]+$/, "", $5)
if ($3 ~ /^[A-F0-9-]{36}$/) print "device|" $3 "|" $1 " — " $5
}' >> "$ALL_FILE" || true
echo "Scanning iPad simulators..."
# simctl line: " iPad Pro 11-inch (M4) (UUID) (Shutdown)"
# strip whitespace, peel off "(state)" then "(uuid)" from the right.
xcrun simctl list devices available 2>/dev/null \
| awk '/iPad/ {
line=$0
sub(/^[[:space:]]+/, "", line); sub(/[[:space:]]+$/, "", line)
if (match(line, /\([^()]+\)$/)) {
state=substr(line, RSTART+1, RLENGTH-2)
line=substr(line, 1, RSTART-1)
sub(/[[:space:]]+$/, "", line)
} else { state="" }
if (match(line, /\([A-F0-9-]{36}\)$/)) {
uuid=substr(line, RSTART+1, 36)
name=substr(line, 1, RSTART-1)
sub(/[[:space:]]+$/, "", name)
} else { next }
print "sim|" uuid "|" name " (" state ")"
}' >> "$ALL_FILE" || true
COUNT=$(wc -l < "$ALL_FILE" | tr -d ' ')
if [ "$COUNT" -eq 0 ]; then
echo "no paired devices and no iPad simulators found" >&2
echo " - connect an iPad via cable and trust this Mac" >&2
echo " - or open Xcode → Window → Devices and Simulators to add a sim" >&2
exit 1
fi
echo
echo "available iOS targets:"
i=1
while IFS= read -r entry; do
[ -z "$entry" ] && continue
IFS='|' read -r kind id label <<< "$entry"
printf " %2d) [%-6s] %s\n" "$i" "$kind" "$label"
i=$((i + 1))
done < "$ALL_FILE"
echo
read -r -p "pick a target (number): " CHOICE
if ! [[ "$CHOICE" =~ ^[0-9]+$ ]] || [ "$CHOICE" -lt 1 ] || [ "$CHOICE" -gt "$COUNT" ]; then
echo "invalid choice: $CHOICE" >&2
exit 1
fi
PICK=$(sed -n "${CHOICE}p" "$ALL_FILE")
IFS='|' read -r KIND ID LABEL <<< "$PICK"
cat > "$CONFIG_FILE" <<EOF
KIND=$KIND
ID=$ID
LABEL=$LABEL
EOF
echo "saved $CONFIG_FILE: $KIND $ID ($LABEL)"

4
scripts/ios/xcodeproj.sh Executable file → Normal file
View File

@ -2,6 +2,7 @@
set -euo pipefail set -euo pipefail
ROOT="$(cd "$(dirname "$0")/../.." && pwd)" ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
source "$ROOT/scripts/_build-dirs.sh"
cd "$ROOT" cd "$ROOT"
if ! command -v xcodegen >/dev/null 2>&1; then if ! command -v xcodegen >/dev/null 2>&1; then
@ -20,6 +21,9 @@ echo "Building Rust staticlibs for both iOS targets (release)..."
cargo build --release --target aarch64-apple-ios -p acord-viewport cargo build --release --target aarch64-apple-ios -p acord-viewport
cargo build --release --target aarch64-apple-ios-sim -p acord-viewport cargo build --release --target aarch64-apple-ios-sim -p acord-viewport
# regenerate the asset catalog so xcode picks up the latest svg.
bash "$ROOT/scripts/ios/generate-icons.sh"
cd "$ROOT/ios" cd "$ROOT/ios"
echo "Generating Acord.xcodeproj..." echo "Generating Acord.xcodeproj..."
xcodegen generate xcodegen generate

3
scripts/linux/build.sh Executable file → Normal file
View File

@ -2,6 +2,7 @@
set -euo pipefail set -euo pipefail
ROOT="$(cd "$(dirname "$0")/../.." && pwd)" ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
source "$ROOT/scripts/_build-dirs.sh"
cd "$ROOT" cd "$ROOT"
case "$(uname -s)" in case "$(uname -s)" in
@ -37,7 +38,7 @@ fi
STAGE="$ROOT/build/bin" STAGE="$ROOT/build/bin"
mkdir -p "$STAGE" mkdir -p "$STAGE"
cp "$ROOT/target/release/acord" "$STAGE/Acord" cp "$CARGO_TARGET_DIR/release/acord" "$STAGE/Acord"
chmod +x "$STAGE/Acord" chmod +x "$STAGE/Acord"
# Rasterize the SVG icon next to the binary so load_window_icon picks it up. # Rasterize the SVG icon next to the binary so load_window_icon picks it up.

5
scripts/linux/debug.sh Executable file → Normal file
View File

@ -5,6 +5,7 @@ set -euo pipefail
# launched in the foreground so Rust panics print straight to this terminal. # launched in the foreground so Rust panics print straight to this terminal.
ROOT="$(cd "$(dirname "$0")/../.." && pwd)" ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
source "$ROOT/scripts/_build-dirs.sh"
cd "$ROOT" cd "$ROOT"
case "$(uname -s)" in case "$(uname -s)" in
@ -20,11 +21,11 @@ else
cargo build -p acord-linux cargo build -p acord-linux
fi fi
EXE="$ROOT/target/debug/acord" EXE="$CARGO_TARGET_DIR/debug/acord"
# Rasterize the icon next to the exe so the dev binary has a window icon too. # Rasterize the icon next to the exe so the dev binary has a window icon too.
if command -v rsvg-convert >/dev/null 2>&1 && [ -f "$ROOT/assets/Acord.svg" ]; then if command -v rsvg-convert >/dev/null 2>&1 && [ -f "$ROOT/assets/Acord.svg" ]; then
rsvg-convert --width 256 --height 256 "$ROOT/assets/Acord.svg" -o "$ROOT/target/debug/icon.png" rsvg-convert --width 256 --height 256 "$ROOT/assets/Acord.svg" -o "$CARGO_TARGET_DIR/debug/icon.png"
fi fi
pkill -x acord 2>/dev/null || true pkill -x acord 2>/dev/null || true

1
scripts/linux/install.sh Executable file → Normal file
View File

@ -2,6 +2,7 @@
set -euo pipefail set -euo pipefail
ROOT="$(cd "$(dirname "$0")/../.." && pwd)" ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
source "$ROOT/scripts/_build-dirs.sh"
cd "$ROOT" cd "$ROOT"
case "$(uname -s)" in case "$(uname -s)" in

11
scripts/macos/build-universal.sh Executable file → Normal file
View File

@ -2,6 +2,7 @@
set -euo pipefail set -euo pipefail
ROOT="$(cd "$(dirname "$0")/../.." && pwd)" ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
source "$ROOT/scripts/_build-dirs.sh"
cd "$ROOT" cd "$ROOT"
case "$(uname -s)" in case "$(uname -s)" in
@ -23,11 +24,11 @@ rustup target add aarch64-apple-darwin x86_64-apple-darwin
cargo build --release -p acord-viewport --target aarch64-apple-darwin cargo build --release -p acord-viewport --target aarch64-apple-darwin
cargo build --release -p acord-viewport --target x86_64-apple-darwin cargo build --release -p acord-viewport --target x86_64-apple-darwin
mkdir -p "$ROOT/target/universal" mkdir -p "$CARGO_TARGET_DIR/universal"
lipo -create \ lipo -create \
"$ROOT/target/aarch64-apple-darwin/release/libacord_viewport.a" \ "$CARGO_TARGET_DIR/aarch64-apple-darwin/release/libacord_viewport.a" \
"$ROOT/target/x86_64-apple-darwin/release/libacord_viewport.a" \ "$CARGO_TARGET_DIR/x86_64-apple-darwin/release/libacord_viewport.a" \
-output "$ROOT/target/universal/libacord_viewport.a" -output "$CARGO_TARGET_DIR/universal/libacord_viewport.a"
# TODO: regenerate AppIcon.icns from assets/Acord.svg here (see build.sh). # TODO: regenerate AppIcon.icns from assets/Acord.svg here (see build.sh).
@ -37,7 +38,7 @@ cp "$ROOT/macos/Info.plist" "$CONTENTS/Info.plist"
echo "Compiling Swift (Universal)..." echo "Compiling Swift (Universal)..."
SWIFT_FILES=("$ROOT"/macos/src/*.swift) SWIFT_FILES=("$ROOT"/macos/src/*.swift)
RUST_INCLUDES=(-import-objc-header "$ROOT/viewport/include/acord.h" -L "$ROOT/target/universal" -lacord_viewport) RUST_INCLUDES=(-import-objc-header "$ROOT/viewport/include/acord.h" -L "$CARGO_TARGET_DIR/universal" -lacord_viewport)
swiftc -target arm64-apple-macosx14.0 -sdk "$SDK" "${RUST_INCLUDES[@]}" \ swiftc -target arm64-apple-macosx14.0 -sdk "$SDK" "${RUST_INCLUDES[@]}" \
-framework Cocoa -framework SwiftUI -framework Metal -framework MetalKit -O \ -framework Cocoa -framework SwiftUI -framework Metal -framework MetalKit -O \

3
scripts/macos/build.sh Executable file → Normal file
View File

@ -2,6 +2,7 @@
set -euo pipefail set -euo pipefail
ROOT="$(cd "$(dirname "$0")/../.." && pwd)" ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
source "$ROOT/scripts/_build-dirs.sh"
cd "$ROOT" cd "$ROOT"
case "$(uname -s)" in case "$(uname -s)" in
@ -16,7 +17,7 @@ MACOS="$CONTENTS/MacOS"
RESOURCES="$CONTENTS/Resources" RESOURCES="$CONTENTS/Resources"
SDK=$(xcrun --show-sdk-path) SDK=$(xcrun --show-sdk-path)
RUST_LIB="$ROOT/target/release" RUST_LIB="$CARGO_TARGET_DIR/release"
export MACOSX_DEPLOYMENT_TARGET=14.0 export MACOSX_DEPLOYMENT_TARGET=14.0
export ZERO_AR_DATE=0 export ZERO_AR_DATE=0

3
scripts/macos/debug.sh Executable file → Normal file
View File

@ -6,6 +6,7 @@ set -euo pipefail
# (the panic hook in viewport/src/lib.rs flushes stderr before SIGABRT). # (the panic hook in viewport/src/lib.rs flushes stderr before SIGABRT).
ROOT="$(cd "$(dirname "$0")/../.." && pwd)" ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
source "$ROOT/scripts/_build-dirs.sh"
cd "$ROOT" cd "$ROOT"
case "$(uname -s)" in case "$(uname -s)" in
@ -20,7 +21,7 @@ MACOS="$CONTENTS/MacOS"
RESOURCES="$CONTENTS/Resources" RESOURCES="$CONTENTS/Resources"
SDK=$(xcrun --show-sdk-path) SDK=$(xcrun --show-sdk-path)
RUST_LIB="$ROOT/target/debug" RUST_LIB="$CARGO_TARGET_DIR/debug"
export MACOSX_DEPLOYMENT_TARGET=14.0 export MACOSX_DEPLOYMENT_TARGET=14.0
export ZERO_AR_DATE=0 export ZERO_AR_DATE=0

1
scripts/macos/install.sh Executable file → Normal file
View File

@ -2,6 +2,7 @@
set -euo pipefail set -euo pipefail
ROOT="$(cd "$(dirname "$0")/../.." && pwd)" ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
source "$ROOT/scripts/_build-dirs.sh"
cd "$ROOT" cd "$ROOT"
case "$(uname -s)" in case "$(uname -s)" in

7
scripts/macos/package.sh Executable file → Normal file
View File

@ -18,6 +18,7 @@ set -euo pipefail
# cargo install cargo-zigbuild # cargo install cargo-zigbuild
ROOT="$(cd "$(dirname "$0")/../.." && pwd)" ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
source "$ROOT/scripts/_build-dirs.sh"
cd "$ROOT" cd "$ROOT"
case "$(uname -s)" in case "$(uname -s)" in
@ -141,7 +142,7 @@ build_macos() {
export ZERO_AR_DATE=0 export ZERO_AR_DATE=0
cargo build --release -p acord-viewport --target "$rust_target" cargo build --release -p acord-viewport --target "$rust_target"
local rust_lib="$ROOT/target/$rust_target/release" local rust_lib="$CARGO_TARGET_DIR/$rust_target/release"
[ -f "$rust_lib/libacord_viewport.a" ] \ [ -f "$rust_lib/libacord_viewport.a" ] \
|| { echo "ERROR: libacord_viewport.a missing for $rust_target" >&2; exit 1; } || { echo "ERROR: libacord_viewport.a missing for $rust_target" >&2; exit 1; }
@ -187,7 +188,7 @@ build_windows() {
rm -rf "$stage" rm -rf "$stage"
mkdir -p "$stage" mkdir -p "$stage"
cp "$ROOT/target/$rust_target/release/acord.exe" "$stage/Acord.exe" cp "$CARGO_TARGET_DIR/$rust_target/release/acord.exe" "$stage/Acord.exe"
[ -f "$ICON_PNG" ] && cp "$ICON_PNG" "$stage/icon.png" [ -f "$ICON_PNG" ] && cp "$ICON_PNG" "$stage/icon.png"
[ -f "$ROOT/LICENCE" ] && cp "$ROOT/LICENCE" "$stage/LICENCE" [ -f "$ROOT/LICENCE" ] && cp "$ROOT/LICENCE" "$stage/LICENCE"
[ -f "$ROOT/README.md" ] && cp "$ROOT/README.md" "$stage/README.md" [ -f "$ROOT/README.md" ] && cp "$ROOT/README.md" "$stage/README.md"
@ -214,7 +215,7 @@ build_linux() {
rm -rf "$stage" rm -rf "$stage"
mkdir -p "$stage" mkdir -p "$stage"
cp "$ROOT/target/$rust_target/release/acord" "$stage/Acord" cp "$CARGO_TARGET_DIR/$rust_target/release/acord" "$stage/Acord"
chmod +x "$stage/Acord" chmod +x "$stage/Acord"
[ -f "$ICON_PNG" ] && cp "$ICON_PNG" "$stage/icon.png" [ -f "$ICON_PNG" ] && cp "$ICON_PNG" "$stage/icon.png"
[ -f "$ROOT/LICENCE" ] && cp "$ROOT/LICENCE" "$stage/LICENCE" [ -f "$ROOT/LICENCE" ] && cp "$ROOT/LICENCE" "$stage/LICENCE"

View File

@ -173,6 +173,12 @@ pub enum Message {
InlineResultRelease, InlineResultRelease,
/// double-click on an inline result /// double-click on an inline result
InlineResultDoubleClick { block_id: crate::selection::BlockId, after_line: usize }, InlineResultDoubleClick { block_id: crate::selection::BlockId, after_line: usize },
/// mouse pressed on a heading or hr block, arms a free-layer drag.
BlockPromotePress(crate::selection::BlockId),
/// mouse pressed on an inline image, arms a free-layer drag.
ImagePromotePress { block_id: crate::selection::BlockId, after_line: usize, src: String },
/// mouse released after a block or image promote press.
PromoteRelease,
ToggleMenu(MenuCategory), ToggleMenu(MenuCategory),
CloseMenu, CloseMenu,
Shell(ShellAction), Shell(ShellAction),
@ -312,6 +318,36 @@ enum LayerItem<'a> {
Image(&'a ComputedImage), Image(&'a ComputedImage),
} }
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum FreeNodeId {
Block(crate::selection::BlockId),
Table(crate::selection::BlockId, usize),
Tree(crate::selection::BlockId, usize),
Image(crate::selection::BlockId, usize, String),
}
/// position and size of a free-layer object in editor-content coordinates.
#[derive(Debug, Clone, Copy)]
pub struct FreePlacement {
pub layer: u8,
pub x: f32,
pub y: f32,
pub w: f32,
pub h: f32,
}
/// pending drag state for promoting a block onto a free layer.
#[derive(Debug, Clone)]
pub struct PromoteDragState {
pub node_id: FreeNodeId,
pub start_cursor: Point,
pub origin: (f32, f32),
pub size: (f32, f32),
pub layer: u8,
pub escalated: bool,
pub fallback_table_idx: Option<usize>,
}
impl LayerItem<'_> { impl LayerItem<'_> {
fn element_height(&self, line_h: f32, font_size: f32) -> f32 { fn element_height(&self, line_h: f32, font_size: f32) -> f32 {
match self { match self {
@ -437,6 +473,11 @@ pub struct EditorState {
pub pending_shell_action: Option<ShellAction>, pub pending_shell_action: Option<ShellAction>,
pub settings_open: bool, pub settings_open: bool,
pub settings_view: SettingsView, pub settings_view: SettingsView,
pub free_placements: HashMap<FreeNodeId, FreePlacement>,
pub frozen_doc_size: Option<(f32, f32)>,
pub viewport_size: (f32, f32),
pub promote_drag: Option<PromoteDragState>,
} }
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
@ -553,9 +594,77 @@ impl EditorState {
pending_shell_action: None, pending_shell_action: None,
settings_open: false, settings_open: false,
settings_view: SettingsView::default(), settings_view: SettingsView::default(),
free_placements: HashMap::new(),
frozen_doc_size: None,
viewport_size: (0.0, 0.0),
promote_drag: None,
} }
} }
/// applies the current cursor delta to any active promote drag.
pub fn tick_promote_drag(&mut self) -> bool {
let (node_id, layer, origin, size, dx, dy, was_escalated) = {
let Some(pd) = self.promote_drag.as_ref() else { return false };
let dx = self.cursor_pos.x - pd.start_cursor.x;
let dy = self.cursor_pos.y - pd.start_cursor.y;
(pd.node_id.clone(), pd.layer, pd.origin, pd.size, dx, dy, pd.escalated)
};
let dist_sq = dx * dx + dy * dy;
let threshold_sq = 16.0;
if dist_sq < threshold_sq && !was_escalated {
return false;
}
if let Some(pd) = self.promote_drag.as_mut() { pd.escalated = true; }
let placement = FreePlacement {
layer,
x: origin.0 + dx,
y: origin.1 + dy,
w: size.0,
h: size.1,
};
self.free_placements.insert(node_id, placement);
if self.frozen_doc_size.is_none() && self.viewport_size != (0.0, 0.0) {
self.frozen_doc_size = Some(self.viewport_size);
}
true
}
/// arms a drag promotion for any free-layer node in live mode.
fn start_promote(&mut self, node_id: FreeNodeId, fallback_table_idx: Option<usize>) {
if !matches!(self.render_mode, RenderMode::Live) { return; }
let existing = self.free_placements.get(&node_id).copied();
let default_size = (360.0, 240.0);
let (origin, size, layer) = match existing {
Some(p) => ((p.x, p.y), (p.w, p.h), p.layer),
None => ((self.cursor_pos.x, self.cursor_pos.y), default_size, 1),
};
self.promote_drag = Some(PromoteDragState {
node_id,
start_cursor: self.cursor_pos,
origin,
size,
layer,
escalated: false,
fallback_table_idx,
});
}
/// arms a corner-drag promotion for the table at a layout index.
pub fn begin_promote_table_corner(&mut self, block_idx: usize) {
let Some(&block_id) = self.layout.get(block_idx) else { return };
self.start_promote(FreeNodeId::Block(block_id), Some(block_idx));
}
/// arms a drag promotion for a heading or hr block.
pub fn begin_promote_block(&mut self, block_id: crate::selection::BlockId) {
self.start_promote(FreeNodeId::Block(block_id), None);
}
/// arms a drag promotion for an inline image at a specific anchor.
pub fn begin_promote_image(&mut self, block_id: crate::selection::BlockId, after_line: usize, src: String) {
self.start_promote(FreeNodeId::Image(block_id, after_line, src), None);
}
/// returns the queued shell action and clears it /// returns the queued shell action and clears it
pub fn take_pending_shell_action(&mut self) -> Option<ShellAction> { pub fn take_pending_shell_action(&mut self) -> Option<ShellAction> {
self.pending_shell_action.take() self.pending_shell_action.take()
@ -2841,6 +2950,23 @@ impl EditorState {
self.update_find_matches(); self.update_find_matches();
} }
Message::TableMsg(idx, tmsg) => { Message::TableMsg(idx, tmsg) => {
match &tmsg {
TableMessage::PromoteCornerPress => {
self.begin_promote_table_corner(idx);
return;
}
TableMessage::PromoteCornerRelease => {
if let Some(pd) = self.promote_drag.take() {
if !pd.escalated {
if let Some(fb_idx) = pd.fallback_table_idx {
self.update(Message::TableMsg(fb_idx, TableMessage::SelectAll));
}
}
}
return;
}
_ => {}
}
let structural = matches!( let structural = matches!(
&tmsg, &tmsg,
TableMessage::InsertRowAbove TableMessage::InsertRowAbove
@ -3312,6 +3438,15 @@ impl EditorState {
self.inline_press = None; self.inline_press = None;
self.handle_result_extract(block_id, after_line); self.handle_result_extract(block_id, after_line);
} }
Message::BlockPromotePress(block_id) => {
self.begin_promote_block(block_id);
}
Message::ImagePromotePress { block_id, after_line, src } => {
self.begin_promote_image(block_id, after_line, src);
}
Message::PromoteRelease => {
self.promote_drag = None;
}
} }
} }
@ -3406,7 +3541,11 @@ impl EditorState {
}) })
.into() .into()
} else { } else {
self.view_blocks() let editor = self.view_blocks();
match self.build_free_overlay() {
Some(overlay) => iced_widget::stack![editor, overlay].into(),
None => editor,
}
}; };
let mode_label = match self.render_mode { let mode_label = match self.render_mode {
@ -3507,6 +3646,9 @@ impl EditorState {
let mut global_line = 0usize; let mut global_line = 0usize;
for (bi, &block_id) in self.layout.iter().enumerate() { for (bi, &block_id) in self.layout.iter().enumerate() {
if self.free_placements.contains_key(&FreeNodeId::Block(block_id)) {
continue;
}
let block = self.registry.get(&block_id).unwrap(); let block = self.registry.get(&block_id).unwrap();
let any = block.as_any(); let any = block.as_any();
@ -3659,14 +3801,14 @@ impl EditorState {
if let Some(hb) = any.downcast_ref::<HeadingBlock>() { if let Some(hb) = any.downcast_ref::<HeadingBlock>() {
let layered = <HeadingBlock as BlockTrait<Message>>::view(hb, &ctx); let layered = <HeadingBlock as BlockTrait<Message>>::view(hb, &ctx);
block_elements.push(layered.base); block_elements.push(self.wrap_block_with_promote(layered.base, hb.id));
global_line += 1; global_line += 1;
continue; continue;
} }
if let Some(hr) = any.downcast_ref::<HrBlock>() { if let Some(hr) = any.downcast_ref::<HrBlock>() {
let layered = <HrBlock as BlockTrait<Message>>::view(hr, &ctx); let layered = <HrBlock as BlockTrait<Message>>::view(hr, &ctx);
block_elements.push(layered.base); block_elements.push(self.wrap_block_with_promote(layered.base, hr.id));
global_line += 1; global_line += 1;
continue; continue;
} }
@ -3830,16 +3972,22 @@ impl EditorState {
} }
for ct in &self.computed_tables { for ct in &self.computed_tables {
if ct.anchor.block_id == block_id { if ct.anchor.block_id == block_id {
let id = FreeNodeId::Table(ct.anchor.block_id, ct.anchor.after_line);
if self.free_placements.contains_key(&id) { continue; }
items.push((ct.anchor.after_line, LayerItem::Table(ct))); items.push((ct.anchor.after_line, LayerItem::Table(ct)));
} }
} }
for ct in &self.computed_trees { for ct in &self.computed_trees {
if ct.anchor.block_id == block_id { if ct.anchor.block_id == block_id {
let id = FreeNodeId::Tree(ct.anchor.block_id, ct.anchor.after_line);
if self.free_placements.contains_key(&id) { continue; }
items.push((ct.anchor.after_line, LayerItem::Tree(ct))); items.push((ct.anchor.after_line, LayerItem::Tree(ct)));
} }
} }
for img in &self.computed_images { for img in &self.computed_images {
if img.anchor.block_id == block_id { if img.anchor.block_id == block_id {
let id = FreeNodeId::Image(img.anchor.block_id, img.anchor.after_line, img.src.clone());
if self.free_placements.contains_key(&id) { continue; }
items.push((img.anchor.after_line, LayerItem::Image(img))); items.push((img.anchor.after_line, LayerItem::Image(img)));
} }
} }
@ -3917,40 +4065,9 @@ impl EditorState {
}); });
} }
LayerItem::Table(ct) => { LayerItem::Table(ct) => {
let mut table_rows: Vec<Element<'a, Message, Theme, iced_wgpu::Renderer>> = Vec::new(); let inner = self.build_computed_table_widget(ct);
for (ri, row) in ct.rows.iter().enumerate() {
let is_header = ri == 0;
let cells: Vec<Element<'a, Message, Theme, iced_wgpu::Renderer>> = row.iter()
.enumerate()
.map(|(ci, cell)| {
let cw = ct.col_widths.get(ci).copied().unwrap_or(80.0);
let mut txt = iced_widget::text(cell)
.font(syntax::EDITOR_FONT)
.size(self.font_size)
.color(oklab::lighten_for_size(p.text, self.font_size));
if is_header {
txt = txt.font(Font { weight: iced_wgpu::core::font::Weight::Bold, ..syntax::EDITOR_FONT });
}
iced_widget::container(txt)
.width(Length::Fixed(cw))
.padding(Padding { top: 2.0, right: 8.0, bottom: 2.0, left: 8.0 })
.style(move |_theme: &Theme| {
let bg_alpha = if is_header { 0.12 } else { 0.06 };
container::Style {
background: Some(Background::Color(Color { a: bg_alpha, ..p.surface1 })),
border: Border { color: p.surface1, width: 0.5, radius: border::Radius::default() },
text_color: None,
shadow: Shadow::default(),
snap: false,
}
})
.into()
})
.collect();
table_rows.push(iced_widget::row(cells).into());
}
let el: Element<'a, Message, Theme, iced_wgpu::Renderer> = let el: Element<'a, Message, Theme, iced_wgpu::Renderer> =
iced_widget::container(iced_widget::column(table_rows)) iced_widget::container(inner)
.padding(Padding { top: 4.0, right: 8.0, bottom: 4.0, left: 40.0 }) .padding(Padding { top: 4.0, right: 8.0, bottom: 4.0, left: 40.0 })
.width(Length::Fill) .width(Length::Fill)
.into(); .into();
@ -3990,10 +4107,16 @@ impl EditorState {
.width(Length::Fill) .width(Length::Fill)
.into() .into()
}; };
let wrapped = self.wrap_image_with_promote(
el,
img.anchor.block_id,
img.anchor.after_line,
img.src.clone(),
);
anchored.push(AnchoredItem { anchored.push(AnchoredItem {
after_line: *after_line, after_line: *after_line,
height: item.element_height(lh, self.font_size), height: item.element_height(lh, self.font_size),
element: el, element: wrapped,
}); });
} }
} }
@ -4002,6 +4125,206 @@ impl EditorState {
anchored anchored
} }
/// builds a column of cell rows from a computed table.
fn build_computed_table_widget<'a>(
&self,
ct: &'a ComputedTable,
) -> Element<'a, Message, Theme, iced_wgpu::Renderer> {
let p = palette::current();
let mut table_rows: Vec<Element<'a, Message, Theme, iced_wgpu::Renderer>> = Vec::new();
for (ri, row) in ct.rows.iter().enumerate() {
let is_header = ri == 0;
let cells: Vec<Element<'a, Message, Theme, iced_wgpu::Renderer>> = row.iter()
.enumerate()
.map(|(ci, cell)| {
let cw = ct.col_widths.get(ci).copied().unwrap_or(80.0);
let mut txt = iced_widget::text(cell)
.font(syntax::EDITOR_FONT)
.size(self.font_size)
.color(oklab::lighten_for_size(p.text, self.font_size));
if is_header {
txt = txt.font(Font { weight: iced_wgpu::core::font::Weight::Bold, ..syntax::EDITOR_FONT });
}
iced_widget::container(txt)
.width(Length::Fixed(cw))
.padding(Padding { top: 2.0, right: 8.0, bottom: 2.0, left: 8.0 })
.style(move |_theme: &Theme| {
let bg_alpha = if is_header { 0.12 } else { 0.06 };
container::Style {
background: Some(Background::Color(Color { a: bg_alpha, ..p.surface1 })),
border: Border { color: p.surface1, width: 0.5, radius: border::Radius::default() },
text_color: None,
shadow: Shadow::default(),
snap: false,
}
})
.into()
})
.collect();
table_rows.push(iced_widget::row(cells).into());
}
iced_widget::column(table_rows).into()
}
/// wraps a block element with the promote press/release mouse area in live mode.
fn wrap_block_with_promote<'a>(
&self,
elem: Element<'a, Message, Theme, iced_wgpu::Renderer>,
block_id: crate::selection::BlockId,
) -> Element<'a, Message, Theme, iced_wgpu::Renderer> {
if !matches!(self.render_mode, RenderMode::Live) {
return elem;
}
MouseArea::new(elem)
.on_press(Message::BlockPromotePress(block_id))
.on_release(Message::PromoteRelease)
.into()
}
/// wraps an inline image element with the promote press/release mouse area in live mode.
fn wrap_image_with_promote<'a>(
&self,
elem: Element<'a, Message, Theme, iced_wgpu::Renderer>,
block_id: crate::selection::BlockId,
after_line: usize,
src: String,
) -> Element<'a, Message, Theme, iced_wgpu::Renderer> {
if !matches!(self.render_mode, RenderMode::Live) {
return elem;
}
MouseArea::new(elem)
.on_press(Message::ImagePromotePress { block_id, after_line, src })
.on_release(Message::PromoteRelease)
.into()
}
/// builds the overlay widget for a single block at the given layout index.
fn build_free_block_widget(
&self,
block_id: crate::selection::BlockId,
) -> Option<Element<'_, Message, Theme, iced_wgpu::Renderer>> {
let bi = self.layout.iter().position(|id| *id == block_id)?;
let block = self.registry.get(&block_id)?;
let any = block.as_any();
if let Some(tab) = any.downcast_ref::<TableBlock>() {
let editing_cell = match self.editing.as_ref() {
Some(path) if path.block_id == tab.id => match &path.inner {
crate::selection::InnerPath::Cell { row, col } => Some((*row, *col)),
_ => None,
},
_ => None,
};
let block_idx = bi;
return Some(table_block::table_view(
tab,
editing_cell,
self.font_size,
&self.computed_cells,
move |tmsg| Message::TableMsg(block_idx, tmsg),
));
}
let ctx: ViewCtx<'_, Message> = ViewCtx {
block_index: bi,
selection: &self.selection,
focus: self.focus.as_ref(),
editing: self.editing.as_ref(),
font_size: self.font_size,
is_dark: true,
on_text_action: |idx, action| Message::BlockAction(idx, action),
on_table_msg: |idx, tmsg| Message::TableMsg(idx, tmsg),
computed_cells: &self.computed_cells,
};
if let Some(hb) = any.downcast_ref::<HeadingBlock>() {
return Some(<HeadingBlock as BlockTrait<Message>>::view(hb, &ctx).base);
}
if let Some(hr) = any.downcast_ref::<HrBlock>() {
return Some(<HrBlock as BlockTrait<Message>>::view(hr, &ctx).base);
}
if let Some(tree) = any.downcast_ref::<TreeBlock>() {
return Some(<TreeBlock as BlockTrait<Message>>::view(tree, &ctx).base);
}
None
}
/// stacks free-placed objects at absolute positions over the editor body.
fn build_free_overlay(&self) -> Option<Element<'_, Message, Theme, iced_wgpu::Renderer>> {
if self.free_placements.is_empty() {
return None;
}
let mut placed: Vec<(&FreeNodeId, &FreePlacement)> = self.free_placements.iter().collect();
placed.sort_by_key(|(_, pl)| pl.layer);
let mut layers: Vec<Element<'_, Message, Theme, iced_wgpu::Renderer>> = Vec::new();
for &(id, placement) in &placed {
let inner_opt: Option<Element<'_, Message, Theme, iced_wgpu::Renderer>> = match id {
FreeNodeId::Table(block_id, after_line) => self
.computed_tables
.iter()
.find(|ct| ct.anchor.block_id == *block_id && ct.anchor.after_line == *after_line)
.map(|ct| self.build_computed_table_widget(ct)),
FreeNodeId::Block(block_id) => self
.build_free_block_widget(*block_id)
.map(|el| self.wrap_block_with_promote(el, *block_id)),
FreeNodeId::Image(block_id, after_line, src) => self
.computed_images
.iter()
.find(|img| img.anchor.block_id == *block_id
&& img.anchor.after_line == *after_line
&& img.src == *src)
.map(|img| {
let inner: Element<'_, Message, Theme, iced_wgpu::Renderer> =
if let Some(entry) = self.image_cache.get(&img.src) {
iced_widget::image(entry.handle.clone())
.width(Length::Fill)
.height(Length::Fill)
.into()
} else {
let p = palette::current();
iced_widget::text(format!("[image: {}]", img.alt))
.font(syntax::EDITOR_FONT)
.size(self.font_size)
.color(p.overlay0)
.into()
};
self.wrap_image_with_promote(
inner,
img.anchor.block_id,
img.anchor.after_line,
img.src.clone(),
)
}),
FreeNodeId::Tree(_, _) => None,
};
let Some(inner) = inner_opt else { continue };
let sized = iced_widget::container(iced_widget::scrollable(inner))
.width(Length::Fixed(placement.w))
.height(Length::Fixed(placement.h))
.style(|_theme: &Theme| {
let p = palette::current();
container::Style {
background: Some(Background::Color(p.base)),
border: Border { color: p.surface1, width: 1.0, radius: 4.0.into() },
text_color: None,
shadow: Shadow::default(),
snap: false,
}
});
let positioned = iced_widget::container(sized)
.padding(Padding { top: placement.y, right: 0.0, bottom: 0.0, left: placement.x })
.width(Length::Fill)
.height(Length::Fill);
layers.push(positioned.into());
}
if layers.is_empty() {
None
} else {
Some(iced_widget::stack(layers).into())
}
}
/// builds the context menu overlay for a right-clicked cell /// builds the context menu overlay for a right-clicked cell
fn context_menu_view( fn context_menu_view(
&self, &self,

View File

@ -113,11 +113,11 @@ pub fn create_native(
height: f32, height: f32,
scale: f32, scale: f32,
) -> Option<ViewportHandle> { ) -> Option<ViewportHandle> {
#[cfg(target_os = "macos")] #[cfg(any(target_os = "macos", target_os = "ios"))]
let backends = wgpu::Backends::METAL; let backends = wgpu::Backends::METAL;
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
let backends = wgpu::Backends::DX12; let backends = wgpu::Backends::DX12;
#[cfg(not(any(target_os = "macos", target_os = "windows")))] #[cfg(not(any(target_os = "macos", target_os = "ios", target_os = "windows")))]
let backends = wgpu::Backends::VULKAN | wgpu::Backends::GL; let backends = wgpu::Backends::VULKAN | wgpu::Backends::GL;
let instance = wgpu::Instance::new(&wgpu::InstanceDescriptor { let instance = wgpu::Instance::new(&wgpu::InstanceDescriptor {
@ -147,6 +147,12 @@ pub fn create_native(
let caps = surface.get_capabilities(&adapter); let caps = surface.get_capabilities(&adapter);
let format = caps.formats.first().copied()?; let format = caps.formats.first().copied()?;
let alpha_mode = caps.alpha_modes.first().copied().unwrap_or(wgpu::CompositeAlphaMode::Auto);
crate::ios_dlog!(
"surface formats={:?} chose={:?} alpha_modes={:?} chose={:?} adapter={}",
caps.formats, format, caps.alpha_modes, alpha_mode,
adapter.get_info().name,
);
surface.configure( surface.configure(
&device, &device,
@ -156,11 +162,7 @@ pub fn create_native(
width: phys_w.max(1), width: phys_w.max(1),
height: phys_h.max(1), height: phys_h.max(1),
present_mode: wgpu::PresentMode::AutoVsync, present_mode: wgpu::PresentMode::AutoVsync,
alpha_mode: caps alpha_mode,
.alpha_modes
.first()
.copied()
.unwrap_or(wgpu::CompositeAlphaMode::Auto),
view_formats: vec![], view_formats: vec![],
desired_maximum_frame_latency: 2, desired_maximum_frame_latency: 2,
}, },
@ -175,6 +177,11 @@ pub fn create_native(
Shell::headless(), Shell::headless(),
); );
// NOTE: `Font::DEFAULT` is cosmic-text's hardcoded "Noto Sans Mono" which
// iOS doesn't ship — we override per-span with EDITOR_FONT, but anything
// unstyled falls back to this missing font. If iOS shows invisible text
// while spans render, this is the suspect.
crate::ios_dlog!("renderer init font=Font::DEFAULT (= cosmic 'Noto Sans Mono', iOS likely missing) editor_font={:?}", crate::syntax::EDITOR_FONT);
let renderer = iced_wgpu::Renderer::new(engine, Font::DEFAULT, Pixels(16.0)); let renderer = iced_wgpu::Renderer::new(engine, Font::DEFAULT, Pixels(16.0));
let viewport = let viewport =
@ -743,6 +750,9 @@ pub fn render(handle: &mut ViewportHandle) {
// anchor the context menu at the current position in the same frame. // anchor the context menu at the current position in the same frame.
if let Some(pt) = handle.cursor.position() { if let Some(pt) = handle.cursor.position() {
handle.state.cursor_pos = pt; handle.state.cursor_pos = pt;
if handle.state.tick_promote_drag() {
handle.needs_redraw = true;
}
} }
handle.state.sync_focused_cell(focused_id.as_ref()); handle.state.sync_focused_cell(focused_id.as_ref());
@ -771,6 +781,26 @@ pub fn render(handle: &mut ViewportHandle) {
text_color: Color::WHITE, text_color: Color::WHITE,
}; };
// First-frame palette/theme dump so we can spot a "white text on white
// background" misconfiguration. Then a periodic re-log so palette swaps
// mid-session show up too.
{
use std::sync::atomic::{AtomicU64, Ordering};
static FRAME: AtomicU64 = AtomicU64::new(0);
let n = FRAME.fetch_add(1, Ordering::Relaxed);
if n == 0 || n == 60 || n % 600 == 0 {
let p = palette::current();
let _ = (n, &p, &theme, &style);
crate::ios_dlog!(
"render frame={n} theme=Dark style.text={:?} palette.base={:?} palette.text={:?} palette.surface0={:?}",
style.text_color,
p.base,
p.text,
p.surface0,
);
}
}
let mut ui = UserInterface::build( let mut ui = UserInterface::build(
handle.state.view(), handle.state.view(),
Size::new(logical_size.width, logical_size.height), Size::new(logical_size.width, logical_size.height),

View File

@ -19,6 +19,20 @@
use std::ffi::{c_char, c_void, CStr, CString}; use std::ffi::{c_char, c_void, CStr, CString};
/// Gated diagnostic logging — `eprintln!` to stderr, prefixed with the
/// callsite. Compiles to nothing outside debug builds on iOS.
/// AcordApp.swift's captureStderr() pumps stderr → stdout + NSLog so output
/// shows up under `cargo xtask debug-ios --console`.
#[macro_export]
macro_rules! ios_dlog {
($($arg:tt)*) => {{
#[cfg(all(debug_assertions, target_os = "ios"))]
{
eprintln!("[viewport {}:{}] {}", file!(), line!(), format!($($arg)*));
}
}};
}
pub mod block; pub mod block;
pub mod blocks; pub mod blocks;
pub mod bridge; pub mod bridge;
@ -136,12 +150,20 @@ pub extern "C" fn viewport_create(
scale: f32, scale: f32,
) -> *mut ViewportHandle { ) -> *mut ViewportHandle {
install_panic_hook(); install_panic_hook();
ios_dlog!("viewport_create: w={width} h={height} scale={scale} font={:?}", crate::syntax::EDITOR_FONT);
if nsview.is_null() { if nsview.is_null() {
ios_dlog!("viewport_create: nsview NULL — bailing");
return std::ptr::null_mut(); return std::ptr::null_mut();
} }
match handle::create(nsview, width, height, scale) { match handle::create(nsview, width, height, scale) {
Some(h) => Box::into_raw(Box::new(h)), Some(h) => {
None => std::ptr::null_mut(), ios_dlog!("viewport_create: ok — surface ready");
Box::into_raw(Box::new(h))
}
None => {
ios_dlog!("viewport_create: handle::create returned None");
std::ptr::null_mut()
}
} }
} }
@ -176,6 +198,7 @@ pub extern "C" fn viewport_resize(
None => return, None => return,
}; };
handle::resize(h, width, height, scale); handle::resize(h, width, height, scale);
h.state.viewport_size = (width, height);
h.needs_redraw = true; h.needs_redraw = true;
} }

View File

@ -75,11 +75,11 @@ const MD_BOLD_ITALIC: u8 = 44;
/// cosmic-text / fontdb to resolve real Bold, Italic and BoldItalic faces, /// cosmic-text / fontdb to resolve real Bold, Italic and BoldItalic faces,
/// which the generic monospace fallback does not reliably do on macOS because /// which the generic monospace fallback does not reliably do on macOS because
/// cosmic-text hardcodes its default monospace family to "Noto Sans Mono". /// cosmic-text hardcodes its default monospace family to "Noto Sans Mono".
#[cfg(target_os = "macos")] #[cfg(any(target_os = "macos", target_os = "ios"))]
pub const EDITOR_FONT: Font = Font::with_name("Menlo"); pub const EDITOR_FONT: Font = Font::with_name("Menlo");
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
pub const EDITOR_FONT: Font = Font::with_name("Consolas"); pub const EDITOR_FONT: Font = Font::with_name("Consolas");
#[cfg(not(any(target_os = "macos", target_os = "windows")))] #[cfg(not(any(target_os = "macos", target_os = "ios", target_os = "windows")))]
pub const EDITOR_FONT: Font = Font::with_name("DejaVu Sans Mono"); pub const EDITOR_FONT: Font = Font::with_name("DejaVu Sans Mono");
#[derive(Clone, PartialEq)] #[derive(Clone, PartialEq)]

View File

@ -131,6 +131,10 @@ pub enum TableMessage {
/// Click on a column-header sort arrow: cycles that column through /// Click on a column-header sort arrow: cycles that column through
/// Neutral → Asc → Desc → Neutral and re-applies the composite sort. /// Neutral → Asc → Desc → Neutral and re-applies the composite sort.
CycleSort(usize), CycleSort(usize),
/// mouse pressed on the corner select-all marker, arms a free-layer drag.
PromoteCornerPress,
/// mouse released on the corner select-all marker, ends or cancels the drag.
PromoteCornerRelease,
} }
/// Trait-implementing block for tables. Owns all the per-table mutable state /// Trait-implementing block for tables. Owns all the per-table mutable state
@ -852,6 +856,7 @@ impl TableBlock {
// so we just clear the bookkeeping. // so we just clear the bookkeeping.
self.end_drag_select(); self.end_drag_select();
} }
TableMessage::PromoteCornerPress | TableMessage::PromoteCornerRelease => {}
} }
} }
@ -1254,7 +1259,7 @@ where
// Visible whenever the chrome is active so the user always has a // Visible whenever the chrome is active so the user always has a
// reachable affordance once they've touched the table once. // reachable affordance once they've touched the table once.
let corner: Element<'a, Message, Theme, iced_wgpu::Renderer> = if chrome_active { let corner: Element<'a, Message, Theme, iced_wgpu::Renderer> = if chrome_active {
iced_widget::button( let inner = container(
text("\u{25A0}") text("\u{25A0}")
.size(corner_font) .size(corner_font)
.font(EDITOR_FONT) .font(EDITOR_FONT)
@ -1262,9 +1267,20 @@ where
.width(Length::Fixed(ROW_NUMBER_WIDTH)) .width(Length::Fixed(ROW_NUMBER_WIDTH))
.height(Length::Fixed(header_h)) .height(Length::Fixed(header_h))
.padding(Padding { top: 0.0, right: 0.0, bottom: 0.0, left: 0.0 }) .padding(Padding { top: 0.0, right: 0.0, bottom: 0.0, left: 0.0 })
.style(plus_button_style) .style(|_theme: &Theme| {
.on_press(on_msg(TableMessage::SelectAll)) let p = palette::current();
.into() container::Style {
background: None,
border: Border::default(),
text_color: Some(p.overlay0),
shadow: Shadow::default(),
snap: false,
}
});
MouseArea::new(inner)
.on_press(on_msg(TableMessage::PromoteCornerPress))
.on_release(on_msg(TableMessage::PromoteCornerRelease))
.into()
} else { } else {
container(text("")) container(text(""))
.width(Length::Fixed(ROW_NUMBER_WIDTH)) .width(Length::Fixed(ROW_NUMBER_WIDTH))

View File

@ -2,7 +2,7 @@ use std::env;
use std::path::PathBuf; use std::path::PathBuf;
use std::process::{Command, ExitCode}; use std::process::{Command, ExitCode};
const KNOWN_PLATFORMS: &[&str] = &["macos", "windows", "linux", "ios"]; const KNOWN_PLATFORMS: &[&str] = &["macos", "windows", "linux", "ios", "android"];
fn main() -> ExitCode { fn main() -> ExitCode {
let args: Vec<String> = env::args().skip(1).collect(); let args: Vec<String> = env::args().skip(1).collect();
@ -111,11 +111,15 @@ fn print_help() {
eprintln!(" --all all six targets"); eprintln!(" --all all six targets");
eprintln!(" --target <name> e.g. macos-aarch64, windows-x86_64"); eprintln!(" --target <name> e.g. macos-aarch64, windows-x86_64");
eprintln!(); eprintln!();
eprintln!("append -macos / -windows / -linux / -ios to any command to force a platform."); eprintln!("append -macos / -windows / -linux / -ios / -android to any command to force a platform.");
eprintln!(" e.g. cargo xtask build-universal-macos"); eprintln!(" e.g. cargo xtask build-universal-macos");
eprintln!(); eprintln!();
eprintln!("ios:"); eprintln!("ios:");
eprintln!(" cargo xtask select-ios interactively pick the iPad / simulator to target");
eprintln!(" cargo xtask build-ios build the .app bundle for the iPad simulator"); eprintln!(" cargo xtask build-ios build the .app bundle for the iPad simulator");
eprintln!(" cargo xtask install-ios build + install + launch (paired device wins, else sim)"); eprintln!(" cargo xtask install-ios build + install + launch (uses selected target)");
eprintln!(" cargo xtask xcodeproj-ios generate Acord.xcodeproj for finishing in Xcode"); eprintln!(" cargo xtask xcodeproj-ios generate Acord.xcodeproj for finishing in Xcode");
eprintln!();
eprintln!("android:");
eprintln!(" cargo xtask select-android interactively pick the Android device / emulator (stub)");
} }