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