From 5e2a514da35d4f367f7084d85bdbe75af7584177 Mon Sep 17 00:00:00 2001 From: clover caruso Date: Fri, 16 Oct 2026 00:26:39 -0700 Subject: [PATCH] feat(ios): float the format bar as Liquid Glass and regroup its menus On iOS 26 the bar above the keyboard is a floating glass capsule, as in Notes, with Hide Keyboard in its own glass circle at the right; earlier systems keep the full-width bar. The top bar loses its checkmark, since the bar and Escape both end editing. Tags takes the To Do button's place and opens a 3 by 3 grid of the first nine tags over the bar, centred on the button as far as the keyboard allows, with More Tags and Remove Tag beneath it, or beside it on a short screen. Bullets and Numbering become one List button with a small menu. A plus button holds everything the page menu used to insert, and Page Background moves onto the bar. Highlight leaves the bar for the selection menu, where it offers OneNote's fifteen highlighter colours and No Highlight through a new sb_view_highlight call. Assisted-by: claude-opus-5.5 --- apps/ios/Snowbound/CanvasView.swift | 41 ++- apps/ios/Snowbound/FormatBar.swift | 362 ++++++++++++++++---- apps/ios/Snowbound/PageViewController.swift | 100 +++--- apps/ios/Snowbound/Script.swift | 54 ++- apps/ios/Snowbound/Tags.swift | 27 +- crates/mobile/include/snowbound.h | 1 + crates/mobile/src/lib.rs | 12 + crates/mobile/src/tests.rs | 36 ++ 8 files changed, 503 insertions(+), 130 deletions(-) diff --git a/apps/ios/Snowbound/CanvasView.swift b/apps/ios/Snowbound/CanvasView.swift index 7482270e0f026b55e118d5cb6fda02a214b3b2e3..ccc816484fbd6a93124ad6f7c6c606e730a35abb 100644 --- a/apps/ios/Snowbound/CanvasView.swift +++ b/apps/ios/Snowbound/CanvasView.swift @@ -855,6 +855,7 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI override func resignFirstResponder() -> Bool { guard super.resignFirstResponder() else { return false } + TagGrid.shown?.close() if let handle { sb_view_focus(handle, false) } dirty = true onChange?() @@ -937,22 +938,42 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI override func toggleItalics(_ sender: Any?) { apply(1) } override func toggleUnderline(_ sender: Any?) { apply(2) } - /// Tags and the highlighter join the system's text actions. + /// OneNote's highlighter colours, COLORREF, as its Text Highlight Color offers them. + private static let highlights: [(UInt32, String)] = [ + (0x00ffff, "Yellow"), (0x00ff00, "Bright Green"), (0xffff00, "Turquoise"), (0xff00ff, "Pink"), + (0xff0000, "Blue"), (0x0000ff, "Red"), (0x800000, "Dark Blue"), (0x808000, "Teal"), (0x008000, "Green"), + (0x800080, "Violet"), (0x000080, "Dark Red"), (0x008080, "Dark Yellow"), (0x808080, "Gray 50%"), + (0xc0c0c0, "Gray 25%"), (0x000000, "Black"), + ] + + /// The highlighter leads the system's text actions for a selection, tags follow them. func editMenu(for textRange: UITextRange, suggestedActions: [UIMenuElement]) -> UIMenu? { guard let handle else { return nil } let bits = sb_view_format(handle) let tag = UIMenu( title: "Tag", image: UIImage(systemName: "tag"), children: Tags.menu(bits: bits) { [weak self] in self?.apply($0) }) - var extra: [UIMenuElement] = [tag] + var highlight: [UIMenuElement] = [] if !textRange.isEmpty { - extra.insert( - UIAction(title: "Highlight", image: UIImage(systemName: "highlighter")) { [weak self] _ in - self?.apply(10) - }, at: 0) + let colors = Self.highlights.map { color, name in + let swatch = UIGraphicsImageRenderer(size: CGSize(width: 20, height: 20)).image { _ in + let dot = UIBezierPath(ovalIn: CGRect(x: 1, y: 1, width: 18, height: 18)) + UIColor(rgb: [UInt8(color & 0xff), UInt8(color >> 8 & 0xff), UInt8(color >> 16 & 0xff)]).setFill() + dot.fill() + UIColor.separator.setStroke() + dot.stroke() + } + return UIAction(title: name, image: swatch.withRenderingMode(.alwaysOriginal)) { [weak self] _ in + self?.highlight(Int64(color)) + } + } + let none = UIAction(title: "No Highlight", image: UIImage(systemName: "circle.slash")) { [weak self] _ in + self?.highlight(-1) + } + highlight = [UIMenu(title: "Highlight", image: UIImage(systemName: "highlighter"), children: colors + [none])] } return UIMenu( - children: spellingActions() + suggestedActions + [UIMenu(options: .displayInline, children: extra)]) + children: spellingActions() + highlight + suggestedActions + [UIMenu(options: .displayInline, children: [tag])]) } private struct Correction: Decodable { @@ -1117,6 +1138,12 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI edit(external: true) { sb_view_apply(handle, command) } } + /// Highlights the selection in COLORREF `color`, or takes its highlight away where negative. + func highlight(_ color: Int64) { + guard let handle else { return } + edit(external: true) { sb_view_highlight(handle, color) } + } + /// Gives the selected paragraphs style `place` of OneNote's gallery, in the page's theme. func applyStyle(_ place: UInt8) { guard let handle else { return } diff --git a/apps/ios/Snowbound/FormatBar.swift b/apps/ios/Snowbound/FormatBar.swift index 91f7980f369c39a0c1463863fc0f44862774222e..30c1fcb092ece70006aad283b9be2a8468f80e6e 100644 --- a/apps/ios/Snowbound/FormatBar.swift +++ b/apps/ios/Snowbound/FormatBar.swift @@ -1,22 +1,11 @@ import UIKit -/// Formatting above the keyboard, as Notes offers it: To Do first, then text styles, lists, -/// indentation, tags and pictures, the keyboard's dismissal at the end. +/// Formatting above the keyboard, as Notes offers it: from iOS 26 a floating glass bar, the +/// keyboard's dismissal always at its right. final class FormatBar: UIInputView { - /// `sb_view_apply` commands and `sb_view_format` bits. - private static let buttons: [(symbol: String, label: String, command: UInt8)] = [ - ("checklist", "To Do", 16), - ("bold", "Bold", 0), - ("italic", "Italic", 1), - ("underline", "Underline", 2), - ("strikethrough", "Strikethrough", 3), - ("highlighter", "Highlight", 10), - ("list.bullet", "Bullets", 4), - ("list.number", "Numbering", 5), - ("decrease.indent", "Decrease Indent", 7), - ("increase.indent", "Increase Indent", 6), - ] private var toggles: [(UIButton, UInt8)] = [] + private let tags = FormatBar.button("tag", "Tags") + private let list = FormatBar.button("list.bullet", "List") private var bits: UInt64 = 0 var onApply: ((UInt8) -> Void)? /// Applies a style of OneNote's gallery, by place; `stylePlace` names the selection's. @@ -27,20 +16,17 @@ final class FormatBar: UIInputView { "Heading 1", "Heading 2", "Heading 3", "Heading 4", "Heading 5", "Heading 6", "Page Title", "Citation", "Quote", "Code", "Normal", ] - /// Asks for a picture from the camera (true) or the photo library. - var onPicture: ((Bool) -> Void)? + /// What `+` offers to insert, asked for as it opens. + var insert: (() -> [UIMenuElement])? + var onPaper: (() -> Void)? var onDismiss: (() -> Void)? init() { - super.init(frame: CGRect(x: 0, y: 0, width: 0, height: 48), inputViewStyle: .keyboard) + let floating = if #available(iOS 26, *) { true } else { false } + super.init( + frame: CGRect(x: 0, y: 0, width: 0, height: floating ? 60 : 48), + inputViewStyle: floating ? .default : .keyboard) allowsSelfSizing = true - // Over the page when a hardware keyboard hides the software one. - let backdrop = UIVisualEffectView(effect: UIBlurEffect(style: .systemChromeMaterial)) - backdrop.frame = bounds - backdrop.autoresizingMask = [.flexibleWidth, .flexibleHeight] - addSubview(backdrop) - let row = UIStackView() - row.spacing = 2 let styles = Self.button("textformat", "Styles") styles.menu = UIMenu(children: [ UIDeferredMenuElement.uncached { [weak self] done in @@ -53,65 +39,112 @@ final class FormatBar: UIInputView { } ]) styles.showsMenuAsPrimaryAction = true - row.addArrangedSubview(styles) - for (symbol, label, command) in Self.buttons { - let button = Self.button(symbol, label) - button.addAction(UIAction { [weak self] _ in self?.onApply?(command) }, for: .primaryActionTriggered) - toggles.append((button, command)) - row.addArrangedSubview(button) - } - let tag = Self.button("tag", "Tags") - tag.menu = UIMenu(children: [ + tags.addAction(UIAction { [weak self] _ in self?.showTags() }, for: .primaryActionTriggered) + list.menu = UIMenu(children: [ UIDeferredMenuElement.uncached { [weak self] done in - done(Tags.menu(bits: self?.bits ?? 0) { self?.onApply?($0) }) + let bits = self?.bits ?? 0 + done([("list.bullet", "Bulleted List", 4), ("list.number", "Numbered List", 5)].map { + symbol, title, command in + UIAction( + title: title, image: UIImage(systemName: symbol), + state: bits & 1 << UInt64(command) != 0 ? .on : .off + ) { _ in self?.onApply?(UInt8(command)) } + }) } ]) - tag.showsMenuAsPrimaryAction = true - row.addArrangedSubview(tag) - let picture = Self.button("photo", "Insert Picture") - picture.menu = UIMenu(children: [ - UIAction(title: "Photo Library", image: UIImage(systemName: "photo.on.rectangle")) { [weak self] _ in - self?.onPicture?(false) - }, - UIAction( - title: "Take Photo", image: UIImage(systemName: "camera"), - attributes: UIImagePickerController.isSourceTypeAvailable(.camera) ? [] : .disabled - ) { [weak self] _ in self?.onPicture?(true) }, + list.showsMenuAsPrimaryAction = true + let insert = Self.button("plus", "Insert") + insert.menu = UIMenu(children: [ + UIDeferredMenuElement.uncached { [weak self] done in done(self?.insert?() ?? []) } ]) - picture.showsMenuAsPrimaryAction = true - row.addArrangedSubview(picture) + insert.showsMenuAsPrimaryAction = true + let paper = Self.button("paintpalette", "Page Background") + paper.addAction(UIAction { [weak self] _ in self?.onPaper?() }, for: .primaryActionTriggered) + // What fits a phone held upright comes first; the rest scrolls into view. + let row = UIStackView(arrangedSubviews: [ + styles, tags, command("bold", "Bold", 0), command("italic", "Italic", 1), + command("underline", "Underline", 2), list, insert, command("strikethrough", "Strikethrough", 3), + command("decrease.indent", "Decrease Indent", 7), command("increase.indent", "Increase Indent", 6), + paper, + ]) + row.spacing = 2 let scroll = UIScrollView() scroll.showsHorizontalScrollIndicator = false scroll.addSubview(row) let dismiss = Self.button("keyboard.chevron.compact.down", "Hide Keyboard") dismiss.addAction(UIAction { [weak self] _ in self?.onDismiss?() }, for: .primaryActionTriggered) for view in [scroll, dismiss, row] as [UIView] { view.translatesAutoresizingMaskIntoConstraints = false } - addSubview(scroll) - addSubview(dismiss) NSLayoutConstraint.activate([ - heightAnchor.constraint(equalToConstant: 48), - scroll.leadingAnchor.constraint(equalTo: safeAreaLayoutGuide.leadingAnchor, constant: 4), - scroll.topAnchor.constraint(equalTo: topAnchor), - scroll.bottomAnchor.constraint(equalTo: bottomAnchor), - scroll.trailingAnchor.constraint(equalTo: dismiss.leadingAnchor, constant: -4), - dismiss.trailingAnchor.constraint(equalTo: safeAreaLayoutGuide.trailingAnchor, constant: -4), - dismiss.centerYAnchor.constraint(equalTo: centerYAnchor), row.leadingAnchor.constraint(equalTo: scroll.contentLayoutGuide.leadingAnchor), row.trailingAnchor.constraint(equalTo: scroll.contentLayoutGuide.trailingAnchor), row.topAnchor.constraint(equalTo: scroll.contentLayoutGuide.topAnchor), row.bottomAnchor.constraint(equalTo: scroll.contentLayoutGuide.bottomAnchor), row.heightAnchor.constraint(equalTo: scroll.frameLayoutGuide.heightAnchor), ]) + if #available(iOS 26, *) { + let capsule = Self.glass(scroll) + let circle = Self.glass(dismiss) + (circle.effect as? UIGlassEffect)?.isInteractive = true + addSubview(capsule) + addSubview(circle) + // The capsule fits its buttons where they fit, as Notes' does. + let fitted = scroll.frameLayoutGuide.widthAnchor.constraint(equalTo: scroll.contentLayoutGuide.widthAnchor) + fitted.priority = .defaultHigh + NSLayoutConstraint.activate([ + fitted, + capsule.leadingAnchor.constraint(equalTo: safeAreaLayoutGuide.leadingAnchor, constant: 16), + capsule.trailingAnchor.constraint(lessThanOrEqualTo: circle.leadingAnchor, constant: -8), + capsule.topAnchor.constraint(equalTo: topAnchor, constant: 4), + capsule.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -8), + scroll.leadingAnchor.constraint(equalTo: capsule.contentView.leadingAnchor, constant: 4), + scroll.trailingAnchor.constraint(equalTo: capsule.contentView.trailingAnchor, constant: -4), + scroll.topAnchor.constraint(equalTo: capsule.contentView.topAnchor), + scroll.bottomAnchor.constraint(equalTo: capsule.contentView.bottomAnchor), + circle.trailingAnchor.constraint(equalTo: safeAreaLayoutGuide.trailingAnchor, constant: -16), + circle.centerYAnchor.constraint(equalTo: capsule.centerYAnchor), + circle.widthAnchor.constraint(equalTo: capsule.heightAnchor), + circle.heightAnchor.constraint(equalTo: capsule.heightAnchor), + dismiss.centerXAnchor.constraint(equalTo: circle.contentView.centerXAnchor), + dismiss.centerYAnchor.constraint(equalTo: circle.contentView.centerYAnchor), + dismiss.heightAnchor.constraint(equalTo: circle.contentView.heightAnchor), + ]) + } else { + // Over the page when a hardware keyboard hides the software one. + let backdrop = UIVisualEffectView(effect: UIBlurEffect(style: .systemChromeMaterial)) + backdrop.frame = bounds + backdrop.autoresizingMask = [.flexibleWidth, .flexibleHeight] + addSubview(backdrop) + addSubview(scroll) + addSubview(dismiss) + NSLayoutConstraint.activate([ + heightAnchor.constraint(equalToConstant: 48), + scroll.leadingAnchor.constraint(equalTo: safeAreaLayoutGuide.leadingAnchor, constant: 4), + scroll.topAnchor.constraint(equalTo: topAnchor), + scroll.bottomAnchor.constraint(equalTo: bottomAnchor), + scroll.trailingAnchor.constraint(equalTo: dismiss.leadingAnchor, constant: -4), + dismiss.trailingAnchor.constraint(equalTo: safeAreaLayoutGuide.trailingAnchor, constant: -4), + dismiss.centerYAnchor.constraint(equalTo: centerYAnchor), + ]) + } } required init?(coder: NSCoder) { fatalError() } + @available(iOS 26, *) + private static func glass(_ content: UIView) -> UIVisualEffectView { + let glass = UIVisualEffectView(effect: UIGlassEffect()) + glass.cornerConfiguration = .capsule() + glass.translatesAutoresizingMaskIntoConstraints = false + glass.contentView.addSubview(content) + return glass + } + private static func button(_ symbol: String, _ label: String) -> UIButton { var configuration = UIButton.Configuration.plain() configuration.image = UIImage(systemName: symbol) configuration.preferredSymbolConfigurationForImage = UIImage.SymbolConfiguration(pointSize: 17) configuration.baseForegroundColor = .label - configuration.cornerStyle = .medium + configuration.cornerStyle = .capsule let button = UIButton(configuration: configuration) button.accessibilityLabel = label button.widthAnchor.constraint(equalToConstant: 42).isActive = true @@ -123,11 +156,224 @@ final class FormatBar: UIInputView { return button } + /// A button for `sb_view_apply` `command`, marked while the selection has it. + private func command(_ symbol: String, _ label: String, _ command: UInt8) -> UIButton { + let button = Self.button(symbol, label) + button.addAction(UIAction { [weak self] _ in self?.onApply?(command) }, for: .primaryActionTriggered) + toggles.append((button, command)) + return button + } + + private func showTags() { + guard let scene = window?.windowScene else { return } + let grid = TagGrid(bits: bits, short: traitCollection.verticalSizeClass == .compact) { [weak self] in + self?.onApply?($0) + } + // Over the bar but never the keyboard, which covers every window of the app. + grid.show( + in: scene, around: tags.convert(CGPoint(x: tags.bounds.midX, y: tags.bounds.midY), to: nil), + above: convert(CGPoint(x: 0, y: bounds.maxY), to: nil).y) + } + /// Marks what the selection has, from `sb_view_format`. func show(_ bits: UInt64) { self.bits = bits for (button, command) in toggles { button.isSelected = bits & (1 << UInt64(command)) != 0 } + tags.isSelected = bits >> 16 != 0 + let numbered = bits & 1 << 5 != 0 + list.isSelected = numbered || bits & 1 << 4 != 0 + list.configuration?.image = UIImage(systemName: numbered ? "list.number" : "list.bullet") + } +} + +/// A window that leaves the keyboard with the page it types on. +private final class Overlay: UIWindow { + override var canBecomeKey: Bool { false } +} + +/// OneNote's first nine tags in a grid centred on the Tags button, so a thumb reaches each, +/// then the rest and Remove Tag; beside the grid rather than below it on a short screen. +final class TagGrid: UIView, UIGestureRecognizerDelegate { + private(set) static weak var shown: TagGrid? + private let panel: UIVisualEffectView + private let grid = UIStackView() + private let apply: (UInt8) -> Void + private var laidOut = CGSize.zero + private var overlay: UIWindow? + + init(bits: UInt64, short: Bool, apply: @escaping (UInt8) -> Void) { + self.apply = apply + if #available(iOS 26, *) { + panel = UIVisualEffectView(effect: UIGlassEffect()) + panel.cornerConfiguration = .uniformCorners(radius: .fixed(28)) + } else { + panel = UIVisualEffectView(effect: UIBlurEffect(style: .systemThickMaterial)) + panel.layer.cornerRadius = 20 + panel.clipsToBounds = true + } + super.init(frame: .zero) + accessibilityViewIsModal = true + let outside = UITapGestureRecognizer(target: self, action: #selector(close)) + outside.delegate = self + addGestureRecognizer(outside) + layer.shadowColor = UIColor.black.cgColor + layer.shadowOpacity = 0.18 + layer.shadowRadius = 24 + grid.axis = .vertical + let tags = Array(Tags.all.prefix(Tags.keyed).enumerated()) + for start in stride(from: 0, to: tags.count, by: 3) { + let line = UIStackView(arrangedSubviews: tags[start..> 16 != 0 + remove.addAction(UIAction { [weak self] _ in self?.choose(8) }, for: .primaryActionTriggered) + let separator = UIView() + separator.backgroundColor = .separator + (short ? separator.widthAnchor : separator.heightAnchor) + .constraint(equalToConstant: 1 / UIScreen.main.scale).isActive = true + let actions = UIStackView(arrangedSubviews: [more, remove]) + actions.distribution = short ? .fill : .fillEqually + actions.axis = short ? .vertical : .horizontal + let stack = UIStackView(arrangedSubviews: [grid, separator, actions]) + stack.axis = short ? .horizontal : .vertical + stack.alignment = short ? .top : .fill + separator.heightAnchor.constraint(equalTo: grid.heightAnchor).isActive = short + stack.spacing = 4 + stack.translatesAutoresizingMaskIntoConstraints = false + panel.contentView.addSubview(stack) + NSLayoutConstraint.activate([ + stack.leadingAnchor.constraint(equalTo: panel.contentView.leadingAnchor, constant: 8), + stack.trailingAnchor.constraint(equalTo: panel.contentView.trailingAnchor, constant: -8), + stack.topAnchor.constraint(equalTo: panel.contentView.topAnchor, constant: 8), + stack.bottomAnchor.constraint(equalTo: panel.contentView.bottomAnchor, constant: -8), + grid.widthAnchor.constraint(equalToConstant: 3 * (short ? 150 : 100)), + ]) + addSubview(panel) + } + + required init?(coder: NSCoder) { fatalError() } + + private func tile(_ tag: Tags.Tag, command: UInt8, on: Bool, short: Bool) -> UIButton { + var configuration = UIButton.Configuration.plain() + configuration.image = Tags.icon(tag.shape, highlight: tag.highlight, points: 24) + configuration.title = tag.name + configuration.imagePlacement = short ? .leading : .top + configuration.imagePadding = 6 + configuration.titleAlignment = short ? .leading : .center + configuration.titleLineBreakMode = .byWordWrapping + configuration.titleTextAttributesTransformer = UIConfigurationTextAttributesTransformer { + var attributes = $0 + attributes.font = .preferredFont(forTextStyle: .caption1) + return attributes + } + configuration.baseForegroundColor = .label + configuration.cornerStyle = .large + configuration.contentInsets = NSDirectionalEdgeInsets( + top: short ? 0 : 10, leading: short ? 8 : 4, bottom: short ? 0 : 8, trailing: 4) + let button = UIButton(configuration: configuration) + if short { button.contentHorizontalAlignment = .leading } + button.isSelected = on + button.configurationUpdateHandler = { button in + button.configuration?.background.backgroundColor = + button.isSelected ? UIColor.tintColor.withAlphaComponent(0.18) : .clear + } + button.heightAnchor.constraint(equalToConstant: short ? 44 : 72).isActive = true + button.addAction(UIAction { [weak self] _ in self?.choose(command) }, for: .primaryActionTriggered) + return button + } + + private static func row(_ title: String, _ symbol: String) -> UIButton { + var configuration = UIButton.Configuration.plain() + configuration.title = title + configuration.image = UIImage(systemName: symbol) + configuration.imagePadding = 6 + configuration.baseForegroundColor = .label + configuration.cornerStyle = .large + configuration.contentInsets = NSDirectionalEdgeInsets(top: 0, leading: 8, bottom: 0, trailing: 8) + configuration.titleLineBreakMode = .byTruncatingTail + configuration.titleTextAttributesTransformer = UIConfigurationTextAttributesTransformer { + var attributes = $0 + attributes.font = .preferredFont(forTextStyle: .subheadline) + return attributes + } + let button = UIButton(configuration: configuration) + button.heightAnchor.constraint(equalToConstant: 44).isActive = true + return button + } + + /// Opens with the grid's middle at `point`, in screen points, as near as it fits on screen + /// above `bottom`. + func show(in scene: UIWindowScene, around point: CGPoint, above bottom: CGFloat) { + Self.shown?.close() + Self.shown = self + // Over the format bar's window. + let window = Overlay(windowScene: scene) + window.windowLevel = .alert + let root = UIViewController() + root.view = self + window.rootViewController = root + window.isHidden = false + overlay = window + let size = panel.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize) + panel.bounds.size = size + panel.layoutIfNeeded() + let middle = grid.convert(CGPoint(x: grid.bounds.midX, y: grid.bounds.midY), to: panel) + var room = window.bounds.inset(by: window.safeAreaInsets).insetBy(dx: 8, dy: 8) + room.size.height = min(room.maxY, bottom - 8) - room.minY + let origin = CGPoint( + x: min(max(point.x - middle.x, room.minX), room.maxX - size.width), + y: min(max(point.y - middle.y, room.minY), room.maxY - size.height)) + panel.frame = CGRect(origin: origin, size: size) + laidOut = bounds.size + panel.alpha = 0 + if !UIAccessibility.isReduceMotionEnabled { + // Grows from the finger, as menus do. + let finger = CGPoint(x: point.x - panel.center.x, y: point.y - panel.center.y) + panel.transform = CGAffineTransform(translationX: finger.x, y: finger.y).scaledBy(x: 0.6, y: 0.6) + .translatedBy(x: -finger.x, y: -finger.y) + } + UIView.animate(withDuration: 0.35, delay: 0, usingSpringWithDamping: 0.82, initialSpringVelocity: 0) { + self.panel.alpha = 1 + self.panel.transform = .identity + } + UIAccessibility.post(notification: .screenChanged, argument: grid.arrangedSubviews.first?.subviews.first) + } + + override func layoutSubviews() { + super.layoutSubviews() + // Turning the device moves the button out from under the grid. + if laidOut != .zero, bounds.size != laidOut { close() } + } + + func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool { + touch.view === self + } + + private func choose(_ command: UInt8) { + apply(command) + close() + } + + @objc func close() { + guard overlay != nil else { return } + laidOut = .zero + UIView.animate(withDuration: 0.15, animations: { self.alpha = 0 }) { _ in + self.overlay?.isHidden = true + self.overlay = nil + } + } + + override func accessibilityPerformEscape() -> Bool { + close() + return true } } diff --git a/apps/ios/Snowbound/PageViewController.swift b/apps/ios/Snowbound/PageViewController.swift index 974aed4a3935e41f8bc59a1b0dc613bd2a16456f..dd3cdf026b882b4971d71d2a4954c8f9bf35c7fe 100644 --- a/apps/ios/Snowbound/PageViewController.swift +++ b/apps/ios/Snowbound/PageViewController.swift @@ -29,8 +29,6 @@ final class PageViewController: UIViewController, PHPickerViewControllerDelegate private var reader: UIBarButtonItem? /// Opens another page or conflict page of the section. var onOpen: ((String) -> Void)? - private lazy var done = UIBarButtonItem( - systemItem: .done, primaryAction: UIAction { [weak self] _ in _ = self?.canvas.resignFirstResponder() }) private lazy var undo = UIBarButtonItem( title: "undo", image: UIImage(systemName: "arrow.uturn.backward"), primaryAction: UIAction { [weak self] _ in self?.canvas.undo(redo: false) }) private lazy var redo = UIBarButtonItem( @@ -52,7 +50,6 @@ final class PageViewController: UIViewController, PHPickerViewControllerDelegate self.reveal = reveal self.titleFocus = titleFocus super.init(nibName: nil, bundle: nil) - done.style = .plain navigationItem.largeTitleDisplayMode = .never navigationItem.rightBarButtonItems = [more, draw] showTitle(section.row(of: page)?.row.title ?? "") @@ -92,7 +89,12 @@ final class PageViewController: UIViewController, PHPickerViewControllerDelegate media.onPause = { [weak self] in self?.pauseMedia() } media.onStop = { [weak self] in self?.stopMedia() } NotificationCenter.default.addObserver(self, selector: #selector(showMedia), name: Recorder.changed, object: nil) - canvas.formatBar.onPicture = { [weak self] camera in self?.pickPicture(camera: camera) } + canvas.formatBar.insert = { [weak self] in self?.insertActions() ?? [] } + canvas.formatBar.onPaper = { [weak self] in + // The keyboard would cover the picker's sheet. + _ = self?.canvas.resignFirstResponder() + self?.showPaper() + } bar.onAction = { [weak self] in self?.conflictAction() } NotificationCenter.default.addObserver( self, selector: #selector(sectionChanged), name: Section.changed, object: section) @@ -134,7 +136,8 @@ final class PageViewController: UIViewController, PHPickerViewControllerDelegate navigationItem.titleView = crowded ? UIView() : nil } - /// Notes' checkmark ends editing and puts the keyboard away; undo and redo sit beside it. + /// Undo and redo join the bar while editing or drawing; the format bar puts the keyboard + /// away. private func editingChanged() { if !canvas.readOnly, let title = canvas.pageTitle { showTitle(title) @@ -152,60 +155,16 @@ final class PageViewController: UIViewController, PHPickerViewControllerDelegate if navigationItem.rightBarButtonItems != resting { navigationItem.rightBarButtonItems = resting } return } - let items = canvas.isFirstResponder ? [done, more, draw, redo, undo] : [more, draw, redo, undo] - if navigationItem.rightBarButtonItems?.first !== items.first - || navigationItem.rightBarButtonItems?.count != items.count - { - navigationItem.rightBarButtonItems = items - } + let editing = [more, draw, redo, undo] + if navigationItem.rightBarButtonItems != editing { navigationItem.rightBarButtonItems = editing } } // MARK: Page menu - /// The desktop's page and insert commands that suit a touch screen. + /// The desktop's page commands that suit a touch screen. private func pageMenu() -> [UIMenuElement] { - let editable: UIMenuElement.Attributes = canvas.readOnly ? .disabled : [] let scene = view.window?.windowScene?.delegate as? SceneDelegate - let insert = UIMenu( - title: "Insert", image: UIImage(systemName: "plus.circle"), - children: [ - UIAction(title: "Photo Library", image: UIImage(systemName: "photo.on.rectangle"), attributes: editable) { - [weak self] _ in self?.pickPicture(camera: false) - }, - UIAction( - title: "Take Photo", image: UIImage(systemName: "camera"), - attributes: UIImagePickerController.isSourceTypeAvailable(.camera) ? editable : .disabled - ) { [weak self] _ in self?.pickPicture(camera: true) }, - UIMenu(options: .displayInline, children: [ - UIAction(title: "Date", image: UIImage(systemName: "calendar"), attributes: editable) { - [weak self] _ in self?.canvas.insertDate(true, time: false) - }, - UIAction(title: "Time", image: UIImage(systemName: "clock"), attributes: editable) { - [weak self] _ in self?.canvas.insertDate(false, time: true) - }, - UIAction(title: "Date & Time", image: UIImage(systemName: "calendar.badge.clock"), attributes: editable) { - [weak self] _ in self?.canvas.insertDate(true, time: true) - }, - ]), - UIAction( - title: "Space", image: UIImage(systemName: "arrow.up.and.down.text.horizontal"), attributes: editable - ) { [weak self] _ in self?.canvas.insertSpace() }, - UIMenu(options: .displayInline, children: [ - UIAction( - title: "Record Audio", image: UIImage(systemName: "mic"), - attributes: Recorder.current == nil ? editable : .disabled - ) { [weak self] _ in self?.record(video: false) }, - UIAction( - title: "Record Video", image: UIImage(systemName: "video"), - attributes: Recorder.current == nil && Recorder.canRecordVideo ? editable : .disabled - ) { [weak self] _ in self?.record(video: true) }, - ]), - ]) return [readingAction()] + [ - insert, - UIAction(title: "Page Background", image: UIImage(systemName: "paintpalette"), attributes: editable) { - [weak self] _ in self?.showPaper() - }, themeMenu(), UIAction(title: "Tags Summary", image: UIImage(systemName: "tag")) { [weak self] _ in guard let self else { return } @@ -232,6 +191,43 @@ final class PageViewController: UIViewController, PHPickerViewControllerDelegate ] } + /// The desktop's Insert tab, as the format bar's `+` offers it. + private func insertActions() -> [UIMenuElement] { + [ + UIAction(title: "Photo Library", image: UIImage(systemName: "photo.on.rectangle")) { [weak self] _ in + self?.pickPicture(camera: false) + }, + UIAction( + title: "Take Photo", image: UIImage(systemName: "camera"), + attributes: UIImagePickerController.isSourceTypeAvailable(.camera) ? [] : .disabled + ) { [weak self] _ in self?.pickPicture(camera: true) }, + UIMenu(options: .displayInline, children: [ + UIAction(title: "Date", image: UIImage(systemName: "calendar")) { [weak self] _ in + self?.canvas.insertDate(true, time: false) + }, + UIAction(title: "Time", image: UIImage(systemName: "clock")) { [weak self] _ in + self?.canvas.insertDate(false, time: true) + }, + UIAction(title: "Date & Time", image: UIImage(systemName: "calendar.badge.clock")) { [weak self] _ in + self?.canvas.insertDate(true, time: true) + }, + ]), + UIAction(title: "Space", image: UIImage(systemName: "arrow.up.and.down.text.horizontal")) { [weak self] _ in + self?.canvas.insertSpace() + }, + UIMenu(options: .displayInline, children: [ + UIAction( + title: "Record Audio", image: UIImage(systemName: "mic"), + attributes: Recorder.current == nil ? [] : .disabled + ) { [weak self] _ in self?.record(video: false) }, + UIAction( + title: "Record Video", image: UIImage(systemName: "video"), + attributes: Recorder.current == nil && Recorder.canRecordVideo ? [] : .disabled + ) { [weak self] _ in self?.record(video: true) }, + ]), + ] + } + /// Reading View: the page reflowed into the screen's width where it reflows. private func readingAction() -> UIAction { let reading = canvas.reading diff --git a/apps/ios/Snowbound/Script.swift b/apps/ios/Snowbound/Script.swift index 7d16411dce6c27f6bd147f9c3a1d64099d95fcd1..803ce0d9ab7c405108e458dff56422b31959a931 100644 --- a/apps/ios/Snowbound/Script.swift +++ b/apps/ios/Snowbound/Script.swift @@ -18,11 +18,20 @@ extension CanvasView { /// `record:video` records, `pauserecording` pauses or resumes and `stoprecording` stops it, /// `wait:N` waits N seconds more before the next step, `tree` saves the view hierarchy to /// Documents/tree.txt, and `shot:a` the window to Documents/a.png, as a device has no - /// screenshot command. + /// screenshot command; `snap:a` only logs, for a simulator screenshot with the keyboard; + /// `activate:Label` activates the element of any window so named, as VoiceOver would, + /// opening a button's menu, and `audit` logs whether the format bar and tag grid name every + /// button. func runScript() { // Only the first page shown runs it, not every page opened afterwards. guard !scriptedPage, let script = ProcessInfo.processInfo.environment["SNOWBOUND_SCRIPT"] else { return } scriptedPage = true + // UIKit's accessibility, as VoiceOver or a UI test loads it, for `activate` and `audit`. + if let library = dlopen("/usr/lib/libAccessibility.dylib", RTLD_NOW), + let enable = dlsym(library, "_AXSSetAutomationEnabled") + { + unsafeBitCast(enable, to: (@convention(c) (Int32) -> Void).self)(1) + } var delay = 1.0 for step in script.split(separator: "|") { if step.hasPrefix("wait:") { delay += Double(step.dropFirst(5)) ?? 0 } @@ -95,7 +104,21 @@ extension CanvasView { let caret = caretRect(for: range.start) menu.presentEditMenu(with: UIEditMenuConfiguration(identifier: nil, sourcePoint: CGPoint(x: caret.midX, y: caret.minY))) case "done": _ = resignFirstResponder() - case "wait": break + case "wait", "snap": break + case "activate": + guard let found = (window?.windowScene?.windows ?? []).reversed().lazy + .compactMap({ Self.element(argument, in: $0) }).first + else { + NSLog("script activate: no \(argument)") + break + } + if let button = found as? UIButton, button.showsMenuAsPrimaryAction, let menu = button.contextMenuInteraction { + let middle = CGPoint(x: button.bounds.midX, y: button.bounds.midY) + menu.perform(Selector(("_presentMenuAtLocation:")), with: NSValue(cgPoint: middle)) + } else if !found.accessibilityActivate(), let control = found as? UIControl { + control.sendActions(for: .primaryActionTriggered) + } + case "audit": audit() case "record", "stoprecording": let page = sequence(first: self as UIResponder, next: \.next).lazy.compactMap { $0 as? PageViewController }.first if command == "record" { page?.record(video: argument == "video") } else { page?.stopRecording() } @@ -132,6 +155,33 @@ extension CanvasView { } } +extension CanvasView { + /// The first view or accessibility element under `root` VoiceOver names `label`. + fileprivate static func element(_ label: String, in root: NSObject) -> NSObject? { + if let view = root as? UIView, view.isHidden { return nil } + if root.isAccessibilityElement, root.accessibilityLabel == label { return root } + let children = ((root as? UIView)?.subviews ?? []) + (root.accessibilityElements as? [NSObject] ?? []) + return children.lazy.compactMap { element(label, in: $0) }.first + } + + /// Logs `audit ok`, or each button of the format bar and tag grid VoiceOver can't name. + fileprivate func audit() { + func buttons(_ view: UIView) -> [UIButton] { + (view as? UIButton).map { [$0] } ?? view.subviews.flatMap(buttons) + } + var problems = buttons(formatBar).filter { ($0.accessibilityLabel ?? "").isEmpty }.map { "unnamed \($0)" } + if let grid = TagGrid.shown { + let named = buttons(grid).filter { + $0.isAccessibilityElement && $0.accessibilityTraits.contains(.button) && !($0.accessibilityLabel ?? "").isEmpty + }.compactMap(\.accessibilityLabel) + let expected = Tags.all.prefix(Tags.keyed).map(\.name) + ["More Tags…", "Remove Tag"] + if named != expected { problems.append("grid names \(named)") } + if !grid.accessibilityViewIsModal { problems.append("grid not modal") } + } + NSLog(problems.isEmpty ? "audit ok, grid \(TagGrid.shown != nil)" : "audit FAIL \(problems)") + } +} + /// Logs `presenter changed` whenever a coordinated write reaches `SNOWBOUND_PRESENTER`, a /// file, to check that saving tells file providers about it. final class Presenter: NSObject, NSFilePresenter { diff --git a/apps/ios/Snowbound/Tags.swift b/apps/ios/Snowbound/Tags.swift index 5f2babb5ee1b5836d8f8aeca58d3ec8fdfa708ba..80dc0e01e37ef056d5a7abca1c5ee7eea5452ca4 100644 --- a/apps/ios/Snowbound/Tags.swift +++ b/apps/ios/Snowbound/Tags.swift @@ -23,15 +23,16 @@ enum Tags { /// The first nine, which ⌘1 to ⌘9 apply, as Ctrl+1 to Ctrl+9 do in OneNote. static let keyed = 9 - private static var icons: [Int: UIImage] = [:] + private static var icons: [[Int]: UIImage] = [:] - /// Tag symbol `shape` as OneNote draws it, a check box `checked`; a highlighter in - /// `highlight` for a highlighting tag. - static func icon(_ shape: UInt16, checked: Bool = false, highlight: UIColor? = nil) -> UIImage? { + /// Tag symbol `shape` as OneNote draws it, `points` square, a check box `checked`; a + /// highlighter in `highlight` for a highlighting tag. + static func icon(_ shape: UInt16, checked: Bool = false, highlight: UIColor? = nil, points: CGFloat = 18) -> UIImage? { guard shape != 0 else { // Highlighted lines, as the desktop draws these tags. let fill = highlight ?? .systemYellow - return UIGraphicsImageRenderer(size: CGSize(width: 18, height: 18)).image { context in + return UIGraphicsImageRenderer(size: CGSize(width: points, height: points)).image { context in + context.cgContext.scaleBy(x: points / 18, y: points / 18) let card = UIBezierPath(roundedRect: CGRect(x: 1.5, y: 3.5, width: 15, height: 11), cornerRadius: 1.5) fill.setFill() card.fill() @@ -42,9 +43,8 @@ enum Tags { context.fill(CGRect(x: 4, y: 10, width: 7, height: 1.5)) } } - let key = Int(shape) << 1 | (checked ? 1 : 0) + let key = [Int(shape), checked ? 1 : 0, Int(points)] if let icon = icons[key] { return icon } - let points: CGFloat = 18 let scale = UIScreen.main.scale let pixels = Int(points * scale) var rgba = [UInt8](repeating: 0, count: pixels * pixels * 4) @@ -61,15 +61,20 @@ enum Tags { return icon } - /// Every tag as a menu, those the selection has checked, then Remove Tag; `apply` takes - /// the `sb_view_apply` command. - static func menu(bits: UInt64, apply: @escaping (UInt8) -> Void) -> [UIMenuElement] { - let actions = all.enumerated().map { index, tag in + /// Every tag as an action, those the selection has checked; `apply` takes the + /// `sb_view_apply` command. + static func actions(bits: UInt64, apply: @escaping (UInt8) -> Void) -> [UIAction] { + all.enumerated().map { index, tag in UIAction( title: tag.name, image: icon(tag.shape, highlight: tag.highlight), state: bits & (1 << UInt64(16 + index)) != 0 ? .on : .off ) { _ in apply(16 + UInt8(index)) } } + } + + /// Every tag as a menu, then Remove Tag. + static func menu(bits: UInt64, apply: @escaping (UInt8) -> Void) -> [UIMenuElement] { + let actions = actions(bits: bits, apply: apply) let tagged = bits >> 16 != 0 return [ UIMenu(options: .displayInline, children: Array(actions.prefix(keyed))), diff --git a/crates/mobile/include/snowbound.h b/crates/mobile/include/snowbound.h index 4be91f7377fb40352e78b4016ab56cc5217d42f4..3e2d290bae945a83ace00dbfbd07f50df20adceb 100644 --- a/crates/mobile/include/snowbound.h +++ b/crates/mobile/include/snowbound.h @@ -148,6 +148,7 @@ bool sb_view_undo(View *view, bool redo); bool sb_view_can_undo(const View *view, bool redo); uint64_t sb_view_format(const View *view); bool sb_view_apply(View *view, uint8_t command); +bool sb_view_highlight(View *view, int64_t color); bool sb_view_style(View *view, uint8_t place); int32_t sb_view_style_place(const View *view); char *sb_view_correction(const View *view); diff --git a/crates/mobile/src/lib.rs b/crates/mobile/src/lib.rs index 4a35d430df620a5f1de86be3ab5ad761d4e9370c..900c39c986467f458b3c0fadf3794d73d390a393 100644 --- a/crates/mobile/src/lib.rs +++ b/crates/mobile/src/lib.rs @@ -418,6 +418,10 @@ impl Canvas { Ok(moved(self.page.format(command)?)) } + fn highlight(&mut self, color: Option) -> Result { + Ok(moved(self.page.format(Formatting::Highlight(color))?)) + } + /// The title outline, where the page has an editable one. fn title(&self) -> Option<&TextOutline> { self.page @@ -1408,6 +1412,14 @@ pub extern "C" fn sb_view_apply(view: &mut View, command: u8) -> bool { view.stored(result) } +/// Highlights the selection in COLORREF `color`, or takes its highlight away where `color` +/// is negative. +#[unsafe(no_mangle)] +pub extern "C" fn sb_view_highlight(view: &mut View, color: i64) -> bool { + let result = view.canvas.highlight(u32::try_from(color).ok()); + view.stored(result) +} + /// Gives the selected paragraphs style `place` of OneNote's Styles gallery (Heading 1 to 6, /// Page Title, Citation, Quote, Code, Normal) in the page's theme, or OneNote 2010's. #[unsafe(no_mangle)] diff --git a/crates/mobile/src/tests.rs b/crates/mobile/src/tests.rs index 053e0ecec558edb98be14e6147347ffc22ce816a..afffa57a2cfe0c427ba69bcb9ac5b45fd28a4505 100644 --- a/crates/mobile/src/tests.rs +++ b/crates/mobile/src/tests.rs @@ -796,6 +796,42 @@ fn find_selects_the_match_and_formatting_reports_and_toggles() { assert!(canvas.edit().unwrap().is_some()); } +#[test] +fn highlight_stores_any_colour_and_none_takes_it_away() { + let (_directory, section) = features(); + let mut canvas = canvas(§ion, "Paragraph controls"); + assert!(canvas.find("collapsed PARENT").unwrap()); + let stored = |canvas: &mut Canvas| { + section + .shared + .apply(canvas.edit().unwrap().unwrap()) + .unwrap(); + let (page, _) = section.shared.page(canvas.space).unwrap(); + let mut highlights = Vec::new(); + for object in &page.objects { + let onestore::page::PageObject::Outline(outline) = object else { + continue; + }; + for paragraph in &outline.paragraphs { + if let onestore::page::ParagraphContent::Text(text) = ¶graph.content { + highlights.extend( + text.text + .spans() + .iter() + .filter_map(|span| span.format.highlight), + ); + } + } + } + highlights + }; + // Bright Green, which `sb_view_apply` has no number for. + assert!(canvas.highlight(Some(0x00ff00)).unwrap()); + assert!(stored(&mut canvas).contains(&0x00ff00)); + assert!(canvas.highlight(None).unwrap()); + assert!(!stored(&mut canvas).contains(&0x00ff00)); +} + #[test] fn a_picture_goes_in_at_the_caret_and_undoes() { let (_directory, section) = features(); -- 2.54.0