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, EditError> { let (container, first, top) = self.leaf(upper).ok_or(EditError::InvalidRange)?; let (end_container, last, bottom) = self.leaf(lower).ok_or(EditError::InvalidRange)?; let nodes = self.container(container)?; + let (above, below) = (&top.text().unwrap().text, &bottom.text().unwrap().text); if container != end_container || last <= first || last > first + 1 && !(top.collapsed && subtree_end(nodes, first) == last) + || moves_object(above, above.utf16_offset(above.text().len())?, below, 0)? { return Ok(None); } let mut head = top.clone(); let text = head.text_mut().unwrap(); - // OneNote moves the lower text object into an emptied upper paragraph. - if text.text.text().is_empty() { - text.id = bottom.text().unwrap().id; + if above.text().is_empty() { + // OneNote moves the lower text object, with its style and any recording link, into + // an emptied upper paragraph, which keeps its own note tags. + let tags = std::mem::take(&mut text.tags); + *text = bottom.text().unwrap().clone(); + text.tags = tags; + head.style = bottom.style; + head.media.clone_from(&bottom.media); + } else { + let automatic = |color: Option| color.map(|_| 0xff000000); + let reset = Format { + bold: base.bold.map(|_| false), + italic: base.italic.map(|_| false), + underline: base.underline.map(|_| false), + strike: base.strike.map(|_| false), + superscript: base.superscript.map(|_| false), + subscript: base.subscript.map(|_| false), + hidden: base.hidden.map(|_| false), + hyperlink: base.hyperlink.map(|_| false), + math: base.math.map(|_| false), + color: automatic(base.color), + highlight: automatic(base.highlight), + ..Format::default() + }; + // The joined text lies in the upper paragraph, whose spacing and alignment it takes. + let paragraph = &above.spans()[0].format; + let mut start = 0; + text.text.append(Paragraph::from_runs(below.spans().iter().map(|span| { + let run = below.text()[start..span.end].to_owned(); + start = span.end; + let format = Format { + alignment: paragraph.alignment, + space_before: paragraph.space_before, + space_after: paragraph.space_after, + line_spacing: paragraph.line_spacing, + list_spacing: paragraph.list_spacing, + ..span.format.inherit(&reset) + }; + (run, format) + })))?; } - text.text.append(bottom.text().unwrap().text.clone())?; let moves = BTreeMap::from([(bottom.id, &head)]); let adopted = adopt(nodes, last + 1, &moves, BTreeMap::new())?; Ok(Some(DocumentEdit { diff --git a/crates/canvas/src/editor.rs b/crates/canvas/src/editor.rs index ca9616bb9be4fe7186b3d9f2d976d59522511451..dc9e1c2458938d2b05f6bc1b109ab5b7f0d23eee 100644 --- a/crates/canvas/src/editor.rs +++ b/crates/canvas/src/editor.rs @@ -8,7 +8,9 @@ use draw::edit::{self, Movement, SelectionUnit}; use onestore::ExGuid; use onestore::document::{Format, Kind}; use onestore::page::text::{EditError, Paragraph}; -use onestore::page::{Definition, Outline, Page, PageObject, PageParagraph, Title}; +use onestore::page::{ + Definition, Outline, Page, PageObject, PageParagraph, ParagraphContent, Title, +}; use parley::{ Affinity, BoundingBox, editing::{Cursor, Selection as ParagraphSelection}, @@ -766,6 +768,35 @@ impl CanvasEditor { .filter(|outline| !self.has_page_outline(outline.id)) .map(|outline| PageObject::Outline(outline.snapshot())), ); + // Definitions stay for undo after their last paragraph lets go; the page, as + // OneNote stores it, holds only those its paragraphs reference. + let mut referenced = BTreeSet::new(); + for object in &objects { + let outlines = match object { + PageObject::Outline(outline) => std::slice::from_ref(outline), + PageObject::Title(title) => title.outlines.as_slice(), + _ => &[], + }; + for (_, _, node) in outlines + .iter() + .flat_map(|outline| descendants(&outline.paragraphs, None)) + { + let content_tags = match &node.content { + ParagraphContent::Text(text) => text.tags.as_slice(), + ParagraphContent::Table(table) => table.tags.as_slice(), + _ => &[], + }; + referenced.extend(node.lists.iter().copied().chain(node.style)); + referenced.extend( + node.tags + .iter() + .chain(content_tags) + .filter_map(|tag| tag.definition), + ); + } + } + let mut definitions = self.definitions.clone(); + definitions.retain(|id, _| referenced.contains(id)); Ok(Page { title: self.header.title.clone(), identity: self.header.identity, @@ -776,7 +807,7 @@ impl CanvasEditor { .or(self.header.created), margin_origin: self.header.margin_origin, objects, - definitions: self.definitions.clone(), + definitions, }) } @@ -1099,10 +1130,14 @@ impl CanvasEditor { }) } + /// An empty paragraph in `base`'s style, whose runs keep `base`'s language as OneNote's do. fn blank_paragraph(&self, base: &PageParagraph) -> Result { - let format = self.style_format(base.style)?; + let mut format = self.style_format(base.style)?; + format.language = format.language.or(base + .text() + .and_then(|text| text.text.spans()[0].format.language)); let mut node = - crate::document::node(Paragraph::new(String::new(), format.clone()), format)?; + crate::document::node(Paragraph::new(String::new(), format), base.format.clone())?; node.level = base.level; node.style = base.style; Ok(node) @@ -1449,8 +1484,13 @@ impl CanvasEditor { .iter() .position(|object| matches!(object, page::Content::Image(image) if image.id == id)) .ok_or(EditError::InvalidRange)?; + // An emptied outline keeps its slot for undo after leaving `outlines`. let editable = |object: &page::Content| match object { - page::Content::Editable(outline) => Some(*outline), + page::Content::Editable(outline) + if self.outlines.iter().any(|item| item.id == *outline) => + { + Some(*outline) + } _ => None, }; let outline = if forward { @@ -2226,21 +2266,31 @@ impl CanvasEditor { /// Pastes plain text as OneNote does: lines become plain Calibri 11 paragraphs without style /// or list between the halves of the caret's paragraph (`evidence/structural-edits/xml/c7-*`). - pub fn paste(&mut self, engine: &mut TextEngine, text: &str) -> Result<(), EditorError> { + /// Pasted runs take the clipboard's `language`, an LCID, not the caret's run's as typing + /// does; Windows derives it from the keyboard language at copy time. + pub fn paste( + &mut self, + engine: &mut TextEngine, + text: &str, + language: u32, + ) -> Result<(), EditorError> { let lines = text .split('\n') .map(|line| line.strip_suffix('\r').unwrap_or(line)) .collect::>(); let last = lines[lines.len() - 1]; + let [anchor, focus] = self.active_outline().selection.positions; + let (start, end) = (anchor.min(focus), anchor.max(focus)); if lines.len() == 1 { - return self.insert(engine, last); + let mut format = self.typing_format(start)?; + format.language = Some(language); + return self.replace(engine, vec![Paragraph::new(last.to_owned(), format)]); } - let [anchor, focus] = self.active_outline().selection.positions; - let (start, end) = (anchor.min(focus), anchor.max(focus)); let edge = Paragraph::new(String::new(), self.typing_format(start)?); let pasted = Format { font: Some("Calibri".into()), font_size: Some(11.0), + language: Some(language), ..Format::default() }; let mut edit = self.active_outline().document.replace( @@ -2283,7 +2333,7 @@ impl CanvasEditor { let format = self.typing_format(start)?; let document = &self.active_outline().document; let mut edit = - document.replace(start..end, vec![Paragraph::new(String::new(), format); 2])?; + document.replace(start..end, vec![Paragraph::new(String::new(), format.clone()); 2])?; let nodes = document.container(edit.container)?; let next = nodes .get(crate::document::subtree_end(nodes, edit.range.start)) @@ -2319,8 +2369,12 @@ impl CanvasEditor { .keys() .copied() .find(|id| style(Some(*id)) == Some("p")); - tail.text_mut().unwrap().text = - Paragraph::new(String::new(), self.style_format(tail.style)?); + // The heading's run formatting carries over under the body style (`c9-h1-*`). + let run = format.over(&self.style_format(head.style)?); + tail.text_mut().unwrap().text = Paragraph::new( + String::new(), + run.inherit(&self.style_format(tail.style)?), + ); } let caret = TextPosition { paragraph: start.paragraph + 1, @@ -2438,7 +2492,12 @@ impl CanvasEditor { } else { [focus.paragraph, neighbor] }; - let Some(edit) = outline.document.join(upper, lower)? else { + let (_, _, top) = outline + .document + .leaf(upper) + .ok_or(EditError::InvalidRange)?; + let base = self.style_format(top.style)?; + let Some(edit) = outline.document.join(upper, lower, &base)? else { return Ok(false); }; let text = outline.document.paragraph(upper).unwrap(); @@ -2982,23 +3041,33 @@ impl CanvasEditor { /// Gives each paragraph an edit adds its own copy of the lists it carries, as OneNote keeps /// a list node per paragraph. fn own_lists(&mut self, edit: &mut DocumentEdit) -> Result<(), EditError> { - let existing = self.active_outline().document.container(edit.container)? - [edit.range.clone()] - .iter() - .map(|node| node.id) + let existing = descendants( + &self.active_outline().document.container(edit.container)?[edit.range.clone()], + None, + ) + .map(|(_, _, node)| node.id) .collect::>(); - for node in &mut edit.replacement { - if existing.contains(&node.id) { - continue; + let mut pending = edit.replacement.iter_mut().collect::>(); + while let Some(node) = pending.pop() { + if !existing.contains(&node.id) { + for list in &mut node.lists { + let definition = self + .definitions + .get(list) + .ok_or(EditError::InvalidStructure)? + .clone(); + *list = onestore::page::text::new_id()?; + self.definitions.insert(*list, definition); + } } - for list in &mut node.lists { - let definition = self - .definitions - .get(list) - .ok_or(EditError::InvalidStructure)? - .clone(); - *list = onestore::page::text::new_id()?; - self.definitions.insert(*list, definition); + if let ParagraphContent::Table(table) = &mut node.content { + pending.extend( + table + .rows + .iter_mut() + .flat_map(|row| &mut row.cells) + .flat_map(|cell| &mut cell.paragraphs), + ); } } Ok(()) @@ -4778,6 +4847,13 @@ mod tests { assert!(editor.step_from_image(&mut engine, id, true).unwrap()); assert_eq!(editor.active_outline().id, after); assert_eq!(editor.selection().positions, caret(0, 0)); + + editor.select_all().unwrap(); + assert!(editor.delete(&mut engine, true).unwrap()); + editor.focus_outline(before).unwrap(); + assert!(editor.outlines().iter().all(|outline| outline.id != after)); + assert!(!editor.step_from_image(&mut engine, id, true).unwrap()); + assert_eq!(editor.active_outline().id, before); } #[test] diff --git a/crates/canvas/src/editor/evidence.rs b/crates/canvas/src/editor/evidence.rs index e257b26eeef0450fd5f2b0e518cb3e94f7b2c96b..6601402a87b96a28c6113398de37d14e6d3ed92f 100644 --- a/crates/canvas/src/editor/evidence.rs +++ b/crates/canvas/src/editor/evidence.rs @@ -207,7 +207,7 @@ enum Key { Tab, ShiftTab, Type(&'static str), - Paste(&'static str), + Paste(&'static str, u32), Bullets, } @@ -238,7 +238,7 @@ fn press(editor: &mut CanvasEditor, engine: &mut TextEngine, key: Key) { Key::Tab => editor.tab(engine, false).unwrap(), Key::ShiftTab => editor.tab(engine, true).unwrap(), Key::Type(text) => editor.insert(engine, text).unwrap(), - Key::Paste(text) => editor.paste(engine, text).unwrap(), + Key::Paste(text, language) => editor.paste(engine, text, language).unwrap(), Key::Bullets => editor.format(engine, Formatting::Bullets).unwrap(), } } @@ -565,11 +565,12 @@ fn pasted_lines_are_plain_paragraphs_between_the_halves() { &mut engine, &target(kind), (1, Offset::Back(4)), - &[(Key::Paste(text), &format!("c7-{kind}-1"))], + &[(Key::Paste(text, 1033), &format!("c7-{kind}-1"))], ); let document = &editor.active_outline().document; let format = Format { bold: None, + language: Some(1033), ..calibri(11.0, false) }; assert_eq!(document.paragraph(2).unwrap().spans()[0].format, format); @@ -586,10 +587,64 @@ fn pasted_lines_are_plain_paragraphs_between_the_halves() { &mut engine, " L1 [] [] qs0 Above\n L2 [] [num:1.] qs0 First\n L2 [] [num:2.] qs0 Second", (1, Offset::Back(1)), - &[(Key::Paste("Line one\r\nLine two"), "c7-num2-1")], + &[(Key::Paste("Line one\r\nLine two", 1033), "c7-num2-1")], ); } +/// OneNote 2010 typing and pasting into a French run: typed text stays French, pasted text, +/// one line or several, takes the clipboard's language (en-US in the captures). +#[test] +fn pasted_text_takes_the_clipboard_language_and_typed_text_the_runs() { + let mut engine = TextEngine::default(); + let (fr, en, de) = (Some(1036), Some(1033), Some(1031)); + let french = Format { + language: fr, + ..calibri(11.0, false) + }; + for (key, languages) in [ + (Key::Type("Typed"), vec![fr]), + (Key::Paste("Pasted", 1033), vec![fr, en, fr]), + ( + Key::Paste("Line one\r\nLine two", 1033), + vec![fr, en, en, fr], + ), + (Key::Paste("Pasted", 1031), vec![fr, de, fr]), + ( + Key::Paste("Line one\r\nLine two", 1031), + vec![fr, de, de, fr], + ), + ] { + let mut editor = open(&mut engine, " L1 [] [] qs0 Target text"); + editor + .select( + [0, 11] + .map(|offset| TextPosition { + paragraph: 0, + offset, + }) + .into(), + ) + .unwrap(); + editor + .replace( + &mut engine, + vec![Paragraph::new("Target text".into(), french.clone())], + ) + .unwrap(); + place(&mut editor, (0, Offset::Back(4))); + press(&mut editor, &mut engine, key); + let document = &editor.active_outline().document; + let paragraphs = (0..).map_while(|paragraph| document.paragraph(paragraph)); + assert_eq!( + paragraphs + .flat_map(|paragraph| paragraph.spans()) + .map(|span| span.format.language) + .collect::>(), + languages + ); + } +} + #[test] fn a_list_applied_after_a_plain_paragraph_nests_under_it_and_stays_nested() { let mut engine = TextEngine::default(); diff --git a/crates/canvas/src/editor/format.rs b/crates/canvas/src/editor/format.rs index 0f6b08ba1146d4611dd3d96c556f5f009e1bbbd6..0df0a6b6640b55b5ec3f1daf16100717b97b4483 100644 --- a/crates/canvas/src/editor/format.rs +++ b/crates/canvas/src/editor/format.rs @@ -560,36 +560,7 @@ impl CanvasEditor { } }); } - Formatting::Check => { - let checkable = |tag: &Tag| { - matches!( - self.tag_kind(tag), - Some(Kind::TagDefinition { - shape: Some(CHECKBOX), - .. - }) - ) - }; - let checked = covered(&replacement, ends) - .flat_map(|(node, _)| tags(node)) - .filter(|tag| checkable(tag)) - .all(|tag| tag.status & 1 != 0); - let completed = if checked { Some(0) } else { time32() }; - leaves_mut(&mut replacement, &mut |node| { - if ranges.remove(&node.id).is_none() { - return; - } - let ParagraphContent::Text(text) = &mut node.content else { - unreachable!() - }; - for tag in node.tags.iter_mut().chain(&mut text.tags) { - if checkable(tag) && (tag.status & 1 != 0) == checked { - tag.status ^= 1; - tag.completed = completed; - } - } - }); - } + Formatting::Check => self.check(&mut replacement, ranges, ends), Formatting::Toggle(_) | Formatting::Font(_) | Formatting::FontSize(_) @@ -668,6 +639,84 @@ impl CanvasEditor { } } +impl CanvasEditor { + /// A click on a check box tag: checks the check boxes of paragraph `id` in outline + /// `outline`, or clears them once all are checked, as one undo step that keeps the + /// selection. + pub fn click_check( + &mut self, + engine: &mut TextEngine, + outline: ExGuid, + id: ExGuid, + ) -> Result<(), EditorError> { + self.focus_outline(outline)?; + let outline = self.active_outline(); + let document = &outline.document; + let paragraph = leaves(document.nodes(), None) + .position(|(_, _, node)| node.id == id) + .ok_or(EditError::InvalidRange)?; + let at = TextPosition { + paragraph, + offset: 0, + }; + let (container, range, ends) = selected(document, [at; 2].into())?; + let mut replacement = document.container(container)?[range.clone()].to_vec(); + let ranges = covered(&replacement, ends) + .map(|(node, range)| (node.id, range)) + .collect(); + self.check(&mut replacement, ranges, ends); + let selection = outline.selection; + self.commit( + engine, + DocumentEdit { + columns: BTreeMap::new(), + container, + range, + replacement, + }, + selection, + ) + } + + /// Checks the check boxes of the `covered` paragraphs, or clears them once all are + /// checked; OneNote keeps a cleared box's completion time as zero. + fn check( + &self, + replacement: &mut [PageParagraph], + mut covered: BTreeMap>, + ends: Ends, + ) { + let checkable = |tag: &Tag| { + matches!( + self.tag_kind(tag), + Some(Kind::TagDefinition { + shape: Some(CHECKBOX), + .. + }) + ) + }; + let checked = self::covered(replacement, ends) + .flat_map(|(node, _)| tags(node)) + .filter(|tag| checkable(tag)) + .all(|tag| tag.status & 1 != 0); + let completed = if checked { Some(0) } else { time32() }; + leaves_mut(replacement, &mut |node| { + if covered.remove(&node.id).is_none() { + return; + } + let ParagraphContent::Text(text) = &mut node.content else { + unreachable!() + }; + for tag in node.tags.iter_mut().chain(&mut text.tags) { + if checkable(tag) && (tag.status & 1 != 0) == checked { + tag.status ^= 1; + tag.completed = completed; + } + } + }); + } +} + /// The list a Tab (`deeper`) or Shift+Tab gives a paragraph with a default list, as OneNote /// 2010 steps • to ○ and 1. to a. (`evidence/structural-edits/xml/c6-bullet-tab.xml`, /// `c8-tab-1.xml`), then to ■ as `corpus/private` nests bullets, and to i.; outdenting stops at @@ -1210,6 +1259,38 @@ mod tests { .collect() } + /// OneNote 2010 checks a clicked To Do box (status 1, completion time now) and clears it + /// (status 0, completion time kept as zero) without moving the caret. + #[test] + fn clicking_a_check_box_checks_and_clears_it_as_one_step() { + let mut engine = TextEngine::default(); + let mut editor = plain(&mut engine, &["task", "other"]); + editor.select([at(0, 0), at(1, 5)].into()).unwrap(); + editor + .format(&mut engine, Formatting::Tag(NoteTag::ToDo)) + .unwrap(); + editor.select([at(1, 2); 2].into()).unwrap(); + let outline = editor.active_outline().id; + let task = editor.active_outline().document.nodes()[0].id; + let selection = editor.selection(); + editor.click_check(&mut engine, outline, task).unwrap(); + assert_eq!(editor.selection(), selection); + assert_eq!(text_tags(&editor, 0), [("To Do".into(), 1, true)]); + assert_eq!(text_tags(&editor, 1), [("To Do".into(), 0, true)]); + let tag = |editor: &CanvasEditor| { + editor.active_outline().document.nodes()[0] + .text() + .unwrap() + .tags[0] + .clone() + }; + assert!(tag(&editor).completed.is_some_and(|time| time > 0)); + editor.click_check(&mut engine, outline, task).unwrap(); + assert_eq!((tag(&editor).status, tag(&editor).completed), (0, Some(0))); + editor.undo(&mut engine).unwrap(); + assert_eq!(tag(&editor).status, 1); + } + #[test] fn tags_toggle_check_and_replace_tags_of_their_action_type() { let mut engine = TextEngine::default(); diff --git a/crates/canvas/src/editor/table.rs b/crates/canvas/src/editor/table.rs index e12e71c9de62a8e71238a8174ffcee8b3795454a..b9d6eaca1c37f063e7cee1b998561a9a1ba6b0f6 100644 --- a/crates/canvas/src/editor/table.rs +++ b/crates/canvas/src/editor/table.rs @@ -311,8 +311,15 @@ impl CanvasEditor { }; let (replacement, next) = if exit { table.rows.pop(); - let first = self.blank_paragraph(source)?; - let second = self.blank_paragraph(source)?; + let blank = || { + Ok::<_, EditError>(PageParagraph { + parent: location.node.parent, + level: location.node.level, + format: location.node.format.clone(), + ..self.blank_paragraph(source)? + }) + }; + let (first, second) = (blank()?, blank()?); ( vec![wrapper, first, second], TextPosition { diff --git a/crates/canvas/src/gpu/mod.rs b/crates/canvas/src/gpu/mod.rs index fa67ea8aae27f9e33de134bd75462a9396c8ff76..b96c3a6920f3b5f5bc6b98c36f5dc0c0c8687878 100644 --- a/crates/canvas/src/gpu/mod.rs +++ b/crates/canvas/src/gpu/mod.rs @@ -32,6 +32,7 @@ impl Viewport { scale: self.scale, origin: self.origin, clip: None, + backdrop: None, primitives, } } @@ -53,7 +54,7 @@ impl Paper { /// A near-neutral colour OneNote draws on white paper, moved onto this paper: its /// darkness becomes ink and its tint tints the paper. - fn shade(&self, light: [f32; 4]) -> [f32; 4] { + pub(crate) fn shade(&self, light: [f32; 4]) -> [f32; 4] { let ink = 1.0 - (light[0] + light[1] + light[2]) / 3.0; let mut shade = light; for channel in 0..3 { @@ -78,8 +79,14 @@ impl Glyphs for TextLayout { bounds.baseline, run.style().brush.rise, [bounds.top, bounds.height], - // A highlight keeps its stored colour, so automatic text on it stays black. - |brush: &TextBrush| brush.color.or(brush.highlight.map(|_| 0)).map(colorref), + // Automatic text on a highlight stays black, but on a black highlight, which + // paints in the ink, it stays ink. + |brush: &TextBrush| { + brush + .color + .or(brush.highlight.filter(|&color| color != 0).map(|_| 0)) + .map(colorref) + }, paint, )?; } diff --git a/crates/canvas/src/gpu/page.rs b/crates/canvas/src/gpu/page.rs index c4a16cb908324020d3f3b2c8bc84d60e9c977236..b49ccd5091da072c02cc7cab8a98818336be046c 100644 --- a/crates/canvas/src/gpu/page.rs +++ b/crates/canvas/src/gpu/page.rs @@ -1,4 +1,5 @@ mod background; +mod picture; use super::{Paper, colorref}; use crate::editor::ReadOnlyObject; @@ -9,18 +10,68 @@ use crate::{ layout::{LayoutError, TextEngine}, }; use background::Background; -use draw::{Primitive, RasterImage, RenderError}; +use draw::Primitive; use onestore::page::Page; -use std::fmt; +use picture::Picture; +use std::{ + fmt, + sync::{Arc, Mutex, OnceLock, mpsc}, + task::Waker, + time::{Duration, Instant}, +}; /// Retained drawing data in the source page's coordinate system. pub struct PageScene { reference: Option>, - images: std::collections::BTreeMap, + /// Pictures and file icons, by the identity of the object that shows them. + pictures: std::collections::BTreeMap, /// Background pictures from OneNote's page templates, painted from their recreations. backgrounds: std::collections::BTreeMap, } +/// How long the zoom must hold before a raster is made again for it. +const SETTLE: Duration = Duration::from_millis(150); + +/// Where work done off the frame thread lands while its requester keeps it. +type Slot = Arc>>; + +/// Runs `work` on the scenes' one raster thread once `start` has passed, unless the slot +/// returned has been dropped by then; the result lands in the slot and wakes `waker`. +fn queue( + start: Instant, + waker: &Waker, + work: impl FnOnce() -> T + Send + 'static, +) -> Slot { + static WORKER: OnceLock>> = OnceLock::new(); + let slot = Slot::default(); + let requester = Arc::downgrade(&slot); + let waker = waker.clone(); + WORKER + .get_or_init(|| { + let (sender, jobs) = mpsc::channel::>(); + std::thread::Builder::new() + .name("page pictures".into()) + .spawn(move || jobs.into_iter().for_each(|job| job())) + .expect("The picture thread starts"); + sender + }) + .send(Box::new(move || { + std::thread::sleep(start.saturating_duration_since(Instant::now())); + if let Some(slot) = requester.upgrade() { + *slot.lock().unwrap() = Some(work()); + waker.wake(); + } + })) + .expect("The picture thread outlives its jobs"); + slot +} + +/// Device pixels per point a raster is made at for `scale`: the next half power of two, +/// so zooming makes rasters again in steps. +fn density(scale: f32) -> f32 { + 2_f32.powf((scale.max(0.25).log2() * 2.0).ceil() / 2.0) +} + #[derive(Debug, PartialEq)] pub enum SceneHit { Outline(T), @@ -106,7 +157,6 @@ pub enum SceneError { Layout(LayoutError), Editor(EditorError), MissingOutline, - Image(RenderError), MissingImage, InvalidGeometry, } @@ -117,7 +167,6 @@ impl fmt::Display for SceneError { Self::Layout(error) => error.fmt(f), Self::Editor(error) => error.fmt(f), Self::MissingOutline => f.write_str("An editable outline is missing from this page."), - Self::Image(error) => write!(f, "Image rendering failed: {error:?}"), Self::MissingImage => f.write_str("An image is missing its data or dimensions."), Self::InvalidGeometry => { f.write_str("The page contains invalid object dimensions or positions.") @@ -146,7 +195,7 @@ impl PageScene { let mut objects = crate::editor::page::build(&mut page, engine, false) .map_err(SceneError::from)? .objects; - let pictures = Self::decode_images(&objects, None)?; + let pictures = Self::pictures(&objects, None)?; pictures.mark_unavailable(&mut objects, engine)?; Ok(Self { reference: Some(objects), @@ -159,12 +208,13 @@ impl PageScene { engine: &mut TextEngine, ) -> Result<(Self, CanvasEditor), SceneError> { let mut editor = CanvasEditor::from_page(page, engine).map_err(SceneError::from)?; - let scene = Self::decode_images(&editor.objects, Some(&editor))?; + let scene = Self::pictures(&editor.objects, Some(&editor))?; scene.mark_unavailable(&mut editor.objects, engine)?; Ok((scene, editor)) } - /// Turns page pictures that did not decode into placeholders; their stored data is kept. + /// Turns page pictures the renderer cannot decode into placeholders; their stored data + /// is kept. fn mark_unavailable( &self, objects: &mut Vec, @@ -174,7 +224,7 @@ impl PageScene { .into_iter() .map(|object| match object { Content::Image(source) - if !self.images.contains_key(&source.id) + if !self.pictures.contains_key(&source.id) && !self.backgrounds.contains_key(&source.id) => { Content::unavailable(onestore::page::PageObject::Image(source), engine) @@ -185,24 +235,21 @@ impl PageScene { Ok(()) } - /// Decodes every picture the page draws, page-level ones and those inside outlines, - /// except the template backgrounds it recognises; one without decodable data is left out. - fn decode_images( - objects: &[Content], - editor: Option<&CanvasEditor>, - ) -> Result { + /// Every picture the page draws, page-level ones and those inside outlines, except the + /// template backgrounds it recognises; one the renderer cannot decode is left out. + fn pictures(objects: &[Content], editor: Option<&CanvasEditor>) -> Result { fn nested<'a>( nodes: &'a [onestore::page::PageParagraph], - payloads: &mut Vec<(onestore::ExGuid, Option<&'a [u8]>)>, + payloads: &mut Vec<(onestore::ExGuid, Option<&'a Arc<[u8]>>)>, ) { for node in nodes { match &node.content { onestore::page::ParagraphContent::Image(image) => { - payloads.push((image.id, image.bytes.as_deref())) + payloads.push((image.id, image.bytes.as_ref())) } // A file without the icon OneNote rendered for it keeps an empty slot. onestore::page::ParagraphContent::Attachment(file) => { - if let Some(icon) = file.preview.as_deref() { + if let Some(icon) = file.preview.as_ref() { payloads.push((file.id, Some(icon))) } } @@ -225,7 +272,7 @@ impl PageScene { { backgrounds.insert(source.id, art); } else { - payloads.push((source.id, source.bytes.as_deref())) + payloads.push((source.id, source.bytes.as_ref())) } } Content::Outline { source, .. } => nested(&source.paragraphs, &mut payloads), @@ -239,46 +286,112 @@ impl PageScene { Content::Date { .. } | Content::Ink(_) | Content::ReadOnly(_) => {} } } - let mut images = std::collections::BTreeMap::new(); - let mut bytes = 0_u64; + let mut pictures = std::collections::BTreeMap::new(); for (id, encoded) in payloads { - let Some(image) = encoded.and_then(|encoded| RasterImage::decode(encoded).ok()) else { + let Some(picture) = encoded.and_then(Picture::new) else { continue; }; - bytes += image.pixels().len() as u64; - if bytes > draw::MAX_IMAGE_BYTES { - return Err(SceneError::Image(RenderError::ImageBudget)); - } - if images.insert(id, image).is_some() { + if pictures.insert(id, picture).is_some() { return Err(SceneError::InvalidGeometry); } } Ok(Self { reference: None, - images, + pictures, backgrounds, }) } - pub fn image(&self, id: onestore::ExGuid) -> Option<&RasterImage> { - self.images.get(&id) - } - - /// Brings template backgrounds to `paper` at `scale` device pixels per point, rasterizing - /// off this thread; `waker` is woken when a raster lands and the page should be drawn - /// again. Call before collecting each frame's primitives. True once every background - /// shows its raster for this paper and scale. - pub fn update_backgrounds( + /// Brings template backgrounds to `paper` and the pictures near `view`, a rectangle in + /// scene coordinates, to `scale` device pixels per point, rasterizing and decoding off + /// this thread; `waker` is woken when one lands and the page should be drawn again. + /// Call before collecting each frame's primitives. True once everything in view shows + /// its raster for this paper and scale. + pub fn update_pictures( &mut self, - paper: Paper, + editor: Option<&CanvasEditor>, + view: [f32; 4], scale: f32, - waker: &std::task::Waker, + paper: Paper, + waker: &Waker, ) -> bool { let mut settled = true; for art in self.backgrounds.values_mut() { settled &= art.update(paper, scale, waker); } - settled + let rects = self.picture_rects(editor); + picture::update(&mut self.pictures, rects, view, scale, waker) && settled + } + + /// Waits until the whole page shows its rasters for `paper` at `scale`, for drawing it + /// offscreen. + pub fn settle(&mut self, editor: Option<&CanvasEditor>, scale: f32, paper: Paper) { + let everything = [ + f32::NEG_INFINITY, + f32::NEG_INFINITY, + f32::INFINITY, + f32::INFINITY, + ]; + while !self.update_pictures(editor, everything, scale, paper, Waker::noop()) { + std::thread::sleep(Duration::from_millis(1)); + } + } + + /// The raster a picture or file icon shows, once decoded. + pub fn image(&self, id: onestore::ExGuid) -> Option<&draw::RasterImage> { + self.pictures.get(&id)?.image() + } + + /// Where pictures and file icons sit, in scene coordinates. + fn picture_rects(&self, editor: Option<&CanvasEditor>) -> Vec<(onestore::ExGuid, [f32; 4])> { + fn outline( + layout: &crate::outline::OutlineLayout, + [x, y]: [f32; 2], + rects: &mut Vec<(onestore::ExGuid, [f32; 4])>, + ) { + rects.extend(layout.objects.iter().map(|object| { + let [x0, y0, x1, y1] = object.rect; + (object.id, [x0 + x, y0 + y, x1 + x, y1 + y]) + })); + } + let mut rects = Vec::new(); + for content in self.objects(editor).into_iter().flatten() { + match content { + Content::Image(source) => { + let [x, y] = [ + source.layout.x.unwrap_or(0.0), + source.layout.y.unwrap_or(0.0), + ]; + if let (Some(width), Some(height)) = + (source.layout.max_width, source.layout.max_height) + { + rects.push((source.id, [x, y, x + width, y + height])); + } + } + Content::Outline { + source, + layout, + below_title, + } => { + let origin = [ + source.layout.x.unwrap_or(0.0), + source.layout.y.unwrap_or(0.0), + ]; + if let Ok(origin) = outline_origin(origin, *below_title, editor) { + outline(layout, origin, &mut rects); + } + } + Content::Editable(id) => { + if let Some(editable) = + editor.and_then(|editor| editor.visible_outlines().find(|o| o.id == *id)) + { + outline(editable.shaped(), editable.origin(), &mut rects); + } + } + Content::Date { .. } | Content::Ink(_) | Content::ReadOnly(_) => {} + } + } + rects } /// Pictures, files and handwriting inside an outline whose origin is `origin`. @@ -302,7 +415,8 @@ impl PageScene { y1 + origin[1], ], }; - if let Some(image) = self.images.get(&object.id) { + let picture = self.pictures.get(&object.id); + if let Some(image) = picture.and_then(Picture::image) { primitives.push(Primitive::Image { image, rect }); } match &object.kind { @@ -312,7 +426,7 @@ impl PageScene { crate::outline::ObjectKind::Unsupported(_) => { append_placeholder(rect, paper, primitives) } - crate::outline::ObjectKind::Picture if !self.images.contains_key(&object.id) => { + crate::outline::ObjectKind::Picture if picture.is_none_or(Picture::failed) => { append_placeholder(rect, paper, primitives) } crate::outline::ObjectKind::Picture | crate::outline::ObjectKind::File(_) => {} @@ -588,21 +702,35 @@ impl PageScene { let object_origin = [origin[0] + offset[0], origin[1] + offset[1]]; match content { Content::Image(source) => { + // The paper shows until the worker's raster lands. let (image, [width, height]) = match self.backgrounds.get(&source.id) { - // The paper shows until the worker's raster lands. - Some(art) => match art.image(paper) { - Some(image) => (image, art.size), - None => continue, - }, - None => ( - self.images + Some(art) => (art.image(paper), art.size), + None => { + let picture = self + .pictures .get(&source.id) - .ok_or(SceneError::MissingImage)?, - [ + .ok_or(SceneError::MissingImage)?; + let size = [ source.layout.max_width.ok_or(SceneError::MissingImage)?, source.layout.max_height.ok_or(SceneError::MissingImage)?, - ], - ), + ]; + if picture.failed() { + append_placeholder( + [ + object_origin[0], + object_origin[1], + object_origin[0] + size[0], + object_origin[1] + size[1], + ], + paper, + primitives, + ); + } + (picture.image(), size) + } + }; + let Some(image) = image else { + continue; }; primitives.push(Primitive::Image { image, @@ -632,7 +760,7 @@ impl PageScene { }; outline.append_table_primitives(primitives, object_origin, paper); let everything = [f32::NEG_INFINITY, f32::INFINITY]; - outline.append_background_primitives(primitives, object_origin, everything); + outline.append_background_primitives(primitives, object_origin, everything, paper); self.append_outline_objects(outline, object_origin, None, paper, primitives); for (index, paragraph) in outline.visible(everything) { outline.append_paragraph_primitives( @@ -671,12 +799,14 @@ impl crate::outline::OutlineLayout { }) } - /// Highlights behind the paragraphs `visible` finds between outline-local `rows`. + /// Highlights behind the paragraphs `visible` finds between outline-local `rows`. A black + /// highlight paints in the paper's ink, censoring the automatic text on it in any theme. pub fn append_background_primitives( &self, primitives: &mut Vec>, origin: [f32; 2], rows: [f32; 2], + paper: Paper, ) { for (index, paragraph) in self.visible(rows) { for (mut rect, color) in paragraph.text.backgrounds() { @@ -697,7 +827,11 @@ impl crate::outline::OutlineLayout { rect.x1 as f32 + origin[0], rect.y1 as f32 + origin[1], ], - color: colorref(color), + color: if color == 0 { + paper.ink + } else { + colorref(color) + }, }); } } @@ -786,7 +920,7 @@ impl crate::outline::OutlineLayout { origin[0] + self.tag_column_offset() + tag.origin[0], y + tag.origin[1], ], - size: crate::outline::ParagraphTag::SIZE, + size: tag.size, tint: [1.0, 1.0, 1.0, if tag.disabled { 0.45 } else { 1.0 }], }); } @@ -1253,7 +1387,8 @@ mod tests { background, })); } - let (scene, mut editor) = PageScene::from_page(page, &mut engine).unwrap(); + let (mut scene, mut editor) = PageScene::from_page(page, &mut engine).unwrap(); + scene.settle(Some(&editor), 1.0, Paper::WHITE); let images = |editor: &CanvasEditor| { let mut primitives = Vec::new(); scene @@ -1749,8 +1884,10 @@ mod tests { ], }; let mut engine = TextEngine::default(); - let reference = PageScene::new(page(), &mut engine).unwrap(); - let (scene, mut editor) = PageScene::from_page(page(), &mut engine).unwrap(); + let mut reference = PageScene::new(page(), &mut engine).unwrap(); + let (mut scene, mut editor) = PageScene::from_page(page(), &mut engine).unwrap(); + reference.settle(None, 1.0, Paper::WHITE); + scene.settle(Some(&editor), 1.0, Paper::WHITE); let mut reference_primitives = Vec::new(); reference .append_primitives(&mut reference_primitives, [0.0; 2], Paper::WHITE) @@ -1975,7 +2112,8 @@ mod tests { })], }; let mut engine = TextEngine::default(); - let scene = PageScene::new(page(3.0), &mut engine).unwrap(); + let mut scene = PageScene::new(page(3.0), &mut engine).unwrap(); + scene.settle(None, 1.0, Paper::WHITE); for invalid in [f32::NAN, f32::INFINITY, -1.0, 0.0] { assert!(matches!( PageScene::new(page(invalid), &mut engine), @@ -2046,7 +2184,8 @@ mod tests { objects: vec![PageObject::Image(background), PageObject::Image(picture)], }; let mut engine = TextEngine::default(); - let (scene, editor) = PageScene::from_page(page, &mut engine).unwrap(); + let (mut scene, editor) = PageScene::from_page(page, &mut engine).unwrap(); + scene.settle(Some(&editor), 1.0, Paper::WHITE); let hit = |x| scene.hit_test::<()>([x, 10.0], Some(&editor), |_| None); assert_eq!(hit(60.0), Some(SceneHit::Image(id))); assert_eq!(hit(10.0), None); @@ -2070,4 +2209,100 @@ mod tests { .collect(); assert_eq!(rects, [[0.0, 0.0, 100.0, 100.0], [70.0, 5.0, 120.0, 55.0]]); } + + #[test] + fn pictures_past_the_budget_open_decode_in_view_and_keep_their_bytes() { + let side = 1024; + let mut bytes = Vec::new(); + { + let mut encoder = png::Encoder::new(&mut bytes, side, side); + encoder.set_color(png::ColorType::Rgba); + encoder.set_depth(png::BitDepth::Eight); + encoder + .write_header() + .unwrap() + .write_image_data(&[40, 90, 160, 255].repeat((side * side) as usize)) + .unwrap(); + } + let bytes: Arc<[u8]> = Arc::from(bytes); + let pictures: Vec<_> = (0..20) + .map(|index| Image { + size: None, + id: onestore::page::text::new_id().unwrap(), + layout: Layout { + x: Some(0.0), + y: Some(index as f32 * 600.0), + max_width: Some(512.0), + max_height: Some(512.0), + ..Default::default() + }, + bytes: Some(Arc::clone(&bytes)), + alt: None, + background: false, + }) + .collect(); + assert!(20 * u64::from(side * side) * 4 > draw::MAX_IMAGE_BYTES); + let page = Page { + identity: None, + created: None, + title: String::new(), + margin_origin: [0.0; 2], + definitions: BTreeMap::new(), + objects: pictures.iter().cloned().map(PageObject::Image).collect(), + }; + let mut engine = TextEngine::default(); + let (mut scene, editor) = PageScene::from_page(page, &mut engine).unwrap(); + let kept = |scene: &PageScene| -> u64 { + let shown = scene.pictures.values().filter_map(Picture::image); + shown.map(|image| image.pixels().len() as u64).sum() + }; + let shown = |scene: &PageScene, index: usize| { + scene.image(pictures[index].id).map(|image| image.size()) + }; + let settle = |scene: &mut PageScene, view: [f32; 4]| { + let give_up = Instant::now() + Duration::from_secs(20); + while !scene.update_pictures(Some(&editor), view, 2.0, Paper::WHITE, Waker::noop()) { + assert!( + Instant::now() < give_up, + "the pictures in view never landed" + ); + std::thread::sleep(Duration::from_millis(2)); + } + assert!(kept(scene) <= picture::BUDGET, "{}", kept(scene)); + }; + for top in (0..20).map(|index| index as f32 * 600.0) { + let view = [0.0, top, 600.0, top + 1200.0]; + settle(&mut scene, view); + let index = (top / 600.0) as usize; + assert_eq!(shown(&scene, index), Some([side; 2])); + let mut primitives = Vec::new(); + scene + .append_primitives_with::( + &mut primitives, + [0.0; 2], + Some(&editor), + None, + Paper::WHITE, + |_, _, _| Ok(()), + ) + .unwrap(); + assert!(primitives.iter().any(|primitive| matches!( + primitive, + Primitive::Image { rect, .. } if rect[1] == top + ))); + } + assert_eq!(shown(&scene, 0), None, "the first picture was let go"); + // Every picture in view at once shrinks to fit rather than failing the frame. + settle(&mut scene, [0.0, 0.0, 600.0, 12_000.0]); + for index in 0..20 { + let [width, height] = shown(&scene, index).unwrap(); + assert!(width < side && width == height, "{width}×{height}"); + } + for object in editor.page().unwrap().objects { + let PageObject::Image(picture) = object else { + panic!() + }; + assert!(Arc::ptr_eq(picture.bytes.as_ref().unwrap(), &bytes)); + } + } } diff --git a/crates/canvas/src/gpu/page/background.rs b/crates/canvas/src/gpu/page/background.rs index 18f2236ba94cda808fd113d280e259bb9d833506..8b34f76d6df5d07ed956381f9bc8f06f7b879b97 100644 --- a/crates/canvas/src/gpu/page/background.rs +++ b/crates/canvas/src/gpu/page/background.rs @@ -1,12 +1,12 @@ //! OneNote 2010's page template art, recognised by the SHA-256 of the embedded picture and //! painted from vector recreations that suit the paper. +use super::{SETTLE, Slot, density, queue}; use crate::gpu::Paper; use draw::RasterImage; use resvg::{tiny_skia, usvg}; use sha2::{Digest, Sha256}; use std::{ - sync::{Arc, Mutex, OnceLock, Weak, mpsc}, task::Waker, time::{Duration, Instant}, }; @@ -311,8 +311,6 @@ const TEMPLATES: &[(&str, &str)] = &[ /// A raster holds at most this many bytes; page-sized art stops sharpening past about 2.9 /// device pixels per point. const MAX_RASTER_BYTES: f32 = 16.0 * 1024.0 * 1024.0; -/// How long the zoom must hold before art is rasterized again for it. -const SETTLE: Duration = Duration::from_millis(150); /// Template art standing in for a page's background picture. pub(super) struct Background { @@ -328,18 +326,7 @@ struct Variant { /// The raster painted and its device pixels per point. shown: Option<(RasterImage, f32)>, /// The one raster being made and its device pixels per point; replacing it abandons it. - pending: Option<(Arc>>, f32)>, -} - -/// Art rasterized on the worker thread, into `slot` unless its scene has let it go. -struct Job { - svg: &'static str, - /// The dark paper to recolour for; light paper takes the art as drawn. - dark: Option, - size: [u32; 2], - start: Instant, - slot: Weak>>, - waker: Waker, + pending: Option<(Slot, f32)>, } impl Background { @@ -375,8 +362,7 @@ impl Background { pub fn update(&mut self, paper: Paper, scale: f32, waker: &Waker) -> bool { let dark = dark(paper); let [width, height] = self.size.map(|side| side.max(1.0)); - let density = 2_f32 - .powf((scale.max(0.25).log2() * 2.0).ceil() / 2.0) + let density = density(scale) .min((MAX_RASTER_BYTES / (4.0 * width * height)).sqrt()) .min(4096.0 / width.max(height)); let variant = &mut self.variants[usize::from(dark)]; @@ -394,22 +380,16 @@ impl Background { if shown != Some(density) && variant.pending.as_ref().map(|(_, made)| *made) != Some(density) { - let slot = Arc::default(); - worker() - .send(Job { - svg: self.svg, - dark: dark.then_some(paper), - size: [width, height].map(|side| (side * density).round().max(1.0) as u32), - start: Instant::now() - + if shown.is_some() { - SETTLE - } else { - Duration::ZERO - }, - slot: Arc::downgrade(&slot), - waker: waker.clone(), - }) - .expect("The background worker outlives its jobs"); + let svg = self.svg; + let paper = dark.then_some(paper); + let size = [width, height].map(|side| (side * density).round().max(1.0) as u32); + let start = Instant::now() + + if shown.is_some() { + SETTLE + } else { + Duration::ZERO + }; + let slot = queue(start, waker, move || rasterize(svg, paper, size)); variant.pending = Some((slot, density)); } shown == Some(density) @@ -417,34 +397,11 @@ impl Background { } fn dark(paper: Paper) -> bool { - let [lightness, ..] = oklab(paper.color); - let [ink, ..] = oklab(paper.ink); + let [lightness, ..] = draw::oklab(paper.color); + let [ink, ..] = draw::oklab(paper.ink); ink > lightness } -/// The process's one background rasterizer, started on first use. -fn worker() -> &'static mpsc::Sender { - static WORKER: OnceLock> = OnceLock::new(); - WORKER.get_or_init(|| { - let (sender, jobs) = mpsc::channel::(); - std::thread::Builder::new() - .name("template backgrounds".into()) - .spawn(move || { - for job in jobs { - std::thread::sleep(job.start.saturating_duration_since(Instant::now())); - let Some(slot) = job.slot.upgrade() else { - continue; - }; - let image = rasterize(job.svg, job.dark, job.size); - *slot.lock().unwrap() = Some(image); - job.waker.wake(); - } - }) - .expect("The background worker starts"); - sender - }) -} - fn rasterize(svg: &str, dark: Option, [width, height]: [u32; 2]) -> RasterImage { let tree = match dark { Some(paper) => parse(&onto(svg, paper)), @@ -479,8 +436,8 @@ fn parse(svg: &str) -> usvg::Tree { /// the paper towards its ink, easing off so the art stays behind the text, and hue and /// chroma carry over. Keywords, such as a mask's `white`, stay as they are. fn onto(svg: &str, paper: Paper) -> String { - let [lightness, a, b] = oklab(paper.color); - let [ink, ..] = oklab(paper.ink); + let [lightness, a, b] = draw::oklab(paper.color); + let [ink, ..] = draw::oklab(paper.ink); let mut pieces = svg.split('#'); let mut recoloured = pieces.next().unwrap_or_default().to_owned(); for piece in pieces { @@ -494,9 +451,9 @@ fn onto(svg: &str, paper: Paper) -> String { continue; }; let [_, red, green, blue] = color.to_be_bytes(); - let [l, ca, cb] = oklab(draw::srgb(red, green, blue)); + let [l, ca, cb] = draw::oklab(draw::srgb(red, green, blue)); let turned = lightness + 0.7 * (1.0 - l).max(0.0).sqrt() * (ink - lightness); - let [red, green, blue] = linear([turned, a + ca, b + cb]).map(|value| { + let [red, green, blue] = draw::from_oklab([turned, a + ca, b + cb]).map(|value| { let encoded = if value <= 0.003_130_8 { value * 12.92 } else { @@ -510,36 +467,6 @@ fn onto(svg: &str, paper: Paper) -> String { recoloured } -/// OKLab lightness and opponent axes of linear RGB. -fn oklab([red, green, blue, _]: [f32; 4]) -> [f32; 3] { - let [l, m, s] = [ - [0.412_221_46, 0.536_332_55, 0.051_445_995], - [0.211_903_5, 0.680_699_5, 0.107_396_96], - [0.088_302_46, 0.281_718_85, 0.629_978_7], - ] - .map(|[r, g, b]| (r * red + g * green + b * blue).cbrt()); - [ - 0.210_454_26 * l + 0.793_617_8 * m - 0.004_072_047 * s, - 1.977_998_5 * l - 2.428_592_2 * m + 0.450_593_7 * s, - 0.025_904_037 * l + 0.782_771_77 * m - 0.808_675_77 * s, - ] -} - -/// Linear RGB of OKLab, clipped to the sRGB gamut. -fn linear([lightness, a, b]: [f32; 3]) -> [f32; 3] { - let [l, m, s] = [ - lightness + 0.396_337_78 * a + 0.215_803_76 * b, - lightness - 0.105_561_346 * a - 0.063_854_17 * b, - lightness - 0.089_484_18 * a - 1.291_485_5 * b, - ] - .map(|value| value.powi(3)); - [ - [4.076_741_7, -3.307_711_6, 0.230_969_94], - [-1.268_438, 2.609_757_4, -0.341_319_38], - [-0.004_196_086_3, -0.703_418_6, 1.707_614_7], - ] - .map(|[x, y, z]| (x * l + y * m + z * s).clamp(0.0, 1.0)) -} #[cfg(test)] mod tests { @@ -550,7 +477,7 @@ mod tests { document::Layout, page::{Image, Page, PageObject}, }; - use std::collections::BTreeMap; + use std::{collections::BTreeMap, sync::Arc}; const DARK: Paper = Paper { color: [0.0137, 0.0144, 0.0159, 1.0], @@ -713,7 +640,7 @@ mod tests { .unwrap(); assert!(before.is_empty(), "the paper shows until the raster lands"); drop(before); - settle(|| scene.update_backgrounds(DARK, 1.0, Waker::noop())); + scene.settle(None, 1.0, DARK); let mut primitives = Vec::new(); scene .append_primitives(&mut primitives, [0.0; 2], DARK) diff --git a/crates/canvas/src/gpu/page/picture.rs b/crates/canvas/src/gpu/page/picture.rs new file mode 100644 index 0000000000000000000000000000000000000000..18516bef894d37e7b9684a6910d0faa872bab01e --- /dev/null +++ b/crates/canvas/src/gpu/page/picture.rs @@ -0,0 +1,173 @@ +//! The page's own pictures, decoded off the frame thread at the size they show and let go +//! under a budget. + +use super::{SETTLE, Slot, density, queue}; +use draw::RasterImage; +use onestore::ExGuid; +use std::{ + collections::BTreeMap, + sync::Arc, + task::Waker, + time::{Duration, Instant}, +}; + +/// Decoded bytes a scene's pictures keep at once. Pictures in view shrink to fit it, and +/// the rest of the renderer's frame budget stays for template art and the interface. +pub(super) const BUDGET: u64 = draw::MAX_IMAGE_BYTES / 2; + +pub(super) struct Picture { + /// The stored picture, which saving keeps as it is. + bytes: Arc<[u8]>, + /// The stored picture's size in pixels. + native: [u32; 2], + shown: Option, + /// The one decode under way and the size it makes; replacing it abandons it. + pending: Option<(Slot>, [u32; 2])>, + /// The stored bytes did not decode, so the picture paints as a placeholder. + failed: bool, + /// When the picture was last near the view; the longest unseen are let go first. + seen: Instant, +} + +impl Picture { + /// Reads the size from the picture's header; None when the renderer cannot decode it. + pub fn new(bytes: &Arc<[u8]>) -> Option { + Some(Self { + native: RasterImage::measure(bytes).ok()?, + bytes: Arc::clone(bytes), + shown: None, + pending: None, + failed: false, + seen: Instant::now(), + }) + } + + /// The latest raster, once one has landed. + pub fn image(&self) -> Option<&RasterImage> { + self.shown.as_ref() + } + + pub fn failed(&self) -> bool { + self.failed + } +} + +fn bytes([width, height]: [u32; 2]) -> u64 { + u64::from(width) * u64::from(height) * 4 +} + +/// Takes finished decodes and asks for the pictures `rects` places near `view` at `scale` +/// device pixels per point; `waker` hears when one lands. Pictures in view come first and +/// shrink together to fit the budget, the half view around it decodes ahead with what +/// room is left, and those unseen longest are let go past it. True once every picture in +/// view shows the raster it asked for. +pub(super) fn update( + pictures: &mut BTreeMap, + rects: impl IntoIterator, + view: [f32; 4], + scale: f32, + waker: &Waker, +) -> bool { + let now = Instant::now(); + for picture in pictures.values_mut() { + let landed = picture + .pending + .as_ref() + .and_then(|(slot, _)| slot.lock().unwrap().take()); + if let Some(image) = landed { + picture.failed = image.is_none(); + picture.shown = image; + picture.pending = None; + } + } + let density = density(scale); + let [x0, y0, x1, y1] = view; + let [ahead_x, ahead_y] = [(x1 - x0) / 2.0, (y1 - y0) / 2.0]; + let near = [x0 - ahead_x, y0 - ahead_y, x1 + ahead_x, y1 + ahead_y]; + let overlaps = |rect: [f32; 4], area: [f32; 4]| { + rect[0] <= area[2] && rect[2] >= area[0] && rect[1] <= area[3] && rect[3] >= area[1] + }; + let mut wanted: Vec<_> = rects + .into_iter() + .filter_map(|(id, rect)| { + let picture = pictures.get(&id)?; + if picture.failed || !overlaps(rect, near) { + return None; + } + let shown = [rect[2] - rect[0], rect[3] - rect[1]]; + let size = [0, 1] + .map(|axis| ((shown[axis] * density).ceil() as u32).clamp(1, picture.native[axis])); + Some((id, size, overlaps(rect, view))) + }) + .collect(); + let visible: u64 = wanted + .iter() + .filter(|(.., visible)| *visible) + .map(|(_, size, _)| bytes(*size)) + .sum(); + let shrink = (BUDGET as f64 / visible as f64).sqrt(); + let mut room = BUDGET.saturating_sub(visible); + wanted.retain_mut(|(_, size, visible)| { + if *visible && shrink < 1.0 { + *size = size.map(|side| ((f64::from(side) * shrink) as u32).max(1)); + } else if !*visible { + if bytes(*size) > room { + return false; + } + room -= bytes(*size); + } + true + }); + let wanted: BTreeMap<_, _> = wanted + .into_iter() + .map(|(id, size, visible)| (id, (size, visible))) + .collect(); + let mut settled = true; + for (id, picture) in pictures.iter_mut() { + let Some(&(size, visible)) = wanted.get(id) else { + picture.pending = None; + continue; + }; + picture.seen = now; + let shown = picture.shown.as_ref().map(RasterImage::size); + if shown != Some(size) && picture.pending.as_ref().map(|(_, made)| *made) != Some(size) { + let encoded = Arc::clone(&picture.bytes); + let start = now + + if shown.is_some() { + SETTLE + } else { + Duration::ZERO + }; + let slot = queue(start, waker, move || { + RasterImage::decode(&encoded, size).ok() + }); + picture.pending = Some((slot, size)); + } + settled &= !visible || shown == Some(size); + } + let mut kept: u64 = pictures + .values() + .filter_map(Picture::image) + .map(|image| bytes(image.size())) + .sum(); + if kept > BUDGET { + let mut spare: Vec<_> = pictures + .iter_mut() + .filter(|(id, picture)| { + picture.shown.as_ref().is_some_and(|image| { + wanted + .get(*id) + .is_none_or(|(size, _)| image.size() != *size) + }) + }) + .collect(); + spare.sort_by_key(|(id, picture)| (wanted.contains_key(*id), picture.seen)); + for (_, picture) in spare { + if kept <= BUDGET { + break; + } + kept -= picture.shown.take().map_or(0, |image| bytes(image.size())); + } + } + settled +} diff --git a/crates/canvas/src/gpu/profile.rs b/crates/canvas/src/gpu/profile.rs index 7e789d5b189854ff894bf0255ce40f2572d2cb10..c6f2e101001290c5512149d5e85d7461f1bb5082 100644 --- a/crates/canvas/src/gpu/profile.rs +++ b/crates/canvas/src/gpu/profile.rs @@ -323,6 +323,7 @@ fn frame_cost() { } let input = start.elapsed(); let start = Instant::now(); + view.update_pictures(colors.paper, std::task::Waker::noop()); let primitives = view.primitives(colors).unwrap(); let built = start.elapsed(); let start = Instant::now(); diff --git a/crates/canvas/src/interaction/accessibility.rs b/crates/canvas/src/interaction/accessibility.rs index 4d492df642143804b356cd17102cbd664a778d18..ff0a27a5aea4a292802c7fb8a6aaad51d4a3b1c1 100644 --- a/crates/canvas/src/interaction/accessibility.rs +++ b/crates/canvas/src/interaction/accessibility.rs @@ -1711,4 +1711,39 @@ pub(super) mod tests { assert!(access.selection(second, selection).is_err()); assert_eq!(access.outline_for_node(fields[0].0), Some(first)); } + + #[test] + fn the_end_of_a_wrapped_rtl_word_stays_on_its_line() { + let mut engine = TextEngine::default(); + let text = "Fictitious: café, 東京, مرحبا"; + let end = text.encode_utf16().count() as u32; + let mut wrapped = false; + for width in [60.0, 90.0, 120.0, 480.0] { + let document = + TextDocument::new(vec![Paragraph::new(text.into(), Format::default())]).unwrap(); + let mut editor = CanvasEditor::new(&mut engine, document, width).unwrap(); + let caret = |editor: &mut CanvasEditor, offset| { + let position = TextPosition { + paragraph: 0, + offset, + }; + editor.select([position; 2].into()).unwrap(); + editor.caret(0.0).unwrap() + }; + let inside = caret(&mut editor, end - 2); + let first = caret(&mut editor, 0); + wrapped |= inside.y0 > first.y0; + assert_eq!(caret(&mut editor, end).y0, inside.y0, "width {width}"); + editor.select_all().unwrap(); + let viewport = Viewport { + size: [800, 600], + origin: [0.0; 2], + scale: 1.0, + }; + Accessibility::default() + .update(&editor, None, viewport, "Test", None, None) + .unwrap(); + } + assert!(wrapped); + } } diff --git a/crates/canvas/src/interaction/mod.rs b/crates/canvas/src/interaction/mod.rs index 4c21f75684fcee6be94b2c1a1f1e4c448b1d1aff..d4ca7e656af4367f77cb57af9af4386847b2c0da 100644 --- a/crates/canvas/src/interaction/mod.rs +++ b/crates/canvas/src/interaction/mod.rs @@ -101,11 +101,11 @@ pub struct TextColors { pub paper: Paper, } -/// How to paint an outline: whether the caret is in its blink-on phase, the view scale, -/// document points per device pixel, and the text colours. +/// How to paint an outline: the caret's opacity, the view scale, document points per +/// logical pixel, and the text colours. #[derive(Clone, Copy)] struct Paint { - show_caret: bool, + caret: f32, scale: f32, pixel: f32, colors: TextColors, @@ -186,15 +186,35 @@ pub struct PageView { object_focus: Option, modifiers: Modifiers, focused: bool, - caret: bool, - blink_at: Instant, + /// The caret's opacity in its blink. + caret: f32, + /// When the caret last moved, which restarts its blink. + blink_from: Instant, } +/// Room, in OneNote pixels, the view leaves beyond content it scrolls to. +const PAD: f32 = 11.0; + /// Where a page's origin sits in a view that has not scrolled, in device pixels. fn home(display_scale: f32) -> [f32; 2] { [48.0 * display_scale; 2] } +/// Brings `scene`, drawn at `offset` in the view, to what `viewport` shows. +fn update_pictures( + scene: &mut PageScene, + [x, y]: [f32; 2], + editor: &CanvasEditor, + viewport: Viewport, + paper: Paper, + waker: &std::task::Waker, +) -> bool { + let [x0, y0] = viewport.document_point([0.0; 2]); + let [x1, y1] = viewport.document_point(viewport.size.map(|side| side as f32)); + let view = [x0 - x, y0 - y, x1 - x, y1 - y]; + scene.update_pictures(Some(editor), view, viewport.scale, paper, waker) +} + impl PageView { /// `size` is in device pixels; `double_click` is the platform's double-click interval. pub fn new( @@ -205,7 +225,7 @@ impl PageView { display_scale: f32, double_click: Duration, ) -> Self { - Self { + let mut view = Self { editor, engine, scene, @@ -222,9 +242,11 @@ impl PageView { object_focus: None, modifiers: Modifiers::default(), focused: true, - caret: true, - blink_at: Instant::now() + Duration::from_millis(500), - } + caret: 1.0, + blink_from: Instant::now(), + }; + view.place_opened(); + view } /// Shows a page reloaded from storage in place of the edited one. @@ -235,11 +257,28 @@ impl PageView { self.object_focus = None; } - /// Shows another page from its top-left corner, keeping the zoom. + /// Shows another page as OneNote opens one, keeping the zoom. pub fn open(&mut self, editor: CanvasEditor, scene: Option<(PageScene, [f32; 2])>) { self.replace(editor, scene); - self.viewport.origin = home(self.display_scale); - self.scroll().clamp(&mut self.viewport); + self.place_opened(); + } + + /// OneNote 2010 opens a page scrolled fully left and up, then down just far enough to + /// show the caret's outline 9 pt and 9 px above the view's bottom. + fn place_opened(&mut self) { + let scroll = self.scroll(); + self.viewport.origin = scroll.min.map(|offset| -offset); + let outline = self.editor.active_outline(); + let offset = self + .scene + .as_ref() + .filter(|_| self.editor.has_page_outline(outline.id)) + .map_or(0.0, |(_, offset)| offset[1]); + let bottom = (outline.bounds().y1 as f32 + offset + 9.0) * self.viewport.scale + + 9.0 * self.display_scale + + self.viewport.origin[1]; + self.viewport.origin[1] -= (bottom - self.viewport.size[1] as f32).max(0.0); + scroll.clamp(&mut self.viewport); } pub fn modifiers(&self) -> Modifiers { @@ -364,8 +403,8 @@ impl PageView { /// Keeps the view in bounds and restarts the caret blink after a change. fn changed(&mut self) -> Result { self.scroll().clamp(&mut self.viewport); - self.caret = true; - self.blink_at = Instant::now() + Duration::from_millis(500); + self.caret = 1.0; + self.blink_from = Instant::now(); Ok(Response::changed()) } @@ -492,9 +531,10 @@ impl PageView { .filter(|_| self.editor.has_page_outline(outline.id)) .map(|(_, offset)| *offset) .unwrap_or([0.0; 2]); + let [left, top] = reach(outline); [ - rect.x0 as f32 + offset[0], - rect.y0 as f32 + offset[1], + left + offset[0], + top + offset[1], rect.x1 as f32 + offset[0], rect.y1 as f32 + offset[1], ] @@ -509,7 +549,11 @@ impl PageView { ] }) }); - scroll::Scroll::new(self.viewport, editable.chain(fixed)) + scroll::Scroll::new( + self.viewport, + PAD * self.display_scale, + editable.chain(fixed), + ) } /// Scrolls the view's corner `offset` device pixels from the page origin along `axis`, @@ -519,15 +563,31 @@ impl PageView { self.moved() } - /// Brings the page's template backgrounds to `paper` and the current zoom before a - /// frame's primitives; `waker` is woken off the main thread when the view should be - /// drawn again because a background raster landed. - pub fn update_backgrounds(&mut self, paper: Paper, waker: &std::task::Waker) { - if let Some((scene, _)) = &mut self.scene { - scene.update_backgrounds(paper, self.viewport.scale, waker); + /// Brings the page's template backgrounds to `paper` and its pictures to the view and + /// zoom before a frame's primitives; `waker` is woken off the main thread when the view + /// should be drawn again because a raster landed. + pub fn update_pictures(&mut self, paper: Paper, waker: &std::task::Waker) { + if let Some((scene, offset)) = &mut self.scene { + update_pictures(scene, *offset, &self.editor, self.viewport, paper, waker); } } + /// `update_pictures` for a page `open` is about to show at `offset`; true once the + /// pictures it would show at first are drawn. + pub fn prepare( + &self, + (scene, offset): &mut (PageScene, [f32; 2]), + editor: &CanvasEditor, + paper: Paper, + waker: &std::task::Waker, + ) -> bool { + let viewport = Viewport { + origin: home(self.display_scale), + ..self.viewport + }; + update_pictures(scene, *offset, editor, viewport, paper, waker) + } + /// Everything to draw this frame. pub fn primitives(&self, colors: TextColors) -> Result>> { let preview = match &self.drag { @@ -565,12 +625,15 @@ impl PageView { preview, self.object_focus, Paint { - show_caret: self.caret - && self.focused + caret: if self.focused && !matches!( self.drag, Some(Drag::Outline { .. } | Drag::Resize { .. } | Drag::Image { .. }) - ), + ) { + self.caret + } else { + 0.0 + }, scale: self.viewport.scale, pixel: self.pixel(), colors, @@ -589,7 +652,7 @@ impl PageView { (Some(Drag::Resize { .. }), _) | (None, Some(Hit::Resize { .. })) => Cursor::EwResize, (Some(Drag::Outline { .. }), _) | (None, Some(Hit::Handle { .. })) => Cursor::Move, (None, Some(Hit::Date(_))) => Cursor::Pointer, - (None, Some(Hit::ReadOnly(_))) => Cursor::Default, + (None, Some(Hit::ReadOnly(_) | Hit::Check { .. })) => Cursor::Default, _ => Cursor::Text, } } @@ -600,12 +663,10 @@ impl PageView { if !(self.focused && self.object_focus.is_none() && anchor == focus) { return (false, None); } - let repaint = now >= self.blink_at; - if repaint { - self.caret = !self.caret; - self.blink_at = now + Duration::from_millis(500); - } - (repaint, Some(self.blink_at)) + let (caret, hold) = edit::caret_blink(now.saturating_duration_since(self.blink_from)); + let repaint = caret != self.caret; + self.caret = caret; + (repaint, Some(now + hold)) } /// `size` in device pixels. @@ -710,6 +771,13 @@ impl PageView { return Ok(Response::request(Request::EditDate(field))); } Some(Hit::ReadOnly(index)) => self.set_object_focus(Some(ObjectFocus::ReadOnly(index))), + Some(Hit::Check { outline, paragraph }) => { + self.set_object_focus(None); + self.drag = None; + self.editor + .click_check(&mut self.engine, outline, paragraph)?; + return self.changed(); + } Some(Hit::Image { id, handle }) => { self.set_object_focus(Some(ObjectFocus::Image(id))); self.drag = Some(Drag::Image { @@ -828,12 +896,12 @@ impl PageView { self.edited() } - /// Clipboard text; see [`CanvasEditor::paste`]. - pub fn paste(&mut self, text: &str) -> Result { + /// Clipboard text in `language`, an LCID; see [`CanvasEditor::paste`]. + pub fn paste(&mut self, text: &str, language: u32) -> Result { if !self.accepts_text() { return Ok(Response::default()); } - self.editor.paste(&mut self.engine, text)?; + self.editor.paste(&mut self.engine, text, language)?; self.edited() } @@ -905,6 +973,9 @@ impl PageView { option, command, } = self.modifiers; + if key == &Key::Named(NamedKey::Modifier) { + return Ok(Response::default()); + } if let Some(ObjectFocus::Image(id)) = self.object_focus && matches!(key, Key::Named(NamedKey::Backspace | NamedKey::Delete)) { @@ -946,9 +1017,6 @@ impl PageView { self.drag, Some(Drag::Outline { .. } | Drag::Resize { .. } | Drag::Image { .. }) ) { - if key == &Key::Named(NamedKey::Modifier) { - return Ok(Response::default()); - } self.drag = None; if key == &Key::Named(NamedKey::Escape) { return self.changed(); @@ -1148,6 +1216,11 @@ pub enum Hit { grab: [f32; 2], }, ReadOnly(usize), + /// A check box tag on paragraph `paragraph`. + Check { + outline: onestore::ExGuid, + paragraph: onestore::ExGuid, + }, /// `handle` is [0, 0] on the picture and a direction on the selected picture's handles. Image { id: onestore::ExGuid, @@ -1211,6 +1284,23 @@ pub fn page_hit_test( ], }); } + let shaped = outline.shaped(); + let [left, top] = outline.origin(); + let check = shaped.paragraphs.iter().find(|paragraph| { + paragraph.tags.iter().any(|tag| { + let tag_x = left + shaped.tag_column_offset() + tag.origin[0]; + let tag_y = top + paragraph.origin[1] + tag.origin[1]; + matches!(tag.icon, crate::outline::TagIcon::CheckBox { .. }) + && (tag_x..=tag_x + tag.size).contains(&x) + && (tag_y..=tag_y + tag.size).contains(&y) + }) + }); + if let Some(paragraph) = check { + return Some(Hit::Check { + outline: outline.id, + paragraph: paragraph.id, + }); + } let text = outline.bounds(); if !(text.x0..=text.x1).contains(&f64::from(x)) || !(text.y0..=text.y1).contains(&f64::from(y)) @@ -1229,17 +1319,7 @@ pub fn page_hit_test( handle: [0, 0], }); } - if (x >= bounds[0] && x <= bounds[2] && y >= body_top && y <= bounds[3]) - || outline.layouts().any(|(_, paragraph)| { - paragraph.tags.iter().any(|tag| { - let origin = outline.origin(); - let x = origin[0] + outline.shaped().tag_column_offset() + tag.origin[0]; - let y = origin[1] + paragraph.origin[1] + tag.origin[1]; - let size = crate::outline::ParagraphTag::SIZE; - (x..=x + size).contains(&local[0]) && (y..=y + size).contains(&local[1]) - }) - }) - { + if x >= bounds[0] && x <= bounds[2] && y >= body_top && y <= bounds[3] { return Some(Hit::Text { id: outline.id, point: [ @@ -1290,10 +1370,10 @@ fn page_primitives<'a>( ) -> Result>> { let mut primitives = Vec::new(); let draw_outline = |id, offset: [f32; 2], primitives: &mut Vec<_>| { - let outline = editor - .visible_outlines() - .find(|outline| outline.id == id) - .ok_or(crate::gpu::page::SceneError::MissingOutline)?; + // An emptied page outline leaves the editor but keeps its paint slot for undo. + let Some(outline) = editor.visible_outlines().find(|outline| outline.id == id) else { + return Ok(()); + }; let outline = match preview { Some(PointerFeedback::Resize(resized)) if resized.id == outline.id => resized, _ => outline, @@ -1310,6 +1390,7 @@ fn page_primitives<'a>( outline, [origin[0] + offset[0], origin[1] + offset[1]], paint.pixel, + paint.colors.paper, primitives, ); } @@ -1531,6 +1612,29 @@ fn append_image_chrome(rect: [f32; 4], pixel: f32, primitives: &mut Vec [f32; 2] { + let shaped = outline.shaped(); + let column = shaped.tag_column_offset(); + let left = shaped + .paragraphs + .iter() + .flat_map(|paragraph| { + let markers = paragraph.markers.iter().map(|(_, [x, _])| *x); + let tags = paragraph + .tags + .iter() + .map(move |tag| tag.origin[0] + column + 0.75); + std::iter::once(paragraph.origin[0] - 7.5) + .chain(markers) + .chain(tags) + }) + .fold(-7.5, f32::min); + let [x, y] = outline.origin(); + [x + left, y - 6.0] +} + fn snap_to_grid(point: [f32; 2], margin: [f32; 2]) -> [f32; 2] { std::array::from_fn(|axis| { let offset = margin[axis]; @@ -1563,9 +1667,23 @@ fn outline_chrome(outline: &TextOutline, pixel: f32) -> ([f32; 4], f32) { body_top, ); } + // OneNote widens the box leftward to hold the tag column and list markers; text and right + // edge stay. + let shaped = outline.shaped(); + let column = shaped.tag_column_offset() + crate::outline::ParagraphTag::INSET + - crate::outline::ParagraphTag::SIZE; + let left = shaped + .paragraphs + .iter() + .flat_map(|paragraph| { + let tags = paragraph.tags.iter().map(|tag| tag.origin[0] + column); + let markers = paragraph.markers.iter().map(|(_, [x, _])| x + 7.5); + tags.chain(markers) + }) + .fold(0.0, f32::min); ( [ - bounds.x0 as f32 - 7.5 - inset, + bounds.x0 as f32 + left - 7.5 - inset, body_top - HANDLE_HEIGHT, bounds.x1 as f32 + inset, bounds.y1 as f32 + HANDLE_HEIGHT + inset, @@ -1578,6 +1696,7 @@ fn append_outline_chrome( outline: &TextOutline, origin: [f32; 2], pixel: f32, + paper: crate::gpu::Paper, primitives: &mut Vec>, ) { let [x, y] = origin; @@ -1595,7 +1714,7 @@ fn append_outline_chrome( rect: [left, top, right, bottom], radius: [6.0 * pixel, (bottom - top) * 0.5], stroke: Some(Stroke::Dashed(pixel)), - color: crate::gpu::colorref(0x007f7f7f), + color: paper.shade(crate::gpu::colorref(0x007f7f7f)), }); return; } @@ -1603,19 +1722,19 @@ fn append_outline_chrome( rect: [left, top, right, body_top], radius: [3.0 * pixel; 2], stroke: None, - color: crate::gpu::colorref(0x00e8ebed), + color: paper.shade(crate::gpu::colorref(0x00e8ebed)), }); primitives.push(Primitive::RoundedRect { rect: [right - 9.0 * pixel, top, right, body_top], radius: [3.0 * pixel; 2], stroke: None, - color: crate::gpu::colorref(0x00e5dee7), + color: paper.shade(crate::gpu::colorref(0x00e5dee7)), }); primitives.push(Primitive::RoundedRect { rect: [left, top, right, bottom], radius: [3.0 * pixel; 2], stroke: Some(Stroke::Solid(pixel)), - color: crate::gpu::colorref(0x00d9cfd8), + color: paper.shade(crate::gpu::colorref(0x00d9cfd8)), }); let middle = (top + body_top) * 0.5; for offset in [-3.0, 0.0, 3.0] { @@ -1629,7 +1748,7 @@ fn append_outline_chrome( ], radius: [pixel * 0.5; 2], stroke: None, - color: crate::gpu::colorref(0x00b4a5b4), + color: paper.shade(crate::gpu::colorref(0x00b4a5b4)), }); } for column in [0.0, 1.0, 2.0] { @@ -1640,7 +1759,7 @@ fn append_outline_chrome( ] { primitives.push(Primitive::Rect { rect: [x, middle - half, x + pixel, middle + half], - color: crate::gpu::colorref(0x00b4a5b4), + color: paper.shade(crate::gpu::colorref(0x00b4a5b4)), }); } } @@ -1655,7 +1774,7 @@ fn append_outline<'a>( ) -> Result<()> { let [x, y] = origin; let Paint { - show_caret, + caret, scale, pixel, colors, @@ -1667,7 +1786,7 @@ fn append_outline<'a>( .append_table_primitives(primitives, origin, colors.paper); outline .shaped() - .append_background_primitives(primitives, origin, rows); + .append_background_primitives(primitives, origin, rows, colors.paper); if let Some(editor) = editor { for rect in editor.selection_rects()? { primitives.push(Primitive::Rect { @@ -1703,18 +1822,21 @@ fn append_outline<'a>( }); } let [anchor, focus] = editor.selection().positions; - if show_caret && anchor == focus { - let rect = editor.caret(2.0 * pixel)?; + if caret > 0.0 && anchor == focus { + // AppKit centres the caret on the insertion point. + let rect = editor.caret(0.0)?; + let half = edit::CARET_WIDTH / 2.0 * pixel; + let middle = rect.x0 as f32 + x; primitives.push(Primitive::RoundedRect { rect: [ - rect.x0 as f32 + x, + middle - half, rect.y0 as f32 + y, - rect.x1 as f32 + x, + middle + half, rect.y1 as f32 + y, ], - radius: [pixel; 2], + radius: [half; 2], stroke: None, - color: colors.caret, + color: edit::caret_color(colors.caret, colors.paper.color, caret), }); } } diff --git a/crates/canvas/src/interaction/scroll.rs b/crates/canvas/src/interaction/scroll.rs index 4c65e28c60889a1f20668ff7d40c0551f504ec08..0b27efd8bbeb17be36691bf75e501412d20a7d6f 100644 --- a/crates/canvas/src/interaction/scroll.rs +++ b/crates/canvas/src/interaction/scroll.rs @@ -9,8 +9,9 @@ pub struct Scroll { } impl Scroll { - pub fn new(viewport: Viewport, bounds: impl Iterator) -> Self { - let mut min = [0.0_f32; 2]; + /// `pad` is the room, in device pixels, OneNote leaves beyond content it scrolls to. + pub fn new(viewport: Viewport, pad: f32, bounds: impl Iterator) -> Self { + let mut min = [f32::INFINITY; 2]; let mut max = [0.0_f32; 2]; for rect in bounds { for axis in 0..2 { @@ -18,9 +19,9 @@ impl Scroll { max[axis] = max[axis].max(rect[axis + 2]); } } - // OneNote stops at the page origin or the object furthest above or left of it. + // OneNote stops at the page origin, or `pad` beyond content that reaches past it. for axis in 0..2 { - min[axis] *= viewport.scale; + min[axis] = (min[axis] * viewport.scale - pad).min(0.0); max[axis] = ((max[axis] + 36.0) * viewport.scale - viewport.size[axis] as f32).max(min[axis]); } @@ -45,9 +46,9 @@ mod tests { scale: 2.0, origin: [9999.0; 2], }; - let scroll = Scroll::new(viewport, [[-80.0, -20.0, 1000.0, 1200.0]].into_iter()); + let scroll = Scroll::new(viewport, 11.0, [[-80.0, -20.0, 1000.0, 1200.0]].into_iter()); scroll.clamp(&mut viewport); - assert_eq!(viewport.origin, [160.0, 40.0]); + assert_eq!(viewport.origin, [171.0, 51.0]); viewport.origin = [-9999.0; 2]; scroll.clamp(&mut viewport); assert_eq!(viewport.origin, [-1272.0, -1872.0]); diff --git a/crates/canvas/src/interaction/tests.rs b/crates/canvas/src/interaction/tests.rs index 3fac287a050675decd9f33a13f66f223be53a387..66aed76d4669eeb37d5ca5e5fbff9af7cf2aee49 100644 --- a/crates/canvas/src/interaction/tests.rs +++ b/crates/canvas/src/interaction/tests.rs @@ -15,7 +15,7 @@ const COLORS: TextColors = TextColors { /// Paint for a view at `scale` device pixels per point on a display of `display_scale`. pub(super) fn paint(show_caret: bool, scale: f32, display_scale: f32) -> Paint { Paint { - show_caret, + caret: f32::from(u8::from(show_caret)), scale, pixel: display_scale / scale, colors: COLORS, @@ -823,7 +823,7 @@ fn table_glyphs_highlights_and_selection_share_cell_paint_bounds() { editor.active_outline(), [24.0, 48.0], Paint { - show_caret: false, + caret: 0.0, scale: 1.0, pixel: 1.0, colors: COLORS, @@ -894,7 +894,7 @@ fn editable_tables_paint_borders_before_selection_and_cell_text() { editor.active_outline(), [24.0, 48.0], Paint { - show_caret: false, + caret: 0.0, scale: 1.0, pixel: 1.0, colors: COLORS, @@ -1522,6 +1522,49 @@ fn picture_view() -> (PageView, onestore::ExGuid) { (view, id) } +#[test] +fn a_page_outline_emptied_by_backspace_still_draws() { + let mut engine = TextEngine::default(); + let objects = [[36.0, 36.0], [36.0, 200.0]] + .map(|origin| { + let document = + TextDocument::new(vec![Paragraph::new("Delete me".into(), Default::default())]) + .unwrap(); + let outline = TextOutline::new(&mut engine, document, 240.0, origin).unwrap(); + onestore::page::PageObject::Outline(outline.snapshot()) + }) + .into(); + let page = Page { + title: String::new(), + identity: None, + created: None, + margin_origin: [36.0, 14.4], + definitions: Default::default(), + objects, + }; + let (scene, editor) = PageScene::from_page(page, &mut engine).unwrap(); + let other = editor.outlines()[1].id; + let mut view = PageView::new( + editor, + engine, + Some((scene, [0.0; 2])), + [800, 600], + 1.0, + Duration::from_millis(500), + ); + view.editor.select_all().unwrap(); + assert!( + view.key(&Key::Named(NamedKey::Backspace), None) + .unwrap() + .changed + ); + assert!(view.editor.caret_outline().is_some()); + view.primitives(COLORS).unwrap(); + view.editor.focus_outline(other).unwrap(); + assert_eq!(view.editor.outlines().len(), 1); + view.primitives(COLORS).unwrap(); +} + fn click(view: &mut PageView, point: [f32; 2], now: Instant) { let _ = view.pointer_moved(point).unwrap(); assert!(view.pointer_pressed(now).unwrap().changed); @@ -1653,3 +1696,191 @@ fn toolbar_commands_wait_for_composition_and_object_focus() { assert!(view.format(bold).unwrap().changed); assert_eq!(view.editor.format_state().unwrap().toggles, [Toggle::Bold]); } + +#[test] +fn outline_chrome_grows_left_to_contain_its_tag_column() { + use crate::editor::{Formatting, NoteTag}; + let mut engine = TextEngine::default(); + let outline = TextOutline::new( + &mut engine, + TextDocument::new(vec![ + Paragraph::new("Plain one".into(), Format::default()), + Paragraph::new("Plain two".into(), Format::default()), + ]) + .unwrap(), + 468.0, + [288.0, 36.0], + ) + .unwrap(); + let mut editor = + CanvasEditor::from_text_outlines(vec![outline], Default::default(), None).unwrap(); + // OneNote 2010 at 100%: the border sits 15 px left of the text, plus 16 px per tag slot, + // and the text and right border stay put. + let pixel = 0.75; + let (plain, _) = outline_chrome(editor.active_outline(), pixel); + let check = |editor: &CanvasEditor, slots: f32| { + let outline = editor.active_outline(); + let (frame, _) = outline_chrome(outline, pixel); + assert_eq!(outline.origin()[0], 288.0); + assert!(((288.0 - frame[0]) / pixel - (15.0 + 16.0 * slots)).abs() < 0.01); + assert_eq!(frame[2], plain[2]); + let shaped = outline.shaped(); + for (_, paragraph) in outline.layouts() { + for tag in ¶graph.tags { + let x = outline.origin()[0] + shaped.tag_column_offset() + tag.origin[0]; + let y = outline.origin()[1] + paragraph.origin[1] + tag.origin[1]; + let size = crate::outline::ParagraphTag::SIZE; + assert!(frame[0] < x && x + size < frame[2], "{frame:?} {x}"); + assert!(frame[1] < y && y + size < frame[3], "{frame:?} {y}"); + // OneNote 2010 shows an arrow over a check box, which a click toggles. + let hit = page_hit_test(editor, None, [x + 1.0, y + 1.0], pixel); + if matches!(tag.icon, crate::outline::TagIcon::CheckBox { .. }) { + assert_eq!( + hit, + Some(Hit::Check { + outline: outline.id, + paragraph: paragraph.id, + }) + ); + } else { + assert!(matches!(hit, Some(Hit::Text { .. }))); + } + } + } + }; + check(&editor, 0.0); + editor + .format(&mut engine, Formatting::Tag(NoteTag::ToDo)) + .unwrap(); + check(&editor, 1.0); + editor + .format(&mut engine, Formatting::Tag(NoteTag::Question)) + .unwrap(); + check(&editor, 2.0); + editor + .format(&mut engine, Formatting::Tag(NoteTag::ToDo)) + .unwrap(); + editor + .format(&mut engine, Formatting::Tag(NoteTag::Question)) + .unwrap(); + check(&editor, 0.0); +} + +#[test] +fn a_click_on_a_check_box_toggles_it_under_an_arrow() { + use crate::editor::{Formatting, NoteTag}; + let mut engine = TextEngine::default(); + let outline = TextOutline::new( + &mut engine, + TextDocument::new(vec![Paragraph::new("Task".into(), Format::default())]).unwrap(), + 468.0, + [288.0, 36.0], + ) + .unwrap(); + let mut editor = + CanvasEditor::from_text_outlines(vec![outline], Default::default(), None).unwrap(); + editor + .format(&mut engine, Formatting::Tag(NoteTag::ToDo)) + .unwrap(); + let outline = editor.active_outline(); + let (_, paragraph) = outline.layouts().next().unwrap(); + let tag = ¶graph.tags[0]; + let centre = [ + outline.origin()[0] + outline.shaped().tag_column_offset() + tag.origin[0] + tag.size / 2.0, + outline.origin()[1] + paragraph.origin[1] + tag.origin[1] + tag.size / 2.0, + ]; + let mut view = PageView::new( + editor, + engine, + None, + [800, 600], + 1.0, + Duration::from_millis(500), + ); + let device = [0, 1].map(|axis| centre[axis] * view.viewport.scale + view.viewport.origin[axis]); + let _ = view.pointer_moved(device).unwrap(); + assert_eq!(view.cursor(), Cursor::Default); + let status = |view: &PageView| { + view.editor.active_outline().document().nodes()[0] + .text() + .unwrap() + .tags[0] + .status + }; + assert!(view.pointer_pressed(Instant::now()).unwrap().changed); + let _ = view.pointer_released().unwrap(); + assert_eq!(status(&view), 1); + let _ = view.pointer_moved([device[0] + 40.0, device[1]]).unwrap(); + assert_eq!(view.cursor(), Cursor::Text); +} + +/// OneNote 2010 moves an outline by the drag from its stored position and snaps it to the +/// nearest 18 pt step from the page's margin origin, (36, 14.4) until one is stored. +#[test] +fn a_dragged_outline_snaps_to_the_margin_grid_as_onenote_stores_it() { + let margin = [36.0, 14.4]; + for (stored, drag, expected) in [ + ([72.0, 72.0], [15.0, 0.0], [90.0, 68.4]), + ([90.0, 68.4], [5.25, 15.0], [90.0, 86.4]), + ([0.0, 8.0], [30.0, 0.0], [36.0, 14.4]), + ] { + let point = [stored[0] + drag[0], stored[1] + drag[1]]; + let snapped = snap_to_grid(point, margin); + assert!( + (0..2).all(|axis| (snapped[axis] - expected[axis]).abs() < 1e-4), + "{snapped:?} {expected:?}" + ); + } +} + +/// OneNote 2010 at 75 to 150% opens a page scrolled fully left and up: 11 px beyond 7.5 pt +/// left of text, a list marker, 0.75 pt into a tag's slot, or 6 pt above an outline, never +/// right of or below the page origin; then down until the caret's outline clears the bottom. +#[test] +fn a_page_opens_where_onenote_places_the_view() { + use crate::editor::{Formatting, NoteTag}; + let open = |origin: [f32; 2], tag: bool| { + let mut engine = TextEngine::default(); + let outline = TextOutline::new( + &mut engine, + TextDocument::new(vec![Paragraph::new("Plain".into(), Format::default())]).unwrap(), + 468.0, + origin, + ) + .unwrap(); + let mut editor = + CanvasEditor::from_text_outlines(vec![outline], Default::default(), None).unwrap(); + if tag { + editor + .format(&mut engine, Formatting::Tag(NoteTag::ToDo)) + .unwrap(); + } + let reached = reach(editor.active_outline()); + let view = PageView::new( + editor, + engine, + None, + [800, 600], + 1.0, + Duration::from_millis(500), + ); + (reached, view.viewport.origin) + }; + // Native at 100%: page origin 21 px right and 20 px down, 74 and 60 px, 37 px and 0. + for (origin, tag, reached, placed) in [ + ([0.0, 0.0], false, [-7.5, -6.0], [21.0, 19.0]), + ([-40.0, -30.0], false, [-47.5, -36.0], [74.33, 59.0]), + ([0.0, 40.0], true, [-19.5, 34.0], [37.0, 0.0]), + ([72.0, 72.0], false, [64.5, 66.0], [0.0, 0.0]), + ] { + let (actual, view) = open(origin, tag); + assert_eq!(actual, reached); + assert!( + (0..2).all(|axis| (view[axis] - placed[axis]).abs() < 0.01), + "{origin:?} {view:?}" + ); + } + // Content far down scrolls into view, 9 pt and 9 px above the bottom. + let (_, view) = open([50.0, 900.0], false); + assert!(view[1] < -600.0); +} diff --git a/crates/canvas/src/language.rs b/crates/canvas/src/language.rs new file mode 100644 index 0000000000000000000000000000000000000000..382b2e38aa0bac0e040a5998f3f8b5c6f351d6ad --- /dev/null +++ b/crates/canvas/src/language.rs @@ -0,0 +1,162 @@ +/// Windows LCIDs keyed by BCP-47 language, `language-Script` or `language-REGION`; a bare +/// language maps to the locale Windows picks for it. +const LCIDS: &[(&str, u32)] = &[ + ("af", 1078), + ("am", 1118), + ("ar", 1025), + ("ar-EG", 3073), + ("az", 1068), + ("be", 1059), + ("bg", 1026), + ("bn", 1093), + ("bn-BD", 2117), + ("ca", 1027), + ("cs", 1029), + ("cy", 1106), + ("da", 1030), + ("de", 1031), + ("de-AT", 3079), + ("de-CH", 2055), + ("de-LI", 5127), + ("de-LU", 4103), + ("el", 1032), + ("en", 1033), + ("en-AU", 3081), + ("en-CA", 4105), + ("en-GB", 2057), + ("en-IE", 6153), + ("en-IN", 16393), + ("en-NZ", 5129), + ("en-SG", 18441), + ("en-ZA", 7177), + ("es", 3082), + ("es-MX", 2058), + ("es-US", 21514), + ("et", 1061), + ("eu", 1069), + ("fa", 1065), + ("fi", 1035), + ("fil", 1124), + ("fr", 1036), + ("fr-BE", 2060), + ("fr-CA", 3084), + ("fr-CH", 4108), + ("gl", 1110), + ("gu", 1095), + ("he", 1037), + ("hi", 1081), + ("hr", 1050), + ("hu", 1038), + ("hy", 1067), + ("id", 1057), + ("is", 1039), + ("it", 1040), + ("it-CH", 2064), + ("ja", 1041), + ("ka", 1079), + ("kk", 1087), + ("km", 1107), + ("kn", 1099), + ("ko", 1042), + ("lo", 1108), + ("lt", 1063), + ("lv", 1062), + ("mk", 1071), + ("ml", 1100), + ("mn", 1104), + ("mr", 1102), + ("ms", 1086), + ("my", 1109), + ("nb", 1044), + ("ne", 1121), + ("nl", 1043), + ("nl-BE", 2067), + ("nn", 2068), + ("no", 1044), + ("pa", 1094), + ("pl", 1045), + ("pt", 1046), + ("pt-PT", 2070), + ("ro", 1048), + ("ru", 1049), + ("si", 1115), + ("sk", 1051), + ("sl", 1060), + ("sq", 1052), + ("sr", 10266), + ("sr-Latn", 9242), + ("sv", 1053), + ("sv-FI", 2077), + ("sw", 1089), + ("ta", 1097), + ("te", 1098), + ("th", 1054), + ("tr", 1055), + ("uk", 1058), + ("ur", 1056), + ("vi", 1066), + ("zh", 2052), + ("zh-HK", 3076), + ("zh-Hant", 1028), + ("zh-MO", 5124), + ("zh-SG", 4100), + ("zh-TW", 1028), +]; + +/// en-US, what OneNote records when nothing better is known. +const EN_US: u32 = 1033; + +/// The Windows LCID of a BCP-47 tag such as an input source's language (`de`, `en-GB`, +/// `zh-Hans`), matched by region, then script, then language, else en-US. +pub fn lcid(tag: &str) -> u32 { + let mut subtags = tag.split(['-', '_']); + let language = subtags.next().unwrap_or_default(); + let (mut script, mut region) = (None, None); + for subtag in subtags { + match subtag.len() { + 4 => script = Some(subtag), + 2 | 3 => region = Some(subtag), + _ => {} + } + } + [region, script] + .into_iter() + .flatten() + .map(|subtag| format!("{language}-{subtag}")) + .chain([language.to_owned()]) + .find_map(|key| { + LCIDS + .iter() + .find(|(tag, _)| tag.eq_ignore_ascii_case(&key)) + .map(|(_, lcid)| *lcid) + }) + .unwrap_or(EN_US) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn tags_map_to_windows_lcids() { + for (tag, lcid) in [ + ("en", 1033), + ("en-US", 1033), + ("en_GB", 2057), + ("de", 1031), + ("DE-at", 3079), + ("fr-FR", 1036), + ("es-419", 3082), + ("ja-JP", 1041), + ("zh-Hans", 2052), + ("zh-Hant", 1028), + ("zh-Hant-HK", 3076), + ("zh-Hans-SG", 4100), + ("sr-Latn-RS", 9242), + ("emoji", EN_US), + ("", EN_US), + ] { + assert_eq!(super::lcid(tag), lcid, "{tag}"); + } + } +} diff --git a/crates/canvas/src/layout.rs b/crates/canvas/src/layout.rs index 0c17ea74574dc21c5d79b751ce5a4f6697b019b2..585c18738b827a4a5a2c44232136266bb6938947 100644 --- a/crates/canvas/src/layout.rs +++ b/crates/canvas/src/layout.rs @@ -414,6 +414,27 @@ impl TextLayout { pub fn caret(&self, cursor: Cursor, width: f32) -> BoundingBox { let mut rect = cursor.geometry(&self.shaped, width); + // parley pairs the logical end of an RTL cluster leading its line with the previous + // line's last cluster, placing the caret at that line's end. + if cursor.affinity() == Affinity::Upstream + && let Some(cluster) = cursor + .index() + .checked_sub(1) + .and_then(|index| parley::Cluster::from_byte_index(&self.shaped, index)) + && cluster.is_rtl() + && cluster + .previous_visual() + .is_some_and(|previous| previous.path().line_index() != cluster.path().line_index()) + { + let x = f64::from(cluster.visual_offset().unwrap_or_default()); + let metrics = *cluster.line().metrics(); + rect = BoundingBox::new( + x, + f64::from(metrics.block_min_coord), + x + f64::from(width), + f64::from(metrics.block_max_coord), + ); + } let index = self .shaped .lines() diff --git a/crates/canvas/src/lib.rs b/crates/canvas/src/lib.rs index d11c82dda1636d9fce147852e91bab53b38ecf2c..9d73865ffbae3620e2ff271a93cb257cef3fb2c7 100644 --- a/crates/canvas/src/lib.rs +++ b/crates/canvas/src/lib.rs @@ -1,5 +1,6 @@ pub mod document; pub mod editor; +pub mod language; pub mod layout; pub mod math; pub mod outline; diff --git a/crates/canvas/src/outline.rs b/crates/canvas/src/outline.rs index c7353b830f463d24ecd7aa8e736fa6a507e89dfc..4bf76d33f527349d205c0bc51a5d087c49c90d34 100644 --- a/crates/canvas/src/outline.rs +++ b/crates/canvas/src/outline.rs @@ -198,6 +198,9 @@ pub struct ParagraphLayout { /// continuing from the previous sibling's. pub(crate) number: Option<(u32, bool)>, pub tags: Vec, + /// Its siblings' group, whose list markers its tags clear: the parent paragraph, or the + /// table cell holding a cell's top-level paragraph. + pub(crate) parent: Option, /// An equation draws in two dimensions in place of its linear text. pub math: Option, } @@ -223,14 +226,48 @@ pub struct ParagraphTag { pub icon: TagIcon, /// Coordinates are outline-local in x and paragraph-local in y. pub origin: [f32; 2], + /// The icon's side, which follows its paragraph's first run. + pub size: f32, pub label: String, pub disabled: bool, } impl ParagraphTag { + /// The icon's side beside 10 to 17.5 pt text. pub const SIZE: f32 = 12.0; + /// How far a 12 pt icon starts left of its text when no sibling has a list marker. + pub const INSET: f32 = 20.25; + /// Space between a tag and the leftmost list marker among its paragraph's siblings. + const MARKER_GAP: f32 = 0.9; + + /// Where an icon of `side` starts below its paragraph's top, for a first line whose + /// baseline is `baseline` below it and a first run of `size` points: OneNote 2010 centres + /// it 0.357 of the run's size, less 0.2 pt, above the baseline (within 0.75 pt, 8 to 60 pt). + fn top(baseline: f32, size: f32, side: f32) -> f32 { + baseline - 0.357 * size + 0.2 - side / 2.0 + } + + /// OneNote 2010's icon side for text of `size` points. + fn side(size: f32) -> f32 { + match size { + 24.0.. => 24.0, + 18.0.. => 18.0, + 10.0.. => Self::SIZE, + _ => 9.0, + } + } + + /// Where a one-tag column of `side` starts for text at `x` among siblings whose leftmost + /// list marker starts at `marker`; its right edge stays put as the icon grows. + fn column(x: f32, marker: Option, side: f32) -> f32 { + let right = x - Self::INSET + Self::SIZE; + marker.map_or(right, |marker| right.min(marker - Self::MARKER_GAP)) - side + } } +/// How much further than its list spacing OneNote 2010 sets a marker's advance from its text. +const MARKER_OFFSET: f32 = 3.9; + /// A one-run paragraph standing in for an object's caption, so it lays out through the same /// shaping (and caching) as the outline's text. fn caption(id: ExGuid, text_id: ExGuid, text: &str, format: Format) -> PageParagraph { @@ -264,25 +301,23 @@ pub(crate) fn image_size(image: &onestore::page::Image) -> Option<[f32; 2]> { .then_some(size) } -/// The table OneNote writes on every outline it creates, standing in for a missing one. -const DEFAULT_INDENTS: [f32; 4] = [18.0, 0.0, 27.0, 27.0]; +/// The step OneNote gives each level past the end of an indentation table. +const MISSING_INDENT: f64 = 27.0; +/// Text offset of `level` from its outline: entry `n` of `indents` steps level `n` in from +/// level `n - 1`; entry 0 moves nothing OneNote 2010 draws, text nor markers. pub(crate) fn indentation(level: u32, indents: &[f32], width: f32) -> Result { - let indents = if indents.is_empty() { - &DEFAULT_INDENTS - } else { - indents - }; if indents.iter().any(|v| !v.is_finite() || *v < 0.0) || level == 0 { return Err(LayoutError::InvalidIndentation); } - let known = (level as usize).min(indents.len() - 1); - let indent = (indents[1..=known] + let known = (level as usize).min(indents.len().saturating_sub(1)); + let indent = (indents + .get(1..=known) + .unwrap_or_default() .iter() .map(|v| f64::from(*v)) .sum::() - + f64::from(level - known as u32) * f64::from(*indents.last().unwrap())) - as f32; + + f64::from(level - known as u32) * MISSING_INDENT) as f32; if !indent.is_finite() || indent >= width { return Err(LayoutError::InvalidIndentation); } @@ -486,7 +521,7 @@ impl ParagraphLayout { )? }; let mut markers = Vec::new(); - let mut marker_x = indent; + let mut marker_x = indent - MARKER_OFFSET; let mut number = None; for id in paragraph.lists.iter().rev() { let definition = definitions.get(id).ok_or(LayoutError::InvalidList)?; @@ -523,7 +558,11 @@ impl ParagraphLayout { let marker = Paragraph::new( value, Format { - font: font.clone().or_else(|| definition.format.font.clone()), + // Without a font of its own a marker takes its text's, as a number does. + font: font + .clone() + .or_else(|| definition.format.font.clone()) + .or_else(|| format.font.clone()), font_size: definition.format.font_size.or(format.font_size), color, ..Format::default() @@ -542,6 +581,7 @@ impl ParagraphLayout { markers.push((layout, [marker_x, y])); } text.minimum_line_height(format.line_spacing.unwrap_or(0.0))?; + let baseline = text.lines().next().unwrap().1.baseline; let mut tags = Vec::new(); for tag in &tag_definitions { let (tag, shape, label) = (tag.0, tag.1, tag.2); @@ -563,11 +603,12 @@ impl ParagraphLayout { Some(121) => TagIcon::Music, _ => return Err(LayoutError::UnsupportedContent), }; - // Later tags follow the first to its right, toward the text. - let x = marker_x - 20.25 + 12.0 * tags.len() as f32; + let size = format.font_size.unwrap_or(11.0); + let side = ParagraphTag::side(size); tags.push(ParagraphTag { icon, - origin: [x, 0.0], + origin: [0.0, ParagraphTag::top(baseline, size, side)], + size: side, label: label.clone().unwrap_or_default(), disabled: tag.status & 2 != 0, }); @@ -575,7 +616,7 @@ impl ParagraphLayout { let math = equation .then(|| crate::math::layout(engine, &source.text)) .transpose()?; - Ok(Self { + let mut result = Self { id: paragraph.id, origin: [indent, 0.0], projection, @@ -584,7 +625,23 @@ impl ParagraphLayout { number, tags, math, - }) + parent: paragraph.parent, + }; + result.place_tags(result.marker_left()); + Ok(result) + } + + fn marker_left(&self) -> Option { + self.markers.iter().map(|(_, [x, _])| *x).reduce(f32::min) + } + + /// Places the tags for siblings whose leftmost marker starts at `marker`; later tags + /// follow the first to its right, toward the text. + fn place_tags(&mut self, marker: Option) { + for (index, tag) in self.tags.iter_mut().enumerate() { + tag.origin[0] = + ParagraphTag::column(self.origin[0], marker, tag.size) + tag.size * index as f32; + } } } @@ -607,13 +664,11 @@ impl OutlineLayout { /// OneNote gives an outline one tag column, as wide as its most-tagged paragraph needs; each /// paragraph's tags start at the column's left edge. Tag origins assume a one-tag column. pub fn tag_column_offset(&self) -> f32 { - let widest = self + -self .paragraphs .iter() - .map(|p| p.tags.len()) - .max() - .unwrap_or(0); - -12.0 * widest.saturating_sub(1) as f32 + .map(|p| p.tags.iter().skip(1).map(|tag| tag.size).sum::()) + .fold(0.0, f32::max) } /// Innermost table cell containing a visible paragraph index. @@ -690,9 +745,25 @@ impl OutlineLayout { shape, )?; result.size[0] = if fixed_width { width } else { result.size[0] }.max(result.table_width()); + result.place_tags(); Ok(result) } + /// Moves each paragraph's tags clear of the widest list marker among its siblings. + fn place_tags(&mut self) { + let mut markers = BTreeMap::, f32>::new(); + for paragraph in &self.paragraphs { + if let Some(x) = paragraph.marker_left() { + let left = markers.entry(paragraph.parent).or_insert(x); + *left = left.min(x); + } + } + for paragraph in &mut self.paragraphs { + let marker = markers.get(¶graph.parent).copied(); + paragraph.place_tags(marker); + } + } + /// Lays out `nodes` after the flow `state` and `siblings` stack, hiding the children of /// `hiding`; at the root, keeps a block for each node. #[allow(clippy::too_many_arguments)] @@ -804,7 +875,7 @@ impl OutlineLayout { &caption(node.id, file.id, name, format), None, ATTACHMENT_WIDTH, - &[0.0], + &[0.0, 0.0], )?; label.reset_origin(x); let object = ObjectLayout { @@ -841,7 +912,7 @@ impl OutlineLayout { ), None, w - 16.0, - &[0.0], + &[0.0, 0.0], )?; label.reset_origin(x + 8.0); let h = unsupported @@ -1187,6 +1258,7 @@ impl OutlineLayout { } self.size = size; self.widths = widths; + self.place_tags(); } pub(crate) fn table_width(&self) -> f32 { @@ -1251,7 +1323,7 @@ impl OutlineLayout { if cell.paragraphs.is_empty() || !cell.unsupported.is_empty() { return Err(LayoutError::UnsupportedContent); } - let child = Self::flow( + let mut child = Self::flow( edited_nodes(&cell.paragraphs, Some(cell.id), edit), &cell.indents, width, @@ -1261,6 +1333,9 @@ impl OutlineLayout { shape, )?; height = height.max(child.size[1]); + for paragraph in &mut child.paragraphs { + paragraph.parent.get_or_insert(cell.id); + } let paragraph_start = result.paragraphs.len(); result.append(child, [x, y + 3.54]); result.tables[0].cells.push(CellLayout { @@ -1754,7 +1829,11 @@ mod tests { ) .unwrap(); assert_eq!(tagged.tags[0].icon, expected); - assert_eq!(tagged.tags[0].origin, [6.75, 0.0]); + let baseline = tagged.text.lines().next().unwrap().1.baseline; + assert_eq!( + tagged.tags[0].origin, + [6.75, baseline - 0.357 * 11.0 + 0.2 - 6.0] + ); assert_eq!(tagged.tags[0].label, "Label"); assert!(tagged.tags[0].disabled); assert_eq!(tagged.text.height(), plain.text.height()); @@ -1797,8 +1876,12 @@ mod tests { &definitions, ) .unwrap(); - assert_eq!(tagged.tags[0].origin[0], tagged.markers[0].1[0] - 20.25); - assert_eq!(tagged.tags[0].origin[1], 0.0); + assert_eq!(tagged.tags[0].origin[0], tagged.markers[0].1[0] - 12.9); + let baseline = tagged.text.lines().next().unwrap().1.baseline; + assert_eq!( + tagged.tags[0].origin[1], + baseline - 0.357 * 11.0 + 0.2 - 6.0 + ); let Kind::TagDefinition { shape, .. } = &mut definitions.get_mut(&id).unwrap().kind else { unreachable!() }; @@ -1816,6 +1899,210 @@ mod tests { )); } + /// OneNote 2010 at 400% (Calibri bullets and numbers from 8 to 24 pt, default list + /// spacing): a marker's advance ends 11.1 pt before its text, and a tag's 12 pt slot ends + /// 0.9 pt before the leftmost marker among its paragraph's siblings, or 8.25 pt before its + /// text when none has one. + #[test] + fn markers_and_tags_sit_where_onenote_draws_them() { + use onestore::document::Tag; + let id = |n| ExGuid { + n, + ..ExGuid::default() + }; + let list = |format: &str| Definition { + kind: Kind::List { + font: None, + format: Some(format.into()), + bullet: None, + restart: None, + }, + format: Format::default(), + }; + let definitions = BTreeMap::from([ + (id(900), list("\u{2022}")), + (id(901), list("\u{fffd}\u{0}.")), + ( + id(902), + Definition { + kind: Kind::TagDefinition { + shape: Some(3), + label: None, + action_type: None, + color: None, + highlight: None, + }, + format: Format::default(), + }, + ), + ]); + let tag = Tag { + definition: Some(id(902)), + action_type: None, + status: 0, + created: None, + completed: None, + start: None, + due: None, + task_id: None, + extra_set: 0, + }; + let tagged = |n, level, parent| { + let mut node = paragraph(n, "Tag", level, parent); + node.text_mut().unwrap().tags.push(tag.clone()); + node + }; + let mut number = paragraph(1, "Number", 1, None); + number.lists.push(id(901)); + let mut bullet = paragraph(5, "Bullet", 2, Some(3)); + bullet.lists.push(id(900)); + let nodes = [ + number, + tagged(2, 1, None), + paragraph(3, "Plain", 1, None), + tagged(4, 2, Some(3)), + bullet, + paragraph(6, "Plain", 2, Some(3)), + ]; + let mut engine = TextEngine::default(); + let layout = OutlineLayout::flow( + nodes.iter(), + &[18.0, 0.0, 27.0, 27.0], + 400.0, + false, + 0, + None, + &mut |node, previous, width, indents| { + ParagraphLayout::shape(&mut engine, node, previous, width, indents, &definitions) + }, + ) + .unwrap(); + let [number, first, _, second, bullet, _] = &layout.paragraphs[..] else { + panic!() + }; + for paragraph in [number, bullet] { + let (marker, [x, _]) = ¶graph.markers[0]; + let advance = marker.lines().next().unwrap().0.metrics().advance; + assert!((x + advance - (paragraph.origin[0] - 11.1)).abs() < 1e-4); + } + // A numbered sibling pushes the level-1 tag left; the level-2 tag clears the bullet + // that follows it rather than its own text. + assert_eq!(first.tags[0].origin[0], number.markers[0].1[0] - 0.9 - 12.0); + assert_eq!( + second.tags[0].origin[0], + bullet.markers[0].1[0] - 0.9 - 12.0 + ); + assert!(first.tags[0].origin[0] < first.origin[0] - 20.25); + let alone = OutlineLayout::flow( + [tagged(7, 1, None)].iter(), + &[18.0, 0.0, 27.0, 27.0], + 400.0, + false, + 0, + None, + &mut |node, previous, width, indents| { + ParagraphLayout::shape(&mut engine, node, previous, width, indents, &definitions) + }, + ) + .unwrap(); + assert_eq!(alone.paragraphs[0].tags[0].origin[0], -20.25); + } + + /// OneNote 2010 at 400%: the first run's size picks the tag icon (9 pt below 10 pt text, + /// 12 pt to 17.5, 18 pt to 23.5, then 24 pt up to at least 60), whose right edge stays + /// 8.25 pt before the text and whose centre sits 0.357 of the run's size less 0.2 pt above + /// the first baseline. + #[test] + fn tag_icons_follow_the_first_run() { + use onestore::document::Tag; + let id = |n| ExGuid { + n, + ..ExGuid::default() + }; + let definitions = BTreeMap::from([( + id(902), + Definition { + kind: Kind::TagDefinition { + shape: Some(3), + label: None, + action_type: None, + color: None, + highlight: None, + }, + format: Format::default(), + }, + )]); + let tag = Tag { + definition: Some(id(902)), + action_type: None, + status: 0, + created: None, + completed: None, + start: None, + due: None, + task_id: None, + extra_set: 0, + }; + let tagged = |sizes: &[f32], tags: usize| { + let mut node = paragraph(1, "", 1, None); + let text = node.text_mut().unwrap(); + text.text = Paragraph::from_runs(sizes.iter().map(|size| { + ( + "Tag ".to_string(), + Format { + font_size: Some(*size), + ..Format::default() + }, + ) + })); + text.tags = vec![tag.clone(); tags]; + node + }; + let mut engine = TextEngine::default(); + let mut lay = |nodes: &[PageParagraph]| { + OutlineLayout::flow( + nodes.iter(), + &[18.0, 0.0, 27.0, 27.0], + 400.0, + false, + 0, + None, + &mut |node, previous, width, indents| { + ParagraphLayout::shape( + &mut engine, + node, + previous, + width, + indents, + &definitions, + ) + }, + ) + .unwrap() + }; + for (sizes, side) in [ + (&[9.5][..], 9.0), + (&[10.0], 12.0), + (&[17.5], 12.0), + (&[18.0], 18.0), + (&[23.5], 18.0), + (&[24.0], 24.0), + (&[60.0], 24.0), + (&[11.0, 24.0], 12.0), + (&[24.0, 11.0], 24.0), + ] { + let layout = lay(&[tagged(sizes, 1)]); + let icon = &layout.paragraphs[0].tags[0]; + assert_eq!((icon.size, icon.origin[0] + icon.size), (side, -8.25)); + let baseline = layout.paragraphs[0].text.lines().next().unwrap().1.baseline; + let centre = icon.origin[1] + side / 2.0; + assert!((centre - (baseline - 0.357 * sizes[0] + 0.2)).abs() < 1e-4); + } + // A second icon extends the column left by its own side. + let layout = lay(&[tagged(&[20.0], 2)]); + assert_eq!(layout.tag_column_offset(), -18.0); + } + #[test] fn files_and_ink_take_their_own_paragraph_in_the_flow() { use onestore::page::{Attachment, Ink, InkStroke}; @@ -1855,7 +2142,7 @@ mod tests { let mut engine = TextEngine::default(); let layout = OutlineLayout::flow( [&before, &file, &ink, &after].into_iter(), - &[0.0], + &[0.0, 0.0], 468.0, false, 0, @@ -2082,11 +2369,40 @@ mod tests { outline.layout(&mut engine, &BTreeMap::new()), Err(LayoutError::InvalidIndentation) )); - // OneNote stores only the level-zero indent for an outline of top-level paragraphs. - outline.indents = vec![0.0]; outline.paragraphs.truncate(1); - let single = outline.layout(&mut engine, &BTreeMap::new()).unwrap(); - assert_eq!(single.paragraphs[0].origin[0], 0.0); + for (indents, x) in [(vec![18.0, 0.0, 27.0, 27.0], 0.0), (vec![0.0], 27.0)] { + outline.indents = indents; + let single = outline.layout(&mut engine, &BTreeMap::new()).unwrap(); + assert_eq!(single.paragraphs[0].origin[0], x); + } + } + + /// Text offsets of levels 1 through 5 as OneNote 2010 draws each table (cold reads of + /// Rust-written outlines, measured to the pixel at 96 dpi). + #[test] + fn indentation_follows_onenote_for_short_and_unusual_tables() { + for (indents, expected) in [ + (&[18.0, 0.0, 27.0, 27.0][..], [0.0, 27.0, 54.0, 81.0, 108.0]), + (&[18.0, 0.0], [0.0, 27.0, 54.0, 81.0, 108.0]), + (&[0.0, 0.0], [0.0, 27.0, 54.0, 81.0, 108.0]), + (&[0.0], [27.0, 54.0, 81.0, 108.0, 135.0]), + (&[18.0], [27.0, 54.0, 81.0, 108.0, 135.0]), + (&[], [27.0, 54.0, 81.0, 108.0, 135.0]), + (&[0.0, 5.0], [5.0, 32.0, 59.0, 86.0, 113.0]), + (&[5.0, 10.0, 20.0, 40.0], [10.0, 30.0, 70.0, 97.0, 124.0]), + ( + &[40.0, 0.0, 10.0, 20.0, 30.0, 50.0], + [0.0, 10.0, 30.0, 60.0, 110.0], + ), + ] { + for (level, x) in (1..).zip(expected) { + assert_eq!( + indentation(level, indents, 500.0).unwrap(), + x, + "{indents:?}" + ); + } + } } #[test] diff --git a/crates/canvas/tests/reopen.rs b/crates/canvas/tests/reopen.rs index cc47fa96d07d1fbfee968071640d25894ff38f64..b2424a9fd99d73bca4d1a7c2c64ab47deb4dbb50 100644 --- a/crates/canvas/tests/reopen.rs +++ b/crates/canvas/tests/reopen.rs @@ -1,6 +1,9 @@ #![cfg(feature = "gpu")] -use canvas::{gpu::page::PageScene, layout::TextEngine}; +use canvas::{ + gpu::{Paper, page::PageScene}, + layout::TextEngine, +}; use onestore::{ ExGuid, PageCreation, PreparedEdit, RevisionIndex, Store, document::Document, @@ -149,7 +152,8 @@ fn undecodable_pictures_show_placeholders_and_keep_their_data() { _ => None, }) .unwrap(); - let (scene, editor) = PageScene::from_page(page.clone(), &mut engine).unwrap(); + let (mut scene, editor) = PageScene::from_page(page.clone(), &mut engine).unwrap(); + scene.settle(Some(&editor), 1.0, Paper::WHITE); assert!(scene.image(picture).is_none()); assert_eq!(editor.page().unwrap(), page); @@ -158,7 +162,8 @@ fn undecodable_pictures_show_placeholders_and_keep_their_data() { .into_iter() .find(|page| page.title == "Image tiff") .unwrap(); - let (scene, _) = PageScene::from_page(page.clone(), &mut engine).unwrap(); + let (mut scene, editor) = PageScene::from_page(page.clone(), &mut engine).unwrap(); + scene.settle(Some(&editor), 1.0, Paper::WHITE); let PageObject::Image(tiff) = page .objects .iter_mut() diff --git a/crates/canvas/tests/structural_roundtrip.rs b/crates/canvas/tests/structural_roundtrip.rs new file mode 100644 index 0000000000000000000000000000000000000000..0eefdb43a1876d99934f5cfe19a057f66af64373 --- /dev/null +++ b/crates/canvas/tests/structural_roundtrip.rs @@ -0,0 +1,577 @@ +//! Every structural edit the editor makes saves through the page writer and rereads as the +//! model it saved: random Enter, Backspace, Delete, Tab, Shift+Tab, list and paste edits, alone +//! and several to a save, on every outline of the probe section and of any sections named in +//! `CANVAS_SWEEP_SECTIONS` (`:`-separated paths). + +use canvas::{ + document::TextPosition, + editor::{CanvasEditor, Formatting}, + layout::TextEngine, +}; +use onestore::{ + ExGuid, PreparedEdit, RevisionIndex, Store, + document::Document, + page::{Outline, Page, PageObject, PageParagraph, ParagraphContent}, +}; +use std::{collections::BTreeMap, path::Path}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] +enum Kind { + EnterStart, + EnterMiddle, + EnterEnd, + Backspace, + Delete, + Tab, + ShiftTab, + TabMiddle, + Paste, + Bullets, + Numbering, +} + +const KINDS: [Kind; 11] = [ + Kind::EnterStart, + Kind::EnterMiddle, + Kind::EnterEnd, + Kind::Backspace, + Kind::Delete, + Kind::Tab, + Kind::ShiftTab, + Kind::TabMiddle, + Kind::Paste, + Kind::Bullets, + Kind::Numbering, +]; + +struct Random(u64); + +impl Random { + fn below(&mut self, bound: usize) -> usize { + self.0 ^= self.0 << 13; + self.0 ^= self.0 >> 7; + self.0 ^= self.0 << 17; + (self.0 % bound as u64) as usize + } +} + +fn pages(section: &[u8]) -> Vec<(ExGuid, Page)> { + let store = Store::parse(section).unwrap(); + let index = RevisionIndex::parse(&store).unwrap(); + let document = Document::parse(&index).unwrap(); + let mut spaces: Vec<_> = document + .pages() + .unwrap() + .into_iter() + .map(|(space, _)| space) + .collect(); + spaces.dedup(); + spaces + .into_iter() + .filter_map(|space| Some((space, Page::from_space(&document, space).ok()?))) + .collect() +} + +/// Applies `kind` at a random paragraph of the focused outline; false when the editor +/// declines it. +fn apply( + editor: &mut CanvasEditor, + engine: &mut TextEngine, + kind: Kind, + random: &mut Random, +) -> bool { + let count = editor.active_outline().document().paragraphs().count(); + let paragraph = random.below(count); + let text = editor + .active_outline() + .document() + .paragraphs() + .nth(paragraph) + .unwrap(); + let length = text.text().encode_utf16().count() as u32; + let middle = || { + let chars: Vec<_> = text.text().char_indices().map(|(at, _)| at).collect(); + let at = chars.get(chars.len() / 2).copied().unwrap_or(0); + text.utf16_offset(at).unwrap() + }; + let offset = match kind { + Kind::EnterStart | Kind::Backspace | Kind::Tab | Kind::ShiftTab => 0, + Kind::EnterEnd | Kind::Delete => length, + Kind::EnterMiddle | Kind::TabMiddle | Kind::Paste | Kind::Bullets | Kind::Numbering => { + middle() + } + }; + let position = TextPosition { paragraph, offset }; + if editor.select([position; 2].into()).is_err() { + return false; + } + let result = match kind { + Kind::EnterStart | Kind::EnterMiddle | Kind::EnterEnd => editor.enter(engine, false), + Kind::Backspace => editor.delete(engine, true).map(|_| ()), + Kind::Delete => editor.delete(engine, false).map(|_| ()), + Kind::Tab | Kind::TabMiddle => editor.tab(engine, false), + Kind::ShiftTab => editor.tab(engine, true), + Kind::Paste => editor.paste(engine, "Line one\r\nLine two", 1033), + Kind::Bullets => editor.format(engine, Formatting::Bullets), + Kind::Numbering => editor.format(engine, Formatting::Numbering), + }; + result.is_ok() +} + +/// Every paragraph on the page, cells' included, by identity. +fn paragraphs(page: &Page) -> BTreeMap { + fn walk(list: &[PageParagraph], out: &mut BTreeMap) { + for paragraph in list { + out.insert(paragraph.id, paragraph.clone()); + if let ParagraphContent::Table(table) = ¶graph.content { + for cell in table.rows.iter().flat_map(|row| &row.cells) { + walk(&cell.paragraphs, out); + } + } + } + } + let mut out = BTreeMap::new(); + for object in &page.objects { + match object { + PageObject::Outline(outline) => walk(&outline.paragraphs, &mut out), + PageObject::Title(title) => { + for outline in &title.outlines { + walk(&outline.paragraphs, &mut out); + } + } + _ => {} + } + } + out +} + +/// The fields two values disagree on, as `name: saved -> reread`. +fn fields(saved: &T, reread: &T) -> String { + let (serde_json::Value::Object(a), serde_json::Value::Object(b)) = ( + serde_json::to_value(saved).unwrap(), + serde_json::to_value(reread).unwrap(), + ) else { + return "differs".into(); + }; + a.iter() + .filter(|(key, value)| b.get(*key) != Some(value)) + .map(|(key, value)| format!("{key}: {value} -> {}", b[key])) + .collect::>() + .join(", ") +} + +/// The first field a reread paragraph lost, as a signature without identities. +fn difference(saved: &Page, reread: &Page) -> String { + let (a, b) = (paragraphs(saved), paragraphs(reread)); + for (id, x) in &a { + let Some(y) = b.get(id) else { + return "a paragraph identity is missing".into(); + }; + macro_rules! field { + ($($name:ident),*) => {$( + if x.$name != y.$name { + return format!( + "{} {:?} reread as {:?}", + stringify!($name), + x.$name, + y.$name + ); + } + )*}; + } + if x.lists.len() == y.lists.len() && x.lists != y.lists { + let shared = a + .values() + .any(|other| other.id != x.id && other.lists.iter().any(|l| x.lists.contains(l))); + return format!("list identities (shared {shared})"); + } + if x.format != y.format { + return format!("paragraph format {}", fields(&x.format, &y.format)); + } + field!(parent, level, style, lists, tags, collapsed); + if let (Some(p), Some(q)) = (x.text(), y.text()) { + if p.id != q.id { + return "text identity".into(); + } + if p.text.text() != q.text.text() { + return "text".into(); + } + if p.text.spans().len() != q.text.spans().len() { + return format!( + "{} spans reread as {}", + p.text.spans().len(), + q.text.spans().len() + ); + } + for (s, t) in p.text.spans().iter().zip(q.text.spans()) { + if s.format != t.format { + return format!("span {}", fields(&s.format, &t.format)); + } + } + if p != q { + return "text object".into(); + } + } + if x != y { + return "content".into(); + } + } + if a.len() != b.len() { + return "extra paragraphs".into(); + } + let kind = |definition: &onestore::page::Definition| match &definition.kind { + onestore::document::Kind::List { .. } => "list", + onestore::document::Kind::Style { .. } => "style", + _ => "tag", + }; + for (id, definition) in &saved.definitions { + match reread.definitions.get(id) { + None => return format!("{} definition missing", kind(definition)), + Some(other) if other != definition => { + return format!( + "{} definition {}", + kind(definition), + fields(&definition.format, &other.format) + ); + } + _ => {} + } + } + if let Some(extra) = reread + .definitions + .iter() + .find(|(id, _)| !saved.definitions.contains_key(id)) + { + return format!("extra {} definition", kind(extra.1)); + } + for (x, y) in saved.objects.iter().zip(&reread.objects) { + if x != y { + return match (x, y) { + (PageObject::Outline(x), PageObject::Outline(y)) => format!( + "outline {}", + fields( + &Outline { + paragraphs: Vec::new(), + ..x.clone() + }, + &Outline { + paragraphs: Vec::new(), + ..y.clone() + } + ) + ), + _ => format!("page object {x:?}\n reread as {y:?}"), + }; + } + } + format!( + "page {} ({} objects reread as {})", + fields( + &Page { + objects: Vec::new(), + definitions: Default::default(), + ..saved.clone() + }, + &Page { + objects: Vec::new(), + definitions: Default::default(), + ..reread.clone() + } + ), + saved.objects.len(), + reread.objects.len() + ) +} + +#[derive(Default)] +struct Tally { + samples: usize, + failures: BTreeMap>, +} + +fn sweep(path: &Path, random: &mut Random, engine: &mut TextEngine, tally: &mut Tally) { + let section = std::fs::read(path).unwrap(); + for (space, page) in pages(§ion) { + let Ok(editor) = CanvasEditor::from_page(page.clone(), engine) else { + continue; + }; + let Ok(before) = editor.page() else { + continue; + }; + let outlines: Vec = editor + .outlines() + .iter() + .filter(|outline| !outline.title) + .map(|outline| outline.id) + .collect(); + for outline in outlines { + let plans: Vec> = KINDS + .iter() + .map(|kind| vec![*kind]) + .chain((0..3).map(|_| { + (0..2 + random.below(3)) + .map(|_| KINDS[random.below(KINDS.len())]) + .collect() + })) + .collect(); + for plan in plans { + let mut editor = CanvasEditor::from_page(page.clone(), engine).unwrap(); + editor.focus_outline(outline).unwrap(); + let mut applied = 0; + for kind in &plan { + applied += usize::from(apply(&mut editor, engine, *kind, random)); + } + let Ok(after) = editor.page() else { continue }; + if applied == 0 || after == before { + continue; + } + tally.samples += 1; + if let Some(dir) = std::env::var_os("CANVAS_SWEEP_DUMP") { + let value = serde_json::json!({"section": path.canonicalize().unwrap(), "space": space, "page": after}); + std::fs::write( + Path::new(&dir).join(format!("{}.json", tally.samples)), + serde_json::to_vec(&value).unwrap(), + ) + .unwrap(); + } + let failure = match PreparedEdit::page(§ion, space, &after, "Sweep") { + Err(error) => Some(error.message.to_owned()), + Ok(saved) => match pages(saved.as_bytes()) + .into_iter() + .find(|(id, _)| *id == space) + { + // An untitled page's title follows its first line as the writer stores it. + Some((_, reread)) + if Page { + title: after.title.clone(), + ..reread.clone() + } == after => + { + None + } + Some((_, reread)) => Some(difference(&after, &reread)), + None => Some("the page does not reread".into()), + }, + }; + if let Some(failure) = failure { + tally.failures.entry(failure).or_default().push(format!( + "{plan:?} on {:?} in {}", + page.title, + path.display() + )); + } + } + } + } +} + +#[test] +fn structural_edits_reread_as_saved() { + let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."); + let mut paths = vec![root.join("evidence/structural-edits/probe-section/probe.one")]; + if let Some(extra) = std::env::var_os("CANVAS_SWEEP_SECTIONS") { + paths.extend(std::env::split_paths(&extra)); + } + let seed = std::env::var("CANVAS_SWEEP_SEED").map_or(1, |seed| seed.parse().unwrap()); + let mut random = Random(seed); + let mut engine = TextEngine::default(); + let mut tally = Tally::default(); + for path in &paths { + sweep(path, &mut random, &mut engine, &mut tally); + } + let failed: usize = tally.failures.values().map(Vec::len).sum(); + let mut report = String::new(); + for (failure, samples) in &tally.failures { + report += &format!("{} x {failure}\n e.g. {}\n", samples.len(), samples[0]); + if std::env::var_os("CANVAS_SWEEP_ALL").is_some() { + for sample in &samples[1..] { + report += &format!(" e.g. {sample}\n"); + } + } + } + println!( + "{} of {} edits reread as saved\n{report}", + tally.samples - failed, + tally.samples + ); + assert_eq!(failed, 0, "{report}"); +} + +/// Where a scripted edit puts the caret: in the first paragraph containing the text, before +/// or after it. +enum Caret { + Before(&'static str), + After(&'static str), +} + +/// A key or command the scripted edits press. +enum Press { + Enter, + Backspace, + Delete, + Tab, + ShiftTab, + Paste(&'static str), + Numbering, +} + +/// Opens the first page titled `title`, presses each `(caret, key)` in one editing session and +/// saves the page once, checking that it rereads as saved. +fn edit(section: &mut Vec, title: &str, keys: &[(Caret, Press)], engine: &mut TextEngine) { + let (space, page) = pages(section) + .into_iter() + .find(|(_, page)| page.title == title) + .unwrap(); + let mut editor = CanvasEditor::from_page(page, engine).unwrap(); + for (caret, key) in keys { + let needle = match caret { + Caret::Before(needle) | Caret::After(needle) => *needle, + }; + let outline = editor + .outlines() + .iter() + .find(|outline| { + outline + .document() + .paragraphs() + .any(|p| p.text().contains(needle)) + }) + .unwrap() + .id; + editor.focus_outline(outline).unwrap(); + let (paragraph, text) = editor + .active_outline() + .document() + .paragraphs() + .enumerate() + .find(|(_, p)| p.text().contains(needle)) + .unwrap(); + let at = text.text().find(needle).unwrap() + + match caret { + Caret::Before(_) => 0, + Caret::After(_) => needle.len(), + }; + let offset = text.utf16_offset(at).unwrap(); + editor + .select([TextPosition { paragraph, offset }; 2].into()) + .unwrap(); + match key { + Press::Enter => editor.enter(engine, false).unwrap(), + Press::Backspace => assert!(editor.delete(engine, true).unwrap()), + Press::Delete => assert!(editor.delete(engine, false).unwrap()), + Press::Tab => editor.tab(engine, false).unwrap(), + Press::ShiftTab => editor.tab(engine, true).unwrap(), + Press::Paste(text) => editor.paste(engine, text, 1033).unwrap(), + Press::Numbering => editor.format(engine, Formatting::Numbering).unwrap(), + } + } + let after = editor.page().unwrap(); + let saved = PreparedEdit::page(section, space, &after, "Snowbound") + .unwrap() + .as_bytes() + .to_vec(); + let (_, reread) = pages(&saved) + .into_iter() + .find(|(id, _)| *id == space) + .unwrap(); + assert_eq!( + Page { + title: after.title.clone(), + ..reread.clone() + }, + after, + "{title}" + ); + *section = saved; +} + +/// Group indents, pasted lines, splits and joins around note tags, headings, hyperlinks and +/// a recording link, and several of them in one save, written into the probe section and two +/// native feature sections. `CANVAS_STRUCTURAL_EXPORT` names a new directory receiving them +/// for a cold OneNote reopen (`corpus/structural-edit`). +#[test] +fn structural_edits_write_what_onenote_reads() { + use Caret::{After, Before}; + let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."); + let read = |path: &str| std::fs::read(root.join(path)).unwrap(); + let mut engine = TextEngine::default(); + let mut probe = read("evidence/structural-edits/probe-section/probe.one"); + for (title, keys) in [ + ("c6-plain", vec![(Before("Above"), Press::Tab)]), + ("c6-first", vec![(Before("Target text"), Press::ShiftTab)]), + ( + "c10-tab-parent", + vec![(Before("Omega"), Press::Tab), (Before("Child"), Press::Tab)], + ), + ("c1-todo", vec![(Before("Target text"), Press::Enter)]), + ( + "c1-bullet", + vec![(After("Target "), Press::Paste("Line one\r\nLine two"))], + ), + ("c9-h1", vec![(After("ing"), Press::Enter)]), + ( + "c2-q", + vec![ + (Before("Target "), Press::Enter), + (Before("Target "), Press::Backspace), + (Before("Xtext"), Press::Backspace), + ], + ), + ( + "c3-q", + vec![ + (After("Above"), Press::Enter), + (After("Above"), Press::Delete), + ], + ), + ( + "c4-imp", + vec![ + (After("First li"), Press::Paste("Line one\r\nLine two")), + (Before("Line one"), Press::Backspace), + ], + ), + ("c1-q", vec![(After("Target "), Press::Tab)]), + ( + "c1-rem", + vec![ + (Before("Target text"), Press::Numbering), + (Before("Target text"), Press::Tab), + (Before("Target text"), Press::ShiftTab), + ], + ), + ] { + edit(&mut probe, title, &keys, &mut engine); + } + let mut features = read("corpus/m6/native-features-01/notebook/Features.one"); + edit( + &mut features, + "Files and recording", + &[(After("Recording "), Press::Enter)], + &mut engine, + ); + let mut links = read("corpus/m6/native-link-controls-01/notebook/Links.one"); + edit( + &mut links, + "Hyperlink boundary controls", + &[ + ( + Before("\u{fddf}HYPERLINK \"https://example.invalid/label/0"), + Press::Enter, + ), + (After("label/4\"label"), Press::Delete), + ], + &mut engine, + ); + if let Some(directory) = std::env::var_os("CANVAS_STRUCTURAL_EXPORT") { + let directory = Path::new(&directory); + std::fs::create_dir(directory).unwrap(); + for (name, bytes) in [ + ("probe.one", &probe), + ("Features.one", &features), + ("Links.one", &links), + ] { + std::fs::write(directory.join(name), bytes).unwrap(); + } + } +} diff --git a/crates/draw/Cargo.toml b/crates/draw/Cargo.toml index 7e86e37a3e8dcabe7d4db7bdacbc99ef969e1f67..47db5a4e8f28857c0b01b1fd0fc988a9c287a02e 100644 --- a/crates/draw/Cargo.toml +++ b/crates/draw/Cargo.toml @@ -7,13 +7,14 @@ publish = false [features] default = ["render"] # The wgpu renderer; `edit` needs only parley. -render = ["dep:bytemuck", "dep:image", "dep:roxmltree", "dep:swash", "dep:wgpu"] +render = ["dep:bytemuck", "dep:image", "dep:linebender_resource_handle", "dep:roxmltree", "dep:swash", "dep:wgpu"] [dependencies] parley.workspace = true bytemuck = { version = "1.25", features = ["derive"], optional = true } image = { version = "0.25", default-features = false, features = ["gif", "jpeg", "png", "tiff"], optional = true } +linebender_resource_handle = { version = "0.1.1", optional = true } roxmltree = { version = "0.21", optional = true } swash = { version = "0.2.10", optional = true } wgpu = { workspace = true, optional = true } diff --git a/crates/draw/src/edit.rs b/crates/draw/src/edit.rs index 3952e5e794b0aab5b99f92be2f43eb12744c49f5..e9d1d2fade22ef59cd673425f61ce54b2cc5c8e7 100644 --- a/crates/draw/src/edit.rs +++ b/crates/draw/src/edit.rs @@ -183,6 +183,69 @@ impl Clicks { } } +/// The caret's width in logical pixels, as AppKit's insertion indicator draws it. +pub const CARET_WIDTH: f32 = 2.0; + +/// The caret's opacity `since` it last moved, and how long that opacity holds. As AppKit's +/// insertion indicator: solid for 650 ms, then 350 ms off and 650 ms on, each phase +/// opening with a fade in four 37.5 ms steps. +pub fn caret_blink(since: Duration) -> (f32, Duration) { + const STEP: u64 = 37_500; + const ON: u64 = 650_000; + const OFF: u64 = 350_000; + let micros = u64::try_from(since.as_micros()).unwrap_or(u64::MAX); + let Some(cycle) = micros.checked_sub(ON) else { + return (1.0, Duration::from_micros(ON + STEP - micros)); + }; + let (into, length, fading_in) = match cycle % (ON + OFF) { + phase if phase < OFF => (phase, OFF, false), + phase => (phase - OFF, ON, true), + }; + let step = into / STEP; + // A settled phase holds into the next one's first step, which shows the same. + let (faded, hold) = if step < 4 { + (step as f32 / 4.0, STEP * (step + 1) - into) + } else { + (1.0, length - into + STEP) + }; + ( + if fading_in { faded } else { 1.0 - faded }, + Duration::from_micros(hold), + ) +} + +/// `caret`, linear RGBA, with the alpha that shows it at `opacity` over `backdrop` as bright +/// as AppKit's layers do, which blend in sRGB rather than linear light. +pub fn caret_color(caret: [f32; 4], backdrop: [f32; 4], opacity: f32) -> [f32; 4] { + let encode = |value: f32| { + if value <= 0.003_130_8 { + value * 12.92 + } else { + 1.055 * value.powf(1.0 / 2.4) - 0.055 + } + }; + let decode = |value: f32| { + if value <= 0.040_45 { + value / 12.92 + } else { + ((value + 0.055) / 1.055).powf(2.4) + } + }; + let luminance = |[red, green, blue]: [f32; 3]| 0.2126 * red + 0.7152 * green + 0.0722 * blue; + let [under, over] = [backdrop, caret].map(|color| [color[0], color[1], color[2]]); + let shown: [f32; 3] = std::array::from_fn(|channel| { + let [under, over] = [under[channel], over[channel]].map(encode); + decode(under + opacity * (over - under)) + }); + let span = luminance(over) - luminance(under); + let alpha = if span.abs() < 1e-4 { + opacity + } else { + ((luminance(shown) - luminance(under)) / span).clamp(0.0, 1.0) + }; + [caret[0], caret[1], caret[2], caret[3] * alpha] +} + /// The selection a drag makes from the selection its press made to the one under the /// pointer, both by `unit`, ordered by `position`. Words and paragraphs stay whole, so /// the press's selection stays covered when the drag reverses past it. @@ -454,4 +517,45 @@ mod tests { "extending keeps the anchor" ); } + + #[test] + fn caret_fades_as_bright_as_appkit_s_srgb_blend() { + let linear = |value: f32| ((value / 255.0 + 0.055) / 1.055).powf(2.4); + let pink = [248.0, 79.0, 158.0].map(linear); + let pink = [pink[0], pink[1], pink[2], 1.0]; + let white = [1.0; 4]; + assert_eq!(caret_color(pink, white, 1.0)[3], 1.0); + assert_eq!(caret_color(pink, white, 0.0)[3], 0.0); + // Half the caret over white blends to sRGB green 167; TextEdit measures 170. + let alpha = caret_color(pink, white, 0.5)[3]; + let green = 1.0 + alpha * (pink[1] - 1.0); + assert!((green - linear(167.5)).abs() < 0.02, "{alpha}"); + } + + #[test] + fn caret_blinks_on_appkit_s_timing() { + let at = |millis: f64| { + let (opacity, hold) = caret_blink(Duration::from_secs_f64(millis / 1000.0)); + (opacity, hold.as_secs_f64() * 1000.0) + }; + let close = |(opacity, hold): (f32, f64), expected: (f32, f64)| { + assert_eq!(opacity, expected.0); + assert!((hold - expected.1).abs() < 0.01, "{hold} ms, not {}", expected.1); + }; + close(at(0.0), (1.0, 687.5)); + close(at(687.5), (0.75, 37.5)); + close(at(760.0), (0.5, 2.5)); + close(at(800.0), (0.0, 237.5)); + close(at(1037.5), (0.25, 37.5)); + close(at(1150.0), (1.0, 537.5)); + close(at(1687.5), (0.75, 37.5)); + let mut changes = 0; + let mut previous = at(1000.0).0; + for millis in 1001..2001 { + let opacity = at(f64::from(millis)).0; + changes += usize::from(opacity != previous); + previous = opacity; + } + assert_eq!(changes, 8, "four fade steps each way per second"); + } } diff --git a/crates/draw/src/render.rs b/crates/draw/src/render.rs index 971d6cbc7e800a57586783a48343c09833503271..35af48927462ee1dae4f4355bb1ee37bf6cf58f1 100644 --- a/crates/draw/src/render.rs +++ b/crates/draw/src/render.rs @@ -5,6 +5,7 @@ pub use text::{Decoration, Glyph, GlyphRun, Glyphs, paint_parley_run}; use bytemuck::{Pod, Zeroable}; use image::ImageDecoder; +use linebender_resource_handle::WeakBlob; use parley::fontique::Blob; use std::{ collections::{HashMap, HashSet}, @@ -22,6 +23,8 @@ const MAX_VERTICES: usize = 65_536; /// Decoded bytes of all images one frame may paint. pub const MAX_IMAGE_BYTES: u64 = 64 * 1024 * 1024; const MAX_IMAGES: usize = 256; +/// Decoded bytes one picture may take before `RasterImage::decode` shrinks it. +const MAX_DECODE_BYTES: u64 = 256 * 1024 * 1024; #[derive(Clone)] pub struct RasterImage { @@ -29,39 +32,84 @@ pub struct RasterImage { pixels: Blob, } +/// A PNG, JPEG, GIF or TIFF decoder for `encoded`, refusing pictures past the decode limits. +fn decoder(encoded: &[u8]) -> Result { + if encoded.len() as u64 > MAX_DECODE_BYTES { + return Err(RenderError::ImageBudget); + } + let format = image::guess_format(encoded).map_err(RenderError::ImageDecode)?; + let mut reader = image::ImageReader::with_format(std::io::Cursor::new(encoded), format); + let mut limits = image::Limits::default(); + limits.max_image_width = Some(16_384); + limits.max_image_height = Some(16_384); + limits.max_alloc = Some(MAX_DECODE_BYTES); + reader.limits(limits.clone()); + let mut decoder = reader.into_decoder().map_err(RenderError::ImageDecode)?; + let (width, height) = decoder.dimensions(); + if u64::from(width) * u64::from(height) * 4 > MAX_DECODE_BYTES + || decoder.total_bytes() > MAX_DECODE_BYTES + { + return Err(RenderError::ImageBudget); + } + limits + .reserve(decoder.total_bytes()) + .map_err(RenderError::ImageDecode)?; + decoder + .set_limits(limits) + .map_err(RenderError::ImageDecode)?; + Ok(decoder) +} + +/// Premultiplies straight-alpha sRGB RGBA in linear light, so filtering never bleeds the +/// colour of transparent pixels. +fn premultiply(pixels: &mut [u8]) { + for pixel in pixels.chunks_exact_mut(4) { + let alpha = pixel[3]; + if alpha == 255 { + continue; + } + let linear = srgb(pixel[0], pixel[1], pixel[2]); + for (byte, value) in pixel[..3].iter_mut().zip(linear) { + *byte = srgb_byte(value * f32::from(alpha) / 255.0); + } + } +} + impl RasterImage { - pub fn decode(encoded: &[u8]) -> Result { - if encoded.len() as u64 > MAX_IMAGE_BYTES { - return Err(RenderError::ImageBudget); - } - let format = image::guess_format(encoded).map_err(RenderError::ImageDecode)?; - let mut reader = image::ImageReader::with_format(std::io::Cursor::new(encoded), format); - let mut limits = image::Limits::default(); - limits.max_image_width = Some(16_384); - limits.max_image_height = Some(16_384); - limits.max_alloc = Some(MAX_IMAGE_BYTES); - reader.limits(limits.clone()); - let mut decoder = reader.into_decoder().map_err(RenderError::ImageDecode)?; - let (width, height) = decoder.dimensions(); - if u64::from(width) * u64::from(height) * 4 > MAX_IMAGE_BYTES - || decoder.total_bytes() > MAX_IMAGE_BYTES - { - return Err(RenderError::ImageBudget); - } - limits - .reserve(decoder.total_bytes()) - .map_err(RenderError::ImageDecode)?; - decoder - .set_limits(limits) - .map_err(RenderError::ImageDecode)?; - let pixels = image::DynamicImage::from_decoder(decoder) + /// The pixel size of an encoded picture, read from its header, or why `decode` would + /// refuse it. + pub fn measure(encoded: &[u8]) -> Result<[u32; 2], RenderError> { + let (width, height) = decoder(encoded)?.dimensions(); + Ok([width, height]) + } + + /// Decodes a picture, shrinking it to at most `within` pixels along each axis. + pub fn decode(encoded: &[u8], within: [u32; 2]) -> Result { + let decoder = decoder(encoded)?; + let mut pixels = image::DynamicImage::from_decoder(decoder) .map_err(RenderError::ImageDecode)? .into_rgba8(); - Self::new([width, height], pixels.into_raw()) + premultiply(&mut pixels); + let size = [pixels.width(), pixels.height()]; + let shown = [0, 1].map(|axis| size[axis].min(within[axis]).max(1)); + if shown != size { + pixels = image::imageops::resize( + &pixels, + shown[0], + shown[1], + image::imageops::FilterType::Triangle, + ); + } + Self::premultiplied(shown, pixels.into_raw()) } /// Immutable, straight-alpha sRGB RGBA pixels retain one cache identity across clones. pub fn new(size: [u32; 2], mut pixels: Vec) -> Result { + premultiply(&mut pixels); + Self::premultiplied(size, pixels) + } + + fn premultiplied(size: [u32; 2], pixels: Vec) -> Result { let bytes = u64::from(size[0]) .checked_mul(u64::from(size[1])) .and_then(|v| v.checked_mul(4)) @@ -72,24 +120,16 @@ impl RasterImage { if bytes > MAX_IMAGE_BYTES { return Err(RenderError::ImageBudget); } - // Premultiply in linear light before filtering; transparent RGB must not bleed at edges. - for pixel in pixels.chunks_exact_mut(4) { - let alpha = pixel[3]; - if alpha == 255 { - continue; - } - let linear = srgb(pixel[0], pixel[1], pixel[2]); - for (byte, value) in pixel[..3].iter_mut().zip(linear) { - let value = value * f32::from(alpha) / 255.0; - *byte = srgb_byte(value); - } - } Ok(Self { size, pixels: pixels.into(), }) } + pub fn size(&self) -> [u32; 2] { + self.size + } + /// The identity caches and uploads share across clones. pub fn id(&self) -> u64 { self.pixels.id() @@ -104,6 +144,8 @@ impl RasterImage { struct CachedImage { binding: wgpu::BindGroup, bytes: u64, + /// The texture goes when every copy of its image has. + pixels: WeakBlob, } struct Batch { @@ -168,6 +210,9 @@ pub struct Layer<'a> { pub origin: [f32; 2], /// Device bounds `[left, top, right, bottom]` the layer paints within. pub clip: Option<[f32; 4]>, + /// The colour behind the layer: on a dark one, text in dark colours of its own is + /// lifted to stay legible, as OneNote's dark page does. + pub backdrop: Option<[f32; 4]>, pub primitives: &'a [Primitive<'a>], } @@ -177,6 +222,7 @@ struct Space { size: [u32; 2], scale: f32, origin: [f32; 2], + backdrop: Option<[f32; 4]>, } impl Space { @@ -545,6 +591,8 @@ impl Renderer { { return Err(RenderError::InvalidLayer); } + self.images + .retain(|_, image| image.pixels.upgrade().is_some()); let mut active_images = HashSet::new(); let mut image_bytes = 0; for layer in layers { @@ -552,6 +600,7 @@ impl Renderer { size, scale: layer.scale, origin: layer.origin, + backdrop: layer.backdrop, }; for primitive in layer.primitives { if let Primitive::Image { image, rect } = primitive @@ -626,6 +675,7 @@ impl Renderer { size, scale: layer.scale, origin: layer.origin, + backdrop: layer.backdrop, }; let bounds = match layer.clip { Some(clip) => space.scissor(clip), @@ -816,8 +866,14 @@ impl Renderer { }, ], }); - self.images - .insert(image.id(), CachedImage { binding, bytes }); + self.images.insert( + image.id(), + CachedImage { + binding, + bytes, + pixels: image.pixels.downgrade(), + }, + ); Ok(()) } @@ -929,7 +985,7 @@ impl Renderer { if glyph.color { [1.0; 4] } else { - run.color.unwrap_or(ink) + run.color.map_or(ink, |color| legible(color, space.backdrop)) }, )?; } @@ -946,7 +1002,9 @@ impl Renderer { y + (decoration.thickness * scale).max(1.0), ], [0.5 / ATLAS_SIZE as f32; 4], - decoration.color.unwrap_or(ink), + decoration + .color + .map_or(ink, |color| legible(color, space.backdrop)), )?; } Ok(()) @@ -1410,6 +1468,51 @@ fn srgb_byte(value: f32) -> u8 { } /// Linear RGBA of an opaque sRGB colour. +/// Least OKLab lightness difference between text and the backdrop it stays legible on. +const LEGIBLE: f32 = 0.4; + +/// `color` lifted clear of a dark `backdrop`'s lightness, keeping hue and chroma. +fn legible(color: [f32; 4], backdrop: Option<[f32; 4]>) -> [f32; 4] { + let [under, ..] = backdrop.map_or([1.0; 3], oklab); + let [lightness, a, b] = oklab(color); + if under >= 0.5 || lightness >= under + LEGIBLE { + return color; + } + let [red, green, blue] = from_oklab([under + LEGIBLE, a, b]); + [red, green, blue, color[3]] +} + +/// OKLab lightness and opponent axes of a linear RGB colour. +pub fn oklab([red, green, blue, _]: [f32; 4]) -> [f32; 3] { + let [l, m, s] = [ + [0.412_221_46, 0.536_332_55, 0.051_445_995], + [0.211_903_5, 0.680_699_5, 0.107_396_96], + [0.088_302_46, 0.281_718_85, 0.629_978_7], + ] + .map(|[r, g, b]| (r * red + g * green + b * blue).cbrt()); + [ + 0.210_454_26 * l + 0.793_617_8 * m - 0.004_072_047 * s, + 1.977_998_5 * l - 2.428_592_2 * m + 0.450_593_7 * s, + 0.025_904_037 * l + 0.782_771_77 * m - 0.808_675_77 * s, + ] +} + +/// Linear RGB of an OKLab colour, clipped to the sRGB gamut. +pub fn from_oklab([lightness, a, b]: [f32; 3]) -> [f32; 3] { + let [l, m, s] = [ + lightness + 0.396_337_78 * a + 0.215_803_76 * b, + lightness - 0.105_561_346 * a - 0.063_854_17 * b, + lightness - 0.089_484_18 * a - 1.291_485_5 * b, + ] + .map(|value| value.powi(3)); + [ + [4.076_741_7, -3.307_711_6, 0.230_969_94], + [-1.268_438, 2.609_757_4, -0.341_319_38], + [-0.004_196_086_3, -0.703_418_6, 1.707_614_7], + ] + .map(|[x, y, z]| (x * l + y * m + z * s).clamp(0.0, 1.0)) +} + pub fn srgb(red: u8, green: u8, blue: u8) -> [f32; 4] { let linear = |byte: u8| { let value = f32::from(byte) / 255.0; @@ -1491,18 +1594,21 @@ mod tests { .unwrap() .write_image_data(&rgba) .unwrap(); - let decoded = RasterImage::decode(&encoded).unwrap(); + let decoded = RasterImage::decode(&encoded, [2, 1]).unwrap(); assert_eq!(decoded.size, [2, 1]); assert_eq!(decoded.pixels(), [255, 64, 0, 255, 0, 93, 188, 128]); for end in 0..encoded.len() - 12 { - assert!(RasterImage::decode(&encoded[..end]).is_err(), "{end}"); + assert!( + RasterImage::decode(&encoded[..end], [2, 1]).is_err(), + "{end}" + ); } let mut jpeg = Vec::new(); image::codecs::jpeg::JpegEncoder::new(&mut jpeg) .encode(&[255; 4 * 4 * 3], 4, 4, image::ExtendedColorType::Rgb8) .unwrap(); - let decoded = RasterImage::decode(&jpeg).unwrap(); + let decoded = RasterImage::decode(&jpeg, [4, 4]).unwrap(); assert_eq!(decoded.size, [4, 4]); assert_eq!(decoded.pixels(), [255; 4 * 4 * 4]); @@ -1510,11 +1616,11 @@ mod tests { image::codecs::gif::GifEncoder::new(&mut gif) .encode(&[255; 2 * 2 * 4], 2, 2, image::ExtendedColorType::Rgba8) .unwrap(); - let decoded = RasterImage::decode(&gif).unwrap(); + let decoded = RasterImage::decode(&gif, [2, 2]).unwrap(); assert_eq!(decoded.size, [2, 2]); assert_eq!(decoded.pixels(), [255; 2 * 2 * 4]); - for size in [[8192, 8192], [16_385, 1]] { + for size in [[8193, 8192], [16_385, 1]] { let mut header = Vec::new(); let mut encoder = png::Encoder::new(&mut header, size[0], size[1]); encoder.set_color(png::ColorType::Rgba); @@ -1522,8 +1628,8 @@ mod tests { let mut writer = encoder.write_header().unwrap(); writer.write_chunk(png::chunk::IDAT, &[]).unwrap(); drop(writer); - let error = RasterImage::decode(&header).err().unwrap(); - if size[0] == 8192 { + let error = RasterImage::measure(&header).err().unwrap(); + if size[0] == 8193 { assert!(matches!(error, RenderError::ImageBudget), "{error:?}"); } else { assert!( @@ -1537,6 +1643,39 @@ mod tests { } } + #[test] + fn decoding_shrinks_to_the_size_shown_without_bleeding_transparent_colour() { + let mut encoded = Vec::new(); + let mut encoder = png::Encoder::new(&mut encoded, 4, 2); + encoder.set_color(png::ColorType::Rgba); + encoder.set_depth(png::BitDepth::Eight); + let row = [ + [255, 0, 0, 255], + [255, 0, 0, 255], + [0, 255, 0, 0], + [0, 255, 0, 0], + ]; + encoder + .write_header() + .unwrap() + .write_image_data(&[row, row].as_flattened().concat()) + .unwrap(); + assert_eq!(RasterImage::measure(&encoded).unwrap(), [4, 2]); + let shown = RasterImage::decode(&encoded, [2, 1]).unwrap(); + assert_eq!(shown.size(), [2, 1]); + let [left, right] = [&shown.pixels()[..4], &shown.pixels()[4..]]; + assert!(left[0] > 200 && left[3] > 200, "{left:?}"); + assert!(right[1] == 0 && right[0] <= right[3], "{right:?}"); + assert_eq!( + RasterImage::decode(&encoded, [8, 8]).unwrap().size(), + [4, 2] + ); + assert_eq!( + RasterImage::decode(&encoded, [0, 0]).unwrap().size(), + [1, 1] + ); + } + #[test] fn image_pixels_have_valid_dimensions_and_immutable_clone_identity() { assert!(matches!( @@ -1641,6 +1780,7 @@ mod tests { scale: 2.0, origin: [24.0; 2], clip: None, + backdrop: None, primitives, }] } @@ -1980,6 +2120,12 @@ mod tests { } assert!(renderer.images.len() <= MAX_IMAGES); assert!(seen.iter().any(|id| !renderer.images.contains_key(id))); + let last = previous.take().unwrap().id(); + target.draw(&mut renderer, &page(&[])).unwrap(); + assert!( + !renderer.images.contains_key(&last), + "a texture outlived its image" + ); renderer.images.clear(); target .draw( @@ -2035,6 +2181,7 @@ mod tests { scale: 2.0, origin: [0.0; 2], clip: None, + backdrop: None, primitives: &primitives, }], ) @@ -2096,12 +2243,14 @@ mod tests { scale: 1.0, origin: [0.0; 2], clip: Some([100.0, 50.0, 200.5, 80.0]), + backdrop: None, primitives: &fill, }, Layer { scale: 2.0, origin: [300.0, 10.0], clip: None, + backdrop: None, primitives: &chrome, }, ], @@ -2128,6 +2277,7 @@ mod tests { scale: 0.0, origin: [0.0; 2], clip: None, + backdrop: None, primitives: &fill, }] ), diff --git a/crates/mobile/Cargo.toml b/crates/mobile/Cargo.toml new file mode 100644 index 0000000000000000000000000000000000000000..a0d7d497d249ed0e144ff821c4438acd73d25715 --- /dev/null +++ b/crates/mobile/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "mobile" +version = "0.1.0" +edition = "2024" +publish = false + +# A C surface over a canvas page for native mobile shells; see resources/ios-port.md. +[lib] +crate-type = ["staticlib"] + +[dependencies] +canvas = { path = "../canvas", features = ["interaction"] } +draw = { path = "../draw" } +onestore = { path = "../onestore" } +wgpu.workspace = true +parley.workspace = true +pollster = "0.4" diff --git a/crates/mobile/include/snowbound.h b/crates/mobile/include/snowbound.h new file mode 100644 index 0000000000000000000000000000000000000000..83fc816a3b1376404b412a395fd90d21bf6650f8 --- /dev/null +++ b/crates/mobile/include/snowbound.h @@ -0,0 +1,40 @@ +// The C surface of crates/mobile; see its lib.rs for each call's contract. +#pragma once + +#include +#include +#include + +typedef struct Section Section; +typedef struct View View; + +Section *sb_section_open(const char *path); +size_t sb_section_count(const Section *section); +const char *sb_section_title(const Section *section, size_t index); +void sb_section_free(Section *section); + +View *sb_view_new(void *layer, const Section *section, size_t index, float width, float height, float scale); +void sb_view_free(View *view); +void sb_view_resize(View *view, float width, float height, float scale); +bool sb_view_render(View *view); +bool sb_view_frame_pending(const View *view); +void sb_view_content(View *view, float bounds[4]); +void sb_view_set_transform(View *view, float zoom, float x, float y); +bool sb_view_tap(View *view, float x, float y); +bool sb_view_in_active_text(const View *view, float x, float y); + +uint32_t sb_text_length(const View *view); +char *sb_text(const View *view, uint32_t start, uint32_t end); +void sb_string_free(char *text); +void sb_selection(const View *view, uint32_t range[2]); +bool sb_select(View *view, uint32_t start, uint32_t end); +bool sb_marked(const View *view, uint32_t range[2]); +bool sb_set_marked(View *view, const char *text, uint32_t selected_start, uint32_t selected_end); +void sb_unmark(View *view); +bool sb_insert(View *view, const char *text); +bool sb_replace(View *view, uint32_t start, uint32_t end, const char *text); +bool sb_paste(View *view, const char *text, const char *language); +bool sb_delete_backward(View *view); +bool sb_caret_rect(const View *view, uint32_t offset, float rect[4]); +size_t sb_range_rects(const View *view, uint32_t start, uint32_t end, float (*rects)[4], size_t capacity); +uint32_t sb_closest(const View *view, float x, float y); diff --git a/crates/mobile/src/lib.rs b/crates/mobile/src/lib.rs new file mode 100644 index 0000000000000000000000000000000000000000..ff9b79c1e1462bd4b14fff642abc2cb85aac528e --- /dev/null +++ b/crates/mobile/src/lib.rs @@ -0,0 +1,676 @@ +//! A canvas page behind a C surface for native mobile shells: a section's pages, one drawn +//! into a `CAMetalLayer`, and the active outline's text as flat UTF-16 offsets, paragraphs +//! joined by `\n`, the model UIKit's `UITextInput` speaks. +//! +//! Lengths and positions are in the host's points; the view converts through its display +//! scale. Calls returning `bool` report whether the page or selection changed, after which +//! the host redraws and rereads `sb_view_content`. + +use canvas::{ + document::TextPosition, + editor::Selection, + gpu::{Paper, Viewport, page::PageScene}, + interaction::{Hit, PageView, Response, TextColors, page_hit_test}, + layout::TextEngine, +}; +use draw::edit::{Key, NamedKey, SelectionUnit}; +use onestore::{RevisionIndex, Store, document::Document, page::Page}; +use parley::{Affinity, BoundingBox}; +use std::{ + error::Error, + ffi::{CStr, CString, c_char, c_void}, + sync::{ + Arc, + atomic::{AtomicBool, Ordering}, + }, + task::{Wake, Waker}, + time::{Duration, Instant}, +}; + +type Result = std::result::Result>; + +/// Host points per document point at 100% zoom, as OneNote's 96 pixels per inch. +const POINT: f32 = 96.0 / 72.0; + +/// The platform's text interaction draws the caret and selection. +const COLORS: TextColors = TextColors { + caret: [0.0; 4], + selection: [0.0; 4], + paper: Paper::WHITE, +}; + +pub struct Section { + pages: Vec, + titles: Vec, +} + +impl Section { + fn open(bytes: &[u8]) -> Result { + let store = Store::parse(bytes)?; + let index = RevisionIndex::parse(&store)?; + let document = Document::parse(&index)?; + let pages = document + .pages()? + .into_iter() + .map(|(space, id)| Page::from_revision(document.active(space)?, id)) + .collect::, _>>()?; + let titles = pages + .iter() + .map(|page| CString::new(page.title.replace('\0', ""))) + .collect::>()?; + Ok(Self { pages, titles }) + } +} + +/// Set off the main thread when a picture raster lands and the page should be drawn again. +#[derive(Default)] +struct Frame(AtomicBool); + +impl Wake for Frame { + fn wake(self: Arc) { + self.0.store(true, Ordering::Release); + } +} + +pub struct View { + page: PageView, + surface: wgpu::Surface<'static>, + config: wgpu::SurfaceConfiguration, + renderer: draw::Renderer, + frame: Arc, +} + +fn report(result: Result) -> Option { + result.map_err(|error| eprintln!("snowbound: {error}")).ok() +} + +fn moved(response: Response) -> bool { + response.changed || response.moved +} + +impl View { + fn new(layer: *mut c_void, page: Page, size: [f32; 2], scale: f32) -> Result { + let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle()); + // SAFETY: the host passes a live CAMetalLayer that outlives the view. + let surface = unsafe { + instance.create_surface_unsafe(wgpu::SurfaceTargetUnsafe::CoreAnimationLayer(layer)) + }?; + let adapter = pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions { + compatible_surface: Some(&surface), + ..Default::default() + }))?; + // The simulator's adapter falls short of wgpu's default limits. + let (device, queue) = + pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor { + required_limits: adapter.limits(), + ..Default::default() + }))?; + let pixels = size.map(|side| (side * scale).round().max(1.0) as u32); + let config = surface + .get_default_config(&adapter, pixels[0], pixels[1]) + .ok_or("No supported surface configuration")?; + surface.configure(&device, &config); + let renderer = draw::Renderer::new(device, queue, config.format); + let mut engine = TextEngine::default(); + let (scene, editor) = PageScene::from_page(page, &mut engine)?; + Ok(Self { + page: PageView::new( + editor, + engine, + Some((scene, [0.0; 2])), + pixels, + scale, + Duration::from_millis(350), + ), + surface, + config, + renderer, + frame: Arc::default(), + }) + } + + /// Device pixels per host point. + fn display_scale(&self) -> f32 { + self.page.viewport.scale / (self.page.zoom() * POINT) + } + + fn device(&self, point: [f32; 2]) -> [f32; 2] { + point.map(|value| value * self.display_scale()) + } + + fn render(&mut self) -> Result<()> { + let frame = match self.surface.get_current_texture() { + wgpu::CurrentSurfaceTexture::Success(frame) + | wgpu::CurrentSurfaceTexture::Suboptimal(frame) => frame, + wgpu::CurrentSurfaceTexture::Outdated | wgpu::CurrentSurfaceTexture::Lost => { + self.surface.configure(&self.renderer.device, &self.config); + return Ok(()); + } + _ => return Ok(()), + }; + self.page + .update_pictures(COLORS.paper, &Waker::from(self.frame.clone())); + let primitives = self.page.primitives(COLORS)?; + self.renderer + .draw( + &frame.texture.create_view(&Default::default()), + [self.config.width, self.config.height], + COLORS.paper.color, + &[self.page.viewport.layer(&primitives)], + ) + .map_err(|error| format!("Page drawing failed: {error:?}"))?; + self.renderer.queue.present(frame); + Ok(()) + } + + /// The page content's extent in document points: the scroll limits of a view with no + /// size at scale 1. + fn content(&mut self) -> [f32; 4] { + let viewport = self.page.viewport; + self.page.viewport = Viewport { + size: [0; 2], + scale: 1.0, + origin: [0.0; 2], + }; + let scroll = self.page.scroll(); + self.page.viewport = viewport; + [scroll.min[0], scroll.min[1], scroll.max[0], scroll.max[1]] + } + + fn set_transform(&mut self, zoom: f32, corner: [f32; 2]) { + let scale = self.display_scale(); + self.page.viewport.scale = scale * POINT * zoom; + self.page.viewport.origin = corner.map(|value| -value * scale * zoom); + } + + fn tap(&mut self, point: [f32; 2]) -> Result { + let mut changed = moved(self.page.pointer_moved(self.device(point))?); + changed |= moved(self.page.pointer_pressed(Instant::now())?); + changed |= moved(self.page.pointer_released()?); + // A finger leaves no hover behind. + let _ = self.page.pointer_left(); + Ok(changed) + } + + /// Whether `point` lands on the text of the outline taking input, where the platform's + /// own text interaction places the caret. + fn in_active_text(&self, point: [f32; 2]) -> bool { + let point = self.page.viewport.document_point(self.device(point)); + let pixel = self.display_scale() / self.page.viewport.scale; + matches!( + page_hit_test(&self.page.editor, self.page.scene.as_ref(), point, pixel), + Some(Hit::Text { id, .. }) if id == self.page.editor.active_outline().id + ) + } + + /// UTF-16 lengths of the active outline's text paragraphs. + fn lengths(&self) -> Result> { + self.page + .editor + .active_outline() + .document() + .paragraphs() + .map(|paragraph| Ok(paragraph.utf16_offset(paragraph.text().len())?)) + .collect() + } + + fn position(&self, flat: u32) -> Result { + let lengths = self.lengths()?; + let mut rest = flat; + for (paragraph, length) in lengths.iter().enumerate() { + if rest <= *length { + return Ok(TextPosition { + paragraph, + offset: rest, + }); + } + rest -= length + 1; + } + Ok(TextPosition { + paragraph: lengths.len() - 1, + offset: lengths[lengths.len() - 1], + }) + } + + fn flat(&self, position: TextPosition) -> Result { + let before: u32 = self.lengths()?[..position.paragraph] + .iter() + .map(|length| length + 1) + .sum(); + Ok(before + position.offset) + } + + fn length(&self) -> Result { + let lengths = self.lengths()?; + Ok(lengths.iter().sum::() + lengths.len() as u32 - 1) + } + + fn text(&self, range: [u32; 2]) -> Result { + let range = self.position(range[0])?..self.position(range[1])?; + Ok(self + .page + .editor + .active_outline() + .document() + .slice(range)? + .iter() + .map(|paragraph| paragraph.text()) + .collect::>() + .join("\n")) + } + + fn selection(&self) -> Result<[u32; 2]> { + let [anchor, focus] = self.page.editor.selection().positions; + Ok([self.flat(anchor.min(focus))?, self.flat(anchor.max(focus))?]) + } + + fn select(&mut self, range: [u32; 2]) -> Result<()> { + let positions = [self.position(range[0])?, self.position(range[1])?]; + Ok(self.page.editor.select(Selection::from(positions))?) + } + + fn marked(&self) -> Result> { + self.page + .editor + .marked_range() + .map(|range| Ok([self.flat(range.start)?, self.flat(range.end)?])) + .transpose() + } + + /// Marked text with its selection in UTF-16 units of `text`. + fn set_marked(&mut self, text: String, selected: [u32; 2]) -> Result { + let byte = |utf16: u32| { + let mut units = 0; + text.char_indices() + .find(|(_, char)| { + let found = units >= utf16; + units += char.len_utf16() as u32; + found + }) + .map_or(text.len(), |(byte, _)| byte) + }; + let cursor = (byte(selected[0]), byte(selected[1])); + Ok(moved(self.page.compose(text, Some(cursor))?)) + } + + fn insert(&mut self, text: String) -> Result { + let response = if text == "\n" { + self.page.key(&Key::Named(NamedKey::Enter), None)? + } else { + self.page.commit_text(text)? + }; + Ok(moved(response)) + } + + /// A document rectangle in the active outline, in the view's points as `[x, y, width, + /// height]`. + fn view_rect(&self, rect: BoundingBox, origin: [f32; 2]) -> [f32; 4] { + let viewport = self.page.viewport; + let points = self.display_scale(); + let [x, y] = [rect.x0 as f32 + origin[0], rect.y0 as f32 + origin[1]]; + [ + (x * viewport.scale + viewport.origin[0]) / points, + (y * viewport.scale + viewport.origin[1]) / points, + rect.width() as f32 * viewport.scale / points, + rect.height() as f32 * viewport.scale / points, + ] + } + + fn caret_rect(&self, flat: u32) -> Result<[f32; 4]> { + let position = self.position(flat)?; + let outline = self.page.editor.active_outline(); + let paragraph = outline.paragraph_layout(position.paragraph)?; + let visible = paragraph.projection.visible_offset(position.offset)?; + let byte = paragraph.projection.text().byte_offset(visible)?; + let caret = paragraph + .text + .caret(paragraph.text.cursor(byte, Affinity::Downstream), 1.0); + let origin = outline.origin(); + Ok(self.view_rect( + caret, + [ + origin[0] + paragraph.origin[0], + origin[1] + paragraph.origin[1], + ], + )) + } + + /// The selection rectangles of a range, one or more per line. + fn range_rects(&self, range: [u32; 2]) -> Result> { + let [start, end] = [self.position(range[0])?, self.position(range[1])?]; + let outline = self.page.editor.active_outline(); + let origin = outline.origin(); + let mut rects = Vec::new(); + for (index, paragraph) in outline.layouts() { + if index < start.paragraph || index > end.paragraph { + continue; + } + let cursor = |offset: u32, affinity| -> Result<_> { + let visible = paragraph.projection.visible_offset(offset)?; + let byte = paragraph.projection.text().byte_offset(visible)?; + Ok(paragraph.text.cursor(byte, affinity)) + }; + let first = cursor( + if index == start.paragraph { + start.offset + } else { + 0 + }, + Affinity::Downstream, + )?; + let last = if index == end.paragraph { + cursor(end.offset, Affinity::Upstream)? + } else { + paragraph + .text + .cursor(paragraph.projection.text().text().len(), Affinity::Upstream) + }; + let origin = [ + origin[0] + paragraph.origin[0], + origin[1] + paragraph.origin[1], + ]; + for rect in paragraph + .text + .selection(parley::editing::Selection::new(first, last)) + { + rects.push(self.view_rect(rect, origin)); + } + } + Ok(rects) + } + + fn closest(&self, point: [f32; 2]) -> Result { + let point = self.page.viewport.document_point(self.device(point)); + let origin = self.page.editor.active_outline().origin(); + let selection = self.page.editor.selection_at( + point[0] - origin[0], + point[1] - origin[1], + SelectionUnit::Grapheme, + )?; + self.flat(selection.positions[1]) + } +} + +/// # Safety +/// `path` is a NUL-terminated path. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn sb_section_open(path: *const c_char) -> *mut Section { + let path = unsafe { CStr::from_ptr(path) }; + report( + std::fs::read(path.to_str().unwrap_or_default()) + .map_err(Into::into) + .and_then(|bytes| Section::open(&bytes)), + ) + .map_or(std::ptr::null_mut(), |section| { + Box::into_raw(Box::new(section)) + }) +} + +#[unsafe(no_mangle)] +pub extern "C" fn sb_section_count(section: &Section) -> usize { + section.pages.len() +} + +/// The page's title, alive as long as the section. +#[unsafe(no_mangle)] +pub extern "C" fn sb_section_title(section: &Section, index: usize) -> *const c_char { + section.titles[index].as_ptr() +} + +/// # Safety +/// `section` came from `sb_section_open` and is not used again. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn sb_section_free(section: *mut Section) { + drop(unsafe { Box::from_raw(section) }); +} + +/// A view drawing page `index` of `section` into `layer`, a `CAMetalLayer`, `size` points +/// at `scale` pixels per point; null if it cannot. +/// +/// # Safety +/// `layer` is a live `CAMetalLayer` that outlives the view. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn sb_view_new( + layer: *mut c_void, + section: &Section, + index: usize, + width: f32, + height: f32, + scale: f32, +) -> *mut View { + report(View::new( + layer, + section.pages[index].clone(), + [width, height], + scale, + )) + .map_or(std::ptr::null_mut(), |view| Box::into_raw(Box::new(view))) +} + +/// # Safety +/// `view` came from `sb_view_new` and is not used again. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn sb_view_free(view: *mut View) { + drop(unsafe { Box::from_raw(view) }); +} + +#[unsafe(no_mangle)] +pub extern "C" fn sb_view_resize(view: &mut View, width: f32, height: f32, scale: f32) { + let pixels = [width, height].map(|side| (side * scale).round().max(1.0) as u32); + if scale != view.display_scale() { + let _ = report(view.page.scale_factor_changed(scale)); + } + let _ = report(view.page.resized(pixels)); + view.config.width = pixels[0]; + view.config.height = pixels[1]; + view.surface.configure(&view.renderer.device, &view.config); +} + +#[unsafe(no_mangle)] +pub extern "C" fn sb_view_render(view: &mut View) -> bool { + report(view.render()).is_some() +} + +/// Whether a picture landed since the last call, so the page should be drawn again. +#[unsafe(no_mangle)] +pub extern "C" fn sb_view_frame_pending(view: &View) -> bool { + view.frame.0.swap(false, Ordering::Acquire) +} + +/// The page content's `[left, top, right, bottom]` in points at 100% zoom, bottom and right +/// including the margin OneNote scrolls past the last object. +#[unsafe(no_mangle)] +pub extern "C" fn sb_view_content(view: &mut View, bounds: &mut [f32; 4]) { + *bounds = view.content().map(|value| value * POINT); +} + +/// Zooms and scrolls so the page point `x`, `y` sits at the view's corner; the host's +/// scroll view owns the limits and rubber-banding. +#[unsafe(no_mangle)] +pub extern "C" fn sb_view_set_transform(view: &mut View, zoom: f32, x: f32, y: f32) { + view.set_transform(zoom, [x, y]); +} + +/// A tap: places the caret, focuses an outline or starts a new one. +#[unsafe(no_mangle)] +pub extern "C" fn sb_view_tap(view: &mut View, x: f32, y: f32) -> bool { + report(view.tap([x, y])).unwrap_or(false) +} + +#[unsafe(no_mangle)] +pub extern "C" fn sb_view_in_active_text(view: &View, x: f32, y: f32) -> bool { + view.in_active_text([x, y]) +} + +#[unsafe(no_mangle)] +pub extern "C" fn sb_text_length(view: &View) -> u32 { + report(view.length()).unwrap_or(0) +} + +/// The text in `start..end`, freed with `sb_string_free`. +#[unsafe(no_mangle)] +pub extern "C" fn sb_text(view: &View, start: u32, end: u32) -> *mut c_char { + report(view.text([start, end])) + .and_then(|text| CString::new(text.replace('\0', "")).ok()) + .map_or(std::ptr::null_mut(), CString::into_raw) +} + +/// # Safety +/// `text` came from `sb_text` and is not used again. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn sb_string_free(text: *mut c_char) { + drop(unsafe { CString::from_raw(text) }); +} + +#[unsafe(no_mangle)] +pub extern "C" fn sb_selection(view: &View, range: &mut [u32; 2]) { + if let Some(selection) = report(view.selection()) { + *range = selection; + } +} + +#[unsafe(no_mangle)] +pub extern "C" fn sb_select(view: &mut View, start: u32, end: u32) -> bool { + report(view.select([start, end])).is_some() +} + +#[unsafe(no_mangle)] +pub extern "C" fn sb_marked(view: &View, range: &mut [u32; 2]) -> bool { + report(view.marked()) + .flatten() + .map(|marked| *range = marked) + .is_some() +} + +/// # Safety +/// `text` is NUL-terminated UTF-8. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn sb_set_marked( + view: &mut View, + text: *const c_char, + selected_start: u32, + selected_end: u32, +) -> bool { + let text = unsafe { CStr::from_ptr(text) } + .to_string_lossy() + .into_owned(); + report(view.set_marked(text, [selected_start, selected_end])).unwrap_or(false) +} + +#[unsafe(no_mangle)] +pub extern "C" fn sb_unmark(view: &mut View) { + view.page.editor.finish_composition(); +} + +/// Types `text`, a lone `\n` as the Return key. +/// +/// # Safety +/// `text` is NUL-terminated UTF-8. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn sb_insert(view: &mut View, text: *const c_char) -> bool { + let text = unsafe { CStr::from_ptr(text) } + .to_string_lossy() + .into_owned(); + report(view.insert(text)).unwrap_or(false) +} + +/// Replaces `start..end` with `text`, as autocorrection and dictation do. +/// +/// # Safety +/// `text` is NUL-terminated UTF-8. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn sb_replace( + view: &mut View, + start: u32, + end: u32, + text: *const c_char, +) -> bool { + let text = unsafe { CStr::from_ptr(text) } + .to_string_lossy() + .into_owned(); + report( + view.select([start, end]) + .and_then(|()| Ok(moved(view.page.commit_text(text)?))), + ) + .unwrap_or(false) +} + +/// Pastes clipboard `text` in `language`, the keyboard's BCP-47 tag. +/// +/// # Safety +/// `text` and `language` are NUL-terminated UTF-8. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn sb_paste( + view: &mut View, + text: *const c_char, + language: *const c_char, +) -> bool { + let text = unsafe { CStr::from_ptr(text) }.to_string_lossy(); + let language = unsafe { CStr::from_ptr(language) }.to_string_lossy(); + report( + view.page + .paste(&text, canvas::language::lcid(&language)) + .map(moved), + ) + .unwrap_or(false) +} + +#[unsafe(no_mangle)] +pub extern "C" fn sb_delete_backward(view: &mut View) -> bool { + report( + view.page + .key(&Key::Named(NamedKey::Backspace), None) + .map(moved), + ) + .unwrap_or(false) +} + +/// The caret at `offset` as `[x, y, width, height]` in the view's points. +#[unsafe(no_mangle)] +pub extern "C" fn sb_caret_rect(view: &View, offset: u32, rect: &mut [f32; 4]) -> bool { + report(view.caret_rect(offset)) + .map(|caret| *rect = caret) + .is_some() +} + +/// Writes up to `capacity` rectangles covering `start..end`; returns how many there are. +/// +/// # Safety +/// `rects` has room for `capacity` rectangles. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn sb_range_rects( + view: &View, + start: u32, + end: u32, + rects: *mut [f32; 4], + capacity: usize, +) -> usize { + let found = report(view.range_rects([start, end])).unwrap_or_default(); + for (index, rect) in found.iter().take(capacity).enumerate() { + unsafe { rects.add(index).write(*rect) }; + } + found.len() +} + +#[unsafe(no_mangle)] +pub extern "C" fn sb_closest(view: &View, x: f32, y: f32) -> u32 { + report(view.closest([x, y])).unwrap_or(0) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn corpus_section_lists_its_pages() { + let path = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../corpus/media-edit/candidate/Features.one" + ); + let section = Section::open(&std::fs::read(path).unwrap()).unwrap(); + for title in §ion.titles { + eprintln!("{title:?}"); + } + assert!(!section.pages.is_empty()); + } +} diff --git a/crates/snowbound/src/macos.rs b/crates/snowbound/src/macos.rs index e5e2e93595bcf68e0059dcd977adbb48e730cf33..929ef7ede236b3c0d095f721b68670465b1d7169 100644 --- a/crates/snowbound/src/macos.rs +++ b/crates/snowbound/src/macos.rs @@ -58,6 +58,33 @@ unsafe extern "C" fn insert_text(view: &AnyObject, _: Sel, text: &AnyObject, ran } } +fn ns_window(window: &Window) -> Retained { + let RawWindowHandle::AppKit(handle) = + window.window_handle().expect("Live AppKit window").as_raw() + else { + unreachable!() + }; + unsafe { + let view = &*handle.ns_view.as_ptr().cast::(); + msg_send_id![view, window] + } +} + +/// Zooms the window once the current event is handled: AppKit's zoom animation runs its +/// own loop, and started from inside winit's handler it would hold every resize until the +/// end, stretching the last frame instead of drawing each step. +pub fn zoom(window: &Window) { + let window = ns_window(window); + unsafe { + let _: () = msg_send![ + &window, + performSelector: sel!(zoom:), + withObject: std::ptr::null::(), + afterDelay: 0.0f64 + ]; + } +} + /// Install before AccessKit subclasses the same view, preserving its restoration chain. pub fn install_text_input(window: &Window) { MainThreadMarker::new().expect("Text input belongs to the main thread"); @@ -261,6 +288,17 @@ pub fn configure_presentation(surface: &wgpu::Surface<'_>) { } } +/// Commits a frame presented with the transaction: winit redraws after Core Animation's +/// commit observer, so otherwise the frame waits for the next event. A live resize leaves +/// the commit to AppKit, which pairs the frame with the window's new size. +pub fn commit_presentation(window: &Window) { + let resizing: bool = unsafe { msg_send![&ns_window(window), inLiveResize] }; + if !resizing { + let class = AnyClass::get("CATransaction").expect("QuartzCore is linked"); + let _: () = unsafe { msg_send![class, flush] }; + } +} + /// The application's icon as the Dock shows it, `pixels` square. pub fn app_icon(pixels: u32) -> Option { let side = pixels as usize; @@ -311,12 +349,22 @@ pub fn app_icon(pixels: u32) -> Option { draw::RasterImage::new([pixels; 2], rgba).ok() } -pub fn text_colors() -> [[f32; 4]; 2] { +/// The insertion point's colour and selected text's fill with and without keyboard focus, +/// linear RGBA, in `window`'s appearance. +pub fn text_colors(window: &Window) -> [[f32; 4]; 3] { + let class = AnyClass::get("NSAppearance").expect("AppKit is linked"); unsafe { + let appearance: Retained = + msg_send_id![&ns_window(window), effectiveAppearance]; + // System colours resolve in the thread's appearance, which outside drawing does + // not follow the window's. + let previous: Option> = msg_send_id![class, currentAppearance]; + let _: () = msg_send![class, setCurrentAppearance: &*appearance]; let space = NSColorSpace::sRGBColorSpace(); - [ + let colors = [ NSColor::textInsertionPointColor(), NSColor::selectedTextBackgroundColor(), + NSColor::unemphasizedSelectedTextBackgroundColor(), ] .map(|color| { let color = color @@ -336,7 +384,9 @@ pub fn text_colors() -> [[f32; 4]; 2] { } }); [r, g, b, color.alphaComponent() as f32] - }) + }); + let _: () = msg_send![class, setCurrentAppearance: previous.as_deref()]; + colors } } @@ -425,3 +475,28 @@ pub fn clear_marked_text(window: &Window) { } } } + +#[link(name = "Carbon", kind = "framework")] +unsafe extern "C" { + fn TISCopyCurrentKeyboardInputSource() -> *mut AnyObject; + fn TISGetInputSourceProperty<'a>( + source: &'a AnyObject, + key: &NSString, + ) -> Option<&'a AnyObject>; + static kTISPropertyInputSourceLanguages: &'static NSString; +} + +/// The current keyboard input source's primary language as a BCP-47 tag, empty if it has none. +pub fn input_language() -> String { + MainThreadMarker::new().expect("Text Input Sources belong to the main thread"); + // Input sources are CFTypes, released as Objective-C objects; languages is an NSArray. + unsafe { + let Some(source) = Retained::from_raw(TISCopyCurrentKeyboardInputSource()) else { + return String::new(); + }; + let language: Option> = + TISGetInputSourceProperty(&source, kTISPropertyInputSourceLanguages) + .and_then(|languages| msg_send_id![languages, firstObject]); + language.map_or_else(String::new, |language| language.to_string()) + } +} diff --git a/crates/snowbound/src/main.rs b/crates/snowbound/src/main.rs index eaf472b951e237188328342d1d7dd25b0a98257f..3a1aecb10e056809f7a4104083624c40539da393 100644 --- a/crates/snowbound/src/main.rs +++ b/crates/snowbound/src/main.rs @@ -21,7 +21,7 @@ use onestore::page::text::Paragraph; use std::{ error::Error, path::{Path, PathBuf}, - sync::Arc, + sync::{Arc, mpsc}, time::Instant, }; use ui::{Axis, Flags, Id, Spec, Theme, Ui, children, fill, fit, px}; @@ -229,6 +229,27 @@ impl Session { } } +/// A section or page read on a loader thread. +enum Loaded { + Section(Session), + Page { space: ExGuid, page: Page }, +} + +/// A loader thread's request number and what it read. +type Read = (u64, Result); + +/// The newest page read, laid out and waiting for the pictures it shows first, so it +/// never appears without them. +struct Opening { + loaded: Loaded, + scene: (PageScene, [f32; 2]), + editor: CanvasEditor, + since: Instant, +} + +/// How long an opening page waits for its pictures before showing without them. +const HOLD: std::time::Duration = std::time::Duration::from_millis(200); + /// Work the interface asked for, done after the frame is built. enum Command { OpenSection(usize), @@ -240,6 +261,11 @@ enum Command { struct State { window: Arc, proxy: EventLoopProxy, + /// Results of reads done off the frame thread, by request number. + loads: (mpsc::Sender, mpsc::Receiver), + /// The newest read requested; older ones are dropped when they finish. + loading: u64, + opening: Option, /// Asks for a frame when a worker thread finishes something the page shows. redraw: std::task::Waker, instance: wgpu::Instance, @@ -248,7 +274,7 @@ struct State { renderer: Renderer, ui: Ui, view: PageView, - library: Option, + library: Option>, session: Option, /// Text filtering the page list. filter: String, @@ -265,7 +291,6 @@ struct State { moved: bool, /// Whether the page last heard it had keyboard focus. page_focused: bool, - window_focused: bool, /// The pointer in logical pixels, for window drags from the strip. pointer: [f32; 2], /// When the strip was last pressed, to zoom on a double click. @@ -399,9 +424,10 @@ impl State { .find(|(_, candidate, _)| *candidate == title) .ok_or_else(|| format!("No page titled {title:?} in {}", file.display()))? .0; - library = Some(opened); - let (scene, editor) = - open_session(section, 0, Some(space), &mut engine, &mut session)?; + library = Some(Arc::new(opened)); + let opened = read_session(section, 0, Some(space))?; + let (scene, editor) = PageScene::from_page(opened.before.clone(), &mut engine)?; + session = Some(opened); (editor, Some((scene, [0.0; 2]))) } Input::Notebook { root, cache } => { @@ -417,8 +443,10 @@ impl State { cache, }; let section = opened.open(0, proxy.clone())?; - library = Some(opened); - let (scene, editor) = open_session(section, 0, None, &mut engine, &mut session)?; + library = Some(Arc::new(opened)); + let opened = read_session(section, 0, None)?; + let (scene, editor) = PageScene::from_page(opened.before.clone(), &mut engine)?; + session = Some(opened); (editor, Some((scene, [0.0; 2]))) } }; @@ -451,6 +479,8 @@ impl State { .fonts .collection .family_names() + // Dot-named families are the system's private ones, hidden from font menus. + .filter(|name| !name.starts_with('.')) .map(String::from) .collect(); fonts.sort_unstable_by_key(|name| name.to_lowercase()); @@ -482,7 +512,6 @@ impl State { changed: false, moved: false, page_focused: true, - window_focused: true, pointer: [0.0; 2], strip_press: None, snapshot: None, @@ -492,6 +521,9 @@ impl State { occluded: false, ime_allowed: true, clipboard: arboard::Clipboard::new()?, + loads: mpsc::channel(), + loading: 0, + opening: None, access_adapter, accessibility: accessibility::Accessibility::default(), }; @@ -503,6 +535,10 @@ impl State { /// frame that shows the result. fn frame(&mut self) -> Result<(), Box> { let start = Instant::now(); + if let Err(error) = self.open_loaded() { + eprintln!("{error}"); + } + lap("open", start); let size = self.window.inner_size(); let scale = self.window.scale_factor() as f32; self.ui.begin( @@ -541,7 +577,7 @@ impl State { self.draw()?; lap("drawn", start); let commands = std::mem::take(&mut self.commands); - let follow = !commands.is_empty() || self.ui.wants_frame(); + let follow = !commands.is_empty() || self.ui.wants_frame() || self.opening.is_some(); for command in commands { self.apply(command)?; } @@ -582,6 +618,11 @@ impl State { accent: ease("accent", target.accent), }; self.ui.theme.accent = section.accent; + [ + self.ui.theme.caret, + self.ui.theme.selection, + self.ui.theme.inactive_selection, + ] = macos::text_colors(&self.window); let theme = self.ui.theme.clone(); self.title_bar(&theme); self.toolbar(&theme)?; @@ -685,7 +726,7 @@ impl State { } self.ui.close(); let signal = self.ui.signal(page()); - let focused = self.window_focused && self.ui.focused() == Some(page()); + let focused = self.ui.window_focused && self.ui.focused() == Some(page()); if focused != self.page_focused { self.page_focused = focused; let response = self.view.focus_changed(focused)?; @@ -891,27 +932,38 @@ impl State { group(ui, "text", |ui| { row(ui, 0, |ui| { let combo = ui.id("font"); - if ui::shell::combo(ui, "font", &font, 120.0).clicked { + if ui::shell::combo(ui, "font", &font, 120.0).pressed { ui.open_popup(popup("font")); } let items: Vec<_> = fonts .iter() .map(|name| ui::popup::Item { text: name, + font: Some(name), checked: *name == font, ..Default::default() }) .collect(); let anchor = ui::Anchor::Over(ui.rect(combo).unwrap_or_default()); - if let Some(index) = ui::popup::menu(ui, popup("font"), anchor, &items, Some(&font)) + if let Some(index) = ui::popup::menu(ui, popup("font"), anchor, &items, Some("Font")) { command = Some(Formatting::Font(fonts[index].clone())); } let combo = ui.id("size"); - if ui::shell::combo(ui, "size", &size, 44.0).clicked { + if ui::shell::combo(ui, "size", &size, 44.0).pressed { ui.open_popup(popup("size")); } - let labels = SIZES.map(|size| format!("{size}")); + // A size typed in the field joins the list, in half points as stored. + let mut sizes = SIZES.to_vec(); + if let Some(typed) = ui::popup::query(ui, popup("size")) + .and_then(|query| query.trim().parse::().ok()) + .map(|typed| (typed * 2.0).round() / 2.0) + .filter(|typed| onestore::FONT_SIZES.contains(typed) && !sizes.contains(typed)) + { + let at = sizes.partition_point(|size| *size < typed); + sizes.insert(at, typed); + } + let labels: Vec<_> = sizes.iter().map(|size| format!("{size}")).collect(); let items: Vec<_> = labels .iter() .map(|label| ui::popup::Item { @@ -923,7 +975,7 @@ impl State { let anchor = ui::Anchor::Over(ui.rect(combo).unwrap_or_default()); if let Some(index) = ui::popup::menu(ui, popup("size"), anchor, &items, Some(&size)) { - command = Some(Formatting::FontSize(SIZES[index])); + command = Some(Formatting::FontSize(sizes[index])); } let bullets = ui::shell::split_button(ui, "bullets", art::BULLETS, None, state.bullets); @@ -970,7 +1022,7 @@ impl State { Toggle::Subscript })); } - if menu.clicked { + if menu.pressed { ui.open_popup(popup("script")); } let items = [ @@ -1026,7 +1078,7 @@ impl State { if button.clicked { command = Some(paint(Some(default))); } - if menu.clicked { + if menu.pressed { ui.open_popup(popup(part)); } let colors: Vec<_> = swatches.iter().map(|color| colorref(*color)).collect(); @@ -1060,7 +1112,7 @@ impl State { .find(|(alignment, ..)| Some(*alignment) == state.alignment) .unwrap_or(&alignments[0]); let [button, menu] = ui::shell::split_button(ui, "align", current.2, None, false); - if button.clicked || menu.clicked { + if button.pressed || menu.pressed { ui.open_popup(popup("align")); } let items = alignments.map(|(alignment, name, icon)| ui::popup::Item { @@ -1350,6 +1402,8 @@ impl State { hover_fill: Some(ui::mix(section.tab, section.accent, 0.3)), radius: ROUNDING, inset: [PILL_MARGIN, 0.0, 6.0, ROW_GAP], + // The label keeps its place as the tab opens and closes. + pad: [spec.pad[0] - PILL_MARGIN, 0.0], ..spec } }; @@ -1425,30 +1479,30 @@ impl State { fn apply(&mut self, command: Command) -> Result<(), Box> { match command { Command::OpenSection(tab) => { - let library = self.library.as_ref().ok_or("No sections to open")?; - let section = library.open(tab, self.proxy.clone())?; - let (scene, editor) = - open_session(section, tab, None, &mut self.view.engine, &mut self.session)?; - self.view.open(editor, Some((scene, [0.0; 2]))); - self.filter.clear(); - self.opened()?; + let library = Arc::clone(self.library.as_ref().ok_or("No sections to open")?); + let proxy = self.proxy.clone(); + self.load(move || { + let section = library.open(tab, proxy)?; + Ok(Loaded::Section(read_session(section, tab, None)?)) + }); } Command::OpenPage(space) => { - let session = self.session.as_mut().ok_or("No section is open")?; - let page = session.section.page(space)?; - let (scene, editor) = PageScene::from_page(page.clone(), &mut self.view.engine)?; - session.space = space; - session.before = page; - session.refresh_conflict()?; - self.view.open(editor, Some((scene, [0.0; 2]))); - self.opened()?; + let session = self.session.as_ref().ok_or("No section is open")?; + let replica = Arc::clone(session.section.replica()); + self.load(move || { + Ok(Loaded::Page { + space, + page: replica.page(space)?, + }) + }); } Command::Resolve { keep_mine } => self.resolve_conflict(keep_mine)?, Command::Page(Request::EditDate(field)) => self.edit_date(field)?, Command::Page(Request::Copy(text)) => self.clipboard.set_text(text)?, Command::Page(Request::Paste) => { let text = self.clipboard.get_text()?; - let response = self.view.paste(&text)?; + let language = canvas::language::lcid(&macos::input_language()); + let response = self.view.paste(&text, language)?; self.respond(response); } Command::Page(Request::CharacterPalette) => macos::show_character_palette(), @@ -1456,6 +1510,68 @@ impl State { Ok(()) } + /// Runs `read` on a thread of its own; `open_loaded` shows what it read unless a newer + /// read was asked for meanwhile. + fn load(&mut self, read: impl FnOnce() -> Result> + Send + 'static) { + self.loading += 1; + let (id, sender, redraw) = (self.loading, self.loads.0.clone(), self.redraw.clone()); + std::thread::spawn(move || { + let _ = sender.send((id, read().map_err(|error| error.to_string()))); + redraw.wake(); + }); + } + + /// Lays out the newest page read, and shows it once the pictures it shows first are + /// drawn, or after `HOLD`. + fn open_loaded(&mut self) -> Result<(), Box> { + for (id, loaded) in self.loads.1.try_iter() { + if id != self.loading { + continue; + } + let loaded = loaded?; + let page = match &loaded { + Loaded::Section(session) => &session.before, + Loaded::Page { page, .. } => page, + }; + let (scene, editor) = PageScene::from_page(page.clone(), &mut self.view.engine)?; + self.opening = Some(Opening { + loaded, + scene: (scene, [0.0; 2]), + editor, + since: Instant::now(), + }); + } + let Some(mut opening) = self.opening.take() else { + return Ok(()); + }; + let paper = canvas::gpu::Paper { + color: self.ui.theme.paper, + ink: self.ui.theme.paper_ink, + }; + if !self + .view + .prepare(&mut opening.scene, &opening.editor, paper, &self.redraw) + && opening.since.elapsed() < HOLD + { + self.opening = Some(opening); + return Ok(()); + } + match opening.loaded { + Loaded::Section(session) => { + self.session = Some(session); + self.filter.clear(); + } + Loaded::Page { space, page } => { + let session = self.session.as_mut().ok_or("No section is open")?; + session.space = space; + session.before = page; + session.refresh_conflict()?; + } + } + self.view.open(opening.editor, Some(opening.scene)); + self.opened() + } + /// Follows a page shown in place of another. fn opened(&mut self) -> Result<(), Box> { self.ui.set_focus(Some(page())); @@ -1465,14 +1581,15 @@ impl State { Ok(()) } - /// Follows an edit: the input method's position, accessibility, saving and the title. + /// Follows an edit: saving, the title, then the input method's position and accessibility, + /// whose failures must not cost the edit. fn after_edit(&mut self) -> Result<(), Box> { - self.after_move()?; let start = Instant::now(); - self.persist()?; + let saved = self.persist(); lap("save", start); self.title(); - Ok(()) + self.after_move()?; + saved } /// Follows the view moving: the input method's position and accessibility. @@ -1625,6 +1742,7 @@ impl State { let shown = (session.status, session.conflict); let mut refreshed = false; let mut stale = false; + let mut conflicted = session.conflict; for (space, outcome) in session.section.saved() { match outcome { Ok(notebook::session::Save::Queued(_)) => session.status = "Saving", @@ -1650,7 +1768,10 @@ impl State { Event::Attempt { status: notebook::EditStatus::Conflict(_), .. - } => "Conflict", + } => { + conflicted = true; + "Conflict" + } Event::Attempt { .. } => "Saving", Event::Unreachable(_) => "Offline", Event::Failed(error) => { @@ -1659,7 +1780,11 @@ impl State { } }; } - session.refresh_conflict()?; + // Reading the queue waits on the save thread, so only a conflict shown or reported + // sends the frame thread there. + if conflicted { + session.refresh_conflict()?; + } if refreshed || shown != (session.status, session.conflict) { self.window.request_redraw(); } @@ -1672,7 +1797,9 @@ impl State { { let first = session.pages.first().ok_or("The section has no pages")?.0; self.commands.push(Command::OpenPage(first)); - } else if session.section.page(session.space)? != session.before { + } else if !session.section.saving() + && session.section.page(session.space)? != session.before + { self.reload()?; } } else if stale { @@ -1817,6 +1944,7 @@ impl State { self.window.pre_present_notify(); let start = Instant::now(); self.renderer.queue.present(frame); + macos::commit_presentation(&self.window); lap("present", start); trace_input(&"Present submitted"); if reconfigure { @@ -1828,15 +1956,19 @@ impl State { /// Paints the interface with the page in its box. fn paint(&mut self, target: &wgpu::TextureView) -> Result<(), Box> { let start = Instant::now(); - let [caret, selection] = macos::text_colors(); let paper = canvas::gpu::Paper { color: self.ui.theme.paper, ink: self.ui.theme.paper_ink, }; - self.view.update_backgrounds(paper, &self.redraw); + self.view.update_pictures(paper, &self.redraw); + let theme = &self.ui.theme; let page_primitives = self.view.primitives(TextColors { - caret, - selection, + caret: theme.caret, + selection: if self.page_focused { + theme.selection + } else { + theme.inactive_selection + }, paper, })?; lap("page primitives", start); @@ -1851,6 +1983,7 @@ impl State { scale, origin: [0.0; 2], clip: clip.map(|clip| clip.map(|value| value * scale)), + backdrop: None, primitives, }, ui::Layer::Custom { rect, .. } => draw::Layer { @@ -1860,6 +1993,7 @@ impl State { viewport.origin[1] + corner[1] * scale, ], clip: Some(rect.map(|value| value * scale)), + backdrop: Some(self.ui.theme.paper), primitives: &page_primitives, }, }) @@ -1969,7 +2103,7 @@ impl State { }); self.strip_press = (!double).then_some(at); if double { - self.window.set_maximized(!self.window.is_maximized()); + macos::zoom(&self.window); } else { let _ = self.window.drag_window(); } @@ -2072,23 +2206,19 @@ fn tabs(notebook: ¬ebook::session::Notebook) -> Vec { .collect() } -/// Makes `section` the open one showing `space`, or its first page, and returns the -/// page's scene and editor. -fn open_session( +/// The session for `section` showing `space`, or its first page. +fn read_session( section: notebook::session::Section, tab: usize, space: Option, - engine: &mut TextEngine, - session: &mut Option, -) -> Result<(PageScene, CanvasEditor), Box> { +) -> Result> { let pages = section.pages()?; let space = match space { Some(space) => space, None => pages.first().ok_or("The section has no pages")?.0, }; let before = section.page(space)?; - let (scene, editor) = PageScene::from_page(before.clone(), engine)?; - let mut opened = Session { + let mut session = Session { section, tab, pages, @@ -2097,9 +2227,8 @@ fn open_session( status: "", conflict: false, }; - opened.refresh_conflict()?; - *session = Some(opened); - Ok((scene, editor)) + session.refresh_conflict()?; + Ok(session) } impl App { @@ -2170,7 +2299,10 @@ impl ApplicationHandler for App { Replay::Input(event) => state.input(event), Replay::Snapshot(path) => state.snapshot = Some(path), Replay::Tick => {} - Replay::Appearance(appearance) => state.ui.theme = theme(appearance), + Replay::Appearance(appearance) => { + state.window.set_theme(Some(appearance)); + state.ui.theme = theme(appearance); + } } // A covered window gets no redraws, so each step draws its own frame. if let Err(error) = state.frame() { @@ -2276,7 +2408,7 @@ impl ApplicationHandler for App { state.window.request_redraw(); } WindowEvent::Focused(focused) => { - state.window_focused = focused; + state.ui.window_focused = focused; state.window.request_redraw(); } WindowEvent::Occluded(occluded) => { @@ -2334,14 +2466,21 @@ impl ApplicationHandler for App { let Some(state) = &mut self.state else { return; }; - let (repaint, next) = if state.occluded { - (false, None) - } else { - state.view.blink(Instant::now()) - }; - if repaint { + if state.occluded { + event_loop.set_control_flow(ControlFlow::Wait); + return; + } + let now = Instant::now(); + let (repaint, blink) = state.view.blink(now); + // A due interface change waits on the frame that shows it, which sets the next. + let wake = state.ui.wake_at(); + if repaint || wake.is_some_and(|wake| wake <= now) { state.window.request_redraw(); } + let next = [blink, wake.filter(|wake| *wake > now)] + .into_iter() + .flatten() + .min(); event_loop.set_control_flow(next.map_or(ControlFlow::Wait, ControlFlow::WaitUntil)); } } diff --git a/crates/ui/examples/popups.rs b/crates/ui/examples/popups.rs index b5c9214b740277976efd3a8308f9b3a77a73d16e..fe1bae19d913942d1fa1f6b43ec46477a055f8a1 100644 --- a/crates/ui/examples/popups.rs +++ b/crates/ui/examples/popups.rs @@ -601,14 +601,14 @@ fn palette(scene: &mut Scene) { fn tags(scene: &mut Scene) { let tags = [ ("To Do", None::<&'static [&'static str]>, "⌘1"), - ("Important", Some(art!("tags/star")), "⌘2"), + ("Important", Some(&[include_str!("../../canvas/assets/tags/star.svg")]), "⌘2"), ("Question", None, "⌘3"), ("Remember for later", Some(art!("tags/remember")), "⌘4"), ("Definition", Some(art!("tags/definition")), "⌘5"), - ("Highlight", Some(art!("tags/highlight")), "⌘6"), - ("Contact", Some(art!("tags/contact")), "⌘7"), - ("Address", Some(art!("tags/address")), "⌘8"), - ("Phone number", Some(art!("tags/phone")), "⌘9"), + ("Highlight", Some(&[include_str!("../../canvas/assets/tags/highlight.svg")]), "⌘6"), + ("Contact", Some(&[include_str!("../../canvas/assets/tags/contact.svg")]), "⌘7"), + ("Address", Some(&[include_str!("../../canvas/assets/tags/address.svg")]), "⌘8"), + ("Phone number", Some(&[include_str!("../../canvas/assets/tags/phone.svg")]), "⌘9"), ("Web site to visit", None, ""), ("Idea", None, ""), ("Password", None, ""), @@ -690,6 +690,7 @@ fn paint( scale: SCALE, origin: [0.0; 2], clip: clip.map(|clip| clip.map(|value| value * SCALE)), + backdrop: None, primitives, }), ui::Layer::Custom { .. } => None, diff --git a/crates/ui/src/layout.rs b/crates/ui/src/layout.rs index a72bf0f164fb090ab640ebb6017ce94020d5b7c5..635af82eb6c7271663c89035acf21782ea85ee99 100644 --- a/crates/ui/src/layout.rs +++ b/crates/ui/src/layout.rs @@ -79,8 +79,7 @@ pub(crate) fn solve(nodes: &mut [Built], states: &HashMap, scale: f32 let mut cursor = nodes[index].pad[axis]; for child in nodes[index].children.clone() { nodes[child].relative[axis] = if let Some(anchor) = nodes[child].anchor { - let slide = (1.0 - nodes[child].alpha) * crate::SLIDE; - anchor.place(axis, nodes[child].computed[axis], window, slide) + anchor.place(axis, nodes[child].computed[axis], window) } else if nodes[child].flags.contains(Flags::FLOAT) { nodes[child].position[axis] } else if along(&nodes[index], axis) { diff --git a/crates/ui/src/lib.rs b/crates/ui/src/lib.rs index 4054c623efac6efdea5ed0b312fdcdcfee36b77e..2b176690247eefd4b180d1898d242c88d13892cf 100644 --- a/crates/ui/src/lib.rs +++ b/crates/ui/src/lib.rs @@ -40,8 +40,6 @@ const HALF_LIFE: f32 = 0.03; /// falls; a popup's, floating higher, spreads and falls further. const SHADOW: [f32; 2] = [3.0, 0.0]; const POPUP_SHADOW: [f32; 2] = [12.0, 4.0]; -/// How far a popup slides in from its anchor as it fades in. -const SLIDE: f32 = 6.0; /// Logical size of a box's icon, and its distance from the label. const ICON: f32 = 16.0; const ICON_GAP: f32 = 6.0; @@ -182,9 +180,8 @@ pub enum Anchor { impl Anchor { /// Where a popup `size` long on `axis` starts in a window `room` long: past the - /// anchor on the axis it opens along, level with it otherwise, `slide` nearer to it - /// along the way it opens. - fn place(self, axis: usize, size: f32, room: f32, slide: f32) -> f32 { + /// anchor on the axis it opens along, level with it otherwise. + fn place(self, axis: usize, size: f32, room: f32) -> f32 { let (rect, along) = match self { Anchor::Below(rect) => (rect, Some(1)), Anchor::Right(rect) => (rect, Some(0)), @@ -192,15 +189,15 @@ impl Anchor { Anchor::Point([x, y]) => ([x, y, x, y], Some(1)), }; let [low, high] = [rect[axis], rect[axis + 2]]; - let (first, second, slide) = if along == Some(axis) { - (high, low - size, slide) + let (first, second) = if along == Some(axis) { + (high, low - size) } else { - (low, high - size, 0.0) + (low, high - size) }; if first + size <= room { - first - slide + first } else if second >= 0.0 { - second + slide + second } else { first.min(room - size).max(0.0) } @@ -215,6 +212,8 @@ pub struct Spec<'a> { /// The axis its children flow along. pub axis: Axis, pub text: Option<&'a str>, + /// The family the label is shaped in, where it has the glyphs; the interface's otherwise. + pub font: Option<&'a str>, /// The label's colour; the theme's text colour otherwise. pub color: Option<[f32; 4]>, pub fill: Option<[f32; 4]>, @@ -334,7 +333,7 @@ struct Built { alpha: f32, fade_into: Option<[f32; 4]>, /// Rectangles relative to the box, painted over its fill. - marks: Vec<([f32; 4], [f32; 4])>, + marks: Vec<([f32; 4], [f32; 4], f32)>, computed: [f32; 2], relative: [f32; 2], rect: [f32; 4], @@ -453,6 +452,9 @@ impl Display { pub struct Ui { pub theme: Theme, + /// Whether the window has keyboard focus; without it a field hides its caret and dims + /// its selection. + pub window_focused: bool, frame: u64, now: Instant, scale: f32, @@ -479,7 +481,13 @@ pub struct Ui { modifiers: ModifiersState, clicks: Clicks, display: Vec, + /// Where the popups' painting starts in `display`, for edges drawn beneath them. + popups_painted: usize, animating: bool, + /// The field showing a caret, when its blink started and the frame it last showed. + caret: Option<(Id, Instant, u64)>, + /// When the next timed change is due, such as a caret's blink. + wake: Option, } impl Ui { @@ -487,6 +495,7 @@ impl Ui { pub fn new(theme: Theme, double_click: Duration) -> Self { Self { theme, + window_focused: true, frame: 0, now: Instant::now(), scale: 1.0, @@ -509,7 +518,10 @@ impl Ui { modifiers: ModifiersState::empty(), clicks: Clicks::new(double_click), display: Vec::new(), + popups_painted: 0, animating: false, + caret: None, + wake: None, } } @@ -523,6 +535,11 @@ impl Ui { self.animating || !self.queue.is_empty() } + /// When a timed change, such as a caret's blink, next needs a frame. + pub fn wake_at(&self) -> Option { + self.wake + } + pub fn scale(&self) -> f32 { self.scale } @@ -591,6 +608,7 @@ impl Ui { self.stack.push(0); self.signals.clear(); self.moved = false; + self.wake = None; for event in std::mem::take(&mut self.queue) { self.route(event); } @@ -766,9 +784,7 @@ impl Ui { highlight: None, }); self.focus = Some(id); - let state = self.states.entry(id).or_default(); - state.touched = self.frame; - state.tween = Some([0.0, 1.0]); + self.states.entry(id).or_default().touched = self.frame; } pub fn popup_open(&self, id: Id) -> bool { @@ -839,7 +855,7 @@ impl Ui { pub fn open_as(&mut self, id: Id, spec: Spec<'_>) -> Id { let label = spec .text - .map(|text| self.texts.label(text, self.theme.font_size, self.frame)); + .map(|text| self.texts.label(text, self.theme.font_size, spec.font, self.frame)); // Popups hang from the root, outside the clips and flow of where they are built. let parent = if spec.anchor.is_some() { 0 @@ -847,15 +863,9 @@ impl Ui { *self.stack.last().unwrap() }; let index = self.nodes.len(); - let state = self.states.entry(id).or_default(); - state.touched = self.frame; - let reveal = match spec.anchor { - Some(Anchor::Point(_)) | None => 1.0, - Some(_) => state.tween.map_or(1.0, |tween| tween[0]), - }; - let mut built = Built::new(id, parent, spec, label, self.theme.text); - built.alpha *= reveal; - self.nodes.push(built); + self.states.entry(id).or_default().touched = self.frame; + self.nodes + .push(Built::new(id, parent, spec, label, self.theme.text)); self.nodes[parent].children.push(index); self.stack.push(index); id @@ -892,14 +902,31 @@ impl Ui { /// The size of `text` as a label, in logical pixels. pub fn measure(&mut self, text: &str) -> [f32; 2] { self.texts - .label(text, self.theme.font_size, self.frame) + .label(text, self.theme.font_size, None, self.frame) .size } - /// Paints `color` over the current box at `rect`, relative to its corner. - pub fn mark(&mut self, rect: [f32; 4], color: [f32; 4]) { + /// Paints `color` over the current box at `rect`, relative to its corner, with corners + /// of `radius`. + pub fn mark(&mut self, rect: [f32; 4], color: [f32; 4], radius: f32) { let index = *self.stack.last().unwrap(); - self.nodes[index].marks.push((rect, color)); + self.nodes[index].marks.push((rect, color, radius)); + } + + /// The caret opacity of field `id` this frame, restarting its blink when `moved` or + /// when it was not shown the frame before. + pub(crate) fn blink(&mut self, id: Id, moved: bool) -> f32 { + let start = match self.caret { + Some((shown, start, frame)) if shown == id && frame + 1 == self.frame && !moved => { + start + } + _ => self.now, + }; + self.caret = Some((id, start, self.frame)); + let (opacity, hold) = draw::edit::caret_blink(self.now.saturating_duration_since(start)); + let due = self.now + hold; + self.wake = Some(self.wake.map_or(due, |wake| wake.min(due))); + opacity } /// How the user acted on the box this frame; a box's routed events are taken once. @@ -940,6 +967,7 @@ impl Ui { self.display.clear(); self.hits.clear(); self.paint(0, None); + self.popups_painted = self.display.len(); let beneath = self.hits.len(); for index in self.nodes[0].children.clone() { if self.nodes[index].anchor.is_some() { @@ -1025,7 +1053,7 @@ impl Ui { }); } } - for (mark, color) in &node.marks { + for (mark, color, radius) in &node.marks { self.display.push(Display::Rect { rect: [ rect[0] + mark[0], @@ -1036,7 +1064,7 @@ impl Ui { fill: *color, shade: None, border: None, - radius: 0.0, + radius: *radius, }); } let inner = [ diff --git a/crates/ui/src/list.rs b/crates/ui/src/list.rs index d527f4e060a1aef949e15fc8cbc05bb0ae83ac2d..df112f3d4ffd2ec1fd588ed3bf2fea983221f548 100644 --- a/crates/ui/src/list.rs +++ b/crates/ui/src/list.rs @@ -7,10 +7,12 @@ use crate::{Axis, Event, Flags, HALF_LIFE, Id, Size, Spec, Ui, fill, mix, px, sc use std::collections::{HashMap, HashSet}; use winit::keyboard::NamedKey; +/// How far rows fade out under a scrolled list's edges. +const EDGE: f64 = 10.0; /// Room beside a scrolling list's rows for its scrollbar. pub(crate) const GUTTER: f32 = 12.0; /// Seconds rows take to reach their new places. -const DURATION: f32 = 0.15; +const DURATION: f32 = 0.25; /// Items a list shows, in order. pub trait Rows { @@ -502,6 +504,27 @@ pub fn list( ); ui.close(); } + // Rows fade out under the list's edges rather than end at a cut. + let clear = [background[0], background[1], background[2], 0.0]; + let edges = [ + (scroll > 0.0, 0.0, [background, clear]), + (scroll < most, view - EDGE, [clear, background]), + ]; + for (index, (shown, at, [top, bottom])) in edges.into_iter().enumerate() { + if shown { + ui.leaf( + ("edge", index), + Spec { + flags: Flags::FLOAT, + size: [width, px(EDGE as f32)], + position: [0.0, at as f32], + fill: Some(top), + gradient: Some(bottom), + ..Spec::default() + }, + ); + } + } let thumb = mix(theme.text_dim, theme.chip, 0.5); if let Some(offset) = scrollbar( ui, @@ -512,6 +535,12 @@ pub fn list( view as f32, thumb, ) { + // Rows keep their places relative to the new offset, or next frame's hold on the + // first row showing would scroll straight back. + let step = f64::from(offset) - scroll; + for shown in shown.iter_mut().chain(&mut folding) { + shown.place -= step; + } state.scroll = f64::from(offset); state.target = state.scroll; } diff --git a/crates/ui/src/popup.rs b/crates/ui/src/popup.rs index fd50ce14f321e6bf75750e0ad1010ad44f98457f..e4f0a9e09ac180c6cc6c86d4690419edacbcb628 100644 --- a/crates/ui/src/popup.rs +++ b/crates/ui/src/popup.rs @@ -35,6 +35,8 @@ const KEYS: [NamedKey; 7] = [ #[derive(Clone, Copy, Debug, Default)] pub struct Item<'a> { pub text: &'a str, + /// The family the text previews, as in a font menu. + pub font: Option<&'a str>, pub icon: Option<&'static [&'static str]>, /// The icon has colours of its own, so the text's colour does not tint it. pub colored: bool, @@ -280,6 +282,7 @@ fn menu_row(ui: &mut Ui, theme: &crate::Theme, matches: &Matches, row: Row) { Spec { size: [fill(), px(ROW)], text: Some(item.text), + font: item.font, color: Some(color), ..Spec::default() }, @@ -444,6 +447,14 @@ fn surface(ui: &mut Ui, id: Id, anchor: Anchor, width: f32) { ui.open_as(id, spec); } +/// What is typed in open popup `id`'s filter field. +pub fn query(ui: &Ui, id: Id) -> Option<&str> { + ui.popups + .iter() + .find(|popup| popup.id == id) + .map(|popup| popup.query.as_str()) +} + fn state(ui: &mut Ui, id: Id) -> &mut Popup { ui.popups .iter_mut() diff --git a/crates/ui/src/shell.rs b/crates/ui/src/shell.rs index f9218421faa528e91dd6845de8fd8bdb005f9413..5863664d91c8a72696e7c8eb815b09d8158f6347 100644 --- a/crates/ui/src/shell.rs +++ b/crates/ui/src/shell.rs @@ -161,7 +161,7 @@ pub fn split_button( if let Some(color) = bar { // The icon leaves its lowest three units for the bar. let top = (TOOL - 16.0) / 2.0; - ui.mark([top, top + 13.0, top + 16.0, top + 16.0], color); + ui.mark([top, top + 13.0, top + 16.0, top + 16.0], color, 0.0); } ui.close(); let menu = ui.leaf( @@ -220,6 +220,12 @@ pub fn combo(ui: &mut Ui, part: impl Hash, text: &str, width: f32) -> Signal { } impl Ui { + /// Paints `item` over the frame's boxes but under its popups. + fn beneath_popups(&mut self, item: Display) { + self.display.insert(self.popups_painted, item); + self.popups_painted += 1; + } + /// Draws a 1 px border along `points`, each with the radius its corner rounds by, /// joining the last back to the first when `closed`. Called between `end` and /// `layers`, it paints over the frame's boxes where they were just laid out. @@ -239,14 +245,14 @@ impl Ui { let to = corners[next] .as_ref() .map_or(points[next].0, |corner| corner.start); - self.display.push(Display::Segment { from, to, color }); + self.beneath_popups(Display::Segment { from, to, color }); } for corner in corners.into_iter().flatten() { let start = [ corner.start[0] - corner.point[0], corner.start[1] - corner.point[1], ]; - self.display.push(Display::Path { + self.beneath_popups(Display::Path { data: format!("M{} {}{}", start[0], start[1], corner.curve(corner.point)), origin: corner.point, style: PathStyle::Stroke(1.0), @@ -273,7 +279,7 @@ impl Ui { corner.start[0] - corner.point[0], corner.start[1] - corner.point[1], ]; - self.display.push(Display::Path { + self.beneath_popups(Display::Path { data: format!( "M{} {}{}L0 0Z", start[0], diff --git a/crates/ui/src/tests.rs b/crates/ui/src/tests.rs index 95305dd93f4dc7d741432fa01f4a5de3be3ee60d..7841863cc2641400cc2fced124dd1f6bdad7e765 100644 --- a/crates/ui/src/tests.rs +++ b/crates/ui/src/tests.rs @@ -341,7 +341,7 @@ fn focused_field(text: &str) -> (Ui, String) { fn point_to(ui: &mut Ui, text: &str, index: usize) { let size = ui.theme.font_size; let (texts, frame) = ui.texts(); - let layout = &texts.label(text, size, frame).layout; + let layout = &texts.label(text, size, None, frame).layout; let x = parley::editing::Cursor::from_byte_index(layout, index, parley::Affinity::Downstream) .geometry(layout, 1.0) .x0 as f32; @@ -382,6 +382,53 @@ fn text_fields_edit_with_keys_and_selection() { assert_eq!(text, ""); } +#[test] +fn text_field_carets_fade_on_appkit_s_blink_and_restart_when_moved() { + let (mut ui, mut text) = focused_field("one"); + let start = Instant::now(); + let mut at = |ui: &mut Ui, millis: u64| { + ui.begin([400.0, 300.0], 2.0, start + Duration::from_millis(millis)); + text_field( + ui, + field(), + &mut text, + "Filter", + Spec { + size: [px(200.0), px(26.0)], + pad: [6.0, 0.0], + ..Spec::default() + }, + ); + ui.end(); + let caret = ui.theme.caret; + let opacity = ui + .layers() + .iter() + .flat_map(|layer| match layer { + Layer::Primitives { primitives, .. } => primitives.as_slice(), + Layer::Custom { .. } => &[], + }) + .find_map(|primitive| match primitive { + Primitive::RoundedRect { rect, color, .. } if color[..3] == caret[..3] => { + assert_eq!(rect[2] - rect[0], draw::edit::CARET_WIDTH); + Some(color[3]) + } + _ => None, + }) + .unwrap_or(0.0); + (opacity, ui.wake_at().map(|wake| wake - start)) + }; + assert_eq!(at(&mut ui, 0), (1.0, Some(Duration::from_micros(687_500)))); + let theme = Theme::dark(); + let faded = draw::edit::caret_color(theme.caret, theme.base, 0.75)[3]; + assert_eq!(at(&mut ui, 700).0, faded); + assert_eq!(at(&mut ui, 900).0, 0.0); + ui.event(key(NamedKey::ArrowRight)); + assert_eq!(at(&mut ui, 910), (1.0, Some(Duration::from_micros(1_597_500)))); + ui.window_focused = false; + assert_eq!(at(&mut ui, 920), (0.0, None), "an inactive window shows no caret"); +} + #[test] fn text_fields_move_and_select_by_word() { let (mut ui, mut text) = focused_field("one two three"); @@ -621,35 +668,31 @@ fn popups_open_beside_their_anchor_and_flip_to_stay_in_the_window() { } #[test] -fn popups_fade_in_except_at_a_point() { +fn popups_show_at_once_by_their_anchor() { let mut ui = Ui::new(Theme::dark(), DOUBLE_CLICK); menu_frame(&mut ui, BELOW, None); ui.open_popup(menu_id()); menu_frame(&mut ui, BELOW, None); menu_frame(&mut ui, BELOW, None); - let reveal = |ui: &Ui| { + let alpha = |ui: &Ui| { ui.nodes .iter() .find(|node| node.id == menu_id()) .unwrap() .alpha }; - assert!(reveal(&ui) > 0.0 && reveal(&ui) < 1.0); - assert!( - ui.rect(menu_id()).unwrap()[1] < 44.0, - "and slide from the anchor" + assert_eq!(alpha(&ui), 1.0); + assert_eq!( + ui.rect(menu_id()).unwrap()[1], + 44.0, + "right under the anchor" ); - assert!(ui.wants_frame()); - for _ in 0..10 { - menu_frame(&mut ui, BELOW, None); - } - assert!(reveal(&ui) > 0.95, "within about 150 ms"); ui.close_popup(menu_id()); menu_frame(&mut ui, Anchor::Point([50.0, 50.0]), None); ui.open_popup(menu_id()); menu_frame(&mut ui, Anchor::Point([50.0, 50.0]), None); menu_frame(&mut ui, Anchor::Point([50.0, 50.0]), None); - assert_eq!(reveal(&ui), 1.0, "a context menu shows at once"); + assert_eq!(alpha(&ui), 1.0); assert_eq!(ui.rect(menu_id()).unwrap()[..2], [50.0, 50.0]); } @@ -1053,16 +1096,17 @@ fn rows_slide_while_the_rest_fold_away_and_unfold_at_once() { assert!(alpha > 0.0 && alpha < 1.0, "{key} folds where it was"); } assert!(row_top(&ui, 5).unwrap() < 0.0, "out of the seam at the top"); - list_frame(&mut ui, &rows, &mut selected, &[]); - list_frame(&mut ui, &rows, &mut selected, &[]); + for _ in 0..4 { + list_frame(&mut ui, &rows, &mut selected, &[]); + } for key in [5, 100] { let alpha = alpha(&ui, key).unwrap(); assert!(alpha > 0.0 && alpha < 1.0, "{key} unfolds where it goes"); } - for _ in 0..7 { + for _ in 0..11 { list_frame(&mut ui, &rows, &mut selected, &[]); } - assert_eq!(row_top(&ui, 5), Some(0.0), "there in about 150 ms"); + assert_eq!(row_top(&ui, 5), Some(0.0), "there in about 250 ms"); assert_eq!(row_top(&ui, 0), Some(LIST_ROW)); assert_eq!(row_top(&ui, 100), Some(5.0 * LIST_ROW)); assert_eq!(alpha(&ui, 100), Some(1.0)); @@ -1162,3 +1206,43 @@ fn keys_move_the_selection_and_the_view_eases_after_it() { settle_list(&mut ui, &rows, &mut selected); assert_eq!((selected, row_top(&ui, 0)), (Some(0), Some(0.0))); } + +#[test] +fn a_long_menu_scrolls_by_dragging_its_thumb() { + let names: Vec = (0..40).map(|index| format!("Item {index}")).collect(); + let items: Vec<_> = names + .iter() + .map(|text| popup::Item { + text, + ..popup::Item::default() + }) + .collect(); + let build = |ui: &mut Ui| { + frame(ui, |ui| { + popup::menu(ui, menu_id(), BELOW, &items, None); + }) + }; + let mut ui = Ui::new(Theme::dark(), DOUBLE_CLICK); + build(&mut ui); + ui.open_popup(menu_id()); + build(&mut ui); + build(&mut ui); + let thumb = ui.rect(menu_id().child("rows").child("bar")).unwrap(); + let top = ui + .rect(menu_id().child("rows").child(0u64)) + .map(|rect| rect[1]); + let at = Instant::now(); + let x = (thumb[0] + thumb[2]) / 2.0; + ui.event(Event::PointerMoved([x, thumb[1] + 2.0])); + press(&mut ui, at, true); + build(&mut ui); + ui.event(Event::PointerMoved([x, thumb[1] + 60.0])); + build(&mut ui); + build(&mut ui); + assert!(ui.popup_open(menu_id())); + assert_ne!( + ui.rect(menu_id().child("rows").child(0u64)) + .map(|rect| rect[1]), + top + ); +} diff --git a/crates/ui/src/text.rs b/crates/ui/src/text.rs index e7f43610a64d4978376fb8acbbcf1cec3aa03b53..a8982c991d95aa33d9a6b17eb845418285fdd188 100644 --- a/crates/ui/src/text.rs +++ b/crates/ui/src/text.rs @@ -11,6 +11,8 @@ pub(crate) struct Label { pub size: [f32; 2], } +type LabelKey = (String, u32, Option); + /// Labels shaped this frame or the previous one, keyed by their text and size. #[derive(Default)] pub(crate) struct Texts { @@ -18,13 +20,14 @@ pub(crate) struct Texts { context: LayoutContext<()>, /// A family registered in place of the system's interface font. family: Option, - cache: HashMap<(String, u32), (Rc