forked from jess/Acord
parent
6001b41fab
commit
56d2b3ce9a
|
|
@ -1901,6 +1901,9 @@ impl Interpreter {
|
||||||
return self.call_solved_fn(name, args, depth);
|
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 {
|
match name {
|
||||||
"sin" | "cos" | "tan" | "asin" | "acos" | "atan" |
|
"sin" | "cos" | "tan" | "asin" | "acos" | "atan" |
|
||||||
"sqrt" | "abs" | "ln" | "log" => {
|
"sqrt" | "abs" | "ln" | "log" => {
|
||||||
|
|
@ -3491,6 +3494,21 @@ fn find(arr, target) {
|
||||||
assert!(matches!(v, Value::Number(n) if n == 30.0));
|
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]
|
#[test]
|
||||||
fn range_ref_returns_2d_array() {
|
fn range_ref_returns_2d_array() {
|
||||||
let mut i = Interpreter::new();
|
let mut i = Interpreter::new();
|
||||||
|
|
|
||||||
|
|
@ -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 */;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<Workspace
|
||||||
|
version = "1.0">
|
||||||
|
<FileRef
|
||||||
|
location = "self:">
|
||||||
|
</FileRef>
|
||||||
|
</Workspace>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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: []
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -16,6 +16,7 @@ use acord_viewport::{
|
||||||
viewport_set_auto_pair_flags,
|
viewport_set_auto_pair_flags,
|
||||||
viewport_send_command, viewport_free_string,
|
viewport_send_command, viewport_free_string,
|
||||||
viewport_take_sidecar_bytes, viewport_apply_sidecar_bytes, viewport_free_bytes,
|
viewport_take_sidecar_bytes, viewport_apply_sidecar_bytes, viewport_free_bytes,
|
||||||
|
viewport_render_pdf,
|
||||||
ViewportHandle,
|
ViewportHandle,
|
||||||
};
|
};
|
||||||
use acord_viewport::sidecar;
|
use acord_viewport::sidecar;
|
||||||
|
|
@ -115,6 +116,7 @@ impl App {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
MenuAction::ExportCrate => { /* TODO: wire crate export */ }
|
MenuAction::ExportCrate => { /* TODO: wire crate export */ }
|
||||||
|
MenuAction::Print => self.print_to_pdf(),
|
||||||
MenuAction::ToggleBrowser => self.toggle_browser(event_loop),
|
MenuAction::ToggleBrowser => self.toggle_browser(event_loop),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -182,6 +184,7 @@ impl App {
|
||||||
ShellAction::Quit => event_loop.exit(),
|
ShellAction::Quit => event_loop.exit(),
|
||||||
ShellAction::Settings => {}
|
ShellAction::Settings => {}
|
||||||
ShellAction::ExportCrate => self.dispatch_menu(MenuAction::ExportCrate, event_loop),
|
ShellAction::ExportCrate => self.dispatch_menu(MenuAction::ExportCrate, event_loop),
|
||||||
|
ShellAction::Print => self.print_to_pdf(),
|
||||||
ShellAction::ToggleBrowser => self.toggle_browser(event_loop),
|
ShellAction::ToggleBrowser => self.toggle_browser(event_loop),
|
||||||
ShellAction::SetThemeMode(v) => {
|
ShellAction::SetThemeMode(v) => {
|
||||||
self.config.set("themeMode", &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 {
|
fn derive_default_filename(&self) -> String {
|
||||||
let text_ptr = viewport_get_text(self.handle);
|
let text_ptr = viewport_get_text(self.handle);
|
||||||
let text = if text_ptr.is_null() {
|
let text = if text_ptr.is_null() {
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ pub enum MenuAction {
|
||||||
Find,
|
Find,
|
||||||
Settings,
|
Settings,
|
||||||
ExportCrate,
|
ExportCrate,
|
||||||
|
Print,
|
||||||
ToggleBrowser,
|
ToggleBrowser,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -60,6 +61,7 @@ pub fn match_shortcut(modifiers: ModifiersState, key: &Key) -> Option<MenuAction
|
||||||
(false, 'f') => Some(MenuAction::Find),
|
(false, 'f') => Some(MenuAction::Find),
|
||||||
(false, 'e') => Some(MenuAction::Evaluate),
|
(false, 'e') => Some(MenuAction::Evaluate),
|
||||||
(true, 'e') => Some(MenuAction::ExportCrate),
|
(true, 'e') => Some(MenuAction::ExportCrate),
|
||||||
|
(false, 'p') => Some(MenuAction::Print),
|
||||||
(false, ',') => Some(MenuAction::Settings),
|
(false, ',') => Some(MenuAction::Settings),
|
||||||
(false, '=') | (false, '+') => Some(MenuAction::ZoomIn),
|
(false, '=') | (false, '+') => Some(MenuAction::ZoomIn),
|
||||||
(false, '-') => Some(MenuAction::ZoomOut),
|
(false, '-') => Some(MenuAction::ZoomOut),
|
||||||
|
|
|
||||||
|
|
@ -225,6 +225,10 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSMenuItemValidation {
|
||||||
exportCrateItem.target = self
|
exportCrateItem.target = self
|
||||||
menu.addItem(exportCrateItem)
|
menu.addItem(exportCrateItem)
|
||||||
|
|
||||||
|
let printItem = NSMenuItem(title: "Print...", action: #selector(printNote), keyEquivalent: "p")
|
||||||
|
printItem.target = self
|
||||||
|
menu.addItem(printItem)
|
||||||
|
|
||||||
menu.addItem(.separator())
|
menu.addItem(.separator())
|
||||||
|
|
||||||
let openStorageItem = NSMenuItem(title: "Open Storage Directory", action: #selector(openStorageDirectory), keyEquivalent: "")
|
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() {
|
@objc private func exportCrate() {
|
||||||
syncTextFromViewport()
|
syncTextFromViewport()
|
||||||
guard let w = window, let vp = w.contentView as? IcedViewportView,
|
guard let w = window, let vp = w.contentView as? IcedViewportView,
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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."
|
||||||
|
|
@ -24,10 +24,13 @@ serde_json = "1"
|
||||||
toml = "0.8"
|
toml = "0.8"
|
||||||
zip = { version = "2", default-features = false, features = ["deflate"] }
|
zip = { version = "2", default-features = false, features = ["deflate"] }
|
||||||
base64 = "0.22"
|
base64 = "0.22"
|
||||||
arboard = "3"
|
|
||||||
ureq = "3"
|
ureq = "3"
|
||||||
trash = "5"
|
|
||||||
filetime = "0.2"
|
filetime = "0.2"
|
||||||
|
printpdf = { version = "0.9", default-features = false }
|
||||||
|
|
||||||
|
[target.'cfg(not(target_os = "ios"))'.dependencies]
|
||||||
|
arboard = "3"
|
||||||
|
trash = "5"
|
||||||
|
|
||||||
[build-dependencies]
|
[build-dependencies]
|
||||||
cbindgen = "0.29"
|
cbindgen = "0.29"
|
||||||
|
|
|
||||||
|
|
@ -101,6 +101,13 @@ void viewport_apply_sidecar_bytes(struct ViewportHandle *handle,
|
||||||
*/
|
*/
|
||||||
void viewport_free_bytes(uint8_t *ptr, uintptr_t len);
|
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_theme(struct ViewportHandle *handle, const char *name);
|
||||||
|
|
||||||
void viewport_set_line_indicator(struct ViewportHandle *handle, const char *mode);
|
void viewport_set_line_indicator(struct ViewportHandle *handle, const char *mode);
|
||||||
|
|
|
||||||
|
|
@ -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>> {
|
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>)
|
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)> {
|
pub fn path_segments(current: &Path, root: &Path) -> Vec<(String, PathBuf)> {
|
||||||
let mut segments: Vec<(String, PathBuf)> = Vec::new();
|
let mut segments: Vec<(String, PathBuf)> = Vec::new();
|
||||||
let mut p = current.to_path_buf();
|
let mut p = current.to_path_buf();
|
||||||
|
|
|
||||||
|
|
@ -195,6 +195,7 @@ pub enum ShellAction {
|
||||||
Quit,
|
Quit,
|
||||||
Settings,
|
Settings,
|
||||||
ExportCrate,
|
ExportCrate,
|
||||||
|
Print,
|
||||||
ToggleBrowser,
|
ToggleBrowser,
|
||||||
SetThemeMode(String),
|
SetThemeMode(String),
|
||||||
SetLineIndicator(String),
|
SetLineIndicator(String),
|
||||||
|
|
@ -853,6 +854,57 @@ impl EditorState {
|
||||||
.and_then(|b| b.as_any_mut().downcast_mut::<TableBlock>())
|
.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> {
|
fn first_text_block_index(&self) -> Option<usize> {
|
||||||
self.layout.iter().enumerate().find_map(|(i, id)| {
|
self.layout.iter().enumerate().find_map(|(i, id)| {
|
||||||
self.registry.get(id).and_then(|b| {
|
self.registry.get(id).and_then(|b| {
|
||||||
|
|
@ -1107,6 +1159,11 @@ impl EditorState {
|
||||||
self.get_clean_text()
|
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.
|
/// returns the archive zip bytes the shell should embed for in-library .md files.
|
||||||
pub fn save_sidecar_bytes(&mut self) -> Option<Vec<u8>> {
|
pub fn save_sidecar_bytes(&mut self) -> Option<Vec<u8>> {
|
||||||
self.rebuild_modules();
|
self.rebuild_modules();
|
||||||
|
|
@ -2834,6 +2891,15 @@ impl EditorState {
|
||||||
} else {
|
} else {
|
||||||
None
|
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 {
|
let edit_target = if let TableMessage::EditCell(r, c) = &tmsg {
|
||||||
Some((*r, *c))
|
Some((*r, *c))
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -4148,6 +4214,7 @@ impl EditorState {
|
||||||
item("Save As...", "Ctrl+Shift+S", Message::Shell(ShellAction::SaveAs)),
|
item("Save As...", "Ctrl+Shift+S", Message::Shell(ShellAction::SaveAs)),
|
||||||
sep(),
|
sep(),
|
||||||
item("Export as Rust Library", "Ctrl+Shift+E", Message::Shell(ShellAction::ExportCrate)),
|
item("Export as Rust Library", "Ctrl+Shift+E", Message::Shell(ShellAction::ExportCrate)),
|
||||||
|
item("Print...", "Ctrl+P", Message::Shell(ShellAction::Print)),
|
||||||
sep(),
|
sep(),
|
||||||
item("Settings...", "Ctrl+,", Message::Shell(ShellAction::Settings)),
|
item("Settings...", "Ctrl+,", Message::Shell(ShellAction::Settings)),
|
||||||
item("Quit", "Ctrl+Q", Message::Shell(ShellAction::Quit)),
|
item("Quit", "Ctrl+Q", Message::Shell(ShellAction::Quit)),
|
||||||
|
|
@ -4562,6 +4629,27 @@ fn parse_let_binding(line: &str) -> Option<String> {
|
||||||
Some(name.to_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>> {
|
fn macos_key_binding(key_press: KeyPress) -> Option<Binding<Message>> {
|
||||||
let KeyPress { key, modifiers, status, .. } = &key_press;
|
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
|
/// 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> {
|
pub fn write_clipboard_image_to_cache(img: &arboard::ImageData) -> Option<String> {
|
||||||
let dir = dirs::home_dir()?.join(".acord").join("cache").join("images");
|
let dir = dirs::home_dir()?.join(".acord").join("cache").join("images");
|
||||||
std::fs::create_dir_all(&dir).ok()?;
|
std::fs::create_dir_all(&dir).ok()?;
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,8 @@ use iced_wgpu::Engine;
|
||||||
use raw_window_handle::{RawDisplayHandle, RawWindowHandle};
|
use raw_window_handle::{RawDisplayHandle, RawWindowHandle};
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
use raw_window_handle::{AppKitDisplayHandle, AppKitWindowHandle};
|
use raw_window_handle::{AppKitDisplayHandle, AppKitWindowHandle};
|
||||||
|
#[cfg(target_os = "ios")]
|
||||||
|
use raw_window_handle::{UiKitDisplayHandle, UiKitWindowHandle};
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
use raw_window_handle::{Win32WindowHandle, WindowsDisplayHandle};
|
use raw_window_handle::{Win32WindowHandle, WindowsDisplayHandle};
|
||||||
|
|
||||||
|
|
@ -18,10 +20,12 @@ use crate::palette;
|
||||||
use crate::table_block::TableMessage;
|
use crate::table_block::TableMessage;
|
||||||
use crate::ViewportHandle;
|
use crate::ViewportHandle;
|
||||||
|
|
||||||
|
#[cfg(not(target_os = "ios"))]
|
||||||
struct AcordClipboard {
|
struct AcordClipboard {
|
||||||
board: std::cell::RefCell<arboard::Clipboard>,
|
board: std::cell::RefCell<arboard::Clipboard>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(not(target_os = "ios"))]
|
||||||
impl clipboard::Clipboard for AcordClipboard {
|
impl clipboard::Clipboard for AcordClipboard {
|
||||||
fn read(&self, _kind: clipboard::Kind) -> Option<String> {
|
fn read(&self, _kind: clipboard::Kind) -> Option<String> {
|
||||||
// arboard uses NSPasteboard on macOS, Win32 on Windows — no subprocess.
|
// 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
|
/// Mac/Windows entry point used by the C FFI. Synthesizes the platform's
|
||||||
/// display handle from the window pointer the Swift bridge provides.
|
/// display handle from the window pointer the Swift bridge provides.
|
||||||
/// Returns None on platforms that need both display and window — those
|
/// Returns None on platforms that need both display and window — those
|
||||||
|
|
@ -65,6 +80,11 @@ pub fn create(
|
||||||
RawWindowHandle::AppKit(AppKitWindowHandle::new(ptr)),
|
RawWindowHandle::AppKit(AppKitWindowHandle::new(ptr)),
|
||||||
RawDisplayHandle::AppKit(AppKitDisplayHandle::new()),
|
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")]
|
#[cfg(target_os = "windows")]
|
||||||
let (raw_window, raw_display) = {
|
let (raw_window, raw_display) = {
|
||||||
let wh = Win32WindowHandle::new(std::num::NonZero::new(ptr.as_ptr() as isize).unwrap());
|
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()),
|
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);
|
let _ = (ptr, width, height, scale);
|
||||||
return None;
|
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)
|
create_native(raw_display, raw_window, width, height, scale)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -217,9 +237,12 @@ pub fn render(handle: &mut ViewportHandle) {
|
||||||
&mut handle.renderer,
|
&mut handle.renderer,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
#[cfg(not(target_os = "ios"))]
|
||||||
let mut clipboard = AcordClipboard {
|
let mut clipboard = AcordClipboard {
|
||||||
board: std::cell::RefCell::new(arboard::Clipboard::new().unwrap()),
|
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 messages: Vec<Message> = Vec::new();
|
||||||
let mut consumed: Vec<usize> = Vec::new();
|
let mut consumed: Vec<usize> = Vec::new();
|
||||||
// Captured during the event scan, applied to `handle.state.mods` AFTER
|
// 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.
|
// 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 Some(text) = handle.state.pending_clipboard.take() {
|
||||||
if let Ok(mut board) = arboard::Clipboard::new() {
|
if let Ok(mut board) = arboard::Clipboard::new() {
|
||||||
let _ = board.set_text(text);
|
let _ = board.set_text(text);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
#[cfg(target_os = "ios")]
|
||||||
|
let _ = handle.state.pending_clipboard.take();
|
||||||
|
|
||||||
handle.state.tick();
|
handle.state.tick();
|
||||||
let pending_focus = handle.state.take_pending_focus();
|
let pending_focus = handle.state.take_pending_focus();
|
||||||
|
|
|
||||||
|
|
@ -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};
|
use std::ffi::{c_char, c_void, CStr, CString};
|
||||||
|
|
||||||
pub mod block;
|
pub mod block;
|
||||||
pub mod blocks;
|
pub mod blocks;
|
||||||
mod bridge;
|
pub mod bridge;
|
||||||
pub mod browser;
|
pub mod browser;
|
||||||
pub mod editor;
|
pub mod editor;
|
||||||
pub mod export;
|
pub mod export;
|
||||||
|
|
@ -12,6 +31,7 @@ pub mod hr_block;
|
||||||
pub mod module;
|
pub mod module;
|
||||||
pub mod oklab;
|
pub mod oklab;
|
||||||
pub mod palette;
|
pub mod palette;
|
||||||
|
pub mod print;
|
||||||
pub mod selection;
|
pub mod selection;
|
||||||
pub mod sidecar;
|
pub mod sidecar;
|
||||||
pub mod syntax;
|
pub mod syntax;
|
||||||
|
|
@ -19,10 +39,18 @@ pub mod table_block;
|
||||||
pub mod text_block;
|
pub mod text_block;
|
||||||
pub mod text_widget;
|
pub mod text_widget;
|
||||||
pub mod tree_block;
|
pub mod tree_block;
|
||||||
|
pub mod widgets;
|
||||||
|
|
||||||
pub use acord_core::*;
|
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_graphics::Viewport;
|
||||||
use iced_runtime::user_interface;
|
use iced_runtime::user_interface;
|
||||||
use iced_wgpu::core::Event;
|
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))); }
|
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)]
|
#[unsafe(no_mangle)]
|
||||||
pub extern "C" fn viewport_set_theme(handle: *mut ViewportHandle, name: *const c_char) {
|
pub extern "C" fn viewport_set_theme(handle: *mut ViewportHandle, name: *const c_char) {
|
||||||
let s = if name.is_null() {
|
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::Quit => "quit".to_string(),
|
||||||
editor::ShellAction::Settings => "settings".to_string(),
|
editor::ShellAction::Settings => "settings".to_string(),
|
||||||
editor::ShellAction::ExportCrate => "export_crate".to_string(),
|
editor::ShellAction::ExportCrate => "export_crate".to_string(),
|
||||||
|
editor::ShellAction::Print => "print".to_string(),
|
||||||
editor::ShellAction::ToggleBrowser => "toggle_browser".to_string(),
|
editor::ShellAction::ToggleBrowser => "toggle_browser".to_string(),
|
||||||
editor::ShellAction::SetThemeMode(v) => format!("set_theme_mode:{}", v),
|
editor::ShellAction::SetThemeMode(v) => format!("set_theme_mode:{}", v),
|
||||||
editor::ShellAction::SetLineIndicator(v) => format!("set_line_indicator:{}", v),
|
editor::ShellAction::SetLineIndicator(v) => format!("set_line_indicator:{}", v),
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
@ -1673,7 +1673,7 @@ fn compute_row_height(
|
||||||
(max_lines as f32 * line_h + pad_h).max(default_h)
|
(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();
|
let mut s = String::new();
|
||||||
loop {
|
loop {
|
||||||
s.insert(0, (b'A' + (idx % 26) as u8) as char);
|
s.insert(0, (b'A' + (idx % 26) as u8) as char);
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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 },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -18,6 +18,7 @@ use acord_viewport::{
|
||||||
viewport_set_auto_pair_flags,
|
viewport_set_auto_pair_flags,
|
||||||
viewport_send_command, viewport_free_string,
|
viewport_send_command, viewport_free_string,
|
||||||
viewport_take_sidecar_bytes, viewport_apply_sidecar_bytes, viewport_free_bytes,
|
viewport_take_sidecar_bytes, viewport_apply_sidecar_bytes, viewport_free_bytes,
|
||||||
|
viewport_render_pdf,
|
||||||
ViewportHandle,
|
ViewportHandle,
|
||||||
};
|
};
|
||||||
use acord_viewport::sidecar;
|
use acord_viewport::sidecar;
|
||||||
|
|
@ -110,6 +111,7 @@ impl App {
|
||||||
(*self.handle).state.settings_open = !(*self.handle).state.settings_open;
|
(*self.handle).state.settings_open = !(*self.handle).state.settings_open;
|
||||||
},
|
},
|
||||||
MenuAction::ExportCrate => {}
|
MenuAction::ExportCrate => {}
|
||||||
|
MenuAction::Print => self.print_to_pdf(),
|
||||||
MenuAction::ToggleBrowser => self.toggle_browser(event_loop),
|
MenuAction::ToggleBrowser => self.toggle_browser(event_loop),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -126,6 +128,7 @@ impl App {
|
||||||
ShellAction::Quit => event_loop.exit(),
|
ShellAction::Quit => event_loop.exit(),
|
||||||
ShellAction::Settings => {}
|
ShellAction::Settings => {}
|
||||||
ShellAction::ExportCrate => {}
|
ShellAction::ExportCrate => {}
|
||||||
|
ShellAction::Print => self.print_to_pdf(),
|
||||||
ShellAction::ToggleBrowser => self.toggle_browser(event_loop),
|
ShellAction::ToggleBrowser => self.toggle_browser(event_loop),
|
||||||
ShellAction::SetThemeMode(v) => {
|
ShellAction::SetThemeMode(v) => {
|
||||||
self.config.set("themeMode", &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 {
|
fn derive_default_filename(&self) -> String {
|
||||||
let text_ptr = viewport_get_text(self.handle);
|
let text_ptr = viewport_get_text(self.handle);
|
||||||
let text = if text_ptr.is_null() {
|
let text = if text_ptr.is_null() {
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ pub enum MenuAction {
|
||||||
Find,
|
Find,
|
||||||
Settings,
|
Settings,
|
||||||
ExportCrate,
|
ExportCrate,
|
||||||
|
Print,
|
||||||
ToggleBrowser,
|
ToggleBrowser,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -51,6 +52,7 @@ pub fn match_shortcut(modifiers: ModifiersState, key: &Key) -> Option<MenuAction
|
||||||
(false, 'f') => Some(MenuAction::Find),
|
(false, 'f') => Some(MenuAction::Find),
|
||||||
(false, 'e') => Some(MenuAction::Evaluate),
|
(false, 'e') => Some(MenuAction::Evaluate),
|
||||||
(true, 'e') => Some(MenuAction::ExportCrate),
|
(true, 'e') => Some(MenuAction::ExportCrate),
|
||||||
|
(false, 'p') => Some(MenuAction::Print),
|
||||||
(false, ',') => Some(MenuAction::Settings),
|
(false, ',') => Some(MenuAction::Settings),
|
||||||
(false, '=') | (false, '+') => Some(MenuAction::ZoomIn),
|
(false, '=') | (false, '+') => Some(MenuAction::ZoomIn),
|
||||||
(false, '-') => Some(MenuAction::ZoomOut),
|
(false, '-') => Some(MenuAction::ZoomOut),
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ use std::env;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::process::{Command, ExitCode};
|
use std::process::{Command, ExitCode};
|
||||||
|
|
||||||
const KNOWN_PLATFORMS: &[&str] = &["macos", "windows", "linux"];
|
const KNOWN_PLATFORMS: &[&str] = &["macos", "windows", "linux", "ios"];
|
||||||
|
|
||||||
fn main() -> ExitCode {
|
fn main() -> ExitCode {
|
||||||
let args: Vec<String> = env::args().skip(1).collect();
|
let args: Vec<String> = env::args().skip(1).collect();
|
||||||
|
|
@ -32,7 +32,7 @@ fn main() -> ExitCode {
|
||||||
"-File",
|
"-File",
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
"linux" | "macos" => (
|
"linux" | "macos" | "ios" => (
|
||||||
repo_root.join(format!("scripts/{platform}/{action}.sh")),
|
repo_root.join(format!("scripts/{platform}/{action}.sh")),
|
||||||
vec!["bash"],
|
vec!["bash"],
|
||||||
),
|
),
|
||||||
|
|
@ -111,6 +111,11 @@ fn print_help() {
|
||||||
eprintln!(" --all all six targets");
|
eprintln!(" --all all six targets");
|
||||||
eprintln!(" --target <name> e.g. macos-aarch64, windows-x86_64");
|
eprintln!(" --target <name> e.g. macos-aarch64, windows-x86_64");
|
||||||
eprintln!();
|
eprintln!();
|
||||||
eprintln!("append -macos / -windows / -linux 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!(" 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");
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue