From 56d2b3ce9a944af4bc7c2412b8d4c5d2cd37c37a Mon Sep 17 00:00:00 2001 From: jess Date: Mon, 4 May 2026 13:09:37 -0700 Subject: [PATCH] - Add printing - Beginning of iPad port --- core/src/interp.rs | 18 + ios/Acord.xcodeproj/project.pbxproj | 320 ++++++++++++ .../contents.xcworkspacedata | 7 + ios/Info.plist | 64 +++ ios/project.yml | 37 ++ ios/src/AcordApp.swift | 18 + ios/src/IcedViewportRepresentable.swift | 13 + ios/src/IcedViewportView.swift | 167 +++++++ linux/src/app.rs | 22 + linux/src/shortcuts.rs | 2 + macos/src/AppDelegate.swift | 41 ++ scripts/ios/build.sh | 145 ++++++ scripts/ios/debug.sh | 11 + scripts/ios/install.sh | 66 +++ scripts/ios/xcodeproj.sh | 32 ++ viewport/Cargo.toml | 7 +- viewport/include/acord.h | 7 + viewport/src/browser/model.rs | 6 + viewport/src/editor.rs | 89 ++++ viewport/src/handle.rs | 30 +- viewport/src/lib.rs | 61 ++- viewport/src/print.rs | 465 ++++++++++++++++++ viewport/src/table_block.rs | 2 +- viewport/src/widgets/dialog.rs | 108 ++++ viewport/src/widgets/menu.rs | 180 +++++++ viewport/src/widgets/mod.rs | 10 + viewport/src/widgets/style.rs | 59 +++ windows/src/app.rs | 22 + windows/src/shortcuts.rs | 2 + xtask/src/main.rs | 11 +- 30 files changed, 2012 insertions(+), 10 deletions(-) create mode 100644 ios/Acord.xcodeproj/project.pbxproj create mode 100644 ios/Acord.xcodeproj/project.xcworkspace/contents.xcworkspacedata create mode 100644 ios/Info.plist create mode 100644 ios/project.yml create mode 100644 ios/src/AcordApp.swift create mode 100644 ios/src/IcedViewportRepresentable.swift create mode 100644 ios/src/IcedViewportView.swift create mode 100755 scripts/ios/build.sh create mode 100755 scripts/ios/debug.sh create mode 100755 scripts/ios/install.sh create mode 100755 scripts/ios/xcodeproj.sh create mode 100644 viewport/src/print.rs create mode 100644 viewport/src/widgets/dialog.rs create mode 100644 viewport/src/widgets/menu.rs create mode 100644 viewport/src/widgets/mod.rs create mode 100644 viewport/src/widgets/style.rs diff --git a/core/src/interp.rs b/core/src/interp.rs index 1f404b4..40a2db0 100644 --- a/core/src/interp.rs +++ b/core/src/interp.rs @@ -1901,6 +1901,9 @@ impl Interpreter { return self.call_solved_fn(name, args, depth); } + // builtins are case-insensitive (spreadsheet convention: SUM, Sum, sum all work). + let canon = name.to_ascii_lowercase(); + let name = canon.as_str(); match name { "sin" | "cos" | "tan" | "asin" | "acos" | "atan" | "sqrt" | "abs" | "ln" | "log" => { @@ -3491,6 +3494,21 @@ fn find(arr, target) { assert!(matches!(v, Value::Number(n) if n == 30.0)); } + #[test] + fn builtin_function_name_is_case_insensitive() { + let mut i = Interpreter::new(); + i.register_table("t", vec![ + vec!["1".into(), "2".into()], + vec!["3".into(), "4".into()], + ]); + i.set_current_table(Some("t")); + for variant in ["sum(A1:B2)", "Sum(A1:B2)", "SUM(A1:B2)", "sUm(A1:B2)"] { + let f = parse_formula(variant).unwrap(); + let v = i.eval_formula(&f).unwrap(); + assert!(matches!(v, Value::Number(n) if n == 10.0), "variant {variant}"); + } + } + #[test] fn range_ref_returns_2d_array() { let mut i = Interpreter::new(); diff --git a/ios/Acord.xcodeproj/project.pbxproj b/ios/Acord.xcodeproj/project.pbxproj new file mode 100644 index 0000000..e9ac0a2 --- /dev/null +++ b/ios/Acord.xcodeproj/project.pbxproj @@ -0,0 +1,320 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 77; + objects = { + +/* Begin PBXBuildFile section */ + 52FAB9972C318EE0F234F494 /* IcedViewportView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4AF21EB9858D4F182A323DAA /* IcedViewportView.swift */; }; + 951153057A1C3373C77657DF /* AcordApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = E391F925E2727D13659EDA02 /* AcordApp.swift */; }; + F0306F25CA56D707AE2E27A0 /* IcedViewportRepresentable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 19D7F3B08824BF5E9E468EF2 /* IcedViewportRepresentable.swift */; }; +/* End PBXBuildFile section */ + +/* Begin PBXFileReference section */ + 19D7F3B08824BF5E9E468EF2 /* IcedViewportRepresentable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IcedViewportRepresentable.swift; sourceTree = ""; }; + 4AF21EB9858D4F182A323DAA /* IcedViewportView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IcedViewportView.swift; sourceTree = ""; }; + C3417095D399AB8B216B5139 /* Acord.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = Acord.app; sourceTree = BUILT_PRODUCTS_DIR; }; + E391F925E2727D13659EDA02 /* AcordApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AcordApp.swift; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXGroup section */ + 76C61BCA6D25861221CA6340 /* Products */ = { + isa = PBXGroup; + children = ( + C3417095D399AB8B216B5139 /* Acord.app */, + ); + name = Products; + sourceTree = ""; + }; + EC6EB229D9262E900A247F0A = { + isa = PBXGroup; + children = ( + FD6FF6B76E15E1EA3D0E676B /* src */, + 76C61BCA6D25861221CA6340 /* Products */, + ); + sourceTree = ""; + }; + FD6FF6B76E15E1EA3D0E676B /* src */ = { + isa = PBXGroup; + children = ( + E391F925E2727D13659EDA02 /* AcordApp.swift */, + 19D7F3B08824BF5E9E468EF2 /* IcedViewportRepresentable.swift */, + 4AF21EB9858D4F182A323DAA /* IcedViewportView.swift */, + ); + path = src; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + BE9963B5332D44E1A854C113 /* Acord */ = { + isa = PBXNativeTarget; + buildConfigurationList = 3E9D564CF91C62A230D65F8C /* Build configuration list for PBXNativeTarget "Acord" */; + buildPhases = ( + BEED27C37617469B917539A1 /* Sources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = Acord; + packageProductDependencies = ( + ); + productName = Acord; + productReference = C3417095D399AB8B216B5139 /* Acord.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 95AA9E1332B7B3FDB04625FA /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastUpgradeCheck = 1430; + TargetAttributes = { + BE9963B5332D44E1A854C113 = { + DevelopmentTeam = Z9CXT3ZVLD; + ProvisioningStyle = Automatic; + }; + }; + }; + buildConfigurationList = 261D70D5A0F2EFA858BAC36B /* Build configuration list for PBXProject "Acord" */; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + Base, + en, + ); + mainGroup = EC6EB229D9262E900A247F0A; + minimizedProjectReferenceProxies = 1; + preferredProjectObjectVersion = 77; + productRefGroup = 76C61BCA6D25861221CA6340 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + BE9963B5332D44E1A854C113 /* Acord */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXSourcesBuildPhase section */ + BEED27C37617469B917539A1 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 951153057A1C3373C77657DF /* AcordApp.swift in Sources */, + F0306F25CA56D707AE2E27A0 /* IcedViewportRepresentable.swift in Sources */, + 52FAB9972C318EE0F234F494 /* IcedViewportView.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin XCBuildConfiguration section */ + 080B2F33A2C01A572AC58876 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_IDENTITY = "iPhone Developer"; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; + B3D8DA79CBDF75AC06C89531 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + CODE_SIGN_STYLE = Automatic; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + DEVELOPMENT_TEAM = Z9CXT3ZVLD; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + "EXCLUDED_ARCHS[sdk=iphonesimulator*]" = x86_64; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "$(inherited)", + "DEBUG=1", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + LIBRARY_SEARCH_PATHS = ( + "$(PROJECT_DIR)/../target/$(SWIFT_PLATFORM_TARGET_PREFIX)/release", + ); + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + OTHER_LDFLAGS = ( + "-lacord_viewport", + ); + PRODUCT_BUNDLE_IDENTIFIER = "org.else-if.acord"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OBJC_BRIDGING_HEADER = ../viewport/include/acord.h; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "2,1"; + }; + name = Debug; + }; + BD94F4C316032DE882EDD91D /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + CODE_SIGN_STYLE = Automatic; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + DEVELOPMENT_TEAM = Z9CXT3ZVLD; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + "EXCLUDED_ARCHS[sdk=iphonesimulator*]" = x86_64; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + LIBRARY_SEARCH_PATHS = ( + "$(PROJECT_DIR)/../target/$(SWIFT_PLATFORM_TARGET_PREFIX)/release", + ); + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + OTHER_LDFLAGS = ( + "-lacord_viewport", + ); + PRODUCT_BUNDLE_IDENTIFIER = "org.else-if.acord"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OBJC_BRIDGING_HEADER = ../viewport/include/acord.h; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "2,1"; + }; + name = Release; + }; + DE73E63D55AA5210B9A4B0A6 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_IDENTITY = "iPhone Developer"; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 261D70D5A0F2EFA858BAC36B /* Build configuration list for PBXProject "Acord" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + B3D8DA79CBDF75AC06C89531 /* Debug */, + BD94F4C316032DE882EDD91D /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; + 3E9D564CF91C62A230D65F8C /* Build configuration list for PBXNativeTarget "Acord" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + DE73E63D55AA5210B9A4B0A6 /* Debug */, + 080B2F33A2C01A572AC58876 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; +/* End XCConfigurationList section */ + }; + rootObject = 95AA9E1332B7B3FDB04625FA /* Project object */; +} diff --git a/ios/Acord.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/ios/Acord.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/ios/Acord.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/ios/Info.plist b/ios/Info.plist new file mode 100644 index 0000000..f79f39d --- /dev/null +++ b/ios/Info.plist @@ -0,0 +1,64 @@ + + + + + CFBundleExecutable + Acord + CFBundleIdentifier + org.else-if.acord + CFBundleName + Acord + CFBundleDisplayName + Acord + CFBundlePackageType + APPL + CFBundleVersion + 0.1.0 + CFBundleShortVersionString + 0.1.0 + CFBundleSupportedPlatforms + + iPhoneOS + iPhoneSimulator + + MinimumOSVersion + 17.0 + UIDeviceFamily + + 2 + 1 + + UILaunchScreen + + UIColorName + + + UIRequiredDeviceCapabilities + + arm64 + metal + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + + LSSupportsOpeningDocumentsInPlace + + UIFileSharingEnabled + + + diff --git a/ios/project.yml b/ios/project.yml new file mode 100644 index 0000000..01c6efc --- /dev/null +++ b/ios/project.yml @@ -0,0 +1,37 @@ +name: Acord +options: + bundleIdPrefix: org.else-if + deploymentTarget: + iOS: "17.0" + developmentLanguage: en + groupSortPosition: top + generateEmptyDirectories: true + +settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: org.else-if.acord + DEVELOPMENT_TEAM: Z9CXT3ZVLD + CODE_SIGN_STYLE: Automatic + SWIFT_VERSION: 5.0 + 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 + OTHER_LDFLAGS: + - -lacord_viewport + EXCLUDED_ARCHS[sdk=iphonesimulator*]: x86_64 + INFOPLIST_FILE: Info.plist + GENERATE_INFOPLIST_FILE: NO + +targets: + Acord: + type: application + platform: iOS + sources: + - path: src + settings: + base: + INFOPLIST_FILE: Info.plist + GENERATE_INFOPLIST_FILE: NO + dependencies: [] diff --git a/ios/src/AcordApp.swift b/ios/src/AcordApp.swift new file mode 100644 index 0000000..5c7cee1 --- /dev/null +++ b/ios/src/AcordApp.swift @@ -0,0 +1,18 @@ +import SwiftUI + +@main +struct AcordApp: App { + var body: some Scene { + WindowGroup { + ContentView() + .ignoresSafeArea(.keyboard) + } + } +} + +struct ContentView: View { + var body: some View { + IcedViewportRepresentable() + .ignoresSafeArea() + } +} diff --git a/ios/src/IcedViewportRepresentable.swift b/ios/src/IcedViewportRepresentable.swift new file mode 100644 index 0000000..1da425e --- /dev/null +++ b/ios/src/IcedViewportRepresentable.swift @@ -0,0 +1,13 @@ +import SwiftUI +import UIKit + +/// SwiftUI wrapper around the UIView that hosts the Rust viewport. +struct IcedViewportRepresentable: UIViewRepresentable { + func makeUIView(context: Context) -> IcedViewportView { + IcedViewportView(frame: .zero) + } + + func updateUIView(_ uiView: IcedViewportView, context: Context) { + // size pushed via setFrameSize; nothing to refresh per SwiftUI tick. + } +} diff --git a/ios/src/IcedViewportView.swift b/ios/src/IcedViewportView.swift new file mode 100644 index 0000000..1fa18e0 --- /dev/null +++ b/ios/src/IcedViewportView.swift @@ -0,0 +1,167 @@ +import UIKit +import QuartzCore + +/// CAMetalLayer-backed UIView that owns the Rust viewport handle and pumps +/// CADisplayLink ticks into `viewport_render`. +class IcedViewportView: UIView { + override class var layerClass: AnyClass { CAMetalLayer.self } + + private(set) var viewportHandle: OpaquePointer? + private var displayLink: CADisplayLink? + private var isTornDown = false + private var cachedText: String = "" + + override init(frame: CGRect) { + super.init(frame: frame) + commonInit() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + commonInit() + } + + private func commonInit() { + backgroundColor = .black + isMultipleTouchEnabled = false + if let metalLayer = layer as? CAMetalLayer { + metalLayer.contentsScale = UIScreen.main.scale + metalLayer.framebufferOnly = true + metalLayer.pixelFormat = .bgra8Unorm + metalLayer.isOpaque = true + } + } + + override func didMoveToWindow() { + super.didMoveToWindow() + if window != nil && viewportHandle == nil && !isTornDown { + createViewport() + startDisplayLink() + becomeFirstResponder() + } else if window == nil { + teardown() + } + } + + override var canBecomeFirstResponder: Bool { true } + + private func createViewport() { + let scale = Float(window?.screen.scale ?? UIScreen.main.scale) + let w = Float(bounds.width) + let h = Float(bounds.height) + let viewPtr = Unmanaged.passUnretained(self).toOpaque() + viewportHandle = viewport_create(viewPtr, w, h, scale) + } + + private func destroyViewport() { + guard let handle = viewportHandle else { return } + viewportHandle = nil + viewport_destroy(handle) + } + + func teardown() { + if isTornDown { return } + isTornDown = true + stopDisplayLink() + if let h = viewportHandle, let cstr = viewport_get_text(h) { + cachedText = String(cString: cstr) + viewport_free_string(cstr) + } + destroyViewport() + } + + deinit { teardown() } + + // MARK: - Display link + + private func startDisplayLink() { + guard displayLink == nil else { return } + let link = CADisplayLink(target: self, selector: #selector(renderFrame)) + link.add(to: .main, forMode: .common) + displayLink = link + } + + private func stopDisplayLink() { + displayLink?.invalidate() + displayLink = nil + } + + @objc private func renderFrame() { + if isTornDown { return } + guard let handle = viewportHandle else { return } + viewport_render(handle) + } + + // MARK: - Resize + + override func layoutSubviews() { + super.layoutSubviews() + let scale = Float(window?.screen.scale ?? UIScreen.main.scale) + if let metalLayer = layer as? CAMetalLayer { + metalLayer.contentsScale = CGFloat(scale) + metalLayer.drawableSize = CGSize( + width: bounds.width * CGFloat(scale), + height: bounds.height * CGFloat(scale) + ) + } + guard let handle = viewportHandle else { return } + viewport_resize(handle, Float(bounds.width), Float(bounds.height), scale) + } + + // MARK: - Touches + + override func touchesBegan(_ touches: Set, with event: UIEvent?) { + guard let h = viewportHandle, let touch = touches.first else { return } + let p = touch.location(in: self) + viewport_mouse_event(h, Float(p.x), Float(p.y), 0, true) + becomeFirstResponder() + } + + override func touchesMoved(_ touches: Set, with event: UIEvent?) { + guard let h = viewportHandle, let touch = touches.first else { return } + let p = touch.location(in: self) + viewport_mouse_event(h, Float(p.x), Float(p.y), 255, false) + } + + override func touchesEnded(_ touches: Set, with event: UIEvent?) { + guard let h = viewportHandle, let touch = touches.first else { return } + let p = touch.location(in: self) + 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) + viewport_mouse_event(h, Float(p.x), Float(p.y), 0, false) + } + + // MARK: - Hardware keyboard (Magic Keyboard / Smart Keyboard) + + override func pressesBegan(_ presses: Set, with event: UIPressesEvent?) { + guard let h = viewportHandle else { + super.pressesBegan(presses, with: event) + return + } + for press in presses { + forwardKey(press, pressed: true, handle: h) + } + } + + override func pressesEnded(_ presses: Set, with event: UIPressesEvent?) { + guard let h = viewportHandle else { + super.pressesEnded(presses, with: event) + return + } + for press in presses { + forwardKey(press, pressed: false, handle: h) + } + } + + private func forwardKey(_ press: UIPress, pressed: Bool, handle: OpaquePointer) { + guard let key = press.key else { return } + let chars = pressed ? key.characters : "" + chars.withCString { cstr in + viewport_key_event(handle, UInt32(key.keyCode.rawValue), UInt32(key.modifierFlags.rawValue), pressed, cstr) + } + } +} diff --git a/linux/src/app.rs b/linux/src/app.rs index 4ec9b5b..314bb17 100644 --- a/linux/src/app.rs +++ b/linux/src/app.rs @@ -16,6 +16,7 @@ use acord_viewport::{ viewport_set_auto_pair_flags, viewport_send_command, viewport_free_string, viewport_take_sidecar_bytes, viewport_apply_sidecar_bytes, viewport_free_bytes, + viewport_render_pdf, ViewportHandle, }; use acord_viewport::sidecar; @@ -115,6 +116,7 @@ impl App { } } MenuAction::ExportCrate => { /* TODO: wire crate export */ } + MenuAction::Print => self.print_to_pdf(), MenuAction::ToggleBrowser => self.toggle_browser(event_loop), } } @@ -182,6 +184,7 @@ impl App { ShellAction::Quit => event_loop.exit(), ShellAction::Settings => {} ShellAction::ExportCrate => self.dispatch_menu(MenuAction::ExportCrate, event_loop), + ShellAction::Print => self.print_to_pdf(), ShellAction::ToggleBrowser => self.toggle_browser(event_loop), ShellAction::SetThemeMode(v) => { self.config.set("themeMode", &v); @@ -454,6 +457,25 @@ impl App { } } + /// renders the document to a PDF chosen via save dialog. + fn print_to_pdf(&mut self) { + if self.handle.is_null() { return; } + let title = self.derive_default_filename(); + let title_c = CString::new(title.clone()).unwrap_or_default(); + let mut len: usize = 0; + let ptr = viewport_render_pdf(self.handle, title_c.as_ptr(), &mut len as *mut usize); + if ptr.is_null() || len == 0 { return; } + let bytes = unsafe { std::slice::from_raw_parts(ptr, len) }.to_vec(); + viewport_free_bytes(ptr, len); + + let dialog = rfd::FileDialog::new() + .add_filter("PDF", &["pdf"]) + .set_file_name(format!("{}.pdf", title)); + if let Some(path) = dialog.save_file() { + let _ = std::fs::write(&path, &bytes); + } + } + fn derive_default_filename(&self) -> String { let text_ptr = viewport_get_text(self.handle); let text = if text_ptr.is_null() { diff --git a/linux/src/shortcuts.rs b/linux/src/shortcuts.rs index ca2fb5a..667d542 100644 --- a/linux/src/shortcuts.rs +++ b/linux/src/shortcuts.rs @@ -24,6 +24,7 @@ pub enum MenuAction { Find, Settings, ExportCrate, + Print, ToggleBrowser, } @@ -60,6 +61,7 @@ pub fn match_shortcut(modifiers: ModifiersState, key: &Key) -> Option Some(MenuAction::Find), (false, 'e') => Some(MenuAction::Evaluate), (true, 'e') => Some(MenuAction::ExportCrate), + (false, 'p') => Some(MenuAction::Print), (false, ',') => Some(MenuAction::Settings), (false, '=') | (false, '+') => Some(MenuAction::ZoomIn), (false, '-') => Some(MenuAction::ZoomOut), diff --git a/macos/src/AppDelegate.swift b/macos/src/AppDelegate.swift index 09e5ae1..bcaf24a 100644 --- a/macos/src/AppDelegate.swift +++ b/macos/src/AppDelegate.swift @@ -225,6 +225,10 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSMenuItemValidation { exportCrateItem.target = self menu.addItem(exportCrateItem) + let printItem = NSMenuItem(title: "Print...", action: #selector(printNote), keyEquivalent: "p") + printItem.target = self + menu.addItem(printItem) + menu.addItem(.separator()) let openStorageItem = NSMenuItem(title: "Open Storage Directory", action: #selector(openStorageDirectory), keyEquivalent: "") @@ -462,6 +466,43 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSMenuItemValidation { } } + /// renders the document to a PDF chosen via save dialog and opens it for printing. + @objc private func printNote() { + guard let w = window, let vp = w.contentView as? IcedViewportView, + let handle = vp.viewportHandle else { return } + syncTextFromViewport() + + let title = appState.currentFileURL?.deletingPathExtension().lastPathComponent ?? "Acord Document" + var len: UInt = 0 + guard let ptr = title.withCString({ t in viewport_render_pdf(handle, t, &len) }), len > 0 else { + let alert = NSAlert() + alert.messageText = "Print failed" + alert.informativeText = "Could not render this document to PDF." + alert.runModal() + return + } + let data = Data(bytes: ptr, count: Int(len)) + viewport_free_bytes(ptr, len) + + let panel = NSSavePanel() + panel.title = "Print to PDF" + panel.prompt = "Save" + panel.allowedContentTypes = [.pdf] + panel.nameFieldStringValue = "\(title).pdf" + panel.beginSheetModal(for: w) { response in + guard response == .OK, let url = panel.url else { return } + do { + try data.write(to: url) + NSWorkspace.shared.open(url) + } catch { + let alert = NSAlert() + alert.messageText = "Print failed" + alert.informativeText = error.localizedDescription + alert.runModal() + } + } + } + @objc private func exportCrate() { syncTextFromViewport() guard let w = window, let vp = w.contentView as? IcedViewportView, diff --git a/scripts/ios/build.sh b/scripts/ios/build.sh new file mode 100755 index 0000000..a51308b --- /dev/null +++ b/scripts/ios/build.sh @@ -0,0 +1,145 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +cd "$ROOT" + +case "$(uname -s)" in + Darwin) ;; + *) echo "wrong platform: $(uname -s) — iOS build requires macOS" >&2; exit 1;; +esac + +# Default to simulator; override with `bash scripts/ios/build.sh device`. +TARGET="${1:-sim}" + +case "$TARGET" in + sim) + RUST_TARGET="aarch64-apple-ios-sim" + SDK_NAME="iphonesimulator" + SWIFT_TARGET="arm64-apple-ios17.0-simulator" + ;; + device) + RUST_TARGET="aarch64-apple-ios" + SDK_NAME="iphoneos" + SWIFT_TARGET="arm64-apple-ios17.0" + ;; + *) + echo "usage: $0 [sim|device]" >&2 + exit 2 + ;; +esac + +BUILD="$ROOT/build" +APP="$BUILD/ios/Acord.app" +RUST_LIB="$ROOT/target/$RUST_TARGET/release" + +SDK="$(xcrun --sdk "$SDK_NAME" --show-sdk-path)" + +# the user has esp-clang on PATH; fall through to apple's so cc-rs picks the right one. +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 + +if [ ! -f "$RUST_LIB/libacord_viewport.a" ]; then + echo "ERROR: libacord_viewport.a not found at $RUST_LIB" >&2 + exit 1 +fi + +# build app bundle (iOS bundles are flat — Info.plist, executable and resources live at the root) +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 +fi + +RUST_FLAGS=(-import-objc-header "$ROOT/viewport/include/acord.h" -L "$RUST_LIB" -lacord_viewport) + +echo "Compiling Swift (release)..." +xcrun -sdk "$SDK_NAME" swiftc \ + -target "$SWIFT_TARGET" \ + -sdk "$SDK" \ + "${RUST_FLAGS[@]}" \ + -framework UIKit \ + -framework SwiftUI \ + -framework QuartzCore \ + -framework Metal \ + -framework MetalKit \ + -framework CoreGraphics \ + -framework CoreFoundation \ + -O \ + -o "$APP/Acord" \ + "$ROOT"/ios/src/*.swift + +if [ "$TARGET" = "sim" ]; then + codesign --force --sign - "$APP" +else + # device build: embed provisioning profile + sign with a real identity. + PROFILE="${ACORD_IOS_PROFILE:-$HOME/Downloads/All.mobileprovision}" + if [ ! -f "$PROFILE" ]; then + echo "ERROR: provisioning profile not found at $PROFILE" >&2 + echo " set ACORD_IOS_PROFILE to point at a valid .mobileprovision" >&2 + exit 1 + fi + cp "$PROFILE" "$APP/embedded.mobileprovision" + + ENT="$BUILD/ios/entitlements.plist" + security cms -D -i "$PROFILE" 2>/dev/null \ + | plutil -extract Entitlements xml1 -o "$ENT" - \ + || { echo "ERROR: could not extract entitlements from profile" >&2; exit 1; } + + # find a codesigning identity that's in the profile's DeveloperCertificates list. + # we pull each cert's SHA1 out of the profile, then pick whichever one find-identity + # also lists as valid in the keychain. fail loudly if none match. + TMPDIR_PROF="$(mktemp -d)" + PROFILE_PLIST="$TMPDIR_PROF/profile.plist" + security cms -D -i "$PROFILE" > "$PROFILE_PLIST" 2>/dev/null + + PROFILE_SHAS="" + for i in 0 1 2 3 4 5 6 7 8 9; do + if ! plutil -extract "DeveloperCertificates.$i" raw -o "$TMPDIR_PROF/c$i.b64" "$PROFILE_PLIST" >/dev/null 2>&1; then + break + fi + base64 -D -i "$TMPDIR_PROF/c$i.b64" -o "$TMPDIR_PROF/c$i.cer" + sha=$(openssl x509 -inform der -in "$TMPDIR_PROF/c$i.cer" -fingerprint -noout 2>/dev/null \ + | sed 's/.*=//;s/://g') + PROFILE_SHAS="$PROFILE_SHAS $sha" + done + + KEYCHAIN_SHAS=$(security find-identity -v 2>/dev/null \ + | awk '/[0-9A-F]{40}/ {gsub(/[^0-9A-F]/, "", $2); print $2}') + + IDENTITY="" + for s in $PROFILE_SHAS; do + if echo "$KEYCHAIN_SHAS" | grep -qi "^$s$"; then + IDENTITY="$s" + break + fi + done + rm -rf "$TMPDIR_PROF" + + if [ -z "$IDENTITY" ]; then + echo "ERROR: no codesigning identity in your keychain matches any cert in the profile" >&2 + echo " profile certs:$PROFILE_SHAS" >&2 + exit 1 + fi + + echo "Signing with $IDENTITY..." + codesign --force \ + --sign "$IDENTITY" \ + --entitlements "$ENT" \ + --options runtime \ + --timestamp=none \ + "$APP" +fi + +echo "Built: $APP" diff --git a/scripts/ios/debug.sh b/scripts/ios/debug.sh new file mode 100755 index 0000000..19c0a0b --- /dev/null +++ b/scripts/ios/debug.sh @@ -0,0 +1,11 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +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 diff --git a/scripts/ios/install.sh b/scripts/ios/install.sh new file mode 100755 index 0000000..99a6d6c --- /dev/null +++ b/scripts/ios/install.sh @@ -0,0 +1,66 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +cd "$ROOT" + +# pick the deploy target: explicit arg, or auto-detect (paired physical device wins). +TARGET="${1:-}" +if [ -z "$TARGET" ]; then + if xcrun devicectl list devices 2>/dev/null | grep -q "available (paired)"; then + TARGET="device" + else + TARGET="sim" + fi +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)" + if [ -z "$DEV" ]; then + DEV="$(xcrun simctl list devices available | awk '/iPad/ && /\([A-F0-9\-]+\)/ {gsub(/[\(\)]/,"",$NF); print $NF; exit}')" + if [ -z "$DEV" ]; then + echo "no iPad simulator available — open Xcode → Window → Devices and Simulators to add one" >&2 + exit 1 + fi + 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" + ;; + + device) + bash "$ROOT/scripts/ios/build.sh" device + APP="$ROOT/build/ios/Acord.app" + + DEVICE_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}}')" + fi + + if [ -z "$DEVICE_ID" ]; then + echo "no paired device found — connect via cable and trust this Mac on the device" >&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 + ;; + + *) + echo "usage: $0 [sim|device]" >&2 + exit 2 + ;; +esac diff --git a/scripts/ios/xcodeproj.sh b/scripts/ios/xcodeproj.sh new file mode 100755 index 0000000..221716f --- /dev/null +++ b/scripts/ios/xcodeproj.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +cd "$ROOT" + +if ! command -v xcodegen >/dev/null 2>&1; then + echo "xcodegen not found. install with:" >&2 + echo " brew install xcodegen" >&2 + exit 1 +fi + +# the user has esp-clang on PATH; make sure cc-rs picks apple's. +export CC=/usr/bin/clang +export CXX=/usr/bin/clang++ +export IPHONEOS_DEPLOYMENT_TARGET=17.0 + +# build the staticlibs xcode will link against — both arches so xcode can target either. +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 + +cd "$ROOT/ios" +echo "Generating Acord.xcodeproj..." +xcodegen generate + +echo +echo "Generated: $ROOT/ios/Acord.xcodeproj" +echo "Open with: open $ROOT/ios/Acord.xcodeproj" +echo +echo "Build/run from xcode: pick a destination (your iPad or a sim) and hit ⌘R." +echo "If you change Rust code, re-run this command to rebuild the staticlibs." diff --git a/viewport/Cargo.toml b/viewport/Cargo.toml index 52a6dd5..9c225cf 100644 --- a/viewport/Cargo.toml +++ b/viewport/Cargo.toml @@ -24,10 +24,13 @@ serde_json = "1" toml = "0.8" zip = { version = "2", default-features = false, features = ["deflate"] } base64 = "0.22" -arboard = "3" ureq = "3" -trash = "5" filetime = "0.2" +printpdf = { version = "0.9", default-features = false } + +[target.'cfg(not(target_os = "ios"))'.dependencies] +arboard = "3" +trash = "5" [build-dependencies] cbindgen = "0.29" diff --git a/viewport/include/acord.h b/viewport/include/acord.h index 971c552..8ca16ff 100644 --- a/viewport/include/acord.h +++ b/viewport/include/acord.h @@ -101,6 +101,13 @@ void viewport_apply_sidecar_bytes(struct ViewportHandle *handle, */ void viewport_free_bytes(uint8_t *ptr, uintptr_t len); +/** + * renders the current document as a printable PDF; returns owned bytes the shell must free with `viewport_free_bytes`. + */ +uint8_t *viewport_render_pdf(struct ViewportHandle *handle, + const char *title, + uintptr_t *out_len); + void viewport_set_theme(struct ViewportHandle *handle, const char *name); void viewport_set_line_indicator(struct ViewportHandle *handle, const char *mode); diff --git a/viewport/src/browser/model.rs b/viewport/src/browser/model.rs index ee6a268..45124a0 100644 --- a/viewport/src/browser/model.rs +++ b/viewport/src/browser/model.rs @@ -288,10 +288,16 @@ pub fn trash(item_path: &Path) -> std::io::Result<()> { } } +#[cfg(not(target_os = "ios"))] fn trash_crate_remove(path: &Path) -> Result<(), Box> { trash::delete(path).map_err(|e| Box::new(e) as Box) } +#[cfg(target_os = "ios")] +fn trash_crate_remove(_path: &Path) -> Result<(), Box> { + Err("trash crate not available on iOS; falling back to permanent delete".into()) +} + pub fn path_segments(current: &Path, root: &Path) -> Vec<(String, PathBuf)> { let mut segments: Vec<(String, PathBuf)> = Vec::new(); let mut p = current.to_path_buf(); diff --git a/viewport/src/editor.rs b/viewport/src/editor.rs index dec08b0..fae94aa 100644 --- a/viewport/src/editor.rs +++ b/viewport/src/editor.rs @@ -195,6 +195,7 @@ pub enum ShellAction { Quit, Settings, ExportCrate, + Print, ToggleBrowser, SetThemeMode(String), SetLineIndicator(String), @@ -853,6 +854,57 @@ impl EditorState { .and_then(|b| b.as_any_mut().downcast_mut::()) } + /// returns the layout index of a block id, if any. + fn index_of_block_id(&self, bid: crate::selection::BlockId) -> Option { + self.layout.iter().position(|&id| id == bid) + } + + /// fills the first empty `[]` slot in the editing cell's formula with the clicked cell's address; returns true when the click was consumed. + fn try_fill_formula_slot( + &mut self, + click_block_idx: usize, + click_row: usize, + click_col: usize, + ) -> bool { + let editing = match self.editing.clone() { + Some(e) => e, + None => return false, + }; + let (er, ec) = match editing.inner { + crate::selection::InnerPath::Cell { row, col } => (row, col), + _ => return false, + }; + let editing_idx = match self.index_of_block_id(editing.block_id) { + Some(i) => i, + None => return false, + }; + if click_block_idx == editing_idx && click_row == er && click_col == ec { + return false; + } + let cell_text = match self.table_block_at(editing_idx) { + Some(tb) => tb.rows.get(er).and_then(|row| row.get(ec)).cloned().unwrap_or_default(), + None => return false, + }; + if !cell_text.trim_start().starts_with("/=") { + return false; + } + let addr = format!("{}{}", table_block::column_letter(click_col), click_row + 1); + let new_text = match splice_first_empty_slot(&cell_text, &addr) { + Some(t) => t, + None => return false, + }; + if let Some(tb) = self.table_block_at_mut(editing_idx) { + if let Some(row) = tb.rows.get_mut(er) { + if let Some(cell) = row.get_mut(ec) { + *cell = new_text; + } + } + } + self.eval_dirty = true; + self.last_edit = Instant::now(); + true + } + fn first_text_block_index(&self) -> Option { self.layout.iter().enumerate().find_map(|(i, id)| { self.registry.get(id).and_then(|b| { @@ -1107,6 +1159,11 @@ impl EditorState { self.get_clean_text() } + /// walks the live blocks in document order. + pub fn iter_blocks(&self) -> impl Iterator { + self.layout.iter().filter_map(move |id| self.registry.get(id)) + } + /// returns the archive zip bytes the shell should embed for in-library .md files. pub fn save_sidecar_bytes(&mut self) -> Option> { self.rebuild_modules(); @@ -2834,6 +2891,15 @@ impl EditorState { } else { None }; + + // formula-fill interception: clicking a different cell while + // editing a `/=` formula fills its first empty `[]` slot with + // the clicked cell's address instead of switching focus. + if let Some((r, c)) = select_target { + if self.try_fill_formula_slot(idx, r, c) { + return; + } + } let edit_target = if let TableMessage::EditCell(r, c) = &tmsg { Some((*r, *c)) } else { @@ -4148,6 +4214,7 @@ impl EditorState { item("Save As...", "Ctrl+Shift+S", Message::Shell(ShellAction::SaveAs)), sep(), item("Export as Rust Library", "Ctrl+Shift+E", Message::Shell(ShellAction::ExportCrate)), + item("Print...", "Ctrl+P", Message::Shell(ShellAction::Print)), sep(), item("Settings...", "Ctrl+,", Message::Shell(ShellAction::Settings)), item("Quit", "Ctrl+Q", Message::Shell(ShellAction::Quit)), @@ -4562,6 +4629,27 @@ fn parse_let_binding(line: &str) -> Option { Some(name.to_string()) } +/// finds the first empty `[]` (whitespace allowed inside) and replaces it with `[]`. +fn splice_first_empty_slot(text: &str, addr: &str) -> Option { + let bytes = text.as_bytes(); + let mut i = 0; + while i < bytes.len() { + if bytes[i] == b'[' { + let mut j = i + 1; + while j < bytes.len() && bytes[j].is_ascii_whitespace() { j += 1; } + if j < bytes.len() && bytes[j] == b']' { + let mut out = String::with_capacity(text.len() + addr.len()); + out.push_str(&text[..i + 1]); + out.push_str(addr); + out.push_str(&text[j..]); + return Some(out); + } + } + i += 1; + } + None +} + fn macos_key_binding(key_press: KeyPress) -> Option> { let KeyPress { key, modifiers, status, .. } = &key_press; @@ -4809,6 +4897,7 @@ fn load_image_from_path(src: &str) -> Option { } /// encodes a clipboard image to PNG and writes it into the on-disk cache +#[cfg(not(target_os = "ios"))] pub fn write_clipboard_image_to_cache(img: &arboard::ImageData) -> Option { let dir = dirs::home_dir()?.join(".acord").join("cache").join("images"); std::fs::create_dir_all(&dir).ok()?; diff --git a/viewport/src/handle.rs b/viewport/src/handle.rs index 43ce8e9..33568d1 100644 --- a/viewport/src/handle.rs +++ b/viewport/src/handle.rs @@ -10,6 +10,8 @@ use iced_wgpu::Engine; use raw_window_handle::{RawDisplayHandle, RawWindowHandle}; #[cfg(target_os = "macos")] use raw_window_handle::{AppKitDisplayHandle, AppKitWindowHandle}; +#[cfg(target_os = "ios")] +use raw_window_handle::{UiKitDisplayHandle, UiKitWindowHandle}; #[cfg(target_os = "windows")] use raw_window_handle::{Win32WindowHandle, WindowsDisplayHandle}; @@ -18,10 +20,12 @@ use crate::palette; use crate::table_block::TableMessage; use crate::ViewportHandle; +#[cfg(not(target_os = "ios"))] struct AcordClipboard { board: std::cell::RefCell, } +#[cfg(not(target_os = "ios"))] impl clipboard::Clipboard for AcordClipboard { fn read(&self, _kind: clipboard::Kind) -> Option { // arboard uses NSPasteboard on macOS, Win32 on Windows — no subprocess. @@ -48,6 +52,17 @@ impl clipboard::Clipboard for AcordClipboard { } } +/// iOS stub — UIPasteboard wiring lives on the Swift side, fed through the +/// FFI shell-action bus when the user explicitly copies/pastes. +#[cfg(target_os = "ios")] +struct AcordClipboard; + +#[cfg(target_os = "ios")] +impl clipboard::Clipboard for AcordClipboard { + fn read(&self, _kind: clipboard::Kind) -> Option { None } + fn write(&mut self, _kind: clipboard::Kind, _contents: String) {} +} + /// Mac/Windows entry point used by the C FFI. Synthesizes the platform's /// display handle from the window pointer the Swift bridge provides. /// Returns None on platforms that need both display and window — those @@ -65,6 +80,11 @@ pub fn create( RawWindowHandle::AppKit(AppKitWindowHandle::new(ptr)), RawDisplayHandle::AppKit(AppKitDisplayHandle::new()), ); + #[cfg(target_os = "ios")] + let (raw_window, raw_display) = ( + RawWindowHandle::UiKit(UiKitWindowHandle::new(ptr)), + RawDisplayHandle::UiKit(UiKitDisplayHandle::new()), + ); #[cfg(target_os = "windows")] let (raw_window, raw_display) = { let wh = Win32WindowHandle::new(std::num::NonZero::new(ptr.as_ptr() as isize).unwrap()); @@ -73,13 +93,13 @@ pub fn create( RawDisplayHandle::Windows(WindowsDisplayHandle::new()), ) }; - #[cfg(not(any(target_os = "macos", target_os = "windows")))] + #[cfg(not(any(target_os = "macos", target_os = "ios", target_os = "windows")))] { let _ = (ptr, width, height, scale); return None; } - #[cfg(any(target_os = "macos", target_os = "windows"))] + #[cfg(any(target_os = "macos", target_os = "ios", target_os = "windows"))] create_native(raw_display, raw_window, width, height, scale) } @@ -217,9 +237,12 @@ pub fn render(handle: &mut ViewportHandle) { &mut handle.renderer, ); + #[cfg(not(target_os = "ios"))] let mut clipboard = AcordClipboard { board: std::cell::RefCell::new(arboard::Clipboard::new().unwrap()), }; + #[cfg(target_os = "ios")] + let mut clipboard = AcordClipboard; let mut messages: Vec = Vec::new(); let mut consumed: Vec = Vec::new(); // Captured during the event scan, applied to `handle.state.mods` AFTER @@ -728,11 +751,14 @@ pub fn render(handle: &mut ViewportHandle) { } // Drain any clipboard write the editor queued during update/tick. + #[cfg(not(target_os = "ios"))] if let Some(text) = handle.state.pending_clipboard.take() { if let Ok(mut board) = arboard::Clipboard::new() { let _ = board.set_text(text); } } + #[cfg(target_os = "ios")] + let _ = handle.state.pending_clipboard.take(); handle.state.tick(); let pending_focus = handle.state.take_pending_focus(); diff --git a/viewport/src/lib.rs b/viewport/src/lib.rs index 1466e94..4e90124 100644 --- a/viewport/src/lib.rs +++ b/viewport/src/lib.rs @@ -1,8 +1,27 @@ +//! Acord viewport — the iced+wgpu editor surface and its supporting widgets. +//! +//! ## Reusing pieces in other apps +//! +//! - [`text_widget`] — the per-line `fill_paragraph` compositor. Can host any +//! iced `Element` inline with text. +//! - [`widgets::menu`] — generic-Message menu strip + dropdown panel. +//! - [`widgets::dialog`] — modal overlay + segmented-row patterns. +//! - [`widgets::style`] — iced style functions matching Acord's chrome. +//! - [`browser`] — the file-grid document browser. +//! - [`palette`] — the global theme palette ([`set_palette_theme`] swaps it). +//! - [`syntax`] — tree-sitter highlighter producing iced `Span` colors. +//! - [`oklab`] — OKLab colour-space utilities used for tone math. +//! - [`bridge`] — FFI helpers translating native key/mouse codes to iced events. +//! +//! The full editor lives in [`editor::EditorState`]; lower-level block kinds +//! (text, table, heading, tree, hr) live in their own modules and implement +//! the [`block::Block`] trait. + use std::ffi::{c_char, c_void, CStr, CString}; pub mod block; pub mod blocks; -mod bridge; +pub mod bridge; pub mod browser; pub mod editor; pub mod export; @@ -12,6 +31,7 @@ pub mod hr_block; pub mod module; pub mod oklab; pub mod palette; +pub mod print; pub mod selection; pub mod sidecar; pub mod syntax; @@ -19,10 +39,18 @@ pub mod table_block; pub mod text_block; pub mod text_widget; pub mod tree_block; +pub mod widgets; pub use acord_core::*; -use editor::EditorState; +// curated re-exports for downstream apps that want to pull individual pieces. +pub use crate::block::{Block, BlockCommand, LayeredView, ViewCtx}; +pub use crate::editor::{EditorState, Message, RenderMode, ShellAction}; +pub use crate::palette::{Palette, current as current_palette, set_theme as set_palette_theme}; +pub use crate::selection::{BlockId, InnerPath, NodePath, Selection, TextPos}; +pub use crate::syntax::{SyntaxHighlight, SyntaxHighlighter, SyntaxSettings, EDITOR_FONT}; +pub use crate::text_widget::{Content, TextEditor}; + use iced_graphics::Viewport; use iced_runtime::user_interface; use iced_wgpu::core::Event; @@ -303,6 +331,34 @@ pub extern "C" fn viewport_free_bytes(ptr: *mut u8, len: usize) { unsafe { drop(Box::from_raw(std::slice::from_raw_parts_mut(ptr, len))); } } +/// renders the current document as a printable PDF; returns owned bytes the shell must free with `viewport_free_bytes`. +#[unsafe(no_mangle)] +pub extern "C" fn viewport_render_pdf( + handle: *mut ViewportHandle, + title: *const c_char, + out_len: *mut usize, +) -> *mut u8 { + let h = match unsafe { handle.as_mut() } { + Some(h) => h, + None => return std::ptr::null_mut(), + }; + let title_str = if title.is_null() { + "Acord Document".to_string() + } else { + unsafe { CStr::from_ptr(title) }.to_string_lossy().into_owned() + }; + let bytes = print::render_pdf(&h.state, &title_str); + if bytes.is_empty() { + unsafe { *out_len = 0; } + return std::ptr::null_mut(); + } + let len = bytes.len(); + let boxed = bytes.into_boxed_slice(); + let ptr = Box::into_raw(boxed) as *mut u8; + unsafe { *out_len = len; } + ptr +} + #[unsafe(no_mangle)] pub extern "C" fn viewport_set_theme(handle: *mut ViewportHandle, name: *const c_char) { let s = if name.is_null() { @@ -418,6 +474,7 @@ pub extern "C" fn viewport_take_shell_action(handle: *mut ViewportHandle) -> *mu editor::ShellAction::Quit => "quit".to_string(), editor::ShellAction::Settings => "settings".to_string(), editor::ShellAction::ExportCrate => "export_crate".to_string(), + editor::ShellAction::Print => "print".to_string(), editor::ShellAction::ToggleBrowser => "toggle_browser".to_string(), editor::ShellAction::SetThemeMode(v) => format!("set_theme_mode:{}", v), editor::ShellAction::SetLineIndicator(v) => format!("set_line_indicator:{}", v), diff --git a/viewport/src/print.rs b/viewport/src/print.rs new file mode 100644 index 0000000..dc48b1e --- /dev/null +++ b/viewport/src/print.rs @@ -0,0 +1,465 @@ +//! Black-and-white PDF print of the open document. +//! +//! No styling beyond what print needs: Helvetica body, Helvetica-Bold headings, +//! Courier code/tables, simple grid borders, page numbers at the bottom. + +use printpdf::{ + BuiltinFont, Color, Greyscale, Line, LinePoint, Mm, Op, PdfDocument, PdfFontHandle, + PdfPage, PdfSaveOptions, Point, Pt, TextItem, +}; + +use crate::block::Block as BlockTrait; +use crate::editor::{EditorState, Message}; +use crate::heading_block::{HeadingBlock, HeadingLevel}; +use crate::hr_block::HrBlock; +use crate::table_block::TableBlock; +use crate::text_block::TextBlock; +use crate::tree_block::TreeBlock; + +/// US Letter with 1-inch margins. +const PAGE_W_MM: f32 = 215.9; +const PAGE_H_MM: f32 = 279.4; +const MARGIN_MM: f32 = 19.05; // 0.75 inch — slightly tighter than 1" so notes fit + +const BODY_PT: f32 = 10.5; +const CODE_PT: f32 = 9.5; +const LINE_GAP: f32 = 1.35; // line-height multiplier +const PARA_GAP_PT: f32 = 4.0; +const BLOCK_GAP_PT: f32 = 8.0; + +const TABLE_PAD_PT: f32 = 4.0; +const TABLE_LINE_PT: f32 = 0.5; + +/// Approximate glyph widths in em-units. Built-in font metrics aren't exposed +/// when `text_layout` is off; these are conservative averages so wrapping +/// under-fills slightly rather than overflowing. +fn avg_em(font: BuiltinFont) -> f32 { + match font { + BuiltinFont::Courier + | BuiltinFont::CourierBold + | BuiltinFont::CourierOblique + | BuiltinFont::CourierBoldOblique => 0.6, + BuiltinFont::HelveticaBold | BuiltinFont::HelveticaBoldOblique => 0.55, + _ => 0.5, + } +} + +fn approx_text_width_pt(text: &str, font: BuiltinFont, size_pt: f32) -> f32 { + text.chars().count() as f32 * avg_em(font) * size_pt +} + +#[derive(Clone)] +enum PrintBlock { + Heading { level: u8, text: String }, + Paragraph { lines: Vec }, + Code { lines: Vec }, + Table { rows: Vec> }, + Hr, +} + +/// pulls printable blocks out of the editor's live block tree. +fn collect_print_blocks(editor: &EditorState) -> Vec { + let mut out: Vec = Vec::new(); + for block in editor.iter_blocks() { + if let Some(h) = block.as_any().downcast_ref::() { + out.push(PrintBlock::Heading { + level: heading_level_to_u8(h.level), + text: h.text.clone(), + }); + } else if block.as_any().downcast_ref::().is_some() { + out.push(PrintBlock::Hr); + } else if let Some(t) = block.as_any().downcast_ref::() { + out.push(PrintBlock::Table { rows: t.rows.clone() }); + } else if let Some(t) = block.as_any().downcast_ref::() { + push_text_block(&mut out, t); + } else if let Some(t) = block.as_any().downcast_ref::() { + let md = >::to_md(t); + push_paragraph(&mut out, &md); + } + } + out +} + +fn heading_level_to_u8(l: HeadingLevel) -> u8 { + match l { + HeadingLevel::H1 => 1, + HeadingLevel::H2 => 2, + HeadingLevel::H3 => 3, + HeadingLevel::H4 => 4, + } +} + +/// splits a TextBlock into runs of code-fenced and plain paragraphs. +fn push_text_block(out: &mut Vec, t: &TextBlock) { + let md = >::to_md(t); + let mut buf: Vec = Vec::new(); + let mut in_fence = false; + for line in md.lines() { + let trimmed = line.trim_start(); + if trimmed.starts_with("```") { + if in_fence { + if !buf.is_empty() { + out.push(PrintBlock::Code { lines: std::mem::take(&mut buf) }); + } + in_fence = false; + } else { + if !buf.is_empty() { + push_paragraph(out, &buf.join("\n")); + buf.clear(); + } + in_fence = true; + } + continue; + } + buf.push(line.to_string()); + } + if in_fence { + if !buf.is_empty() { + out.push(PrintBlock::Code { lines: buf }); + } + } else if !buf.is_empty() { + push_paragraph(out, &buf.join("\n")); + } +} + +/// drops trailing blanks and pushes a paragraph if non-empty. +fn push_paragraph(out: &mut Vec, text: &str) { + let lines: Vec = text.lines().map(|s| s.to_string()).collect(); + let trimmed = trim_blank_edges(&lines); + if trimmed.is_empty() { return; } + out.push(PrintBlock::Paragraph { lines: trimmed }); +} + +fn trim_blank_edges(lines: &[String]) -> Vec { + let start = lines.iter().position(|l| !l.trim().is_empty()).unwrap_or(lines.len()); + let end = lines.iter().rposition(|l| !l.trim().is_empty()).map(|i| i + 1).unwrap_or(0); + if start >= end { Vec::new() } else { lines[start..end].to_vec() } +} + +/// breaks a paragraph string into wrap-fit lines for the given font/width. +fn wrap_lines(text: &str, font: BuiltinFont, size_pt: f32, max_w_pt: f32) -> Vec { + let mut out: Vec = Vec::new(); + for raw in text.lines() { + if raw.trim().is_empty() { + out.push(String::new()); + continue; + } + let mut current = String::new(); + for word in raw.split_whitespace() { + let candidate = if current.is_empty() { + word.to_string() + } else { + format!("{} {}", current, word) + }; + if approx_text_width_pt(&candidate, font, size_pt) <= max_w_pt { + current = candidate; + } else if current.is_empty() { + // single word too long for the line — let it overflow rather than truncate + out.push(word.to_string()); + current = String::new(); + } else { + out.push(current); + current = word.to_string(); + } + } + if !current.is_empty() { out.push(current); } + } + out +} + +/// strips inline markdown markers (`**`, `*`, `_`, backticks) so plain text prints clean. +fn strip_inline_md(line: &str) -> String { + let mut out = String::with_capacity(line.len()); + let mut chars = line.chars().peekable(); + while let Some(c) = chars.next() { + match c { + '*' | '_' => { + // collapse runs of the same marker + while chars.peek() == Some(&c) { chars.next(); } + } + '`' => { + while chars.peek() == Some(&'`') { chars.next(); } + } + _ => out.push(c), + } + } + out +} + +struct Layout { + page_w_pt: f32, + page_h_pt: f32, + margin_pt: f32, + pages: Vec>, + cur: Vec, + /// y-cursor in PDF coordinates (origin at bottom-left, growing upward). + y_pt: f32, + page_count: usize, +} + +impl Layout { + fn new() -> Self { + let page_w_pt = mm_to_pt(PAGE_W_MM); + let page_h_pt = mm_to_pt(PAGE_H_MM); + let margin_pt = mm_to_pt(MARGIN_MM); + let mut me = Self { + page_w_pt, + page_h_pt, + margin_pt, + pages: Vec::new(), + cur: Vec::new(), + y_pt: page_h_pt - margin_pt, + page_count: 0, + }; + me.start_page(); + me + } + + fn body_width_pt(&self) -> f32 { + self.page_w_pt - 2.0 * self.margin_pt + } + + fn bottom_limit_pt(&self) -> f32 { + self.margin_pt + } + + fn start_page(&mut self) { + self.cur = Vec::new(); + self.cur.push(Op::SetFillColor { col: Color::Greyscale(Greyscale { percent: 0.0, icc_profile: None }) }); + self.cur.push(Op::SetOutlineColor { col: Color::Greyscale(Greyscale { percent: 0.0, icc_profile: None }) }); + self.y_pt = self.page_h_pt - self.margin_pt; + self.page_count += 1; + } + + fn finish_page(&mut self) { + let footer = format!("{}", self.page_count); + let w = approx_text_width_pt(&footer, BuiltinFont::Helvetica, 9.0); + let x = (self.page_w_pt - w) / 2.0; + let y = self.margin_pt / 2.0; + self.cur.push(Op::StartTextSection); + self.cur.push(Op::SetFont { + font: PdfFontHandle::Builtin(BuiltinFont::Helvetica), + size: Pt(9.0), + }); + self.cur.push(Op::SetTextCursor { pos: Point { x: Pt(x), y: Pt(y) } }); + self.cur.push(Op::ShowText { items: vec![TextItem::Text(footer)] }); + self.cur.push(Op::EndTextSection); + self.pages.push(std::mem::take(&mut self.cur)); + } + + fn ensure_space(&mut self, needed_pt: f32) { + if self.y_pt - needed_pt < self.bottom_limit_pt() { + self.finish_page(); + self.start_page(); + } + } + + fn advance(&mut self, dy_pt: f32) { + self.y_pt -= dy_pt; + } + + fn draw_text_line(&mut self, line: &str, font: BuiltinFont, size_pt: f32) { + let line_h = size_pt * LINE_GAP; + self.ensure_space(line_h); + self.advance(line_h); + if line.is_empty() { return; } + self.cur.push(Op::StartTextSection); + self.cur.push(Op::SetFont { + font: PdfFontHandle::Builtin(font), + size: Pt(size_pt), + }); + self.cur.push(Op::SetTextCursor { + pos: Point { x: Pt(self.margin_pt), y: Pt(self.y_pt) }, + }); + self.cur.push(Op::ShowText { items: vec![TextItem::Text(line.to_string())] }); + self.cur.push(Op::EndTextSection); + } + + fn draw_hr(&mut self) { + let h = 6.0; + self.ensure_space(h); + self.advance(h / 2.0); + let y = self.y_pt; + self.cur.push(Op::SetOutlineThickness { pt: Pt(0.5) }); + self.cur.push(Op::DrawLine { + line: Line { + points: vec![ + LinePoint { p: Point { x: Pt(self.margin_pt), y: Pt(y) }, bezier: false }, + LinePoint { p: Point { x: Pt(self.page_w_pt - self.margin_pt), y: Pt(y) }, bezier: false }, + ], + is_closed: false, + }, + }); + self.advance(h / 2.0); + } + + fn draw_table(&mut self, rows: &[Vec]) { + if rows.is_empty() { return; } + let cols = rows.iter().map(|r| r.len()).max().unwrap_or(0); + if cols == 0 { return; } + let body_w = self.body_width_pt(); + let col_w = body_w / cols as f32; + + let font_body = BuiltinFont::Helvetica; + let font_head = BuiltinFont::HelveticaBold; + let cell_inner_w = col_w - 2.0 * TABLE_PAD_PT; + + // pre-wrap each cell into lines so we can compute row heights up front. + let wrapped: Vec>> = rows + .iter() + .enumerate() + .map(|(ri, row)| { + let f = if ri == 0 { font_head } else { font_body }; + row.iter() + .map(|c| wrap_lines(&strip_inline_md(c), f, BODY_PT, cell_inner_w)) + .collect() + }) + .collect(); + + for (ri, row) in rows.iter().enumerate() { + let line_h = BODY_PT * LINE_GAP; + let max_lines = wrapped[ri].iter().map(|c| c.len().max(1)).max().unwrap_or(1); + let row_h = max_lines as f32 * line_h + 2.0 * TABLE_PAD_PT; + + self.ensure_space(row_h); + + let top_y = self.y_pt; + let bottom_y = top_y - row_h; + + // borders + self.cur.push(Op::SetOutlineThickness { pt: Pt(TABLE_LINE_PT) }); + // top + self.cur.push(Op::DrawLine { line: hline(self.margin_pt, top_y, self.margin_pt + body_w, top_y) }); + // bottom + self.cur.push(Op::DrawLine { line: hline(self.margin_pt, bottom_y, self.margin_pt + body_w, bottom_y) }); + // left + verticals + right + for c in 0..=cols { + let x = self.margin_pt + c as f32 * col_w; + self.cur.push(Op::DrawLine { line: vline(x, top_y, x, bottom_y) }); + } + + // cell text + let font = if ri == 0 { font_head } else { font_body }; + for (ci, lines) in wrapped[ri].iter().enumerate() { + if ci >= row.len() { break; } + let cell_x = self.margin_pt + ci as f32 * col_w + TABLE_PAD_PT; + let mut text_y = top_y - TABLE_PAD_PT - BODY_PT * 0.85; + for line in lines { + self.cur.push(Op::StartTextSection); + self.cur.push(Op::SetFont { + font: PdfFontHandle::Builtin(font), + size: Pt(BODY_PT), + }); + self.cur.push(Op::SetTextCursor { + pos: Point { x: Pt(cell_x), y: Pt(text_y) }, + }); + self.cur.push(Op::ShowText { items: vec![TextItem::Text(line.clone())] }); + self.cur.push(Op::EndTextSection); + text_y -= line_h; + } + } + + self.y_pt = bottom_y; + } + } + + fn finish(mut self) -> Vec> { + self.finish_page(); + self.pages + } +} + +fn hline(x1: f32, y1: f32, x2: f32, y2: f32) -> Line { + Line { + points: vec![ + LinePoint { p: Point { x: Pt(x1), y: Pt(y1) }, bezier: false }, + LinePoint { p: Point { x: Pt(x2), y: Pt(y2) }, bezier: false }, + ], + is_closed: false, + } +} + +fn vline(x1: f32, y1: f32, x2: f32, y2: f32) -> Line { + Line { + points: vec![ + LinePoint { p: Point { x: Pt(x1), y: Pt(y1) }, bezier: false }, + LinePoint { p: Point { x: Pt(x2), y: Pt(y2) }, bezier: false }, + ], + is_closed: false, + } +} + +fn mm_to_pt(mm: f32) -> f32 { + mm * 72.0 / 25.4 +} + +fn heading_size(level: u8) -> f32 { + match level { + 1 => 18.0, + 2 => 15.0, + 3 => 13.0, + _ => 12.0, + } +} + +fn render_blocks(blocks: &[PrintBlock], layout: &mut Layout) { + let body_w = layout.body_width_pt(); + for (i, block) in blocks.iter().enumerate() { + if i > 0 { layout.advance(BLOCK_GAP_PT); } + match block { + PrintBlock::Heading { level, text } => { + let size = heading_size(*level); + let lines = wrap_lines(&strip_inline_md(text), BuiltinFont::HelveticaBold, size, body_w); + for line in &lines { + layout.draw_text_line(line, BuiltinFont::HelveticaBold, size); + } + } + PrintBlock::Paragraph { lines } => { + for raw in lines { + let stripped = strip_inline_md(raw); + if stripped.trim().is_empty() { + layout.advance(PARA_GAP_PT); + continue; + } + let wrapped = wrap_lines(&stripped, BuiltinFont::Helvetica, BODY_PT, body_w); + for w in &wrapped { + layout.draw_text_line(w, BuiltinFont::Helvetica, BODY_PT); + } + } + } + PrintBlock::Code { lines } => { + for raw in lines { + let wrapped = wrap_lines(raw, BuiltinFont::Courier, CODE_PT, body_w); + if wrapped.is_empty() { + layout.draw_text_line("", BuiltinFont::Courier, CODE_PT); + } else { + for w in &wrapped { + layout.draw_text_line(w, BuiltinFont::Courier, CODE_PT); + } + } + } + } + PrintBlock::Table { rows } => { + layout.draw_table(rows); + } + PrintBlock::Hr => { + layout.draw_hr(); + } + } + } +} + +/// renders the current document as a printable PDF and returns the bytes. +pub fn render_pdf(editor: &EditorState, title: &str) -> Vec { + let blocks = collect_print_blocks(editor); + let mut layout = Layout::new(); + render_blocks(&blocks, &mut layout); + let pages_ops = layout.finish(); + + let mut doc = PdfDocument::new(title); + for ops in pages_ops { + doc.pages.push(PdfPage::new(Mm(PAGE_W_MM), Mm(PAGE_H_MM), ops)); + } + let mut warnings = Vec::new(); + doc.save(&PdfSaveOptions::default(), &mut warnings) +} diff --git a/viewport/src/table_block.rs b/viewport/src/table_block.rs index 6d0e728..7021289 100644 --- a/viewport/src/table_block.rs +++ b/viewport/src/table_block.rs @@ -1673,7 +1673,7 @@ fn compute_row_height( (max_lines as f32 * line_h + pad_h).max(default_h) } -fn column_letter(mut idx: usize) -> String { +pub fn column_letter(mut idx: usize) -> String { let mut s = String::new(); loop { s.insert(0, (b'A' + (idx % 26) as u8) as char); diff --git a/viewport/src/widgets/dialog.rs b/viewport/src/widgets/dialog.rs new file mode 100644 index 0000000..20f5850 --- /dev/null +++ b/viewport/src/widgets/dialog.rs @@ -0,0 +1,108 @@ +//! Modal dialog scaffolding — matches Acord's settings panel. +//! +//! [`overlay`] dims the underlying view and centers a panel; [`segmented_row`] +//! is the labeled multi-button row used for theme/line-indicator toggles. + +use iced_wgpu::Renderer; +use iced_wgpu::core::{Background, Border, Color, Element, Length, Padding, Shadow, Theme}; +use iced_widget::{button, container, row, text}; + +use crate::palette; +use crate::syntax; + +/// Wraps `panel` content in a centered card over a translucent dim backdrop. +/// `panel` is rendered as-is — the caller controls its content, padding, and +/// width. Set `width` to a fixed value (e.g. `Length::Fixed(font_size * 28.0)`) +/// for a stable layout. +pub fn overlay<'a, Message>( + panel: Element<'a, Message, Theme, Renderer>, + width: Length, + font_size: f32, +) -> Element<'a, Message, Theme, Renderer> +where + Message: Clone + 'static, +{ + let p = palette::current(); + let f = font_size; + let radius = f * 0.30; + + let card = container(panel) + .padding(Padding { top: f, right: f, bottom: f, left: f }) + .width(width) + .style(move |_t: &Theme| container::Style { + background: Some(Background::Color(p.surface0)), + border: Border { + color: p.surface1, + width: 1.0, + radius: radius.into(), + }, + text_color: Some(p.text), + shadow: Shadow::default(), + snap: false, + }); + + container(card) + .width(Length::Fill) + .height(Length::Fill) + .center_x(Length::Fill) + .center_y(Length::Fill) + .style(|_t: &Theme| container::Style { + background: Some(Background::Color(Color { r: 0.0, g: 0.0, b: 0.0, a: 0.4 })), + border: Border::default(), + text_color: None, + shadow: Shadow::default(), + snap: false, + }) + .into() +} + +/// A "label … [Option1] [Option2] [Option3]" row with the current option +/// highlighted. `options` is `(display_label, value)`; `current` is the +/// active value; `on_select(value)` fires when one is clicked. +pub fn segmented_row<'a, Message>( + label: &str, + options: &[(&'a str, &'a str)], + current: &str, + font_size: f32, + on_select: impl Fn(&'a str) -> Message + 'a, +) -> Element<'a, Message, Theme, Renderer> +where + Message: Clone + 'static, +{ + let p = palette::current(); + let f = font_size; + let label_size = f * 0.92; + let radius = f * 0.18; + + let mut buttons: Vec> = Vec::new(); + for (display, value) in options { + let active = *value == current; + let display = display.to_string(); + let v = *value; + buttons.push( + button( + text(display).size(label_size).font(syntax::EDITOR_FONT), + ) + .padding(Padding { top: f * 0.18, right: f * 0.55, bottom: f * 0.18, left: f * 0.55 }) + .style(move |_t: &Theme, _s| button::Style { + background: Some(Background::Color(if active { p.surface2 } else { p.surface1 })), + text_color: if active { p.text } else { p.subtext0 }, + border: Border { color: p.surface2, width: 1.0, radius: radius.into() }, + shadow: Shadow::default(), + snap: false, + }) + .on_press(on_select(v)) + .into(), + ); + } + + let label_w = text(label.to_string()) + .size(label_size) + .font(syntax::EDITOR_FONT) + .color(p.text) + .width(Length::Fill); + + row![label_w, row(buttons).spacing(f * 0.25)] + .spacing(f) + .into() +} diff --git a/viewport/src/widgets/menu.rs b/viewport/src/widgets/menu.rs new file mode 100644 index 0000000..ed9ce4a --- /dev/null +++ b/viewport/src/widgets/menu.rs @@ -0,0 +1,180 @@ +//! Menu strip + dropdown panel, generic over the host's `Message` type. +//! +//! `strip()` builds the horizontal category bar; `dropdown()` builds an +//! anchored panel of label+shortcut rows. Hosts handle "is this category +//! open?" themselves and stack the dropdown over their content with +//! `iced_widget::stack!` after picking the click position. + +use iced_wgpu::Renderer; +use iced_wgpu::core::{Background, Border, Element, Length, Padding, Shadow, Theme}; +use iced_widget::{button, container, row, text}; + +use crate::palette; +use crate::syntax; +use crate::widgets::style; + +/// One row in a dropdown — either a clickable item or a horizontal separator. +#[derive(Clone)] +pub enum Row { + Item { + label: String, + shortcut: String, + on_press: Message, + }, + Separator, +} + +impl Row { + pub fn item(label: impl Into, shortcut: impl Into, on_press: Message) -> Self { + Row::Item { label: label.into(), shortcut: shortcut.into(), on_press } + } + + pub fn separator() -> Self { + Row::Separator + } +} + +/// One category in the strip bar. +#[derive(Clone)] +pub struct Category { + pub key: K, + pub label: String, +} + +impl Category { + pub fn new(key: K, label: impl Into) -> Self { + Self { key, label: label.into() } + } +} + +/// Approximate button width for a category label, useful when computing +/// horizontal anchor offsets for dropdowns. +pub fn category_button_width(label: &str, font_size: f32) -> f32 { + let char_w = font_size * 0.6; + let pad_x = font_size * 0.85; + label.len() as f32 * char_w + pad_x * 2.0 +} + +/// Renders the horizontal category bar. `is_active` tells whether the dropdown +/// for that category is currently open (renders a highlighted background). +pub fn strip<'a, K, Message, F>( + categories: &'a [Category], + is_active: impl Fn(&K) -> bool, + font_size: f32, + mut on_toggle: F, +) -> Element<'a, Message, Theme, Renderer> +where + K: Clone + 'a, + Message: Clone + 'static, + F: FnMut(K) -> Message + 'a, +{ + let p = palette::current(); + let f = font_size; + let pad_x = f * 0.85; + let pad_y = f * 0.18; + let label_size = f * 0.92; + + let mut row_items: Vec> = Vec::new(); + for cat in categories { + let active = is_active(&cat.key); + let key = cat.key.clone(); + let on_press = on_toggle(key); + row_items.push( + button( + text(cat.label.clone()) + .size(label_size) + .font(syntax::EDITOR_FONT), + ) + .width(Length::Fixed(category_button_width(&cat.label, f))) + .padding(Padding { top: pad_y, right: pad_x, bottom: pad_y, left: pad_x }) + .style(move |_t: &Theme, _s| button::Style { + background: if active { Some(Background::Color(p.surface1)) } else { None }, + text_color: p.text, + border: Border::default(), + shadow: Shadow::default(), + snap: false, + }) + .on_press(on_press) + .into(), + ); + } + + container(row(row_items).spacing(0.0)) + .width(Length::Fill) + .style(move |_t: &Theme| container::Style { + background: Some(Background::Color(p.mantle)), + border: Border::default(), + text_color: Some(p.text), + shadow: Shadow::default(), + snap: false, + }) + .into() +} + +/// Renders a dropdown panel of rows. Caller positions the panel over the +/// strip via `iced_widget::stack!` (typically with a `column!` of [empty +/// padding, dropdown]). +pub fn dropdown<'a, Message>( + rows: Vec>, + font_size: f32, + width: Length, +) -> Element<'a, Message, Theme, Renderer> +where + Message: Clone + 'static, +{ + let p = palette::current(); + let f = font_size; + let item_pad_x = f * 0.95; + let item_pad_y = f * 0.32; + let radius = f * 0.30; + let separator_h = (f * 0.08).max(1.0); + let label_size = f * 0.85; + let hint_size = f * 0.78; + + let mut items: Vec> = Vec::new(); + for r in rows { + match r { + Row::Item { label, shortcut, on_press } => { + let label_w = text(label).size(label_size).font(syntax::EDITOR_FONT).width(Length::Fill); + let hint_w = text(shortcut).size(hint_size).font(syntax::EDITOR_FONT).color(p.overlay0); + items.push( + button(row![label_w, hint_w].spacing(f)) + .width(Length::Fill) + .padding(Padding { top: item_pad_y, right: item_pad_x, bottom: item_pad_y, left: item_pad_x }) + .style(style::menu_item) + .on_press(on_press) + .into(), + ); + } + Row::Separator => { + items.push( + container(text("")) + .width(Length::Fill) + .height(Length::Fixed(separator_h)) + .style(move |_t: &Theme| container::Style { + background: Some(Background::Color(p.surface1)), + border: Border::default(), + text_color: None, + shadow: Shadow::default(), + snap: false, + }) + .into(), + ); + } + } + } + + container(iced_widget::column(items).spacing(0.0).width(width)) + .style(move |_t: &Theme| container::Style { + background: Some(Background::Color(p.base)), + border: Border { + color: p.surface1, + width: 1.0, + radius: radius.into(), + }, + text_color: Some(p.text), + shadow: Shadow::default(), + snap: false, + }) + .into() +} diff --git a/viewport/src/widgets/mod.rs b/viewport/src/widgets/mod.rs new file mode 100644 index 0000000..fcd0b29 --- /dev/null +++ b/viewport/src/widgets/mod.rs @@ -0,0 +1,10 @@ +//! Reusable iced widgets pulled out of the Acord editor for use in other apps. +//! +//! Each submodule is generic over the host's `Message` type and renders against +//! the `iced_wgpu::Renderer`. The widgets read from the global Acord palette +//! (see [`crate::palette`]) so callers can theme them by calling +//! `palette::set_theme(...)` before building their UI. + +pub mod dialog; +pub mod menu; +pub mod style; diff --git a/viewport/src/widgets/style.rs b/viewport/src/widgets/style.rs new file mode 100644 index 0000000..6538a34 --- /dev/null +++ b/viewport/src/widgets/style.rs @@ -0,0 +1,59 @@ +//! iced style functions matching Acord's look. +//! +//! Each reads from the global Acord palette ([`crate::palette::current`]) at +//! call time, so they automatically follow theme switches. + +use iced_wgpu::core::{Background, Border, Color, Shadow, Theme}; +use iced_widget::{button, text_input}; + +use crate::palette; + +/// hover/pressed background for a flat menu-row button. +pub fn menu_item(_theme: &Theme, status: button::Status) -> button::Style { + let p = palette::current(); + let bg = match status { + button::Status::Hovered => Some(Background::Color(p.surface1)), + button::Status::Pressed => Some(Background::Color(p.surface2)), + _ => None, + }; + button::Style { + background: bg, + text_color: p.text, + border: Border::default(), + shadow: Shadow::default(), + snap: false, + } +} + +/// solid surface-1 button with a 1px outline; used by Acord's find bar. +pub fn outlined_button(_theme: &Theme, _status: button::Status) -> button::Style { + let p = palette::current(); + button::Style { + background: Some(Background::Color(p.surface1)), + text_color: p.text, + border: Border { + color: p.surface2, + width: 1.0, + radius: 3.0.into(), + }, + shadow: Shadow::default(), + snap: false, + } +} + +/// outlined text input matching Acord's find bar. +pub fn outlined_input(_theme: &Theme, _status: text_input::Status) -> text_input::Style { + let p = palette::current(); + text_input::Style { + background: Background::Color(p.surface0), + border: Border { + color: p.surface2, + width: 1.0, + radius: 3.0.into(), + }, + icon: p.overlay2, + placeholder: p.overlay0, + value: p.text, + selection: Color { a: 0.4, ..p.blue }, + } +} diff --git a/windows/src/app.rs b/windows/src/app.rs index 603814a..1bf4257 100644 --- a/windows/src/app.rs +++ b/windows/src/app.rs @@ -18,6 +18,7 @@ use acord_viewport::{ viewport_set_auto_pair_flags, viewport_send_command, viewport_free_string, viewport_take_sidecar_bytes, viewport_apply_sidecar_bytes, viewport_free_bytes, + viewport_render_pdf, ViewportHandle, }; use acord_viewport::sidecar; @@ -110,6 +111,7 @@ impl App { (*self.handle).state.settings_open = !(*self.handle).state.settings_open; }, MenuAction::ExportCrate => {} + MenuAction::Print => self.print_to_pdf(), MenuAction::ToggleBrowser => self.toggle_browser(event_loop), } } @@ -126,6 +128,7 @@ impl App { ShellAction::Quit => event_loop.exit(), ShellAction::Settings => {} ShellAction::ExportCrate => {} + ShellAction::Print => self.print_to_pdf(), ShellAction::ToggleBrowser => self.toggle_browser(event_loop), ShellAction::SetThemeMode(v) => { self.config.set("themeMode", &v); @@ -354,6 +357,25 @@ impl App { } } + /// renders the document to a PDF chosen via save dialog. + fn print_to_pdf(&mut self) { + if self.handle.is_null() { return; } + let title = self.derive_default_filename(); + let mut len: usize = 0; + let title_c = CString::new(title.clone()).unwrap_or_default(); + let ptr = viewport_render_pdf(self.handle, title_c.as_ptr(), &mut len as *mut usize); + if ptr.is_null() || len == 0 { return; } + let bytes = unsafe { std::slice::from_raw_parts(ptr, len) }.to_vec(); + viewport_free_bytes(ptr, len); + + let dialog = rfd::FileDialog::new() + .add_filter("PDF", &["pdf"]) + .set_file_name(format!("{}.pdf", title)); + if let Some(path) = dialog.save_file() { + let _ = std::fs::write(&path, &bytes); + } + } + fn derive_default_filename(&self) -> String { let text_ptr = viewport_get_text(self.handle); let text = if text_ptr.is_null() { diff --git a/windows/src/shortcuts.rs b/windows/src/shortcuts.rs index d8c2ac0..5572414 100644 --- a/windows/src/shortcuts.rs +++ b/windows/src/shortcuts.rs @@ -21,6 +21,7 @@ pub enum MenuAction { Find, Settings, ExportCrate, + Print, ToggleBrowser, } @@ -51,6 +52,7 @@ pub fn match_shortcut(modifiers: ModifiersState, key: &Key) -> Option Some(MenuAction::Find), (false, 'e') => Some(MenuAction::Evaluate), (true, 'e') => Some(MenuAction::ExportCrate), + (false, 'p') => Some(MenuAction::Print), (false, ',') => Some(MenuAction::Settings), (false, '=') | (false, '+') => Some(MenuAction::ZoomIn), (false, '-') => Some(MenuAction::ZoomOut), diff --git a/xtask/src/main.rs b/xtask/src/main.rs index 3cdd939..59088f4 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"]; +const KNOWN_PLATFORMS: &[&str] = &["macos", "windows", "linux", "ios"]; fn main() -> ExitCode { let args: Vec = env::args().skip(1).collect(); @@ -32,7 +32,7 @@ fn main() -> ExitCode { "-File", ], ), - "linux" | "macos" => ( + "linux" | "macos" | "ios" => ( repo_root.join(format!("scripts/{platform}/{action}.sh")), vec!["bash"], ), @@ -111,6 +111,11 @@ fn print_help() { eprintln!(" --all all six targets"); eprintln!(" --target e.g. macos-aarch64, windows-x86_64"); eprintln!(); - eprintln!("append -macos / -windows / -linux to any command to force a platform."); + eprintln!("append -macos / -windows / -linux / -ios to any command to force a platform."); eprintln!(" e.g. cargo xtask build-universal-macos"); + eprintln!(); + eprintln!("ios:"); + 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 xcodeproj-ios generate Acord.xcodeproj for finishing in Xcode"); }