diff --git a/assets/Acord.svg b/assets/Acord.svg index 0d07305..7559c7d 100644 --- a/assets/Acord.svg +++ b/assets/Acord.svg @@ -1,12 +1,12 @@ - + - + Drop shadow - + @@ -22,7 +22,7 @@ - + diff --git a/ios/Acord.xcodeproj/project.pbxproj b/ios/Acord.xcodeproj/project.pbxproj index e9ac0a2..e3e5d0f 100644 --- a/ios/Acord.xcodeproj/project.pbxproj +++ b/ios/Acord.xcodeproj/project.pbxproj @@ -7,16 +7,28 @@ objects = { /* 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 */; }; 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 */; }; + 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 */ /* Begin PBXFileReference section */ 19D7F3B08824BF5E9E468EF2 /* IcedViewportRepresentable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IcedViewportRepresentable.swift; sourceTree = ""; }; + 22A61FD61FB3606DC153A210 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 4AF21EB9858D4F182A323DAA /* IcedViewportView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IcedViewportView.swift; sourceTree = ""; }; + 5FB74A74224F1D3571524FA8 /* PermissionsManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PermissionsManager.swift; sourceTree = ""; }; + A1D4361480B1086E42F1C44D /* ViewportController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewportController.swift; sourceTree = ""; }; 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 = ""; }; + D1CC2AF06102C6AF52E50D50 /* MenuBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuBar.swift; sourceTree = ""; }; E391F925E2727D13659EDA02 /* AcordApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AcordApp.swift; sourceTree = ""; }; + F50B6D3A3481F01ECC66CDBD /* DocumentPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DocumentPicker.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXGroup section */ @@ -32,6 +44,7 @@ isa = PBXGroup; children = ( FD6FF6B76E15E1EA3D0E676B /* src */, + 22A61FD61FB3606DC153A210 /* Assets.xcassets */, 76C61BCA6D25861221CA6340 /* Products */, ); sourceTree = ""; @@ -40,8 +53,13 @@ isa = PBXGroup; children = ( E391F925E2727D13659EDA02 /* AcordApp.swift */, + CF09AEF6E0546502D3FB36BE /* Debug.swift */, + F50B6D3A3481F01ECC66CDBD /* DocumentPicker.swift */, 19D7F3B08824BF5E9E468EF2 /* IcedViewportRepresentable.swift */, 4AF21EB9858D4F182A323DAA /* IcedViewportView.swift */, + D1CC2AF06102C6AF52E50D50 /* MenuBar.swift */, + 5FB74A74224F1D3571524FA8 /* PermissionsManager.swift */, + A1D4361480B1086E42F1C44D /* ViewportController.swift */, ); path = src; sourceTree = ""; @@ -54,6 +72,7 @@ buildConfigurationList = 3E9D564CF91C62A230D65F8C /* Build configuration list for PBXNativeTarget "Acord" */; buildPhases = ( BEED27C37617469B917539A1 /* Sources */, + 5C3B8F8816A1704304EF6942 /* Resources */, ); buildRules = ( ); @@ -100,14 +119,30 @@ }; /* 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 */ BEED27C37617469B917539A1 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( 951153057A1C3373C77657DF /* AcordApp.swift in Sources */, + F6003A9958CB3CA65BAFE23F /* Debug.swift in Sources */, + F10B810345E36162B23064AF /* DocumentPicker.swift in Sources */, F0306F25CA56D707AE2E27A0 /* IcedViewportRepresentable.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; }; @@ -187,9 +222,8 @@ GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 17.0; - LIBRARY_SEARCH_PATHS = ( - "$(PROJECT_DIR)/../target/$(SWIFT_PLATFORM_TARGET_PREFIX)/release", - ); + "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"; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; ONLY_ACTIVE_ARCH = YES; @@ -258,9 +292,8 @@ GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 17.0; - LIBRARY_SEARCH_PATHS = ( - "$(PROJECT_DIR)/../target/$(SWIFT_PLATFORM_TARGET_PREFIX)/release", - ); + "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"; MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; OTHER_LDFLAGS = ( diff --git a/ios/Acord.xcodeproj/project.xcworkspace/xcuserdata/pszsh.xcuserdatad/UserInterfaceState.xcuserstate b/ios/Acord.xcodeproj/project.xcworkspace/xcuserdata/pszsh.xcuserdatad/UserInterfaceState.xcuserstate new file mode 100644 index 0000000..2d5c1d2 Binary files /dev/null and b/ios/Acord.xcodeproj/project.xcworkspace/xcuserdata/pszsh.xcuserdatad/UserInterfaceState.xcuserstate differ diff --git a/ios/Acord.xcodeproj/xcuserdata/pszsh.xcuserdatad/xcschemes/xcschememanagement.plist b/ios/Acord.xcodeproj/xcuserdata/pszsh.xcuserdatad/xcschemes/xcschememanagement.plist new file mode 100644 index 0000000..21cbdf7 --- /dev/null +++ b/ios/Acord.xcodeproj/xcuserdata/pszsh.xcuserdatad/xcschemes/xcschememanagement.plist @@ -0,0 +1,14 @@ + + + + + SchemeUserState + + Acord.xcscheme_^#shared#^_ + + orderHint + 0 + + + + diff --git a/ios/Assets.xcassets/AppIcon.appiconset/Contents.json b/ios/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..e447aca --- /dev/null +++ b/ios/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -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 + } +} diff --git a/ios/Assets.xcassets/AppIcon.appiconset/Icon-1024.png b/ios/Assets.xcassets/AppIcon.appiconset/Icon-1024.png new file mode 100644 index 0000000..c303ca2 Binary files /dev/null and b/ios/Assets.xcassets/AppIcon.appiconset/Icon-1024.png differ diff --git a/ios/Assets.xcassets/AppIcon.appiconset/Icon-20.png b/ios/Assets.xcassets/AppIcon.appiconset/Icon-20.png new file mode 100644 index 0000000..c807603 Binary files /dev/null and b/ios/Assets.xcassets/AppIcon.appiconset/Icon-20.png differ diff --git a/ios/Assets.xcassets/AppIcon.appiconset/Icon-20@2x.png b/ios/Assets.xcassets/AppIcon.appiconset/Icon-20@2x.png new file mode 100644 index 0000000..8ec3cf1 Binary files /dev/null and b/ios/Assets.xcassets/AppIcon.appiconset/Icon-20@2x.png differ diff --git a/ios/Assets.xcassets/AppIcon.appiconset/Icon-20@3x.png b/ios/Assets.xcassets/AppIcon.appiconset/Icon-20@3x.png new file mode 100644 index 0000000..0f2d1c2 Binary files /dev/null and b/ios/Assets.xcassets/AppIcon.appiconset/Icon-20@3x.png differ diff --git a/ios/Assets.xcassets/AppIcon.appiconset/Icon-29.png b/ios/Assets.xcassets/AppIcon.appiconset/Icon-29.png new file mode 100644 index 0000000..7cb253e Binary files /dev/null and b/ios/Assets.xcassets/AppIcon.appiconset/Icon-29.png differ diff --git a/ios/Assets.xcassets/AppIcon.appiconset/Icon-29@2x.png b/ios/Assets.xcassets/AppIcon.appiconset/Icon-29@2x.png new file mode 100644 index 0000000..60a88c3 Binary files /dev/null and b/ios/Assets.xcassets/AppIcon.appiconset/Icon-29@2x.png differ diff --git a/ios/Assets.xcassets/AppIcon.appiconset/Icon-29@3x.png b/ios/Assets.xcassets/AppIcon.appiconset/Icon-29@3x.png new file mode 100644 index 0000000..520add7 Binary files /dev/null and b/ios/Assets.xcassets/AppIcon.appiconset/Icon-29@3x.png differ diff --git a/ios/Assets.xcassets/AppIcon.appiconset/Icon-40.png b/ios/Assets.xcassets/AppIcon.appiconset/Icon-40.png new file mode 100644 index 0000000..8ec3cf1 Binary files /dev/null and b/ios/Assets.xcassets/AppIcon.appiconset/Icon-40.png differ diff --git a/ios/Assets.xcassets/AppIcon.appiconset/Icon-40@2x.png b/ios/Assets.xcassets/AppIcon.appiconset/Icon-40@2x.png new file mode 100644 index 0000000..a18dadd Binary files /dev/null and b/ios/Assets.xcassets/AppIcon.appiconset/Icon-40@2x.png differ diff --git a/ios/Assets.xcassets/AppIcon.appiconset/Icon-40@3x.png b/ios/Assets.xcassets/AppIcon.appiconset/Icon-40@3x.png new file mode 100644 index 0000000..ed892bf Binary files /dev/null and b/ios/Assets.xcassets/AppIcon.appiconset/Icon-40@3x.png differ diff --git a/ios/Assets.xcassets/AppIcon.appiconset/Icon-60@2x.png b/ios/Assets.xcassets/AppIcon.appiconset/Icon-60@2x.png new file mode 100644 index 0000000..ed892bf Binary files /dev/null and b/ios/Assets.xcassets/AppIcon.appiconset/Icon-60@2x.png differ diff --git a/ios/Assets.xcassets/AppIcon.appiconset/Icon-60@3x.png b/ios/Assets.xcassets/AppIcon.appiconset/Icon-60@3x.png new file mode 100644 index 0000000..4f016e8 Binary files /dev/null and b/ios/Assets.xcassets/AppIcon.appiconset/Icon-60@3x.png differ diff --git a/ios/Assets.xcassets/AppIcon.appiconset/Icon-76.png b/ios/Assets.xcassets/AppIcon.appiconset/Icon-76.png new file mode 100644 index 0000000..bd067fb Binary files /dev/null and b/ios/Assets.xcassets/AppIcon.appiconset/Icon-76.png differ diff --git a/ios/Assets.xcassets/AppIcon.appiconset/Icon-76@2x.png b/ios/Assets.xcassets/AppIcon.appiconset/Icon-76@2x.png new file mode 100644 index 0000000..6a72553 Binary files /dev/null and b/ios/Assets.xcassets/AppIcon.appiconset/Icon-76@2x.png differ diff --git a/ios/Assets.xcassets/AppIcon.appiconset/Icon-83.5@2x.png b/ios/Assets.xcassets/AppIcon.appiconset/Icon-83.5@2x.png new file mode 100644 index 0000000..89378a8 Binary files /dev/null and b/ios/Assets.xcassets/AppIcon.appiconset/Icon-83.5@2x.png differ diff --git a/ios/Assets.xcassets/Contents.json b/ios/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/ios/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ios/Info.plist b/ios/Info.plist index f79f39d..c601713 100644 --- a/ios/Info.plist +++ b/ios/Info.plist @@ -60,5 +60,68 @@ UIFileSharingEnabled + UISupportsDocumentBrowser + + CFBundleDocumentTypes + + + CFBundleTypeName + Markdown + CFBundleTypeRole + Editor + LSHandlerRank + Default + LSItemContentTypes + + net.daringfireball.markdown + public.plain-text + + CFBundleTypeExtensions + + md + markdown + mdown + txt + + + + NSPhotoLibraryUsageDescription + Acord uses your photo library to embed images into notes. + NSPhotoLibraryAddUsageDescription + Acord saves rendered notes and exported PDFs to your photo library. + NSCameraUsageDescription + Acord uses the camera to attach photos directly to notes. + NSMicrophoneUsageDescription + Acord uses the microphone to attach voice notes. + NSDocumentsFolderUsageDescription + Acord reads and writes notes from your chosen documents folder. + NSDownloadsFolderUsageDescription + Acord opens and saves notes in your Downloads folder. + UTExportedTypeDeclarations + + + UTTypeIdentifier + net.daringfireball.markdown + UTTypeDescription + Markdown + UTTypeConformsTo + + public.plain-text + + UTTypeTagSpecification + + public.filename-extension + + md + markdown + mdown + + public.mime-type + + text/markdown + + + + diff --git a/ios/project.yml b/ios/project.yml index 01c6efc..47425ab 100644 --- a/ios/project.yml +++ b/ios/project.yml @@ -16,8 +16,10 @@ settings: TARGETED_DEVICE_FAMILY: "2,1" IPHONEOS_DEPLOYMENT_TARGET: "17.0" SWIFT_OBJC_BRIDGING_HEADER: ../viewport/include/acord.h - LIBRARY_SEARCH_PATHS: - - $(PROJECT_DIR)/../target/$(SWIFT_PLATFORM_TARGET_PREFIX)/release + # Cargo writes to /tmp/acord/target by default (see scripts/_build-dirs.sh). + # 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: - -lacord_viewport EXCLUDED_ARCHS[sdk=iphonesimulator*]: x86_64 @@ -30,8 +32,10 @@ targets: platform: iOS sources: - path: src + - path: Assets.xcassets settings: base: INFOPLIST_FILE: Info.plist GENERATE_INFOPLIST_FILE: NO + ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon dependencies: [] diff --git a/ios/src/AcordApp.swift b/ios/src/AcordApp.swift index 5c7cee1..1f701d2 100644 --- a/ios/src/AcordApp.swift +++ b/ios/src/AcordApp.swift @@ -1,18 +1,65 @@ import SwiftUI +import os.log +import Darwin @main 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? + 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 { WindowGroup { ContentView() - .ignoresSafeArea(.keyboard) + .onAppear { dlog("AcordApp scene WindowGroup appeared") } } } } struct ContentView: View { + @StateObject private var controller = ViewportController() + var body: some View { - IcedViewportRepresentable() - .ignoresSafeArea() + VStack(spacing: 0) { + MenuBar(controller: controller) + IcedViewportRepresentable(controller: controller) + .ignoresSafeArea(.container, edges: .bottom) + } + .ignoresSafeArea(.keyboard) + .onAppear { + dlog("ContentView.onAppear") + PermissionsManager.requestSystemPermissions() + } } } diff --git a/ios/src/Debug.swift b/ios/src/Debug.swift new file mode 100644 index 0000000..c417b09 --- /dev/null +++ b/ios/src/Debug.swift @@ -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 +} diff --git a/ios/src/DocumentPicker.swift b/ios/src/DocumentPicker.swift new file mode 100644 index 0000000..ac94de0 --- /dev/null +++ b/ios/src/DocumentPicker.swift @@ -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) + } +} diff --git a/ios/src/IcedViewportRepresentable.swift b/ios/src/IcedViewportRepresentable.swift index 1da425e..1ce9949 100644 --- a/ios/src/IcedViewportRepresentable.swift +++ b/ios/src/IcedViewportRepresentable.swift @@ -2,12 +2,20 @@ import SwiftUI import UIKit /// 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 { + let controller: ViewportController + 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) { - // 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 } } diff --git a/ios/src/IcedViewportView.swift b/ios/src/IcedViewportView.swift index 1fa18e0..576b72b 100644 --- a/ios/src/IcedViewportView.swift +++ b/ios/src/IcedViewportView.swift @@ -2,8 +2,9 @@ import UIKit import QuartzCore /// CAMetalLayer-backed UIView that owns the Rust viewport handle and pumps -/// CADisplayLink ticks into `viewport_render`. -class IcedViewportView: UIView { +/// CADisplayLink ticks into `viewport_render`. UIKeyInput conformance is what +/// makes the soft keyboard appear when the view becomes first responder. +class IcedViewportView: UIView, UIKeyInput { override class var layerClass: AnyClass { CAMetalLayer.self } private(set) var viewportHandle: OpaquePointer? @@ -29,15 +30,22 @@ class IcedViewportView: UIView { metalLayer.framebufferOnly = true metalLayer.pixelFormat = .bgra8Unorm 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() { 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 { createViewport() 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 { teardown() } @@ -45,27 +53,66 @@ class IcedViewportView: UIView { 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() { let scale = Float(window?.screen.scale ?? UIScreen.main.scale) let w = Float(bounds.width) 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() 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() { guard let handle = viewportHandle else { return } + dlog("destroyViewport") viewportHandle = nil viewport_destroy(handle) } func teardown() { if isTornDown { return } + dlog("teardown") isTornDown = true stopDisplayLink() if let h = viewportHandle, let cstr = viewport_get_text(h) { cachedText = String(cString: cstr) viewport_free_string(cstr) + dlog("teardown: cached \(cachedText.count) chars") } destroyViewport() } @@ -79,6 +126,7 @@ class IcedViewportView: UIView { let link = CADisplayLink(target: self, selector: #selector(renderFrame)) link.add(to: .main, forMode: .common) displayLink = link + dlog("startDisplayLink") } private func stopDisplayLink() { @@ -86,10 +134,17 @@ class IcedViewportView: UIView { displayLink = nil } + private var renderCount: Int = 0 @objc private func renderFrame() { if isTornDown { return } guard let handle = viewportHandle else { return } 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 @@ -103,8 +158,12 @@ class IcedViewportView: UIView { width: bounds.width * 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) } @@ -113,8 +172,10 @@ class IcedViewportView: UIView { override func touchesBegan(_ touches: Set, with event: UIEvent?) { guard let h = viewportHandle, let touch = touches.first else { return } let p = touch.location(in: self) + dlog("touchesBegan: \(p)") viewport_mouse_event(h, Float(p.x), Float(p.y), 0, true) becomeFirstResponder() + dlog("touchesBegan: firstResponder=\(isFirstResponder)") } override func touchesMoved(_ touches: Set, with event: UIEvent?) { @@ -126,12 +187,14 @@ class IcedViewportView: UIView { override func touchesEnded(_ touches: Set, with event: UIEvent?) { guard let h = viewportHandle, let touch = touches.first else { return } let p = touch.location(in: self) + dlog("touchesEnded: \(p)") viewport_mouse_event(h, Float(p.x), Float(p.y), 0, false) } override func touchesCancelled(_ touches: Set, with event: UIEvent?) { guard let h = viewportHandle, let touch = touches.first else { return } let p = touch.location(in: self) + dlog("touchesCancelled: \(p)") 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) { guard let key = press.key else { return } let chars = pressed ? key.characters : "" + dlog("forwardKey: keyCode=\(key.keyCode.rawValue) mods=\(key.modifierFlags.rawValue) pressed=\(pressed) chars=\(chars.debugDescription)") chars.withCString { cstr in viewport_key_event(handle, UInt32(key.keyCode.rawValue), UInt32(key.modifierFlags.rawValue), pressed, cstr) } diff --git a/ios/src/MenuBar.swift b/ios/src/MenuBar.swift new file mode 100644 index 0000000..935cde4 --- /dev/null +++ b/ios/src/MenuBar.swift @@ -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()) + } +} diff --git a/ios/src/PermissionsManager.swift b/ios/src/PermissionsManager.swift new file mode 100644 index 0000000..d6f2cf8 --- /dev/null +++ b/ios/src/PermissionsManager.swift @@ -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))" + } + } +} diff --git a/ios/src/ViewportController.swift b/ios/src/ViewportController.swift new file mode 100644 index 0000000..d823b8f --- /dev/null +++ b/ios/src/ViewportController.swift @@ -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 + } +} diff --git a/scripts/_build-dirs.sh b/scripts/_build-dirs.sh new file mode 100755 index 0000000..6c7ccb7 --- /dev/null +++ b/scripts/_build-dirs.sh @@ -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" diff --git a/scripts/android/select.sh b/scripts/android/select.sh new file mode 100755 index 0000000..94297ec --- /dev/null +++ b/scripts/android/select.sh @@ -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 diff --git a/scripts/ios/build.sh b/scripts/ios/build.sh index a51308b..7807e69 100755 --- a/scripts/ios/build.sh +++ b/scripts/ios/build.sh @@ -2,6 +2,7 @@ set -euo pipefail ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +source "$ROOT/scripts/_build-dirs.sh" cd "$ROOT" case "$(uname -s)" in @@ -29,9 +30,29 @@ case "$TARGET" in ;; 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" 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)" @@ -40,8 +61,8 @@ export CC=/usr/bin/clang export CXX=/usr/bin/clang++ export IPHONEOS_DEPLOYMENT_TARGET=17.0 -echo "Building Rust workspace for $RUST_TARGET (release)..." -cargo build --release --target "$RUST_TARGET" -p acord-viewport +echo "Building Rust workspace for $RUST_TARGET ($CONFIG)..." +cargo build "${CARGO_FLAGS[@]}" --target "$RUST_TARGET" -p acord-viewport if [ ! -f "$RUST_LIB/libacord_viewport.a" ]; then echo "ERROR: libacord_viewport.a not found at $RUST_LIB" >&2 @@ -53,18 +74,32 @@ rm -rf "$APP" mkdir -p "$APP" cp "$ROOT/ios/Info.plist" "$APP/Info.plist" -# generate icon (PNG variants required by iOS) — single 1024 master, scaled to bundle entries. -SVG="$ROOT/assets/Acord.svg" -if [ -f "$SVG" ] && command -v rsvg-convert >/dev/null 2>&1; then - echo "Generating app icons..." - 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" - done +# asset catalog → compiled Assets.car + partial Info.plist via actool. +# regenerate the source PNGs from the SVG so a fresh checkout doesn't need +# a manual icon-rebuild step. +bash "$ROOT/scripts/ios/generate-icons.sh" + +ACTOOL_PARTIAL="$BUILD/ios/actool-partial-info.plist" +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 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 \ -target "$SWIFT_TARGET" \ -sdk "$SDK" \ @@ -76,7 +111,10 @@ xcrun -sdk "$SDK_NAME" swiftc \ -framework MetalKit \ -framework CoreGraphics \ -framework CoreFoundation \ - -O \ + -framework Photos \ + -framework AVFoundation \ + -framework UniformTypeIdentifiers \ + "${SWIFT_OPT_FLAGS[@]}" \ -o "$APP/Acord" \ "$ROOT"/ios/src/*.swift diff --git a/scripts/ios/debug.sh b/scripts/ios/debug.sh old mode 100755 new mode 100644 index 19c0a0b..b887de5 --- a/scripts/ios/debug.sh +++ b/scripts/ios/debug.sh @@ -1,11 +1,85 @@ #!/usr/bin/env bash 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)" +source "$ROOT/scripts/_build-dirs.sh" cd "$ROOT" -# debug build: skip release flags by reusing build.sh but with the dev profile. -# build.sh always uses release; for now, point users to install.sh for normal use, -# and stream the simulator log for the bundle id. -bash "$ROOT/scripts/ios/install.sh" -xcrun simctl spawn booted log stream --predicate 'subsystem == "org.else-if.acord" OR processImagePath CONTAINS "Acord"' --level debug +export ACORD_IOS_CONFIG=debug + +CONFIG_FILE="$HOME/.acord/ios-target" +KIND="" ; ID="" +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 diff --git a/scripts/ios/generate-icons.sh b/scripts/ios/generate-icons.sh new file mode 100755 index 0000000..0471afb --- /dev/null +++ b/scripts/ios/generate-icons.sh @@ -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)" diff --git a/scripts/ios/install.sh b/scripts/ios/install.sh index 99a6d6c..e745911 100755 --- a/scripts/ios/install.sh +++ b/scripts/ios/install.sh @@ -2,10 +2,32 @@ set -euo pipefail ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +source "$ROOT/scripts/_build-dirs.sh" cd "$ROOT" -# pick the deploy target: explicit arg, or auto-detect (paired physical device wins). -TARGET="${1:-}" +CONFIG_FILE="$HOME/.acord/ios-target" + +# 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 xcrun devicectl list devices 2>/dev/null | grep -q "available (paired)"; then TARGET="device" @@ -14,34 +36,52 @@ if [ -z "$TARGET" ]; then fi fi +if [ -n "$SELECTED_LABEL" ]; then + echo "Target: $TARGET — $SELECTED_LABEL ($SELECTED_ID)" +else + echo "Target: $TARGET (auto-detected)" +fi + case "$TARGET" in sim) bash "$ROOT/scripts/ios/build.sh" sim APP="$ROOT/build/ios/Acord.app" 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 - 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 echo "no iPad simulator available — open Xcode → Window → Devices and Simulators to add one" >&2 exit 1 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 open -a Simulator fi echo "Installing to simulator $DEV..." xcrun simctl install "$DEV" "$APP" - echo "Launching..." - xcrun simctl launch "$DEV" "$BUNDLE_ID" + if [ "${ACORD_IOS_NOLAUNCH:-0}" = "1" ]; then + echo "Skipping launch (ACORD_IOS_NOLAUNCH=1)" + else + echo "Launching..." + xcrun simctl launch "$DEV" "$BUNDLE_ID" + fi ;; device) bash "$ROOT/scripts/ios/build.sh" device APP="$ROOT/build/ios/Acord.app" - DEVICE_ID="${ACORD_IOS_DEVICE:-}" + DEVICE_ID="${SELECTED_ID:-${ACORD_IOS_DEVICE:-}}" if [ -z "$DEVICE_ID" ]; then 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}}')" @@ -49,14 +89,19 @@ case "$TARGET" in if [ -z "$DEVICE_ID" ]; then 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 fi echo "Installing to device $DEVICE_ID..." xcrun devicectl device install app --device "$DEVICE_ID" "$APP" - echo "Launching..." - xcrun devicectl device process launch --device "$DEVICE_ID" org.else-if.acord || true + if [ "${ACORD_IOS_NOLAUNCH:-0}" = "1" ]; then + echo "Skipping launch (ACORD_IOS_NOLAUNCH=1)" + else + echo "Launching..." + xcrun devicectl device process launch --device "$DEVICE_ID" org.else-if.acord || true + fi ;; *) diff --git a/scripts/ios/select.sh b/scripts/ios/select.sh new file mode 100755 index 0000000..bdae084 --- /dev/null +++ b/scripts/ios/select.sh @@ -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" </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-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" echo "Generating Acord.xcodeproj..." xcodegen generate diff --git a/scripts/linux/build.sh b/scripts/linux/build.sh old mode 100755 new mode 100644 index bd8de00..1325881 --- a/scripts/linux/build.sh +++ b/scripts/linux/build.sh @@ -2,6 +2,7 @@ set -euo pipefail ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +source "$ROOT/scripts/_build-dirs.sh" cd "$ROOT" case "$(uname -s)" in @@ -37,7 +38,7 @@ fi STAGE="$ROOT/build/bin" mkdir -p "$STAGE" -cp "$ROOT/target/release/acord" "$STAGE/Acord" +cp "$CARGO_TARGET_DIR/release/acord" "$STAGE/Acord" chmod +x "$STAGE/Acord" # Rasterize the SVG icon next to the binary so load_window_icon picks it up. diff --git a/scripts/linux/debug.sh b/scripts/linux/debug.sh old mode 100755 new mode 100644 index 4560ea7..67c9af0 --- a/scripts/linux/debug.sh +++ b/scripts/linux/debug.sh @@ -5,6 +5,7 @@ set -euo pipefail # launched in the foreground so Rust panics print straight to this terminal. ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +source "$ROOT/scripts/_build-dirs.sh" cd "$ROOT" case "$(uname -s)" in @@ -20,11 +21,11 @@ else cargo build -p acord-linux 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. 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 pkill -x acord 2>/dev/null || true diff --git a/scripts/linux/install.sh b/scripts/linux/install.sh old mode 100755 new mode 100644 index 6e4b374..01783ea --- a/scripts/linux/install.sh +++ b/scripts/linux/install.sh @@ -2,6 +2,7 @@ set -euo pipefail ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +source "$ROOT/scripts/_build-dirs.sh" cd "$ROOT" case "$(uname -s)" in diff --git a/scripts/macos/build-universal.sh b/scripts/macos/build-universal.sh old mode 100755 new mode 100644 index dcc9305..350681b --- a/scripts/macos/build-universal.sh +++ b/scripts/macos/build-universal.sh @@ -2,6 +2,7 @@ set -euo pipefail ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +source "$ROOT/scripts/_build-dirs.sh" cd "$ROOT" 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 x86_64-apple-darwin -mkdir -p "$ROOT/target/universal" +mkdir -p "$CARGO_TARGET_DIR/universal" lipo -create \ - "$ROOT/target/aarch64-apple-darwin/release/libacord_viewport.a" \ - "$ROOT/target/x86_64-apple-darwin/release/libacord_viewport.a" \ - -output "$ROOT/target/universal/libacord_viewport.a" + "$CARGO_TARGET_DIR/aarch64-apple-darwin/release/libacord_viewport.a" \ + "$CARGO_TARGET_DIR/x86_64-apple-darwin/release/libacord_viewport.a" \ + -output "$CARGO_TARGET_DIR/universal/libacord_viewport.a" # 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)..." 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[@]}" \ -framework Cocoa -framework SwiftUI -framework Metal -framework MetalKit -O \ diff --git a/scripts/macos/build.sh b/scripts/macos/build.sh old mode 100755 new mode 100644 index 04fb1cd..2fe5424 --- a/scripts/macos/build.sh +++ b/scripts/macos/build.sh @@ -2,6 +2,7 @@ set -euo pipefail ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +source "$ROOT/scripts/_build-dirs.sh" cd "$ROOT" case "$(uname -s)" in @@ -16,7 +17,7 @@ MACOS="$CONTENTS/MacOS" RESOURCES="$CONTENTS/Resources" SDK=$(xcrun --show-sdk-path) -RUST_LIB="$ROOT/target/release" +RUST_LIB="$CARGO_TARGET_DIR/release" export MACOSX_DEPLOYMENT_TARGET=14.0 export ZERO_AR_DATE=0 diff --git a/scripts/macos/debug.sh b/scripts/macos/debug.sh old mode 100755 new mode 100644 index a4a65e5..36b0173 --- a/scripts/macos/debug.sh +++ b/scripts/macos/debug.sh @@ -6,6 +6,7 @@ set -euo pipefail # (the panic hook in viewport/src/lib.rs flushes stderr before SIGABRT). ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +source "$ROOT/scripts/_build-dirs.sh" cd "$ROOT" case "$(uname -s)" in @@ -20,7 +21,7 @@ MACOS="$CONTENTS/MacOS" RESOURCES="$CONTENTS/Resources" SDK=$(xcrun --show-sdk-path) -RUST_LIB="$ROOT/target/debug" +RUST_LIB="$CARGO_TARGET_DIR/debug" export MACOSX_DEPLOYMENT_TARGET=14.0 export ZERO_AR_DATE=0 diff --git a/scripts/macos/install.sh b/scripts/macos/install.sh old mode 100755 new mode 100644 index 009e3cc..4e7265d --- a/scripts/macos/install.sh +++ b/scripts/macos/install.sh @@ -2,6 +2,7 @@ set -euo pipefail ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +source "$ROOT/scripts/_build-dirs.sh" cd "$ROOT" case "$(uname -s)" in diff --git a/scripts/macos/package.sh b/scripts/macos/package.sh old mode 100755 new mode 100644 index 99306c4..7b7cd60 --- a/scripts/macos/package.sh +++ b/scripts/macos/package.sh @@ -18,6 +18,7 @@ set -euo pipefail # cargo install cargo-zigbuild ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +source "$ROOT/scripts/_build-dirs.sh" cd "$ROOT" case "$(uname -s)" in @@ -141,7 +142,7 @@ build_macos() { export ZERO_AR_DATE=0 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" ] \ || { echo "ERROR: libacord_viewport.a missing for $rust_target" >&2; exit 1; } @@ -187,7 +188,7 @@ build_windows() { rm -rf "$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 "$ROOT/LICENCE" ] && cp "$ROOT/LICENCE" "$stage/LICENCE" [ -f "$ROOT/README.md" ] && cp "$ROOT/README.md" "$stage/README.md" @@ -214,7 +215,7 @@ build_linux() { rm -rf "$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" [ -f "$ICON_PNG" ] && cp "$ICON_PNG" "$stage/icon.png" [ -f "$ROOT/LICENCE" ] && cp "$ROOT/LICENCE" "$stage/LICENCE" diff --git a/viewport/src/editor.rs b/viewport/src/editor.rs index 68295de..70ee96d 100644 --- a/viewport/src/editor.rs +++ b/viewport/src/editor.rs @@ -173,6 +173,12 @@ pub enum Message { InlineResultRelease, /// double-click on an inline result 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), CloseMenu, Shell(ShellAction), @@ -312,6 +318,36 @@ enum LayerItem<'a> { 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, +} + impl LayerItem<'_> { fn element_height(&self, line_h: f32, font_size: f32) -> f32 { match self { @@ -437,6 +473,11 @@ pub struct EditorState { pub pending_shell_action: Option, pub settings_open: bool, pub settings_view: SettingsView, + + pub free_placements: HashMap, + pub frozen_doc_size: Option<(f32, f32)>, + pub viewport_size: (f32, f32), + pub promote_drag: Option, } #[derive(Debug, Clone)] @@ -553,9 +594,77 @@ impl EditorState { pending_shell_action: None, settings_open: false, 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) { + 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 pub fn take_pending_shell_action(&mut self) -> Option { self.pending_shell_action.take() @@ -2841,6 +2950,23 @@ impl EditorState { self.update_find_matches(); } 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!( &tmsg, TableMessage::InsertRowAbove @@ -3312,6 +3438,15 @@ impl EditorState { self.inline_press = None; 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() } 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 { @@ -3507,6 +3646,9 @@ impl EditorState { let mut global_line = 0usize; 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 any = block.as_any(); @@ -3659,14 +3801,14 @@ impl EditorState { if let Some(hb) = any.downcast_ref::() { let layered = >::view(hb, &ctx); - block_elements.push(layered.base); + block_elements.push(self.wrap_block_with_promote(layered.base, hb.id)); global_line += 1; continue; } if let Some(hr) = any.downcast_ref::() { let layered = >::view(hr, &ctx); - block_elements.push(layered.base); + block_elements.push(self.wrap_block_with_promote(layered.base, hr.id)); global_line += 1; continue; } @@ -3830,16 +3972,22 @@ impl EditorState { } for ct in &self.computed_tables { 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))); } } for ct in &self.computed_trees { 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))); } } for img in &self.computed_images { 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))); } } @@ -3917,40 +4065,9 @@ impl EditorState { }); } LayerItem::Table(ct) => { - let mut table_rows: Vec> = Vec::new(); - for (ri, row) in ct.rows.iter().enumerate() { - let is_header = ri == 0; - let cells: Vec> = 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 inner = self.build_computed_table_widget(ct); 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 }) .width(Length::Fill) .into(); @@ -3990,10 +4107,16 @@ impl EditorState { .width(Length::Fill) .into() }; + let wrapped = self.wrap_image_with_promote( + el, + img.anchor.block_id, + img.anchor.after_line, + img.src.clone(), + ); anchored.push(AnchoredItem { after_line: *after_line, height: item.element_height(lh, self.font_size), - element: el, + element: wrapped, }); } } @@ -4002,6 +4125,206 @@ impl EditorState { 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> = Vec::new(); + for (ri, row) in ct.rows.iter().enumerate() { + let is_header = ri == 0; + let cells: Vec> = 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> { + 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::() { + 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::() { + return Some(>::view(hb, &ctx).base); + } + if let Some(hr) = any.downcast_ref::() { + return Some(>::view(hr, &ctx).base); + } + if let Some(tree) = any.downcast_ref::() { + return Some(>::view(tree, &ctx).base); + } + None + } + + /// stacks free-placed objects at absolute positions over the editor body. + fn build_free_overlay(&self) -> Option> { + 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> = Vec::new(); + for &(id, placement) in &placed { + let inner_opt: Option> = 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 fn context_menu_view( &self, diff --git a/viewport/src/handle.rs b/viewport/src/handle.rs index 33568d1..6af8cb5 100644 --- a/viewport/src/handle.rs +++ b/viewport/src/handle.rs @@ -113,11 +113,11 @@ pub fn create_native( height: f32, scale: f32, ) -> Option { - #[cfg(target_os = "macos")] + #[cfg(any(target_os = "macos", target_os = "ios"))] let backends = wgpu::Backends::METAL; #[cfg(target_os = "windows")] 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 instance = wgpu::Instance::new(&wgpu::InstanceDescriptor { @@ -147,6 +147,12 @@ pub fn create_native( let caps = surface.get_capabilities(&adapter); 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( &device, @@ -156,11 +162,7 @@ pub fn create_native( width: phys_w.max(1), height: phys_h.max(1), present_mode: wgpu::PresentMode::AutoVsync, - alpha_mode: caps - .alpha_modes - .first() - .copied() - .unwrap_or(wgpu::CompositeAlphaMode::Auto), + alpha_mode, view_formats: vec![], desired_maximum_frame_latency: 2, }, @@ -175,6 +177,11 @@ pub fn create_native( 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 viewport = @@ -743,6 +750,9 @@ pub fn render(handle: &mut ViewportHandle) { // anchor the context menu at the current position in the same frame. if let Some(pt) = handle.cursor.position() { handle.state.cursor_pos = pt; + if handle.state.tick_promote_drag() { + handle.needs_redraw = true; + } } handle.state.sync_focused_cell(focused_id.as_ref()); @@ -771,6 +781,26 @@ pub fn render(handle: &mut ViewportHandle) { 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( handle.state.view(), Size::new(logical_size.width, logical_size.height), diff --git a/viewport/src/lib.rs b/viewport/src/lib.rs index 4e90124..a139663 100644 --- a/viewport/src/lib.rs +++ b/viewport/src/lib.rs @@ -19,6 +19,20 @@ 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 blocks; pub mod bridge; @@ -136,12 +150,20 @@ pub extern "C" fn viewport_create( scale: f32, ) -> *mut ViewportHandle { install_panic_hook(); + ios_dlog!("viewport_create: w={width} h={height} scale={scale} font={:?}", crate::syntax::EDITOR_FONT); if nsview.is_null() { + ios_dlog!("viewport_create: nsview NULL — bailing"); return std::ptr::null_mut(); } match handle::create(nsview, width, height, scale) { - Some(h) => Box::into_raw(Box::new(h)), - None => std::ptr::null_mut(), + Some(h) => { + 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, }; handle::resize(h, width, height, scale); + h.state.viewport_size = (width, height); h.needs_redraw = true; } diff --git a/viewport/src/syntax.rs b/viewport/src/syntax.rs index bf0e8a4..4222735 100644 --- a/viewport/src/syntax.rs +++ b/viewport/src/syntax.rs @@ -75,11 +75,11 @@ const MD_BOLD_ITALIC: u8 = 44; /// cosmic-text / fontdb to resolve real Bold, Italic and BoldItalic faces, /// which the generic monospace fallback does not reliably do on macOS because /// cosmic-text hardcodes its default monospace family to "Noto Sans Mono". -#[cfg(target_os = "macos")] +#[cfg(any(target_os = "macos", target_os = "ios"))] pub const EDITOR_FONT: Font = Font::with_name("Menlo"); #[cfg(target_os = "windows")] pub const EDITOR_FONT: Font = Font::with_name("Consolas"); -#[cfg(not(any(target_os = "macos", target_os = "windows")))] +#[cfg(not(any(target_os = "macos", target_os = "ios", target_os = "windows")))] pub const EDITOR_FONT: Font = Font::with_name("DejaVu Sans Mono"); #[derive(Clone, PartialEq)] diff --git a/viewport/src/table_block.rs b/viewport/src/table_block.rs index 354528f..d173db3 100644 --- a/viewport/src/table_block.rs +++ b/viewport/src/table_block.rs @@ -131,6 +131,10 @@ pub enum TableMessage { /// Click on a column-header sort arrow: cycles that column through /// Neutral → Asc → Desc → Neutral and re-applies the composite sort. 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 @@ -852,6 +856,7 @@ impl TableBlock { // so we just clear the bookkeeping. 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 // reachable affordance once they've touched the table once. let corner: Element<'a, Message, Theme, iced_wgpu::Renderer> = if chrome_active { - iced_widget::button( + let inner = container( text("\u{25A0}") .size(corner_font) .font(EDITOR_FONT) @@ -1262,9 +1267,20 @@ where .width(Length::Fixed(ROW_NUMBER_WIDTH)) .height(Length::Fixed(header_h)) .padding(Padding { top: 0.0, right: 0.0, bottom: 0.0, left: 0.0 }) - .style(plus_button_style) - .on_press(on_msg(TableMessage::SelectAll)) - .into() + .style(|_theme: &Theme| { + let p = palette::current(); + 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 { container(text("")) .width(Length::Fixed(ROW_NUMBER_WIDTH)) diff --git a/xtask/src/main.rs b/xtask/src/main.rs index 59088f4..9836c6d 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -2,7 +2,7 @@ use std::env; use std::path::PathBuf; 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 { let args: Vec = env::args().skip(1).collect(); @@ -111,11 +111,15 @@ fn print_help() { eprintln!(" --all all six targets"); eprintln!(" --target e.g. macos-aarch64, windows-x86_64"); 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!(); 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 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!(); + eprintln!("android:"); + eprintln!(" cargo xtask select-android interactively pick the Android device / emulator (stub)"); }