forked from jess/Acord
1
0
Fork 0

- Add printing

- Beginning of iPad port
This commit is contained in:
jess 2026-05-04 13:09:37 -07:00
parent 6001b41fab
commit 56d2b3ce9a
30 changed files with 2012 additions and 10 deletions

View File

@ -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();

View File

@ -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 = "<group>"; };
4AF21EB9858D4F182A323DAA /* IcedViewportView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IcedViewportView.swift; sourceTree = "<group>"; };
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 = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXGroup section */
76C61BCA6D25861221CA6340 /* Products */ = {
isa = PBXGroup;
children = (
C3417095D399AB8B216B5139 /* Acord.app */,
);
name = Products;
sourceTree = "<group>";
};
EC6EB229D9262E900A247F0A = {
isa = PBXGroup;
children = (
FD6FF6B76E15E1EA3D0E676B /* src */,
76C61BCA6D25861221CA6340 /* Products */,
);
sourceTree = "<group>";
};
FD6FF6B76E15E1EA3D0E676B /* src */ = {
isa = PBXGroup;
children = (
E391F925E2727D13659EDA02 /* AcordApp.swift */,
19D7F3B08824BF5E9E468EF2 /* IcedViewportRepresentable.swift */,
4AF21EB9858D4F182A323DAA /* IcedViewportView.swift */,
);
path = src;
sourceTree = "<group>";
};
/* 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 */;
}

View File

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "self:">
</FileRef>
</Workspace>

64
ios/Info.plist Normal file
View File

@ -0,0 +1,64 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleExecutable</key>
<string>Acord</string>
<key>CFBundleIdentifier</key>
<string>org.else-if.acord</string>
<key>CFBundleName</key>
<string>Acord</string>
<key>CFBundleDisplayName</key>
<string>Acord</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleVersion</key>
<string>0.1.0</string>
<key>CFBundleShortVersionString</key>
<string>0.1.0</string>
<key>CFBundleSupportedPlatforms</key>
<array>
<string>iPhoneOS</string>
<string>iPhoneSimulator</string>
</array>
<key>MinimumOSVersion</key>
<string>17.0</string>
<key>UIDeviceFamily</key>
<array>
<integer>2</integer>
<integer>1</integer>
</array>
<key>UILaunchScreen</key>
<dict>
<key>UIColorName</key>
<string></string>
</dict>
<key>UIRequiredDeviceCapabilities</key>
<array>
<string>arm64</string>
<string>metal</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UIApplicationSceneManifest</key>
<dict>
<key>UIApplicationSupportsMultipleScenes</key>
<false/>
</dict>
<key>LSSupportsOpeningDocumentsInPlace</key>
<true/>
<key>UIFileSharingEnabled</key>
<true/>
</dict>
</plist>

37
ios/project.yml Normal file
View File

@ -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: []

18
ios/src/AcordApp.swift Normal file
View File

@ -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()
}
}

View File

@ -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.
}
}

View File

@ -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<UITouch>, 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<UITouch>, 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<UITouch>, 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<UITouch>, 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<UIPress>, 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<UIPress>, 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)
}
}
}

View File

@ -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() {

View File

@ -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<MenuAction
(false, 'f') => 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),

View File

@ -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,

145
scripts/ios/build.sh Executable file
View File

@ -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"

11
scripts/ios/debug.sh Executable file
View File

@ -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

66
scripts/ios/install.sh Executable file
View File

@ -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

32
scripts/ios/xcodeproj.sh Executable file
View File

@ -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."

View File

@ -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"

View File

@ -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);

View File

@ -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<dyn std::error::Error>> {
trash::delete(path).map_err(|e| Box::new(e) as Box<dyn std::error::Error>)
}
#[cfg(target_os = "ios")]
fn trash_crate_remove(_path: &Path) -> Result<(), Box<dyn std::error::Error>> {
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();

View File

@ -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::<TableBlock>())
}
/// returns the layout index of a block id, if any.
fn index_of_block_id(&self, bid: crate::selection::BlockId) -> Option<usize> {
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<usize> {
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<Item = &BoxedBlock> {
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<Vec<u8>> {
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<String> {
Some(name.to_string())
}
/// finds the first empty `[]` (whitespace allowed inside) and replaces it with `[<addr>]`.
fn splice_first_empty_slot(text: &str, addr: &str) -> Option<String> {
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<Binding<Message>> {
let KeyPress { key, modifiers, status, .. } = &key_press;
@ -4809,6 +4897,7 @@ fn load_image_from_path(src: &str) -> Option<ImageCacheEntry> {
}
/// 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<String> {
let dir = dirs::home_dir()?.join(".acord").join("cache").join("images");
std::fs::create_dir_all(&dir).ok()?;

View File

@ -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<arboard::Clipboard>,
}
#[cfg(not(target_os = "ios"))]
impl clipboard::Clipboard for AcordClipboard {
fn read(&self, _kind: clipboard::Kind) -> Option<String> {
// 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<String> { 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<Message> = Vec::new();
let mut consumed: Vec<usize> = 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();

View File

@ -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),

465
viewport/src/print.rs Normal file
View File

@ -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<String> },
Code { lines: Vec<String> },
Table { rows: Vec<Vec<String>> },
Hr,
}
/// pulls printable blocks out of the editor's live block tree.
fn collect_print_blocks(editor: &EditorState) -> Vec<PrintBlock> {
let mut out: Vec<PrintBlock> = Vec::new();
for block in editor.iter_blocks() {
if let Some(h) = block.as_any().downcast_ref::<HeadingBlock>() {
out.push(PrintBlock::Heading {
level: heading_level_to_u8(h.level),
text: h.text.clone(),
});
} else if block.as_any().downcast_ref::<HrBlock>().is_some() {
out.push(PrintBlock::Hr);
} else if let Some(t) = block.as_any().downcast_ref::<TableBlock>() {
out.push(PrintBlock::Table { rows: t.rows.clone() });
} else if let Some(t) = block.as_any().downcast_ref::<TextBlock>() {
push_text_block(&mut out, t);
} else if let Some(t) = block.as_any().downcast_ref::<TreeBlock>() {
let md = <TreeBlock as BlockTrait<Message>>::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<PrintBlock>, t: &TextBlock) {
let md = <TextBlock as BlockTrait<Message>>::to_md(t);
let mut buf: Vec<String> = 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<PrintBlock>, text: &str) {
let lines: Vec<String> = 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<String> {
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<String> {
let mut out: Vec<String> = 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<Vec<Op>>,
cur: Vec<Op>,
/// 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<String>]) {
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<Vec<Vec<String>>> = 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<Vec<Op>> {
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<u8> {
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)
}

View File

@ -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);

View File

@ -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<Element<'a, Message, Theme, Renderer>> = 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()
}

View File

@ -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<Message: Clone + 'static> {
Item {
label: String,
shortcut: String,
on_press: Message,
},
Separator,
}
impl<Message: Clone + 'static> Row<Message> {
pub fn item(label: impl Into<String>, shortcut: impl Into<String>, 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<K: Clone> {
pub key: K,
pub label: String,
}
impl<K: Clone> Category<K> {
pub fn new(key: K, label: impl Into<String>) -> 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<K>],
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<Element<'a, Message, Theme, Renderer>> = 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<Row<Message>>,
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<Element<'a, Message, Theme, Renderer>> = 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()
}

View File

@ -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;

View File

@ -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 },
}
}

View File

@ -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() {

View File

@ -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<MenuAction
(false, 'f') => 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),

View File

@ -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<String> = 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 <name> 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");
}