diff --git a/Cargo.lock b/Cargo.lock
index 34a6fe55afe48023c7ef60de5d8340d15e9c73d1..1c8b8268362b464f9eaa30a2ca0c8c66c5c8f1c4 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -735,6 +735,7 @@ version = "0.1.0"
dependencies = [
"bytemuck",
"image",
+ "linebender_resource_handle",
"parley",
"png",
"pollster",
@@ -1550,6 +1551,18 @@ dependencies = [
"windows-sys 0.61.2",
]
+[[package]]
+name = "mobile"
+version = "0.1.0"
+dependencies = [
+ "canvas",
+ "draw",
+ "onestore",
+ "parley",
+ "pollster",
+ "wgpu",
+]
+
[[package]]
name = "moxcms"
version = "0.8.1"
diff --git a/apps/ios/Info.plist b/apps/ios/Info.plist
new file mode 100644
index 0000000000000000000000000000000000000000..fa335c4fb30e8abae2a23db7b8149bd129770c09
--- /dev/null
+++ b/apps/ios/Info.plist
@@ -0,0 +1,22 @@
+
+
+
+
+ CADisableMinimumFrameDurationOnPhone
+
+ UIApplicationSceneManifest
+
+ UIApplicationSupportsMultipleScenes
+
+
+ UILaunchScreen
+
+ UISupportedInterfaceOrientations
+
+ UIInterfaceOrientationPortrait
+ UIInterfaceOrientationPortraitUpsideDown
+ UIInterfaceOrientationLandscapeLeft
+ UIInterfaceOrientationLandscapeRight
+
+
+
diff --git a/apps/ios/Snowbound-Bridging-Header.h b/apps/ios/Snowbound-Bridging-Header.h
new file mode 100644
index 0000000000000000000000000000000000000000..32590292c6deef0ae2d979ddfa6f5fc6111e71ed
--- /dev/null
+++ b/apps/ios/Snowbound-Bridging-Header.h
@@ -0,0 +1 @@
+#include "snowbound.h"
diff --git a/apps/ios/Snowbound.xcodeproj/project.pbxproj b/apps/ios/Snowbound.xcodeproj/project.pbxproj
new file mode 100644
index 0000000000000000000000000000000000000000..ade163a6cf6230b92abeae2b766737955dd236de
--- /dev/null
+++ b/apps/ios/Snowbound.xcodeproj/project.pbxproj
@@ -0,0 +1,291 @@
+// !$*UTF8*$!
+{
+ archiveVersion = 1;
+ classes = {
+ };
+ objectVersion = 77;
+ objects = {
+
+/* Begin PBXBuildFile section */
+ 5B0000000000000000000101 /* Features.one in Resources */ = {isa = PBXBuildFile; fileRef = 5B0000000000000000000201 /* Features.one */; };
+/* End PBXBuildFile section */
+
+/* Begin PBXFileReference section */
+ 5B0000000000000000000200 /* Snowbound.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Snowbound.app; sourceTree = BUILT_PRODUCTS_DIR; };
+ 5B0000000000000000000201 /* Features.one */ = {isa = PBXFileReference; lastKnownFileType = file; name = Features.one; path = "../../corpus/media-edit/candidate/Features.one"; sourceTree = SOURCE_ROOT; };
+ 5B0000000000000000000202 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; };
+ 5B0000000000000000000203 /* Snowbound-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Snowbound-Bridging-Header.h"; sourceTree = ""; };
+ 5B0000000000000000000204 /* build-rust.sh */ = {isa = PBXFileReference; lastKnownFileType = text.script.sh; path = "build-rust.sh"; sourceTree = ""; };
+/* End PBXFileReference section */
+
+/* Begin PBXFileSystemSynchronizedRootGroup section */
+ 5B0000000000000000000300 /* Snowbound */ = {
+ isa = PBXFileSystemSynchronizedRootGroup;
+ path = Snowbound;
+ sourceTree = "";
+ };
+/* End PBXFileSystemSynchronizedRootGroup section */
+
+/* Begin PBXFrameworksBuildPhase section */
+ 5B0000000000000000000400 /* Frameworks */ = {
+ isa = PBXFrameworksBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXFrameworksBuildPhase section */
+
+/* Begin PBXGroup section */
+ 5B0000000000000000000500 = {
+ isa = PBXGroup;
+ children = (
+ 5B0000000000000000000300 /* Snowbound */,
+ 5B0000000000000000000201 /* Features.one */,
+ 5B0000000000000000000202 /* Info.plist */,
+ 5B0000000000000000000203 /* Snowbound-Bridging-Header.h */,
+ 5B0000000000000000000204 /* build-rust.sh */,
+ 5B0000000000000000000501 /* Products */,
+ );
+ sourceTree = "";
+ };
+ 5B0000000000000000000501 /* Products */ = {
+ isa = PBXGroup;
+ children = (
+ 5B0000000000000000000200 /* Snowbound.app */,
+ );
+ name = Products;
+ sourceTree = "";
+ };
+/* End PBXGroup section */
+
+/* Begin PBXNativeTarget section */
+ 5B0000000000000000000600 /* Snowbound */ = {
+ isa = PBXNativeTarget;
+ buildConfigurationList = 5B0000000000000000000901 /* Build configuration list for PBXNativeTarget "Snowbound" */;
+ buildPhases = (
+ 5B0000000000000000000700 /* Build Rust */,
+ 5B0000000000000000000701 /* Sources */,
+ 5B0000000000000000000400 /* Frameworks */,
+ 5B0000000000000000000702 /* Resources */,
+ );
+ buildRules = (
+ );
+ dependencies = (
+ );
+ fileSystemSynchronizedGroups = (
+ 5B0000000000000000000300 /* Snowbound */,
+ );
+ name = Snowbound;
+ productName = Snowbound;
+ productReference = 5B0000000000000000000200 /* Snowbound.app */;
+ productType = "com.apple.product-type.application";
+ };
+/* End PBXNativeTarget section */
+
+/* Begin PBXProject section */
+ 5B0000000000000000000800 /* Project object */ = {
+ isa = PBXProject;
+ attributes = {
+ BuildIndependentTargetsInParallel = 1;
+ LastSwiftUpdateCheck = 2630;
+ LastUpgradeCheck = 2630;
+ TargetAttributes = {
+ 5B0000000000000000000600 = {
+ CreatedOnToolsVersion = 26.3;
+ };
+ };
+ };
+ buildConfigurationList = 5B0000000000000000000900 /* Build configuration list for PBXProject "Snowbound" */;
+ developmentRegion = en;
+ hasScannedForEncodings = 0;
+ knownRegions = (
+ en,
+ Base,
+ );
+ mainGroup = 5B0000000000000000000500;
+ minimizedProjectReferenceProxies = 1;
+ preferredProjectObjectVersion = 77;
+ productRefGroup = 5B0000000000000000000501 /* Products */;
+ projectDirPath = "";
+ projectRoot = "";
+ targets = (
+ 5B0000000000000000000600 /* Snowbound */,
+ );
+ };
+/* End PBXProject section */
+
+/* Begin PBXResourcesBuildPhase section */
+ 5B0000000000000000000702 /* Resources */ = {
+ isa = PBXResourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ 5B0000000000000000000101 /* Features.one in Resources */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXResourcesBuildPhase section */
+
+/* Begin PBXShellScriptBuildPhase section */
+ 5B0000000000000000000700 /* Build Rust */ = {
+ isa = PBXShellScriptBuildPhase;
+ alwaysOutOfDate = 1;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ inputFileListPaths = (
+ );
+ inputPaths = (
+ );
+ name = "Build Rust";
+ outputFileListPaths = (
+ );
+ outputPaths = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ shellPath = /bin/sh;
+ shellScript = "\"$SRCROOT/build-rust.sh\"\n";
+ };
+/* End PBXShellScriptBuildPhase section */
+
+/* Begin PBXSourcesBuildPhase section */
+ 5B0000000000000000000701 /* Sources */ = {
+ isa = PBXSourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXSourcesBuildPhase section */
+
+/* Begin XCBuildConfiguration section */
+ 5B0000000000000000000A00 /* Debug */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ALWAYS_SEARCH_USER_PATHS = NO;
+ CLANG_ENABLE_MODULES = YES;
+ CODE_SIGN_STYLE = Automatic;
+ DEBUG_INFORMATION_FORMAT = dwarf;
+ DEVELOPMENT_TEAM = 9R7DPNW28H;
+ ENABLE_TESTABILITY = YES;
+ ENABLE_USER_SCRIPT_SANDBOXING = NO;
+ GCC_OPTIMIZATION_LEVEL = 0;
+ IPHONEOS_DEPLOYMENT_TARGET = 17.0;
+ ONLY_ACTIVE_ARCH = YES;
+ SDKROOT = iphoneos;
+ SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
+ SWIFT_OPTIMIZATION_LEVEL = "-Onone";
+ };
+ name = Debug;
+ };
+ 5B0000000000000000000A01 /* Release */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ALWAYS_SEARCH_USER_PATHS = NO;
+ CLANG_ENABLE_MODULES = YES;
+ CODE_SIGN_STYLE = Automatic;
+ DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
+ DEVELOPMENT_TEAM = 9R7DPNW28H;
+ ENABLE_USER_SCRIPT_SANDBOXING = NO;
+ IPHONEOS_DEPLOYMENT_TARGET = 17.0;
+ SDKROOT = iphoneos;
+ SWIFT_COMPILATION_MODE = wholemodule;
+ VALIDATE_PRODUCT = YES;
+ };
+ name = Release;
+ };
+ 5B0000000000000000000A02 /* Debug */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ARCHS = arm64;
+ CURRENT_PROJECT_VERSION = 1;
+ GENERATE_INFOPLIST_FILE = YES;
+ HEADER_SEARCH_PATHS = "$(SRCROOT)/../../crates/mobile/include";
+ INFOPLIST_FILE = Info.plist;
+ INFOPLIST_KEY_CFBundleDisplayName = Snowbound;
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/Frameworks",
+ );
+ "LIBRARY_SEARCH_PATHS[sdk=iphoneos*]" = "$(SRCROOT)/../../target/ios/aarch64-apple-ios/debug";
+ "LIBRARY_SEARCH_PATHS[sdk=iphonesimulator*]" = "$(SRCROOT)/../../target/ios/aarch64-apple-ios-sim/debug";
+ MARKETING_VERSION = 0.1;
+ OTHER_LDFLAGS = (
+ "-lmobile",
+ "-framework",
+ Metal,
+ "-framework",
+ QuartzCore,
+ "-framework",
+ CoreText,
+ );
+ PRODUCT_BUNDLE_IDENTIFIER = dev.clover.snowbound;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
+ SWIFT_EMIT_LOC_STRINGS = NO;
+ SWIFT_OBJC_BRIDGING_HEADER = "Snowbound-Bridging-Header.h";
+ SWIFT_VERSION = 5.0;
+ TARGETED_DEVICE_FAMILY = "1,2";
+ };
+ name = Debug;
+ };
+ 5B0000000000000000000A03 /* Release */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ARCHS = arm64;
+ CURRENT_PROJECT_VERSION = 1;
+ GENERATE_INFOPLIST_FILE = YES;
+ HEADER_SEARCH_PATHS = "$(SRCROOT)/../../crates/mobile/include";
+ INFOPLIST_FILE = Info.plist;
+ INFOPLIST_KEY_CFBundleDisplayName = Snowbound;
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/Frameworks",
+ );
+ "LIBRARY_SEARCH_PATHS[sdk=iphoneos*]" = "$(SRCROOT)/../../target/ios/aarch64-apple-ios/release";
+ "LIBRARY_SEARCH_PATHS[sdk=iphonesimulator*]" = "$(SRCROOT)/../../target/ios/aarch64-apple-ios-sim/release";
+ MARKETING_VERSION = 0.1;
+ OTHER_LDFLAGS = (
+ "-lmobile",
+ "-framework",
+ Metal,
+ "-framework",
+ QuartzCore,
+ "-framework",
+ CoreText,
+ );
+ PRODUCT_BUNDLE_IDENTIFIER = dev.clover.snowbound;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
+ SWIFT_EMIT_LOC_STRINGS = NO;
+ SWIFT_OBJC_BRIDGING_HEADER = "Snowbound-Bridging-Header.h";
+ SWIFT_VERSION = 5.0;
+ TARGETED_DEVICE_FAMILY = "1,2";
+ };
+ name = Release;
+ };
+/* End XCBuildConfiguration section */
+
+/* Begin XCConfigurationList section */
+ 5B0000000000000000000900 /* Build configuration list for PBXProject "Snowbound" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ 5B0000000000000000000A00 /* Debug */,
+ 5B0000000000000000000A01 /* Release */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Release;
+ };
+ 5B0000000000000000000901 /* Build configuration list for PBXNativeTarget "Snowbound" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ 5B0000000000000000000A02 /* Debug */,
+ 5B0000000000000000000A03 /* Release */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Release;
+ };
+/* End XCConfigurationList section */
+ };
+ rootObject = 5B0000000000000000000800 /* Project object */;
+}
diff --git a/apps/ios/Snowbound/App.swift b/apps/ios/Snowbound/App.swift
new file mode 100644
index 0000000000000000000000000000000000000000..a6f5061283c3e4975dc6e68cde6692b6494dd941
--- /dev/null
+++ b/apps/ios/Snowbound/App.swift
@@ -0,0 +1,130 @@
+import UIKit
+import UniformTypeIdentifiers
+
+@main
+final class AppDelegate: UIResponder, UIApplicationDelegate {
+ func application(
+ _ application: UIApplication,
+ configurationForConnecting session: UISceneSession,
+ options: UIScene.ConnectionOptions
+ ) -> UISceneConfiguration {
+ let configuration = UISceneConfiguration(name: nil, sessionRole: session.role)
+ configuration.delegateClass = SceneDelegate.self
+ return configuration
+ }
+}
+
+final class SceneDelegate: UIResponder, UIWindowSceneDelegate {
+ var window: UIWindow?
+
+ func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options: UIScene.ConnectionOptions) {
+ guard let scene = scene as? UIWindowScene else { return }
+ let window = UIWindow(windowScene: scene)
+ let path = ProcessInfo.processInfo.environment["SNOWBOUND_SECTION"]
+ ?? Bundle.main.path(forResource: "Features", ofType: "one")!
+ let pages = PagesViewController(section: Section(path: path))
+ window.rootViewController = UINavigationController(rootViewController: pages)
+ window.makeKeyAndVisible()
+ self.window = window
+ if let page = ProcessInfo.processInfo.environment["SNOWBOUND_PAGE"].flatMap(Int.init) {
+ pages.show(page: page, animated: false)
+ }
+ }
+}
+
+/// A parsed .one section; pages are read-only copies until saving lands.
+final class Section {
+ let handle: OpaquePointer?
+ let name: String
+
+ init(path: String) {
+ handle = sb_section_open(path)
+ name = (path as NSString).lastPathComponent.replacingOccurrences(of: ".one", with: "")
+ }
+
+ deinit { if let handle { sb_section_free(handle) } }
+
+ var titles: [String] {
+ guard let handle else { return [] }
+ return (0.. Int {
+ titles.count
+ }
+
+ override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
+ let cell = tableView.dequeueReusableCell(withIdentifier: "page", for: indexPath)
+ var content = cell.defaultContentConfiguration()
+ content.text = titles[indexPath.row].isEmpty ? "Untitled page" : titles[indexPath.row]
+ cell.contentConfiguration = content
+ cell.accessoryType = .disclosureIndicator
+ return cell
+ }
+
+ override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
+ tableView.deselectRow(at: indexPath, animated: true)
+ show(page: indexPath.row, animated: true)
+ }
+}
+
+final class PageViewController: UIViewController {
+ private let section: Section
+ private let page: Int
+
+ init(section: Section, page: Int, title: String) {
+ self.section = section
+ self.page = page
+ super.init(nibName: nil, bundle: nil)
+ self.title = title
+ navigationItem.largeTitleDisplayMode = .never
+ }
+
+ required init?(coder: NSCoder) { fatalError() }
+
+ override func loadView() {
+ view = CanvasView(section: section, page: page)
+ }
+}
diff --git a/apps/ios/Snowbound/CanvasView.swift b/apps/ios/Snowbound/CanvasView.swift
new file mode 100644
index 0000000000000000000000000000000000000000..ef59eaa3fedca7b425b9d89f0b582df180894a77
--- /dev/null
+++ b/apps/ios/Snowbound/CanvasView.swift
@@ -0,0 +1,449 @@
+import UIKit
+
+private final class MetalView: UIView {
+ override class var layerClass: AnyClass { CAMetalLayer.self }
+}
+
+/// An offset into the active outline's text in UTF-16 units, paragraphs joined by a newline.
+private final class Position: UITextPosition {
+ let value: Int
+ init(_ value: Int) { self.value = value }
+}
+
+private final class Range: UITextRange {
+ let lo: Int
+ let hi: Int
+ init(_ a: Int, _ b: Int) { (lo, hi) = (min(a, b), max(a, b)) }
+ override var start: UITextPosition { Position(lo) }
+ override var end: UITextPosition { Position(hi) }
+ override var isEmpty: Bool { lo == hi }
+}
+
+private final class SelectionRect: UITextSelectionRect {
+ private let frame: CGRect
+ private let first: Bool
+ private let last: Bool
+ init(_ frame: CGRect, first: Bool, last: Bool) { (self.frame, self.first, self.last) = (frame, first, last) }
+ override var rect: CGRect { frame }
+ override var writingDirection: NSWritingDirection { .leftToRight }
+ override var containsStart: Bool { first }
+ override var containsEnd: Bool { last }
+ override var isVertical: Bool { false }
+}
+
+private extension UITextPosition { var offset: Int { (self as! Position).value } }
+private extension UITextRange {
+ var lower: Int { (self as! Range).lo }
+ var upper: Int { (self as! Range).hi }
+}
+
+/// A canvas page: a scroll view whose pan and pinch drive the canvas viewport, with the page
+/// drawn into a Metal layer pinned to the visible bounds, and the active outline's text
+/// behind `UITextInput` so the system keyboard, marked text, autocorrection, dictation,
+/// selection handles and loupe work on it.
+final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextInteractionDelegate,
+ UITextSelectionDisplayInteractionDelegate
+{
+ private let section: Section
+ private let page: Int
+ private let metal = MetalView()
+ /// Sized to the page content at 100%, so the scroll view's zoom scale is the page zoom.
+ private let content = UIView()
+ private var handle: OpaquePointer?
+ private var link: CADisplayLink?
+ private var dirty = true
+ /// The content's corner at 100%, from which the scroll offset is measured.
+ private var origin = CGPoint.zero
+ private var syncing = false
+ private var scrolled = false
+ private lazy var tap = UITapGestureRecognizer(target: self, action: #selector(tapped))
+ private let interaction = UITextInteraction(for: .editable)
+ /// Draws the system caret, selection highlight and handles; the canvas paints neither.
+ private lazy var display = UITextSelectionDisplayInteraction(textInput: self, delegate: self)
+
+ weak var inputDelegate: UITextInputDelegate?
+ lazy var tokenizer: UITextInputTokenizer = UITextInputStringTokenizer(textInput: self)
+
+ init(section: Section, page: Int) {
+ self.section = section
+ self.page = page
+ super.init(frame: .zero)
+ backgroundColor = .white
+ delegate = self
+ minimumZoomScale = 0.25
+ maximumZoomScale = 4
+ keyboardDismissMode = .interactive
+ addSubview(content)
+ insertSubview(metal, at: 0)
+ metal.isUserInteractionEnabled = false
+ let layer = metal.layer as! CAMetalLayer
+ layer.isOpaque = true
+ // Frames reach the screen with the transaction that moves UIKit's caret and handles.
+ layer.presentsWithTransaction = true
+ addGestureRecognizer(tap)
+ interaction.textInput = self
+ interaction.delegate = self
+ addInteraction(interaction)
+ addInteraction(display)
+ NotificationCenter.default.addObserver(
+ self, selector: #selector(keyboardChanged), name: UIResponder.keyboardWillChangeFrameNotification,
+ object: nil)
+ }
+
+ required init?(coder: NSCoder) { fatalError() }
+
+ deinit {
+ link?.invalidate()
+ if let handle { sb_view_free(handle) }
+ }
+
+ // MARK: Drawing
+
+ override func layoutSubviews() {
+ super.layoutSubviews()
+ metal.frame = bounds
+ let scale = window?.screen.scale ?? 3
+ metal.layer.contentsScale = scale
+ guard bounds.width > 0, bounds.height > 0 else { return }
+ let pixels = CGSize(width: (bounds.width * scale).rounded(), height: (bounds.height * scale).rounded())
+ if let handle {
+ // Scrolling lays the view out too; only a new size reconfigures the surface.
+ guard (metal.layer as! CAMetalLayer).drawableSize != pixels else { return }
+ sb_view_resize(handle, Float(bounds.width), Float(bounds.height), Float(scale))
+ transform()
+ } else if let sectionHandle = section.handle {
+ handle = sb_view_new(
+ Unmanaged.passUnretained(metal.layer).toOpaque(), sectionHandle, page,
+ Float(bounds.width), Float(bounds.height), Float(scale))
+ guard let handle else { return }
+ sync()
+ let fit = (bounds.width - safeAreaInsets.left - safeAreaInsets.right) / content.bounds.width
+ zoomScale = min(1, max(0.5, fit))
+ home()
+ #if DEBUG
+ runScript()
+ #endif
+ }
+ }
+
+ override func didMoveToWindow() {
+ super.didMoveToWindow()
+ link?.invalidate()
+ link = nil
+ guard window != nil else { return }
+ let link = CADisplayLink(target: self, selector: #selector(frame(_:)))
+ link.preferredFrameRateRange = CAFrameRateRange(minimum: 60, maximum: 120, preferred: 120)
+ link.add(to: .main, forMode: .common)
+ self.link = link
+ }
+
+ @objc private func frame(_ link: CADisplayLink) {
+ guard let handle else { return }
+ if sb_view_frame_pending(handle) { dirty = true }
+ guard dirty else { return }
+ dirty = false
+ sb_view_render(handle)
+ }
+
+ // MARK: Scrolling and zoom
+
+ /// Sizes the scroll content to the page's after an edit, keeping the page point at the
+ /// view's corner. The scroll view owns the offset: the canvas's own clamping and caret
+ /// reveal know neither the navigation bar nor the keyboard, so `transform` overrides them.
+ private func sync() {
+ guard let handle else { return }
+ var bounds: [Float] = [0, 0, 0, 0]
+ sb_view_content(handle, &bounds)
+ let corner = CGPoint(x: origin.x + contentOffset.x / zoomScale, y: origin.y + contentOffset.y / zoomScale)
+ origin = CGPoint(x: CGFloat(bounds[0]), y: CGFloat(bounds[1]))
+ let size = CGSize(width: CGFloat(bounds[2]) - origin.x, height: CGFloat(bounds[3]) - origin.y)
+ syncing = true
+ content.bounds = CGRect(origin: .zero, size: size)
+ content.center = CGPoint(x: size.width * zoomScale / 2, y: size.height * zoomScale / 2)
+ contentSize = CGSize(width: size.width * zoomScale, height: size.height * zoomScale)
+ contentOffset = CGPoint(x: (corner.x - origin.x) * zoomScale, y: (corner.y - origin.y) * zoomScale)
+ syncing = false
+ transform()
+ }
+
+ private func transform() {
+ guard let handle, !syncing else { return }
+ sb_view_set_transform(
+ handle, Float(zoomScale), Float(origin.x + contentOffset.x / zoomScale),
+ Float(origin.y + contentOffset.y / zoomScale))
+ metal.frame = bounds
+ dirty = true
+ display.setNeedsSelectionUpdate()
+ }
+
+ /// Until the reader scrolls, the page's corner stays below the navigation bar as its
+ /// insets settle.
+ override func adjustedContentInsetDidChange() {
+ super.adjustedContentInsetDidChange()
+ home()
+ }
+
+ private func home() {
+ if !scrolled { contentOffset = CGPoint(x: -adjustedContentInset.left, y: -adjustedContentInset.top) }
+ }
+
+ func scrollViewWillBeginDragging(_ scrollView: UIScrollView) { scrolled = true }
+ func viewForZooming(in scrollView: UIScrollView) -> UIView? { content }
+ func scrollViewDidScroll(_ scrollView: UIScrollView) { transform() }
+ func scrollViewDidZoom(_ scrollView: UIScrollView) { transform() }
+
+ @objc private func keyboardChanged(_ notification: Notification) {
+ guard let frame = notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect,
+ let window
+ else { return }
+ let keyboard = convert(frame, from: window.screen.coordinateSpace)
+ let overlap = max(0, bounds.maxY - keyboard.minY - safeAreaInsets.bottom)
+ contentInset.bottom = overlap
+ verticalScrollIndicatorInsets.bottom = overlap
+ revealCaret()
+ }
+
+ // MARK: Touch
+
+ /// The canvas places the caret, focuses outlines and starts new ones; the system's text
+ /// interaction takes over inside the outline already taking input.
+ private func inActiveText(_ point: CGPoint) -> Bool {
+ guard let handle else { return false }
+ let local = visible(point)
+ return isFirstResponder && sb_view_in_active_text(handle, Float(local.x), Float(local.y))
+ }
+
+ override func gestureRecognizerShouldBegin(_ recognizer: UIGestureRecognizer) -> Bool {
+ recognizer === tap
+ ? !inActiveText(recognizer.location(in: self)) : super.gestureRecognizerShouldBegin(recognizer)
+ }
+
+ func interactionShouldBegin(_ interaction: UITextInteraction, at point: CGPoint) -> Bool {
+ inActiveText(point)
+ }
+
+ @objc private func tapped(_ recognizer: UITapGestureRecognizer) {
+ tap(at: recognizer.location(in: self))
+ }
+
+ /// A tap at `point` in the scroll view's bounds.
+ func tap(at point: CGPoint) {
+ guard let handle else { return }
+ let point = visible(point)
+ edit(external: true) { sb_view_tap(handle, Float(point.x), Float(point.y)) }
+ if !isFirstResponder { _ = becomeFirstResponder() }
+ }
+
+ /// Runs a change to the page, telling the system when it did not ask for it.
+ private func edit(external: Bool = false, _ change: () -> Bool) {
+ if external {
+ inputDelegate?.selectionWillChange(self)
+ inputDelegate?.textWillChange(self)
+ }
+ let changed = change()
+ if external {
+ inputDelegate?.textDidChange(self)
+ inputDelegate?.selectionDidChange(self)
+ }
+ if changed {
+ sync()
+ revealCaret()
+ }
+ }
+
+ private func revealCaret() {
+ guard let handle, isFirstResponder else { return }
+ var range: [UInt32] = [0, 0]
+ sb_selection(handle, &range)
+ let caret = caretRect(for: Position(Int(range[1])))
+ scrollRectToVisible(caret.insetBy(dx: -8, dy: -16), animated: false)
+ }
+
+ override var canBecomeFirstResponder: Bool { handle != nil }
+
+ override func becomeFirstResponder() -> Bool {
+ guard super.becomeFirstResponder() else { return false }
+ display.isActivated = true
+ return true
+ }
+
+ override func resignFirstResponder() -> Bool {
+ guard super.resignFirstResponder() else { return false }
+ display.isActivated = false
+ return true
+ }
+
+ /// Pasted text takes the keyboard's language, as Windows gives the clipboard.
+ override func paste(_ sender: Any?) {
+ guard let handle, let text = UIPasteboard.general.string else { return }
+ edit(external: true) { sb_paste(handle, text, textInputMode?.primaryLanguage ?? "") }
+ }
+
+ // MARK: Coordinates
+
+ /// A point in the scroll view's bounds as the canvas view's points from its corner.
+ private func visible(_ point: CGPoint) -> CGPoint {
+ CGPoint(x: point.x - contentOffset.x, y: point.y - contentOffset.y)
+ }
+
+ private func bounded(_ rect: [Float]) -> CGRect {
+ CGRect(
+ x: CGFloat(rect[0]) + contentOffset.x, y: CGFloat(rect[1]) + contentOffset.y,
+ width: CGFloat(rect[2]), height: CGFloat(rect[3]))
+ }
+
+ // MARK: UIKeyInput
+
+ var hasText: Bool { handle.map { sb_text_length($0) > 0 } ?? false }
+
+ func insertText(_ text: String) {
+ guard let handle else { return }
+ edit { sb_insert(handle, text) }
+ }
+
+ func deleteBackward() {
+ guard let handle else { return }
+ edit { sb_delete_backward(handle) }
+ }
+
+ // MARK: UITextInput
+
+ func text(in range: UITextRange) -> String? {
+ guard let handle, let text = sb_text(handle, UInt32(range.lower), UInt32(range.upper)) else { return nil }
+ defer { sb_string_free(text) }
+ return String(cString: text)
+ }
+
+ func replace(_ range: UITextRange, withText text: String) {
+ guard let handle else { return }
+ edit { sb_replace(handle, UInt32(range.lower), UInt32(range.upper), text) }
+ }
+
+ var selectedTextRange: UITextRange? {
+ get {
+ guard let handle else { return nil }
+ var range: [UInt32] = [0, 0]
+ sb_selection(handle, &range)
+ return Range(Int(range[0]), Int(range[1]))
+ }
+ set {
+ guard let handle, let newValue else { return }
+ _ = sb_select(handle, UInt32(newValue.lower), UInt32(newValue.upper))
+ dirty = true
+ display.setNeedsSelectionUpdate()
+ }
+ }
+
+ var markedTextRange: UITextRange? {
+ guard let handle else { return nil }
+ var range: [UInt32] = [0, 0]
+ return sb_marked(handle, &range) ? Range(Int(range[0]), Int(range[1])) : nil
+ }
+
+ var markedTextStyle: [NSAttributedString.Key: Any]?
+
+ func setMarkedText(_ markedText: String?, selectedRange: NSRange) {
+ guard let handle else { return }
+ edit {
+ sb_set_marked(
+ handle, markedText ?? "", UInt32(selectedRange.location),
+ UInt32(selectedRange.location + selectedRange.length))
+ }
+ }
+
+ func unmarkText() {
+ guard let handle else { return }
+ sb_unmark(handle)
+ dirty = true
+ }
+
+ var beginningOfDocument: UITextPosition { Position(0) }
+ var endOfDocument: UITextPosition { Position(handle.map { Int(sb_text_length($0)) } ?? 0) }
+
+ func textRange(from fromPosition: UITextPosition, to toPosition: UITextPosition) -> UITextRange? {
+ Range(fromPosition.offset, toPosition.offset)
+ }
+
+ func position(from position: UITextPosition, offset: Int) -> UITextPosition? {
+ let target = position.offset + offset
+ guard target >= 0, target <= endOfDocument.offset else { return nil }
+ return Position(target)
+ }
+
+ func position(
+ from position: UITextPosition, in direction: UITextLayoutDirection, offset: Int
+ ) -> UITextPosition? {
+ switch direction {
+ case .left: return self.position(from: position, offset: -offset)
+ case .right: return self.position(from: position, offset: offset)
+ default:
+ let caret = caretRect(for: position)
+ let step = (direction == .up ? -1 : 1) * CGFloat(offset) * caret.height
+ return closestPosition(to: CGPoint(x: caret.midX, y: caret.midY + step))
+ }
+ }
+
+ func compare(_ position: UITextPosition, to other: UITextPosition) -> ComparisonResult {
+ position.offset < other.offset
+ ? .orderedAscending : position.offset > other.offset ? .orderedDescending : .orderedSame
+ }
+
+ func offset(from: UITextPosition, to toPosition: UITextPosition) -> Int { toPosition.offset - from.offset }
+
+ func position(within range: UITextRange, farthestIn direction: UITextLayoutDirection) -> UITextPosition? {
+ direction == .left || direction == .up ? range.start : range.end
+ }
+
+ func characterRange(byExtending position: UITextPosition, in direction: UITextLayoutDirection)
+ -> UITextRange?
+ {
+ direction == .left || direction == .up
+ ? Range(0, position.offset) : Range(position.offset, endOfDocument.offset)
+ }
+
+ func baseWritingDirection(
+ for position: UITextPosition, in direction: UITextStorageDirection
+ ) -> NSWritingDirection { .leftToRight }
+
+ func setBaseWritingDirection(_ writingDirection: NSWritingDirection, for range: UITextRange) {}
+
+ func firstRect(for range: UITextRange) -> CGRect {
+ selectionRects(for: range).first?.rect ?? caretRect(for: range.start)
+ }
+
+ func caretRect(for position: UITextPosition) -> CGRect {
+ guard let handle else { return .zero }
+ var rect: [Float] = [0, 0, 0, 0]
+ guard sb_caret_rect(handle, UInt32(position.offset), &rect) else { return .zero }
+ var caret = bounded(rect)
+ caret.size.width = 2
+ return caret
+ }
+
+ func selectionRects(for range: UITextRange) -> [UITextSelectionRect] {
+ guard let handle else { return [] }
+ var rects = [(Float, Float, Float, Float)](repeating: (0, 0, 0, 0), count: 64)
+ let count = min(64, sb_range_rects(handle, UInt32(range.lower), UInt32(range.upper), &rects, 64))
+ return (0.. UITextPosition? {
+ guard let handle else { return nil }
+ let local = visible(point)
+ return Position(Int(sb_closest(handle, Float(local.x), Float(local.y))))
+ }
+
+ func closestPosition(to point: CGPoint, within range: UITextRange) -> UITextPosition? {
+ let offset = closestPosition(to: point)?.offset ?? range.lower
+ return Position(min(max(offset, range.lower), range.upper))
+ }
+
+ func characterRange(at point: CGPoint) -> UITextRange? {
+ guard let position = closestPosition(to: point) else { return nil }
+ return Range(position.offset, min(position.offset + 1, endOfDocument.offset))
+ }
+}
diff --git a/apps/ios/Snowbound/Script.swift b/apps/ios/Snowbound/Script.swift
new file mode 100644
index 0000000000000000000000000000000000000000..d98e1cae5c17dc4fb7feb25a3b12e114b307432c
--- /dev/null
+++ b/apps/ios/Snowbound/Script.swift
@@ -0,0 +1,56 @@
+#if DEBUG
+import UIKit
+
+extension CanvasView {
+ /// Replays `SNOWBOUND_SCRIPT` through the calls touch and the keyboard make, to check
+ /// input without a finger: steps joined by `|`, such as
+ /// `tap:120,300|type:hi|mark:かな|unmark|return|delete|select:2,9|scroll:0,600|zoom:1.5|shot:a`.
+ /// `tap` takes points from the view's corner, `select` text offsets, `scroll` a content
+ /// offset; `shot:a` saves the window to Documents/a.png, as a device has no screenshot command.
+ func runScript() {
+ guard let script = ProcessInfo.processInfo.environment["SNOWBOUND_SCRIPT"] else { return }
+ for (index, step) in script.split(separator: "|").enumerated() {
+ DispatchQueue.main.asyncAfter(deadline: .now() + 1 + Double(index) * 0.4) { [weak self] in
+ self?.perform(String(step))
+ }
+ }
+ }
+
+ private func perform(_ step: String) {
+ let (command, argument) = step.firstIndex(of: ":").map {
+ (String(step[..<$0]), String(step[step.index(after: $0)...]))
+ } ?? (step, "")
+ NSLog("script \(command) \(argument)")
+ switch command {
+ case "tap":
+ let values = argument.split(separator: ",").compactMap { Double($0) }
+ tap(at: CGPoint(x: values[0] + contentOffset.x, y: values[1] + contentOffset.y))
+ case "type": insertText(argument)
+ case "return": insertText("\n")
+ case "delete": deleteBackward()
+ case "mark": setMarkedText(argument, selectedRange: NSRange(location: argument.utf16.count, length: 0))
+ case "unmark": unmarkText()
+ case "select":
+ let values = argument.split(separator: ",").compactMap { Int($0) }
+ selectedTextRange = textRange(
+ from: position(from: beginningOfDocument, offset: values[0])!,
+ to: position(from: beginningOfDocument, offset: values[1])!)
+ case "scroll":
+ let values = argument.split(separator: ",").compactMap { Double($0) }
+ setContentOffset(CGPoint(x: values[0], y: values[1]), animated: true)
+ case "zoom": setZoomScale(Double(argument) ?? 1, animated: false)
+ case "shot":
+ guard let window else { break }
+ let image = UIGraphicsImageRenderer(bounds: window.bounds).image { _ in
+ window.drawHierarchy(in: window.bounds, afterScreenUpdates: false)
+ }
+ let documents = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
+ try? image.pngData()?.write(to: documents.appendingPathComponent("\(argument).png"))
+ default: NSLog("script: unknown step \(step)")
+ }
+ if let range = selectedTextRange, let all = textRange(from: beginningOfDocument, to: endOfDocument) {
+ NSLog("script selection \(offset(from: beginningOfDocument, to: range.start))..\(offset(from: beginningOfDocument, to: range.end)) text \(text(in: all) ?? "nil")")
+ }
+ }
+}
+#endif
diff --git a/apps/ios/build-rust.sh b/apps/ios/build-rust.sh
new file mode 100755
index 0000000000000000000000000000000000000000..aae3316c1622e3d2918d81abdf230cfa5dd10874
--- /dev/null
+++ b/apps/ios/build-rust.sh
@@ -0,0 +1,14 @@
+#!/bin/sh
+# Builds crates/mobile for the platform Xcode is building; Xcode links the static library.
+set -eu
+case "$PLATFORM_NAME" in
+iphonesimulator) target=aarch64-apple-ios-sim ;;
+*) target=aarch64-apple-ios ;;
+esac
+release=
+[ "$CONFIGURATION" = Release ] && release=--release
+root="$SRCROOT/../.."
+# Xcode's SDK variables would point host build scripts at the iOS SDK.
+exec env -i HOME="$HOME" PATH="$HOME/.cargo/bin:/etc/profiles/per-user/$USER/bin:/run/current-system/sw/bin:/usr/bin:/bin:/usr/sbin:/sbin" USER="$USER" \
+ CARGO_TARGET_DIR="$root/target/ios" \
+ cargo build --manifest-path "$root/Cargo.toml" -p mobile --target "$target" $release
diff --git a/crates/canvas/examples/render_page.rs b/crates/canvas/examples/render_page.rs
index ce1e54e584546004c6493815e24a6174b53ec663..437c22fdc536dee602850161aa3fb4d3e212ec59 100644
--- a/crates/canvas/examples/render_page.rs
+++ b/crates/canvas/examples/render_page.rs
@@ -39,9 +39,7 @@ fn main() -> Result<(), Box> {
let margin_origin = page.margin_origin;
let object_count = page.objects.len();
let mut scene = PageScene::new(page, &mut engine)?;
- while !scene.update_backgrounds(Paper::WHITE, 96.0 / 72.0, std::task::Waker::noop()) {
- std::thread::sleep(Duration::from_millis(1));
- }
+ scene.settle(None, 96.0 / 72.0, Paper::WHITE);
let mut primitives = Vec::new();
scene.append_primitives(&mut primitives, [0.0; 2], Paper::WHITE)?;
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
diff --git a/crates/canvas/src/document.rs b/crates/canvas/src/document.rs
index 2cf577707378dae05471f0c125696899da26cd19..5f8e9b3db5797ca8ea5408c5c1b8d2e52d8c8e60 100644
--- a/crates/canvas/src/document.rs
+++ b/crates/canvas/src/document.rs
@@ -234,6 +234,39 @@ fn validate_text(nodes: &[PageParagraph]) -> Result<(), EditError> {
Ok(())
}
+/// Whether UTF-16 `offset` lies inside a hyperlink field, which OneNote has not been seen
+/// splitting between paragraphs.
+fn divides_link(text: &Paragraph, offset: u32) -> Result {
+ let at = text.byte_offset(offset)?;
+ let link = |byte: usize| {
+ text.spans()
+ .iter()
+ .find(|span| byte < span.end)
+ .is_some_and(|span| span.format.hyperlink == Some(true))
+ };
+ Ok(at > 0 && link(at - 1) && link(at) && !text.text()[at..].starts_with('\u{fddf}'))
+}
+
+/// Whether a split or join keeping `first` up to UTF-16 `start` and `last` from `end` on meets
+/// an equation or embedded object at the seam or carries one to another paragraph: their run
+/// data belongs to the whole paragraph, and OneNote has not been seen dividing them.
+fn moves_object(first: &Paragraph, start: u32, last: &Paragraph, end: u32) -> Result {
+ let object = |text: &Paragraph, span: usize| {
+ let format = &text.spans()[span].format;
+ [format.math, format.embedded_object].contains(&Some(true))
+ };
+ let (before, after) = (first.byte_offset(start)?, last.byte_offset(end)?);
+ Ok(first.text()[..before].ends_with('\u{fffc}')
+ || last.text()[after..].contains('\u{fffc}')
+ || before > 0
+ && first
+ .spans()
+ .iter()
+ .position(|span| before - 1 < span.end)
+ .is_some_and(|span| object(first, span))
+ || (0..last.spans().len()).any(|span| last.spans()[span].end > after && object(last, span)))
+}
+
/// Paragraph positions of each node's first text leaf, counting from `first`.
fn starts(nodes: &[PageParagraph], first: usize) -> impl Iterator- + '_ {
nodes.iter().scan(first, |next, node| {
@@ -610,8 +643,18 @@ impl TextDocument {
return Err(EditError::UnsupportedContent);
}
let nodes = self.container(container)?;
- let mut prefix = first.text().unwrap().text.slice(0..range.start.offset)?;
+ let first_text = &first.text().unwrap().text;
let last_text = &last.text().unwrap().text;
+ let split = replacement.len() > 1;
+ if (split || start != end)
+ && (moves_object(first_text, range.start.offset, last_text, range.end.offset)?
+ || split
+ && (divides_link(first_text, range.start.offset)?
+ || divides_link(last_text, range.end.offset)?))
+ {
+ return Err(EditError::UnsupportedContent);
+ }
+ let mut prefix = first_text.slice(0..range.start.offset)?;
let suffix =
last_text.slice(range.end.offset..last_text.utf16_offset(last_text.text().len())?)?;
let mut replacement = replacement.into_iter();
@@ -666,28 +709,70 @@ impl TextDocument {
/// Appends text leaf `lower`'s text to `upper`'s, keeping the upper paragraph's properties
/// and giving it the lower one's children; None unless nothing but `upper`'s hidden subtree
- /// lies between them in one container.
+ /// lies between them in one container. The lower text keeps its look under `base`, the
+ /// upper paragraph's style: as OneNote stores it, a flag it leaves unset becomes false and
+ /// an unset colour automatic where the style sets them. An emptied upper paragraph takes
+ /// the lower text whole.
pub(crate) fn join(
&self,
upper: usize,
lower: usize,
+ base: &Format,
) -> Result