| author | |
| committer | |
| log | 5e2a514da35d4f367f7084d85bdbe75af7584177 |
| tree | 801f3536a1f0af289daae64c5c0e99249564c2f8 |
| parent | 486eeeb292a5b37fb9af763e9ee014c61bcbdd9c |
| signature | Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU (~clover) |
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.58 files changed, 503 insertions(+), 130 deletions(-)
apps/ios/Snowbound/CanvasView.swift+34-7| ... | @@ -855,6 +855,7 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI | ... | @@ -855,6 +855,7 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI |
| 855 | 855 | ||
| 856 | override func resignFirstResponder() -> Bool { | 856 | override func resignFirstResponder() -> Bool { |
| 857 | guard super.resignFirstResponder() else { return false } | 857 | guard super.resignFirstResponder() else { return false } |
| 858 | TagGrid.shown?.close() | ||
| 858 | if let handle { sb_view_focus(handle, false) } | 859 | if let handle { sb_view_focus(handle, false) } |
| 859 | dirty = true | 860 | dirty = true |
| 860 | onChange?() | 861 | onChange?() |
| ... | @@ -937,22 +938,42 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI | ... | @@ -937,22 +938,42 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI |
| 937 | override func toggleItalics(_ sender: Any?) { apply(1) } | 938 | override func toggleItalics(_ sender: Any?) { apply(1) } |
| 938 | override func toggleUnderline(_ sender: Any?) { apply(2) } | 939 | override func toggleUnderline(_ sender: Any?) { apply(2) } |
| 939 | 940 | ||
| 940 | /// Tags and the highlighter join the system's text actions. | 941 | /// OneNote's highlighter colours, COLORREF, as its Text Highlight Color offers them. |
| 942 | private static let highlights: [(UInt32, String)] = [ | ||
| 943 | (0x00ffff, "Yellow"), (0x00ff00, "Bright Green"), (0xffff00, "Turquoise"), (0xff00ff, "Pink"), | ||
| 944 | (0xff0000, "Blue"), (0x0000ff, "Red"), (0x800000, "Dark Blue"), (0x808000, "Teal"), (0x008000, "Green"), | ||
| 945 | (0x800080, "Violet"), (0x000080, "Dark Red"), (0x008080, "Dark Yellow"), (0x808080, "Gray 50%"), | ||
| 946 | (0xc0c0c0, "Gray 25%"), (0x000000, "Black"), | ||
| 947 | ] | ||
| 948 | |||
| 949 | /// The highlighter leads the system's text actions for a selection, tags follow them. | ||
| 941 | func editMenu(for textRange: UITextRange, suggestedActions: [UIMenuElement]) -> UIMenu? { | 950 | func editMenu(for textRange: UITextRange, suggestedActions: [UIMenuElement]) -> UIMenu? { |
| 942 | guard let handle else { return nil } | 951 | guard let handle else { return nil } |
| 943 | let bits = sb_view_format(handle) | 952 | let bits = sb_view_format(handle) |
| 944 | let tag = UIMenu( | 953 | let tag = UIMenu( |
| 945 | title: "Tag", image: UIImage(systemName: "tag"), | 954 | title: "Tag", image: UIImage(systemName: "tag"), |
| 946 | children: Tags.menu(bits: bits) { [weak self] in self?.apply($0) }) | 955 | children: Tags.menu(bits: bits) { [weak self] in self?.apply($0) }) |
| 947 | var extra: [UIMenuElement] = [tag] | 956 | var highlight: [UIMenuElement] = [] |
| 948 | if !textRange.isEmpty { | 957 | if !textRange.isEmpty { |
| 949 | extra.insert( | 958 | let colors = Self.highlights.map { color, name in |
| 950 | UIAction(title: "Highlight", image: UIImage(systemName: "highlighter")) { [weak self] _ in | 959 | let swatch = UIGraphicsImageRenderer(size: CGSize(width: 20, height: 20)).image { _ in |
| 951 | self?.apply(10) | 960 | let dot = UIBezierPath(ovalIn: CGRect(x: 1, y: 1, width: 18, height: 18)) |
| 952 | }, at: 0) | 961 | UIColor(rgb: [UInt8(color & 0xff), UInt8(color >> 8 & 0xff), UInt8(color >> 16 & 0xff)]).setFill() |
| 962 | dot.fill() | ||
| 963 | UIColor.separator.setStroke() | ||
| 964 | dot.stroke() | ||
| 965 | } | ||
| 966 | return UIAction(title: name, image: swatch.withRenderingMode(.alwaysOriginal)) { [weak self] _ in | ||
| 967 | self?.highlight(Int64(color)) | ||
| 968 | } | ||
| 969 | } | ||
| 970 | let none = UIAction(title: "No Highlight", image: UIImage(systemName: "circle.slash")) { [weak self] _ in | ||
| 971 | self?.highlight(-1) | ||
| 972 | } | ||
| 973 | highlight = [UIMenu(title: "Highlight", image: UIImage(systemName: "highlighter"), children: colors + [none])] | ||
| 953 | } | 974 | } |
| 954 | return UIMenu( | 975 | return UIMenu( |
| 955 | children: spellingActions() + suggestedActions + [UIMenu(options: .displayInline, children: extra)]) | 976 | children: spellingActions() + highlight + suggestedActions + [UIMenu(options: .displayInline, children: [tag])]) |
| 956 | } | 977 | } |
| 957 | 978 | ||
| 958 | private struct Correction: Decodable { | 979 | private struct Correction: Decodable { |
| ... | @@ -1117,6 +1138,12 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI | ... | @@ -1117,6 +1138,12 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI |
| 1117 | edit(external: true) { sb_view_apply(handle, command) } | 1138 | edit(external: true) { sb_view_apply(handle, command) } |
| 1118 | } | 1139 | } |
| 1119 | 1140 | ||
| 1141 | /// Highlights the selection in COLORREF `color`, or takes its highlight away where negative. | ||
| 1142 | func highlight(_ color: Int64) { | ||
| 1143 | guard let handle else { return } | ||
| 1144 | edit(external: true) { sb_view_highlight(handle, color) } | ||
| 1145 | } | ||
| 1146 | |||
| 1120 | /// Gives the selected paragraphs style `place` of OneNote's gallery, in the page's theme. | 1147 | /// Gives the selected paragraphs style `place` of OneNote's gallery, in the page's theme. |
| 1121 | func applyStyle(_ place: UInt8) { | 1148 | func applyStyle(_ place: UInt8) { |
| 1122 | guard let handle else { return } | 1149 | guard let handle else { return } |
apps/ios/Snowbound/FormatBar.swift+304-58| ... | @@ -1,22 +1,11 @@ | ... | @@ -1,22 +1,11 @@ |
| 1 | import UIKit | 1 | import UIKit |
| 2 | 2 | ||
| 3 | /// Formatting above the keyboard, as Notes offers it: To Do first, then text styles, lists, | 3 | /// Formatting above the keyboard, as Notes offers it: from iOS 26 a floating glass bar, the |
| 4 | /// indentation, tags and pictures, the keyboard's dismissal at the end. | 4 | /// keyboard's dismissal always at its right. |
| 5 | final class FormatBar: UIInputView { | 5 | final class FormatBar: UIInputView { |
| 6 | /// `sb_view_apply` commands and `sb_view_format` bits. | ||
| 7 | private static let buttons: [(symbol: String, label: String, command: UInt8)] = [ | ||
| 8 | ("checklist", "To Do", 16), | ||
| 9 | ("bold", "Bold", 0), | ||
| 10 | ("italic", "Italic", 1), | ||
| 11 | ("underline", "Underline", 2), | ||
| 12 | ("strikethrough", "Strikethrough", 3), | ||
| 13 | ("highlighter", "Highlight", 10), | ||
| 14 | ("list.bullet", "Bullets", 4), | ||
| 15 | ("list.number", "Numbering", 5), | ||
| 16 | ("decrease.indent", "Decrease Indent", 7), | ||
| 17 | ("increase.indent", "Increase Indent", 6), | ||
| 18 | ] | ||
| 19 | private var toggles: [(UIButton, UInt8)] = [] | 6 | private var toggles: [(UIButton, UInt8)] = [] |
| 7 | private let tags = FormatBar.button("tag", "Tags") | ||
| 8 | private let list = FormatBar.button("list.bullet", "List") | ||
| 20 | private var bits: UInt64 = 0 | 9 | private var bits: UInt64 = 0 |
| 21 | var onApply: ((UInt8) -> Void)? | 10 | var onApply: ((UInt8) -> Void)? |
| 22 | /// Applies a style of OneNote's gallery, by place; `stylePlace` names the selection's. | 11 | /// Applies a style of OneNote's gallery, by place; `stylePlace` names the selection's. |
| ... | @@ -27,20 +16,17 @@ final class FormatBar: UIInputView { | ... | @@ -27,20 +16,17 @@ final class FormatBar: UIInputView { |
| 27 | "Heading 1", "Heading 2", "Heading 3", "Heading 4", "Heading 5", "Heading 6", | 16 | "Heading 1", "Heading 2", "Heading 3", "Heading 4", "Heading 5", "Heading 6", |
| 28 | "Page Title", "Citation", "Quote", "Code", "Normal", | 17 | "Page Title", "Citation", "Quote", "Code", "Normal", |
| 29 | ] | 18 | ] |
| 30 | /// Asks for a picture from the camera (true) or the photo library. | 19 | /// What `+` offers to insert, asked for as it opens. |
| 31 | var onPicture: ((Bool) -> Void)? | 20 | var insert: (() -> [UIMenuElement])? |
| 21 | var onPaper: (() -> Void)? | ||
| 32 | var onDismiss: (() -> Void)? | 22 | var onDismiss: (() -> Void)? |
| 33 | 23 | ||
| 34 | init() { | 24 | init() { |
| 35 | super.init(frame: CGRect(x: 0, y: 0, width: 0, height: 48), inputViewStyle: .keyboard) | 25 | let floating = if #available(iOS 26, *) { true } else { false } |
| 26 | super.init( | ||
| 27 | frame: CGRect(x: 0, y: 0, width: 0, height: floating ? 60 : 48), | ||
| 28 | inputViewStyle: floating ? .default : .keyboard) | ||
| 36 | allowsSelfSizing = true | 29 | allowsSelfSizing = true |
| 37 | // Over the page when a hardware keyboard hides the software one. | ||
| 38 | let backdrop = UIVisualEffectView(effect: UIBlurEffect(style: .systemChromeMaterial)) | ||
| 39 | backdrop.frame = bounds | ||
| 40 | backdrop.autoresizingMask = [.flexibleWidth, .flexibleHeight] | ||
| 41 | addSubview(backdrop) | ||
| 42 | let row = UIStackView() | ||
| 43 | row.spacing = 2 | ||
| 44 | let styles = Self.button("textformat", "Styles") | 30 | let styles = Self.button("textformat", "Styles") |
| 45 | styles.menu = UIMenu(children: [ | 31 | styles.menu = UIMenu(children: [ |
| 46 | UIDeferredMenuElement.uncached { [weak self] done in | 32 | UIDeferredMenuElement.uncached { [weak self] done in |
| ... | @@ -53,65 +39,112 @@ final class FormatBar: UIInputView { | ... | @@ -53,65 +39,112 @@ final class FormatBar: UIInputView { |
| 53 | } | 39 | } |
| 54 | ]) | 40 | ]) |
| 55 | styles.showsMenuAsPrimaryAction = true | 41 | styles.showsMenuAsPrimaryAction = true |
| 56 | row.addArrangedSubview(styles) | 42 | tags.addAction(UIAction { [weak self] _ in self?.showTags() }, for: .primaryActionTriggered) |
| 57 | for (symbol, label, command) in Self.buttons { | 43 | list.menu = UIMenu(children: [ |
| 58 | let button = Self.button(symbol, label) | ||
| 59 | button.addAction(UIAction { [weak self] _ in self?.onApply?(command) }, for: .primaryActionTriggered) | ||
| 60 | toggles.append((button, command)) | ||
| 61 | row.addArrangedSubview(button) | ||
| 62 | } | ||
| 63 | let tag = Self.button("tag", "Tags") | ||
| 64 | tag.menu = UIMenu(children: [ | ||
| 65 | UIDeferredMenuElement.uncached { [weak self] done in | 44 | UIDeferredMenuElement.uncached { [weak self] done in |
| 66 | done(Tags.menu(bits: self?.bits ?? 0) { self?.onApply?($0) }) | 45 | let bits = self?.bits ?? 0 |
| 46 | done([("list.bullet", "Bulleted List", 4), ("list.number", "Numbered List", 5)].map { | ||
| 47 | symbol, title, command in | ||
| 48 | UIAction( | ||
| 49 | title: title, image: UIImage(systemName: symbol), | ||
| 50 | state: bits & 1 << UInt64(command) != 0 ? .on : .off | ||
| 51 | ) { _ in self?.onApply?(UInt8(command)) } | ||
| 52 | }) | ||
| 67 | } | 53 | } |
| 68 | ]) | 54 | ]) |
| 69 | tag.showsMenuAsPrimaryAction = true | 55 | list.showsMenuAsPrimaryAction = true |
| 70 | row.addArrangedSubview(tag) | 56 | let insert = Self.button("plus", "Insert") |
| 71 | let picture = Self.button("photo", "Insert Picture") | 57 | insert.menu = UIMenu(children: [ |
| 72 | picture.menu = UIMenu(children: [ | 58 | UIDeferredMenuElement.uncached { [weak self] done in done(self?.insert?() ?? []) } |
| 73 | UIAction(title: "Photo Library", image: UIImage(systemName: "photo.on.rectangle")) { [weak self] _ in | ||
| 74 | self?.onPicture?(false) | ||
| 75 | }, | ||
| 76 | UIAction( | ||
| 77 | title: "Take Photo", image: UIImage(systemName: "camera"), | ||
| 78 | attributes: UIImagePickerController.isSourceTypeAvailable(.camera) ? [] : .disabled | ||
| 79 | ) { [weak self] _ in self?.onPicture?(true) }, | ||
| 80 | ]) | 59 | ]) |
| 81 | picture.showsMenuAsPrimaryAction = true | 60 | insert.showsMenuAsPrimaryAction = true |
| 82 | row.addArrangedSubview(picture) | 61 | let paper = Self.button("paintpalette", "Page Background") |
| 62 | paper.addAction(UIAction { [weak self] _ in self?.onPaper?() }, for: .primaryActionTriggered) | ||
| 63 | // What fits a phone held upright comes first; the rest scrolls into view. | ||
| 64 | let row = UIStackView(arrangedSubviews: [ | ||
| 65 | styles, tags, command("bold", "Bold", 0), command("italic", "Italic", 1), | ||
| 66 | command("underline", "Underline", 2), list, insert, command("strikethrough", "Strikethrough", 3), | ||
| 67 | command("decrease.indent", "Decrease Indent", 7), command("increase.indent", "Increase Indent", 6), | ||
| 68 | paper, | ||
| 69 | ]) | ||
| 70 | row.spacing = 2 | ||
| 83 | let scroll = UIScrollView() | 71 | let scroll = UIScrollView() |
| 84 | scroll.showsHorizontalScrollIndicator = false | 72 | scroll.showsHorizontalScrollIndicator = false |
| 85 | scroll.addSubview(row) | 73 | scroll.addSubview(row) |
| 86 | let dismiss = Self.button("keyboard.chevron.compact.down", "Hide Keyboard") | 74 | let dismiss = Self.button("keyboard.chevron.compact.down", "Hide Keyboard") |
| 87 | dismiss.addAction(UIAction { [weak self] _ in self?.onDismiss?() }, for: .primaryActionTriggered) | 75 | dismiss.addAction(UIAction { [weak self] _ in self?.onDismiss?() }, for: .primaryActionTriggered) |
| 88 | for view in [scroll, dismiss, row] as [UIView] { view.translatesAutoresizingMaskIntoConstraints = false } | 76 | for view in [scroll, dismiss, row] as [UIView] { view.translatesAutoresizingMaskIntoConstraints = false } |
| 89 | addSubview(scroll) | ||
| 90 | addSubview(dismiss) | ||
| 91 | NSLayoutConstraint.activate([ | 77 | NSLayoutConstraint.activate([ |
| 92 | heightAnchor.constraint(equalToConstant: 48), | ||
| 93 | scroll.leadingAnchor.constraint(equalTo: safeAreaLayoutGuide.leadingAnchor, constant: 4), | ||
| 94 | scroll.topAnchor.constraint(equalTo: topAnchor), | ||
| 95 | scroll.bottomAnchor.constraint(equalTo: bottomAnchor), | ||
| 96 | scroll.trailingAnchor.constraint(equalTo: dismiss.leadingAnchor, constant: -4), | ||
| 97 | dismiss.trailingAnchor.constraint(equalTo: safeAreaLayoutGuide.trailingAnchor, constant: -4), | ||
| 98 | dismiss.centerYAnchor.constraint(equalTo: centerYAnchor), | ||
| 99 | row.leadingAnchor.constraint(equalTo: scroll.contentLayoutGuide.leadingAnchor), | 78 | row.leadingAnchor.constraint(equalTo: scroll.contentLayoutGuide.leadingAnchor), |
| 100 | row.trailingAnchor.constraint(equalTo: scroll.contentLayoutGuide.trailingAnchor), | 79 | row.trailingAnchor.constraint(equalTo: scroll.contentLayoutGuide.trailingAnchor), |
| 101 | row.topAnchor.constraint(equalTo: scroll.contentLayoutGuide.topAnchor), | 80 | row.topAnchor.constraint(equalTo: scroll.contentLayoutGuide.topAnchor), |
| 102 | row.bottomAnchor.constraint(equalTo: scroll.contentLayoutGuide.bottomAnchor), | 81 | row.bottomAnchor.constraint(equalTo: scroll.contentLayoutGuide.bottomAnchor), |
| 103 | row.heightAnchor.constraint(equalTo: scroll.frameLayoutGuide.heightAnchor), | 82 | row.heightAnchor.constraint(equalTo: scroll.frameLayoutGuide.heightAnchor), |
| 104 | ]) | 83 | ]) |
| 84 | if #available(iOS 26, *) { | ||
| 85 | let capsule = Self.glass(scroll) | ||
| 86 | let circle = Self.glass(dismiss) | ||
| 87 | (circle.effect as? UIGlassEffect)?.isInteractive = true | ||
| 88 | addSubview(capsule) | ||
| 89 | addSubview(circle) | ||
| 90 | // The capsule fits its buttons where they fit, as Notes' does. | ||
| 91 | let fitted = scroll.frameLayoutGuide.widthAnchor.constraint(equalTo: scroll.contentLayoutGuide.widthAnchor) | ||
| 92 | fitted.priority = .defaultHigh | ||
| 93 | NSLayoutConstraint.activate([ | ||
| 94 | fitted, | ||
| 95 | capsule.leadingAnchor.constraint(equalTo: safeAreaLayoutGuide.leadingAnchor, constant: 16), | ||
| 96 | capsule.trailingAnchor.constraint(lessThanOrEqualTo: circle.leadingAnchor, constant: -8), | ||
| 97 | capsule.topAnchor.constraint(equalTo: topAnchor, constant: 4), | ||
| 98 | capsule.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -8), | ||
| 99 | scroll.leadingAnchor.constraint(equalTo: capsule.contentView.leadingAnchor, constant: 4), | ||
| 100 | scroll.trailingAnchor.constraint(equalTo: capsule.contentView.trailingAnchor, constant: -4), | ||
| 101 | scroll.topAnchor.constraint(equalTo: capsule.contentView.topAnchor), | ||
| 102 | scroll.bottomAnchor.constraint(equalTo: capsule.contentView.bottomAnchor), | ||
| 103 | circle.trailingAnchor.constraint(equalTo: safeAreaLayoutGuide.trailingAnchor, constant: -16), | ||
| 104 | circle.centerYAnchor.constraint(equalTo: capsule.centerYAnchor), | ||
| 105 | circle.widthAnchor.constraint(equalTo: capsule.heightAnchor), | ||
| 106 | circle.heightAnchor.constraint(equalTo: capsule.heightAnchor), | ||
| 107 | dismiss.centerXAnchor.constraint(equalTo: circle.contentView.centerXAnchor), | ||
| 108 | dismiss.centerYAnchor.constraint(equalTo: circle.contentView.centerYAnchor), | ||
| 109 | dismiss.heightAnchor.constraint(equalTo: circle.contentView.heightAnchor), | ||
| 110 | ]) | ||
| 111 | } else { | ||
| 112 | // Over the page when a hardware keyboard hides the software one. | ||
| 113 | let backdrop = UIVisualEffectView(effect: UIBlurEffect(style: .systemChromeMaterial)) | ||
| 114 | backdrop.frame = bounds | ||
| 115 | backdrop.autoresizingMask = [.flexibleWidth, .flexibleHeight] | ||
| 116 | addSubview(backdrop) | ||
| 117 | addSubview(scroll) | ||
| 118 | addSubview(dismiss) | ||
| 119 | NSLayoutConstraint.activate([ | ||
| 120 | heightAnchor.constraint(equalToConstant: 48), | ||
| 121 | scroll.leadingAnchor.constraint(equalTo: safeAreaLayoutGuide.leadingAnchor, constant: 4), | ||
| 122 | scroll.topAnchor.constraint(equalTo: topAnchor), | ||
| 123 | scroll.bottomAnchor.constraint(equalTo: bottomAnchor), | ||
| 124 | scroll.trailingAnchor.constraint(equalTo: dismiss.leadingAnchor, constant: -4), | ||
| 125 | dismiss.trailingAnchor.constraint(equalTo: safeAreaLayoutGuide.trailingAnchor, constant: -4), | ||
| 126 | dismiss.centerYAnchor.constraint(equalTo: centerYAnchor), | ||
| 127 | ]) | ||
| 128 | } | ||
| 105 | } | 129 | } |
| 106 | 130 | ||
| 107 | required init?(coder: NSCoder) { fatalError() } | 131 | required init?(coder: NSCoder) { fatalError() } |
| 108 | 132 | ||
| 133 | @available(iOS 26, *) | ||
| 134 | private static func glass(_ content: UIView) -> UIVisualEffectView { | ||
| 135 | let glass = UIVisualEffectView(effect: UIGlassEffect()) | ||
| 136 | glass.cornerConfiguration = .capsule() | ||
| 137 | glass.translatesAutoresizingMaskIntoConstraints = false | ||
| 138 | glass.contentView.addSubview(content) | ||
| 139 | return glass | ||
| 140 | } | ||
| 141 | |||
| 109 | private static func button(_ symbol: String, _ label: String) -> UIButton { | 142 | private static func button(_ symbol: String, _ label: String) -> UIButton { |
| 110 | var configuration = UIButton.Configuration.plain() | 143 | var configuration = UIButton.Configuration.plain() |
| 111 | configuration.image = UIImage(systemName: symbol) | 144 | configuration.image = UIImage(systemName: symbol) |
| 112 | configuration.preferredSymbolConfigurationForImage = UIImage.SymbolConfiguration(pointSize: 17) | 145 | configuration.preferredSymbolConfigurationForImage = UIImage.SymbolConfiguration(pointSize: 17) |
| 113 | configuration.baseForegroundColor = .label | 146 | configuration.baseForegroundColor = .label |
| 114 | configuration.cornerStyle = .medium | 147 | configuration.cornerStyle = .capsule |
| 115 | let button = UIButton(configuration: configuration) | 148 | let button = UIButton(configuration: configuration) |
| 116 | button.accessibilityLabel = label | 149 | button.accessibilityLabel = label |
| 117 | button.widthAnchor.constraint(equalToConstant: 42).isActive = true | 150 | button.widthAnchor.constraint(equalToConstant: 42).isActive = true |
| ... | @@ -123,11 +156,224 @@ final class FormatBar: UIInputView { | ... | @@ -123,11 +156,224 @@ final class FormatBar: UIInputView { |
| 123 | return button | 156 | return button |
| 124 | } | 157 | } |
| 125 | 158 | ||
| 159 | /// A button for `sb_view_apply` `command`, marked while the selection has it. | ||
| 160 | private func command(_ symbol: String, _ label: String, _ command: UInt8) -> UIButton { | ||
| 161 | let button = Self.button(symbol, label) | ||
| 162 | button.addAction(UIAction { [weak self] _ in self?.onApply?(command) }, for: .primaryActionTriggered) | ||
| 163 | toggles.append((button, command)) | ||
| 164 | return button | ||
| 165 | } | ||
| 166 | |||
| 167 | private func showTags() { | ||
| 168 | guard let scene = window?.windowScene else { return } | ||
| 169 | let grid = TagGrid(bits: bits, short: traitCollection.verticalSizeClass == .compact) { [weak self] in | ||
| 170 | self?.onApply?($0) | ||
| 171 | } | ||
| 172 | // Over the bar but never the keyboard, which covers every window of the app. | ||
| 173 | grid.show( | ||
| 174 | in: scene, around: tags.convert(CGPoint(x: tags.bounds.midX, y: tags.bounds.midY), to: nil), | ||
| 175 | above: convert(CGPoint(x: 0, y: bounds.maxY), to: nil).y) | ||
| 176 | } | ||
| 177 | |||
| 126 | /// Marks what the selection has, from `sb_view_format`. | 178 | /// Marks what the selection has, from `sb_view_format`. |
| 127 | func show(_ bits: UInt64) { | 179 | func show(_ bits: UInt64) { |
| 128 | self.bits = bits | 180 | self.bits = bits |
| 129 | for (button, command) in toggles { | 181 | for (button, command) in toggles { |
| 130 | button.isSelected = bits & (1 << UInt64(command)) != 0 | 182 | button.isSelected = bits & (1 << UInt64(command)) != 0 |
| 131 | } | 183 | } |
| 184 | tags.isSelected = bits >> 16 != 0 | ||
| 185 | let numbered = bits & 1 << 5 != 0 | ||
| 186 | list.isSelected = numbered || bits & 1 << 4 != 0 | ||
| 187 | list.configuration?.image = UIImage(systemName: numbered ? "list.number" : "list.bullet") | ||
| 188 | } | ||
| 189 | } | ||
| 190 | |||
| 191 | /// A window that leaves the keyboard with the page it types on. | ||
| 192 | private final class Overlay: UIWindow { | ||
| 193 | override var canBecomeKey: Bool { false } | ||
| 194 | } | ||
| 195 | |||
| 196 | /// OneNote's first nine tags in a grid centred on the Tags button, so a thumb reaches each, | ||
| 197 | /// then the rest and Remove Tag; beside the grid rather than below it on a short screen. | ||
| 198 | final class TagGrid: UIView, UIGestureRecognizerDelegate { | ||
| 199 | private(set) static weak var shown: TagGrid? | ||
| 200 | private let panel: UIVisualEffectView | ||
| 201 | private let grid = UIStackView() | ||
| 202 | private let apply: (UInt8) -> Void | ||
| 203 | private var laidOut = CGSize.zero | ||
| 204 | private var overlay: UIWindow? | ||
| 205 | |||
| 206 | init(bits: UInt64, short: Bool, apply: @escaping (UInt8) -> Void) { | ||
| 207 | self.apply = apply | ||
| 208 | if #available(iOS 26, *) { | ||
| 209 | panel = UIVisualEffectView(effect: UIGlassEffect()) | ||
| 210 | panel.cornerConfiguration = .uniformCorners(radius: .fixed(28)) | ||
| 211 | } else { | ||
| 212 | panel = UIVisualEffectView(effect: UIBlurEffect(style: .systemThickMaterial)) | ||
| 213 | panel.layer.cornerRadius = 20 | ||
| 214 | panel.clipsToBounds = true | ||
| 215 | } | ||
| 216 | super.init(frame: .zero) | ||
| 217 | accessibilityViewIsModal = true | ||
| 218 | let outside = UITapGestureRecognizer(target: self, action: #selector(close)) | ||
| 219 | outside.delegate = self | ||
| 220 | addGestureRecognizer(outside) | ||
| 221 | layer.shadowColor = UIColor.black.cgColor | ||
| 222 | layer.shadowOpacity = 0.18 | ||
| 223 | layer.shadowRadius = 24 | ||
| 224 | grid.axis = .vertical | ||
| 225 | let tags = Array(Tags.all.prefix(Tags.keyed).enumerated()) | ||
| 226 | for start in stride(from: 0, to: tags.count, by: 3) { | ||
| 227 | let line = UIStackView(arrangedSubviews: tags[start..<min(start + 3, tags.count)].map { index, tag in | ||
| 228 | tile(tag, command: 16 + UInt8(index), on: bits & 1 << UInt64(16 + index) != 0, short: short) | ||
| 229 | }) | ||
| 230 | line.distribution = .fillEqually | ||
| 231 | grid.addArrangedSubview(line) | ||
| 232 | } | ||
| 233 | let more = Self.row("More Tags…", "tag") | ||
| 234 | more.menu = UIMenu(children: Array(Tags.actions(bits: bits) { [weak self] in self?.choose($0) }.dropFirst(Tags.keyed))) | ||
| 235 | more.showsMenuAsPrimaryAction = true | ||
| 236 | let remove = Self.row("Remove Tag", "tag.slash") | ||
| 237 | remove.isEnabled = bits >> 16 != 0 | ||
| 238 | remove.addAction(UIAction { [weak self] _ in self?.choose(8) }, for: .primaryActionTriggered) | ||
| 239 | let separator = UIView() | ||
| 240 | separator.backgroundColor = .separator | ||
| 241 | (short ? separator.widthAnchor : separator.heightAnchor) | ||
| 242 | .constraint(equalToConstant: 1 / UIScreen.main.scale).isActive = true | ||
| 243 | let actions = UIStackView(arrangedSubviews: [more, remove]) | ||
| 244 | actions.distribution = short ? .fill : .fillEqually | ||
| 245 | actions.axis = short ? .vertical : .horizontal | ||
| 246 | let stack = UIStackView(arrangedSubviews: [grid, separator, actions]) | ||
| 247 | stack.axis = short ? .horizontal : .vertical | ||
| 248 | stack.alignment = short ? .top : .fill | ||
| 249 | separator.heightAnchor.constraint(equalTo: grid.heightAnchor).isActive = short | ||
| 250 | stack.spacing = 4 | ||
| 251 | stack.translatesAutoresizingMaskIntoConstraints = false | ||
| 252 | panel.contentView.addSubview(stack) | ||
| 253 | NSLayoutConstraint.activate([ | ||
| 254 | stack.leadingAnchor.constraint(equalTo: panel.contentView.leadingAnchor, constant: 8), | ||
| 255 | stack.trailingAnchor.constraint(equalTo: panel.contentView.trailingAnchor, constant: -8), | ||
| 256 | stack.topAnchor.constraint(equalTo: panel.contentView.topAnchor, constant: 8), | ||
| 257 | stack.bottomAnchor.constraint(equalTo: panel.contentView.bottomAnchor, constant: -8), | ||
| 258 | grid.widthAnchor.constraint(equalToConstant: 3 * (short ? 150 : 100)), | ||
| 259 | ]) | ||
| 260 | addSubview(panel) | ||
| 261 | } | ||
| 262 | |||
| 263 | required init?(coder: NSCoder) { fatalError() } | ||
| 264 | |||
| 265 | private func tile(_ tag: Tags.Tag, command: UInt8, on: Bool, short: Bool) -> UIButton { | ||
| 266 | var configuration = UIButton.Configuration.plain() | ||
| 267 | configuration.image = Tags.icon(tag.shape, highlight: tag.highlight, points: 24) | ||
| 268 | configuration.title = tag.name | ||
| 269 | configuration.imagePlacement = short ? .leading : .top | ||
| 270 | configuration.imagePadding = 6 | ||
| 271 | configuration.titleAlignment = short ? .leading : .center | ||
| 272 | configuration.titleLineBreakMode = .byWordWrapping | ||
| 273 | configuration.titleTextAttributesTransformer = UIConfigurationTextAttributesTransformer { | ||
| 274 | var attributes = $0 | ||
| 275 | attributes.font = .preferredFont(forTextStyle: .caption1) | ||
| 276 | return attributes | ||
| 277 | } | ||
| 278 | configuration.baseForegroundColor = .label | ||
| 279 | configuration.cornerStyle = .large | ||
| 280 | configuration.contentInsets = NSDirectionalEdgeInsets( | ||
| 281 | top: short ? 0 : 10, leading: short ? 8 : 4, bottom: short ? 0 : 8, trailing: 4) | ||
| 282 | let button = UIButton(configuration: configuration) | ||
| 283 | if short { button.contentHorizontalAlignment = .leading } | ||
| 284 | button.isSelected = on | ||
| 285 | button.configurationUpdateHandler = { button in | ||
| 286 | button.configuration?.background.backgroundColor = | ||
| 287 | button.isSelected ? UIColor.tintColor.withAlphaComponent(0.18) : .clear | ||
| 288 | } | ||
| 289 | button.heightAnchor.constraint(equalToConstant: short ? 44 : 72).isActive = true | ||
| 290 | button.addAction(UIAction { [weak self] _ in self?.choose(command) }, for: .primaryActionTriggered) | ||
| 291 | return button | ||
| 292 | } | ||
| 293 | |||
| 294 | private static func row(_ title: String, _ symbol: String) -> UIButton { | ||
| 295 | var configuration = UIButton.Configuration.plain() | ||
| 296 | configuration.title = title | ||
| 297 | configuration.image = UIImage(systemName: symbol) | ||
| 298 | configuration.imagePadding = 6 | ||
| 299 | configuration.baseForegroundColor = .label | ||
| 300 | configuration.cornerStyle = .large | ||
| 301 | configuration.contentInsets = NSDirectionalEdgeInsets(top: 0, leading: 8, bottom: 0, trailing: 8) | ||
| 302 | configuration.titleLineBreakMode = .byTruncatingTail | ||
| 303 | configuration.titleTextAttributesTransformer = UIConfigurationTextAttributesTransformer { | ||
| 304 | var attributes = $0 | ||
| 305 | attributes.font = .preferredFont(forTextStyle: .subheadline) | ||
| 306 | return attributes | ||
| 307 | } | ||
| 308 | let button = UIButton(configuration: configuration) | ||
| 309 | button.heightAnchor.constraint(equalToConstant: 44).isActive = true | ||
| 310 | return button | ||
| 311 | } | ||
| 312 | |||
| 313 | /// Opens with the grid's middle at `point`, in screen points, as near as it fits on screen | ||
| 314 | /// above `bottom`. | ||
| 315 | func show(in scene: UIWindowScene, around point: CGPoint, above bottom: CGFloat) { | ||
| 316 | Self.shown?.close() | ||
| 317 | Self.shown = self | ||
| 318 | // Over the format bar's window. | ||
| 319 | let window = Overlay(windowScene: scene) | ||
| 320 | window.windowLevel = .alert | ||
| 321 | let root = UIViewController() | ||
| 322 | root.view = self | ||
| 323 | window.rootViewController = root | ||
| 324 | window.isHidden = false | ||
| 325 | overlay = window | ||
| 326 | let size = panel.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize) | ||
| 327 | panel.bounds.size = size | ||
| 328 | panel.layoutIfNeeded() | ||
| 329 | let middle = grid.convert(CGPoint(x: grid.bounds.midX, y: grid.bounds.midY), to: panel) | ||
| 330 | var room = window.bounds.inset(by: window.safeAreaInsets).insetBy(dx: 8, dy: 8) | ||
| 331 | room.size.height = min(room.maxY, bottom - 8) - room.minY | ||
| 332 | let origin = CGPoint( | ||
| 333 | x: min(max(point.x - middle.x, room.minX), room.maxX - size.width), | ||
| 334 | y: min(max(point.y - middle.y, room.minY), room.maxY - size.height)) | ||
| 335 | panel.frame = CGRect(origin: origin, size: size) | ||
| 336 | laidOut = bounds.size | ||
| 337 | panel.alpha = 0 | ||
| 338 | if !UIAccessibility.isReduceMotionEnabled { | ||
| 339 | // Grows from the finger, as menus do. | ||
| 340 | let finger = CGPoint(x: point.x - panel.center.x, y: point.y - panel.center.y) | ||
| 341 | panel.transform = CGAffineTransform(translationX: finger.x, y: finger.y).scaledBy(x: 0.6, y: 0.6) | ||
| 342 | .translatedBy(x: -finger.x, y: -finger.y) | ||
| 343 | } | ||
| 344 | UIView.animate(withDuration: 0.35, delay: 0, usingSpringWithDamping: 0.82, initialSpringVelocity: 0) { | ||
| 345 | self.panel.alpha = 1 | ||
| 346 | self.panel.transform = .identity | ||
| 347 | } | ||
| 348 | UIAccessibility.post(notification: .screenChanged, argument: grid.arrangedSubviews.first?.subviews.first) | ||
| 349 | } | ||
| 350 | |||
| 351 | override func layoutSubviews() { | ||
| 352 | super.layoutSubviews() | ||
| 353 | // Turning the device moves the button out from under the grid. | ||
| 354 | if laidOut != .zero, bounds.size != laidOut { close() } | ||
| 355 | } | ||
| 356 | |||
| 357 | func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool { | ||
| 358 | touch.view === self | ||
| 359 | } | ||
| 360 | |||
| 361 | private func choose(_ command: UInt8) { | ||
| 362 | apply(command) | ||
| 363 | close() | ||
| 364 | } | ||
| 365 | |||
| 366 | @objc func close() { | ||
| 367 | guard overlay != nil else { return } | ||
| 368 | laidOut = .zero | ||
| 369 | UIView.animate(withDuration: 0.15, animations: { self.alpha = 0 }) { _ in | ||
| 370 | self.overlay?.isHidden = true | ||
| 371 | self.overlay = nil | ||
| 372 | } | ||
| 373 | } | ||
| 374 | |||
| 375 | override func accessibilityPerformEscape() -> Bool { | ||
| 376 | close() | ||
| 377 | return true | ||
| 132 | } | 378 | } |
| 133 | } | 379 | } |
apps/ios/Snowbound/PageViewController.swift+48-52| ... | @@ -29,8 +29,6 @@ final class PageViewController: UIViewController, PHPickerViewControllerDelegate | ... | @@ -29,8 +29,6 @@ final class PageViewController: UIViewController, PHPickerViewControllerDelegate |
| 29 | private var reader: UIBarButtonItem? | 29 | private var reader: UIBarButtonItem? |
| 30 | /// Opens another page or conflict page of the section. | 30 | /// Opens another page or conflict page of the section. |
| 31 | var onOpen: ((String) -> Void)? | 31 | var onOpen: ((String) -> Void)? |
| 32 | private lazy var done = UIBarButtonItem( | ||
| 33 | systemItem: .done, primaryAction: UIAction { [weak self] _ in _ = self?.canvas.resignFirstResponder() }) | ||
| 34 | private lazy var undo = UIBarButtonItem( | 32 | private lazy var undo = UIBarButtonItem( |
| 35 | title: "undo", image: UIImage(systemName: "arrow.uturn.backward"), primaryAction: UIAction { [weak self] _ in self?.canvas.undo(redo: false) }) | 33 | title: "undo", image: UIImage(systemName: "arrow.uturn.backward"), primaryAction: UIAction { [weak self] _ in self?.canvas.undo(redo: false) }) |
| 36 | private lazy var redo = UIBarButtonItem( | 34 | private lazy var redo = UIBarButtonItem( |
| ... | @@ -52,7 +50,6 @@ final class PageViewController: UIViewController, PHPickerViewControllerDelegate | ... | @@ -52,7 +50,6 @@ final class PageViewController: UIViewController, PHPickerViewControllerDelegate |
| 52 | self.reveal = reveal | 50 | self.reveal = reveal |
| 53 | self.titleFocus = titleFocus | 51 | self.titleFocus = titleFocus |
| 54 | super.init(nibName: nil, bundle: nil) | 52 | super.init(nibName: nil, bundle: nil) |
| 55 | done.style = .plain | ||
| 56 | navigationItem.largeTitleDisplayMode = .never | 53 | navigationItem.largeTitleDisplayMode = .never |
| 57 | navigationItem.rightBarButtonItems = [more, draw] | 54 | navigationItem.rightBarButtonItems = [more, draw] |
| 58 | showTitle(section.row(of: page)?.row.title ?? "") | 55 | showTitle(section.row(of: page)?.row.title ?? "") |
| ... | @@ -92,7 +89,12 @@ final class PageViewController: UIViewController, PHPickerViewControllerDelegate | ... | @@ -92,7 +89,12 @@ final class PageViewController: UIViewController, PHPickerViewControllerDelegate |
| 92 | media.onPause = { [weak self] in self?.pauseMedia() } | 89 | media.onPause = { [weak self] in self?.pauseMedia() } |
| 93 | media.onStop = { [weak self] in self?.stopMedia() } | 90 | media.onStop = { [weak self] in self?.stopMedia() } |
| 94 | NotificationCenter.default.addObserver(self, selector: #selector(showMedia), name: Recorder.changed, object: nil) | 91 | NotificationCenter.default.addObserver(self, selector: #selector(showMedia), name: Recorder.changed, object: nil) |
| 95 | canvas.formatBar.onPicture = { [weak self] camera in self?.pickPicture(camera: camera) } | 92 | canvas.formatBar.insert = { [weak self] in self?.insertActions() ?? [] } |
| 93 | canvas.formatBar.onPaper = { [weak self] in | ||
| 94 | // The keyboard would cover the picker's sheet. | ||
| 95 | _ = self?.canvas.resignFirstResponder() | ||
| 96 | self?.showPaper() | ||
| 97 | } | ||
| 96 | bar.onAction = { [weak self] in self?.conflictAction() } | 98 | bar.onAction = { [weak self] in self?.conflictAction() } |
| 97 | NotificationCenter.default.addObserver( | 99 | NotificationCenter.default.addObserver( |
| 98 | self, selector: #selector(sectionChanged), name: Section.changed, object: section) | 100 | self, selector: #selector(sectionChanged), name: Section.changed, object: section) |
| ... | @@ -134,7 +136,8 @@ final class PageViewController: UIViewController, PHPickerViewControllerDelegate | ... | @@ -134,7 +136,8 @@ final class PageViewController: UIViewController, PHPickerViewControllerDelegate |
| 134 | navigationItem.titleView = crowded ? UIView() : nil | 136 | navigationItem.titleView = crowded ? UIView() : nil |
| 135 | } | 137 | } |
| 136 | 138 | ||
| 137 | /// Notes' checkmark ends editing and puts the keyboard away; undo and redo sit beside it. | 139 | /// Undo and redo join the bar while editing or drawing; the format bar puts the keyboard |
| 140 | /// away. | ||
| 138 | private func editingChanged() { | 141 | private func editingChanged() { |
| 139 | if !canvas.readOnly, let title = canvas.pageTitle { | 142 | if !canvas.readOnly, let title = canvas.pageTitle { |
| 140 | showTitle(title) | 143 | showTitle(title) |
| ... | @@ -152,60 +155,16 @@ final class PageViewController: UIViewController, PHPickerViewControllerDelegate | ... | @@ -152,60 +155,16 @@ final class PageViewController: UIViewController, PHPickerViewControllerDelegate |
| 152 | if navigationItem.rightBarButtonItems != resting { navigationItem.rightBarButtonItems = resting } | 155 | if navigationItem.rightBarButtonItems != resting { navigationItem.rightBarButtonItems = resting } |
| 153 | return | 156 | return |
| 154 | } | 157 | } |
| 155 | let items = canvas.isFirstResponder ? [done, more, draw, redo, undo] : [more, draw, redo, undo] | 158 | let editing = [more, draw, redo, undo] |
| 156 | if navigationItem.rightBarButtonItems?.first !== items.first | 159 | if navigationItem.rightBarButtonItems != editing { navigationItem.rightBarButtonItems = editing } |
| 157 | || navigationItem.rightBarButtonItems?.count != items.count | ||
| 158 | { | ||
| 159 | navigationItem.rightBarButtonItems = items | ||
| 160 | } | ||
| 161 | } | 160 | } |
| 162 | 161 | ||
| 163 | // MARK: Page menu | 162 | // MARK: Page menu |
| 164 | 163 | ||
| 165 | /// The desktop's page and insert commands that suit a touch screen. | 164 | /// The desktop's page commands that suit a touch screen. |
| 166 | private func pageMenu() -> [UIMenuElement] { | 165 | private func pageMenu() -> [UIMenuElement] { |
| 167 | let editable: UIMenuElement.Attributes = canvas.readOnly ? .disabled : [] | ||
| 168 | let scene = view.window?.windowScene?.delegate as? SceneDelegate | 166 | let scene = view.window?.windowScene?.delegate as? SceneDelegate |
| 169 | let insert = UIMenu( | ||
| 170 | title: "Insert", image: UIImage(systemName: "plus.circle"), | ||
| 171 | children: [ | ||
| 172 | UIAction(title: "Photo Library", image: UIImage(systemName: "photo.on.rectangle"), attributes: editable) { | ||
| 173 | [weak self] _ in self?.pickPicture(camera: false) | ||
| 174 | }, | ||
| 175 | UIAction( | ||
| 176 | title: "Take Photo", image: UIImage(systemName: "camera"), | ||
| 177 | attributes: UIImagePickerController.isSourceTypeAvailable(.camera) ? editable : .disabled | ||
| 178 | ) { [weak self] _ in self?.pickPicture(camera: true) }, | ||
| 179 | UIMenu(options: .displayInline, children: [ | ||
| 180 | UIAction(title: "Date", image: UIImage(systemName: "calendar"), attributes: editable) { | ||
| 181 | [weak self] _ in self?.canvas.insertDate(true, time: false) | ||
| 182 | }, | ||
| 183 | UIAction(title: "Time", image: UIImage(systemName: "clock"), attributes: editable) { | ||
| 184 | [weak self] _ in self?.canvas.insertDate(false, time: true) | ||
| 185 | }, | ||
| 186 | UIAction(title: "Date & Time", image: UIImage(systemName: "calendar.badge.clock"), attributes: editable) { | ||
| 187 | [weak self] _ in self?.canvas.insertDate(true, time: true) | ||
| 188 | }, | ||
| 189 | ]), | ||
| 190 | UIAction( | ||
| 191 | title: "Space", image: UIImage(systemName: "arrow.up.and.down.text.horizontal"), attributes: editable | ||
| 192 | ) { [weak self] _ in self?.canvas.insertSpace() }, | ||
| 193 | UIMenu(options: .displayInline, children: [ | ||
| 194 | UIAction( | ||
| 195 | title: "Record Audio", image: UIImage(systemName: "mic"), | ||
| 196 | attributes: Recorder.current == nil ? editable : .disabled | ||
| 197 | ) { [weak self] _ in self?.record(video: false) }, | ||
| 198 | UIAction( | ||
| 199 | title: "Record Video", image: UIImage(systemName: "video"), | ||
| 200 | attributes: Recorder.current == nil && Recorder.canRecordVideo ? editable : .disabled | ||
| 201 | ) { [weak self] _ in self?.record(video: true) }, | ||
| 202 | ]), | ||
| 203 | ]) | ||
| 204 | return [readingAction()] + [ | 167 | return [readingAction()] + [ |
| 205 | insert, | ||
| 206 | UIAction(title: "Page Background", image: UIImage(systemName: "paintpalette"), attributes: editable) { | ||
| 207 | [weak self] _ in self?.showPaper() | ||
| 208 | }, | ||
| 209 | themeMenu(), | 168 | themeMenu(), |
| 210 | UIAction(title: "Tags Summary", image: UIImage(systemName: "tag")) { [weak self] _ in | 169 | UIAction(title: "Tags Summary", image: UIImage(systemName: "tag")) { [weak self] _ in |
| 211 | guard let self else { return } | 170 | guard let self else { return } |
| ... | @@ -232,6 +191,43 @@ final class PageViewController: UIViewController, PHPickerViewControllerDelegate | ... | @@ -232,6 +191,43 @@ final class PageViewController: UIViewController, PHPickerViewControllerDelegate |
| 232 | ] | 191 | ] |
| 233 | } | 192 | } |
| 234 | 193 | ||
| 194 | /// The desktop's Insert tab, as the format bar's `+` offers it. | ||
| 195 | private func insertActions() -> [UIMenuElement] { | ||
| 196 | [ | ||
| 197 | UIAction(title: "Photo Library", image: UIImage(systemName: "photo.on.rectangle")) { [weak self] _ in | ||
| 198 | self?.pickPicture(camera: false) | ||
| 199 | }, | ||
| 200 | UIAction( | ||
| 201 | title: "Take Photo", image: UIImage(systemName: "camera"), | ||
| 202 | attributes: UIImagePickerController.isSourceTypeAvailable(.camera) ? [] : .disabled | ||
| 203 | ) { [weak self] _ in self?.pickPicture(camera: true) }, | ||
| 204 | UIMenu(options: .displayInline, children: [ | ||
| 205 | UIAction(title: "Date", image: UIImage(systemName: "calendar")) { [weak self] _ in | ||
| 206 | self?.canvas.insertDate(true, time: false) | ||
| 207 | }, | ||
| 208 | UIAction(title: "Time", image: UIImage(systemName: "clock")) { [weak self] _ in | ||
| 209 | self?.canvas.insertDate(false, time: true) | ||
| 210 | }, | ||
| 211 | UIAction(title: "Date & Time", image: UIImage(systemName: "calendar.badge.clock")) { [weak self] _ in | ||
| 212 | self?.canvas.insertDate(true, time: true) | ||
| 213 | }, | ||
| 214 | ]), | ||
| 215 | UIAction(title: "Space", image: UIImage(systemName: "arrow.up.and.down.text.horizontal")) { [weak self] _ in | ||
| 216 | self?.canvas.insertSpace() | ||
| 217 | }, | ||
| 218 | UIMenu(options: .displayInline, children: [ | ||
| 219 | UIAction( | ||
| 220 | title: "Record Audio", image: UIImage(systemName: "mic"), | ||
| 221 | attributes: Recorder.current == nil ? [] : .disabled | ||
| 222 | ) { [weak self] _ in self?.record(video: false) }, | ||
| 223 | UIAction( | ||
| 224 | title: "Record Video", image: UIImage(systemName: "video"), | ||
| 225 | attributes: Recorder.current == nil && Recorder.canRecordVideo ? [] : .disabled | ||
| 226 | ) { [weak self] _ in self?.record(video: true) }, | ||
| 227 | ]), | ||
| 228 | ] | ||
| 229 | } | ||
| 230 | |||
| 235 | /// Reading View: the page reflowed into the screen's width where it reflows. | 231 | /// Reading View: the page reflowed into the screen's width where it reflows. |
| 236 | private func readingAction() -> UIAction { | 232 | private func readingAction() -> UIAction { |
| 237 | let reading = canvas.reading | 233 | let reading = canvas.reading |
apps/ios/Snowbound/Script.swift+52-2| ... | @@ -18,11 +18,20 @@ extension CanvasView { | ... | @@ -18,11 +18,20 @@ extension CanvasView { |
| 18 | /// `record:video` records, `pauserecording` pauses or resumes and `stoprecording` stops it, | 18 | /// `record:video` records, `pauserecording` pauses or resumes and `stoprecording` stops it, |
| 19 | /// `wait:N` waits N seconds more before the next step, `tree` saves the view hierarchy to | 19 | /// `wait:N` waits N seconds more before the next step, `tree` saves the view hierarchy to |
| 20 | /// Documents/tree.txt, and `shot:a` the window to Documents/a.png, as a device has no | 20 | /// Documents/tree.txt, and `shot:a` the window to Documents/a.png, as a device has no |
| 21 | /// screenshot command. | 21 | /// screenshot command; `snap:a` only logs, for a simulator screenshot with the keyboard; |
| 22 | /// `activate:Label` activates the element of any window so named, as VoiceOver would, | ||
| 23 | /// opening a button's menu, and `audit` logs whether the format bar and tag grid name every | ||
| 24 | /// button. | ||
| 22 | func runScript() { | 25 | func runScript() { |
| 23 | // Only the first page shown runs it, not every page opened afterwards. | 26 | // Only the first page shown runs it, not every page opened afterwards. |
| 24 | guard !scriptedPage, let script = ProcessInfo.processInfo.environment["SNOWBOUND_SCRIPT"] else { return } | 27 | guard !scriptedPage, let script = ProcessInfo.processInfo.environment["SNOWBOUND_SCRIPT"] else { return } |
| 25 | scriptedPage = true | 28 | scriptedPage = true |
| 29 | // UIKit's accessibility, as VoiceOver or a UI test loads it, for `activate` and `audit`. | ||
| 30 | if let library = dlopen("/usr/lib/libAccessibility.dylib", RTLD_NOW), | ||
| 31 | let enable = dlsym(library, "_AXSSetAutomationEnabled") | ||
| 32 | { | ||
| 33 | unsafeBitCast(enable, to: (@convention(c) (Int32) -> Void).self)(1) | ||
| 34 | } | ||
| 26 | var delay = 1.0 | 35 | var delay = 1.0 |
| 27 | for step in script.split(separator: "|") { | 36 | for step in script.split(separator: "|") { |
| 28 | if step.hasPrefix("wait:") { delay += Double(step.dropFirst(5)) ?? 0 } | 37 | if step.hasPrefix("wait:") { delay += Double(step.dropFirst(5)) ?? 0 } |
| ... | @@ -95,7 +104,21 @@ extension CanvasView { | ... | @@ -95,7 +104,21 @@ extension CanvasView { |
| 95 | let caret = caretRect(for: range.start) | 104 | let caret = caretRect(for: range.start) |
| 96 | menu.presentEditMenu(with: UIEditMenuConfiguration(identifier: nil, sourcePoint: CGPoint(x: caret.midX, y: caret.minY))) | 105 | menu.presentEditMenu(with: UIEditMenuConfiguration(identifier: nil, sourcePoint: CGPoint(x: caret.midX, y: caret.minY))) |
| 97 | case "done": _ = resignFirstResponder() | 106 | case "done": _ = resignFirstResponder() |
| 98 | case "wait": break | 107 | case "wait", "snap": break |
| 108 | case "activate": | ||
| 109 | guard let found = (window?.windowScene?.windows ?? []).reversed().lazy | ||
| 110 | .compactMap({ Self.element(argument, in: $0) }).first | ||
| 111 | else { | ||
| 112 | NSLog("script activate: no \(argument)") | ||
| 113 | break | ||
| 114 | } | ||
| 115 | if let button = found as? UIButton, button.showsMenuAsPrimaryAction, let menu = button.contextMenuInteraction { | ||
| 116 | let middle = CGPoint(x: button.bounds.midX, y: button.bounds.midY) | ||
| 117 | menu.perform(Selector(("_presentMenuAtLocation:")), with: NSValue(cgPoint: middle)) | ||
| 118 | } else if !found.accessibilityActivate(), let control = found as? UIControl { | ||
| 119 | control.sendActions(for: .primaryActionTriggered) | ||
| 120 | } | ||
| 121 | case "audit": audit() | ||
| 99 | case "record", "stoprecording": | 122 | case "record", "stoprecording": |
| 100 | let page = sequence(first: self as UIResponder, next: \.next).lazy.compactMap { $0 as? PageViewController }.first | 123 | let page = sequence(first: self as UIResponder, next: \.next).lazy.compactMap { $0 as? PageViewController }.first |
| 101 | if command == "record" { page?.record(video: argument == "video") } else { page?.stopRecording() } | 124 | if command == "record" { page?.record(video: argument == "video") } else { page?.stopRecording() } |
| ... | @@ -132,6 +155,33 @@ extension CanvasView { | ... | @@ -132,6 +155,33 @@ extension CanvasView { |
| 132 | } | 155 | } |
| 133 | } | 156 | } |
| 134 | 157 | ||
| 158 | extension CanvasView { | ||
| 159 | /// The first view or accessibility element under `root` VoiceOver names `label`. | ||
| 160 | fileprivate static func element(_ label: String, in root: NSObject) -> NSObject? { | ||
| 161 | if let view = root as? UIView, view.isHidden { return nil } | ||
| 162 | if root.isAccessibilityElement, root.accessibilityLabel == label { return root } | ||
| 163 | let children = ((root as? UIView)?.subviews ?? []) + (root.accessibilityElements as? [NSObject] ?? []) | ||
| 164 | return children.lazy.compactMap { element(label, in: $0) }.first | ||
| 165 | } | ||
| 166 | |||
| 167 | /// Logs `audit ok`, or each button of the format bar and tag grid VoiceOver can't name. | ||
| 168 | fileprivate func audit() { | ||
| 169 | func buttons(_ view: UIView) -> [UIButton] { | ||
| 170 | (view as? UIButton).map { [$0] } ?? view.subviews.flatMap(buttons) | ||
| 171 | } | ||
| 172 | var problems = buttons(formatBar).filter { ($0.accessibilityLabel ?? "").isEmpty }.map { "unnamed \($0)" } | ||
| 173 | if let grid = TagGrid.shown { | ||
| 174 | let named = buttons(grid).filter { | ||
| 175 | $0.isAccessibilityElement && $0.accessibilityTraits.contains(.button) && !($0.accessibilityLabel ?? "").isEmpty | ||
| 176 | }.compactMap(\.accessibilityLabel) | ||
| 177 | let expected = Tags.all.prefix(Tags.keyed).map(\.name) + ["More Tags…", "Remove Tag"] | ||
| 178 | if named != expected { problems.append("grid names \(named)") } | ||
| 179 | if !grid.accessibilityViewIsModal { problems.append("grid not modal") } | ||
| 180 | } | ||
| 181 | NSLog(problems.isEmpty ? "audit ok, grid \(TagGrid.shown != nil)" : "audit FAIL \(problems)") | ||
| 182 | } | ||
| 183 | } | ||
| 184 | |||
| 135 | /// Logs `presenter changed` whenever a coordinated write reaches `SNOWBOUND_PRESENTER`, a | 185 | /// Logs `presenter changed` whenever a coordinated write reaches `SNOWBOUND_PRESENTER`, a |
| 136 | /// file, to check that saving tells file providers about it. | 186 | /// file, to check that saving tells file providers about it. |
| 137 | final class Presenter: NSObject, NSFilePresenter { | 187 | final class Presenter: NSObject, NSFilePresenter { |
apps/ios/Snowbound/Tags.swift+16-11| ... | @@ -23,15 +23,16 @@ enum Tags { | ... | @@ -23,15 +23,16 @@ enum Tags { |
| 23 | /// The first nine, which ⌘1 to ⌘9 apply, as Ctrl+1 to Ctrl+9 do in OneNote. | 23 | /// The first nine, which ⌘1 to ⌘9 apply, as Ctrl+1 to Ctrl+9 do in OneNote. |
| 24 | static let keyed = 9 | 24 | static let keyed = 9 |
| 25 | 25 | ||
| 26 | private static var icons: [Int: UIImage] = [:] | 26 | private static var icons: [[Int]: UIImage] = [:] |
| 27 | 27 | ||
| 28 | /// Tag symbol `shape` as OneNote draws it, a check box `checked`; a highlighter in | 28 | /// Tag symbol `shape` as OneNote draws it, `points` square, a check box `checked`; a |
| 29 | /// `highlight` for a highlighting tag. | 29 | /// highlighter in `highlight` for a highlighting tag. |
| 30 | static func icon(_ shape: UInt16, checked: Bool = false, highlight: UIColor? = nil) -> UIImage? { | 30 | static func icon(_ shape: UInt16, checked: Bool = false, highlight: UIColor? = nil, points: CGFloat = 18) -> UIImage? { |
| 31 | guard shape != 0 else { | 31 | guard shape != 0 else { |
| 32 | // Highlighted lines, as the desktop draws these tags. | 32 | // Highlighted lines, as the desktop draws these tags. |
| 33 | let fill = highlight ?? .systemYellow | 33 | let fill = highlight ?? .systemYellow |
| 34 | return UIGraphicsImageRenderer(size: CGSize(width: 18, height: 18)).image { context in | 34 | return UIGraphicsImageRenderer(size: CGSize(width: points, height: points)).image { context in |
| 35 | context.cgContext.scaleBy(x: points / 18, y: points / 18) | ||
| 35 | let card = UIBezierPath(roundedRect: CGRect(x: 1.5, y: 3.5, width: 15, height: 11), cornerRadius: 1.5) | 36 | let card = UIBezierPath(roundedRect: CGRect(x: 1.5, y: 3.5, width: 15, height: 11), cornerRadius: 1.5) |
| 36 | fill.setFill() | 37 | fill.setFill() |
| 37 | card.fill() | 38 | card.fill() |
| ... | @@ -42,9 +43,8 @@ enum Tags { | ... | @@ -42,9 +43,8 @@ enum Tags { |
| 42 | context.fill(CGRect(x: 4, y: 10, width: 7, height: 1.5)) | 43 | context.fill(CGRect(x: 4, y: 10, width: 7, height: 1.5)) |
| 43 | } | 44 | } |
| 44 | } | 45 | } |
| 45 | let key = Int(shape) << 1 | (checked ? 1 : 0) | 46 | let key = [Int(shape), checked ? 1 : 0, Int(points)] |
| 46 | if let icon = icons[key] { return icon } | 47 | if let icon = icons[key] { return icon } |
| 47 | let points: CGFloat = 18 | ||
| 48 | let scale = UIScreen.main.scale | 48 | let scale = UIScreen.main.scale |
| 49 | let pixels = Int(points * scale) | 49 | let pixels = Int(points * scale) |
| 50 | var rgba = [UInt8](repeating: 0, count: pixels * pixels * 4) | 50 | var rgba = [UInt8](repeating: 0, count: pixels * pixels * 4) |
| ... | @@ -61,15 +61,20 @@ enum Tags { | ... | @@ -61,15 +61,20 @@ enum Tags { |
| 61 | return icon | 61 | return icon |
| 62 | } | 62 | } |
| 63 | 63 | ||
| 64 | /// Every tag as a menu, those the selection has checked, then Remove Tag; `apply` takes | 64 | /// Every tag as an action, those the selection has checked; `apply` takes the |
| 65 | /// the `sb_view_apply` command. | 65 | /// `sb_view_apply` command. |
| 66 | static func menu(bits: UInt64, apply: @escaping (UInt8) -> Void) -> [UIMenuElement] { | 66 | static func actions(bits: UInt64, apply: @escaping (UInt8) -> Void) -> [UIAction] { |
| 67 | let actions = all.enumerated().map { index, tag in | 67 | all.enumerated().map { index, tag in |
| 68 | UIAction( | 68 | UIAction( |
| 69 | title: tag.name, image: icon(tag.shape, highlight: tag.highlight), | 69 | title: tag.name, image: icon(tag.shape, highlight: tag.highlight), |
| 70 | state: bits & (1 << UInt64(16 + index)) != 0 ? .on : .off | 70 | state: bits & (1 << UInt64(16 + index)) != 0 ? .on : .off |
| 71 | ) { _ in apply(16 + UInt8(index)) } | 71 | ) { _ in apply(16 + UInt8(index)) } |
| 72 | } | 72 | } |
| 73 | } | ||
| 74 | |||
| 75 | /// Every tag as a menu, then Remove Tag. | ||
| 76 | static func menu(bits: UInt64, apply: @escaping (UInt8) -> Void) -> [UIMenuElement] { | ||
| 77 | let actions = actions(bits: bits, apply: apply) | ||
| 73 | let tagged = bits >> 16 != 0 | 78 | let tagged = bits >> 16 != 0 |
| 74 | return [ | 79 | return [ |
| 75 | UIMenu(options: .displayInline, children: Array(actions.prefix(keyed))), | 80 | UIMenu(options: .displayInline, children: Array(actions.prefix(keyed))), |
crates/mobile/include/snowbound.h+1| ... | @@ -148,6 +148,7 @@ bool sb_view_undo(View *view, bool redo); | ... | @@ -148,6 +148,7 @@ bool sb_view_undo(View *view, bool redo); |
| 148 | bool sb_view_can_undo(const View *view, bool redo); | 148 | bool sb_view_can_undo(const View *view, bool redo); |
| 149 | uint64_t sb_view_format(const View *view); | 149 | uint64_t sb_view_format(const View *view); |
| 150 | bool sb_view_apply(View *view, uint8_t command); | 150 | bool sb_view_apply(View *view, uint8_t command); |
| 151 | bool sb_view_highlight(View *view, int64_t color); | ||
| 151 | bool sb_view_style(View *view, uint8_t place); | 152 | bool sb_view_style(View *view, uint8_t place); |
| 152 | int32_t sb_view_style_place(const View *view); | 153 | int32_t sb_view_style_place(const View *view); |
| 153 | char *sb_view_correction(const View *view); | 154 | char *sb_view_correction(const View *view); |
crates/mobile/src/lib.rs+12| ... | @@ -418,6 +418,10 @@ impl Canvas { | ... | @@ -418,6 +418,10 @@ impl Canvas { |
| 418 | Ok(moved(self.page.format(command)?)) | 418 | Ok(moved(self.page.format(command)?)) |
| 419 | } | 419 | } |
| 420 | 420 | ||
| 421 | fn highlight(&mut self, color: Option<u32>) -> Result<bool> { | ||
| 422 | Ok(moved(self.page.format(Formatting::Highlight(color))?)) | ||
| 423 | } | ||
| 424 | |||
| 421 | /// The title outline, where the page has an editable one. | 425 | /// The title outline, where the page has an editable one. |
| 422 | fn title(&self) -> Option<&TextOutline> { | 426 | fn title(&self) -> Option<&TextOutline> { |
| 423 | self.page | 427 | self.page |
| ... | @@ -1408,6 +1412,14 @@ pub extern "C" fn sb_view_apply(view: &mut View, command: u8) -> bool { | ... | @@ -1408,6 +1412,14 @@ pub extern "C" fn sb_view_apply(view: &mut View, command: u8) -> bool { |
| 1408 | view.stored(result) | 1412 | view.stored(result) |
| 1409 | } | 1413 | } |
| 1410 | 1414 | ||
| 1415 | /// Highlights the selection in COLORREF `color`, or takes its highlight away where `color` | ||
| 1416 | /// is negative. | ||
| 1417 | #[unsafe(no_mangle)] | ||
| 1418 | pub extern "C" fn sb_view_highlight(view: &mut View, color: i64) -> bool { | ||
| 1419 | let result = view.canvas.highlight(u32::try_from(color).ok()); | ||
| 1420 | view.stored(result) | ||
| 1421 | } | ||
| 1422 | |||
| 1411 | /// Gives the selected paragraphs style `place` of OneNote's Styles gallery (Heading 1 to 6, | 1423 | /// Gives the selected paragraphs style `place` of OneNote's Styles gallery (Heading 1 to 6, |
| 1412 | /// Page Title, Citation, Quote, Code, Normal) in the page's theme, or OneNote 2010's. | 1424 | /// Page Title, Citation, Quote, Code, Normal) in the page's theme, or OneNote 2010's. |
| 1413 | #[unsafe(no_mangle)] | 1425 | #[unsafe(no_mangle)] |
crates/mobile/src/tests.rs+36| ... | @@ -796,6 +796,42 @@ fn find_selects_the_match_and_formatting_reports_and_toggles() { | ... | @@ -796,6 +796,42 @@ fn find_selects_the_match_and_formatting_reports_and_toggles() { |
| 796 | assert!(canvas.edit().unwrap().is_some()); | 796 | assert!(canvas.edit().unwrap().is_some()); |
| 797 | } | 797 | } |
| 798 | 798 | ||
| 799 | #[test] | ||
| 800 | fn highlight_stores_any_colour_and_none_takes_it_away() { | ||
| 801 | let (_directory, section) = features(); | ||
| 802 | let mut canvas = canvas(&section, "Paragraph controls"); | ||
| 803 | assert!(canvas.find("collapsed PARENT").unwrap()); | ||
| 804 | let stored = |canvas: &mut Canvas| { | ||
| 805 | section | ||
| 806 | .shared | ||
| 807 | .apply(canvas.edit().unwrap().unwrap()) | ||
| 808 | .unwrap(); | ||
| 809 | let (page, _) = section.shared.page(canvas.space).unwrap(); | ||
| 810 | let mut highlights = Vec::new(); | ||
| 811 | for object in &page.objects { | ||
| 812 | let onestore::page::PageObject::Outline(outline) = object else { | ||
| 813 | continue; | ||
| 814 | }; | ||
| 815 | for paragraph in &outline.paragraphs { | ||
| 816 | if let onestore::page::ParagraphContent::Text(text) = &paragraph.content { | ||
| 817 | highlights.extend( | ||
| 818 | text.text | ||
| 819 | .spans() | ||
| 820 | .iter() | ||
| 821 | .filter_map(|span| span.format.highlight), | ||
| 822 | ); | ||
| 823 | } | ||
| 824 | } | ||
| 825 | } | ||
| 826 | highlights | ||
| 827 | }; | ||
| 828 | // Bright Green, which `sb_view_apply` has no number for. | ||
| 829 | assert!(canvas.highlight(Some(0x00ff00)).unwrap()); | ||
| 830 | assert!(stored(&mut canvas).contains(&0x00ff00)); | ||
| 831 | assert!(canvas.highlight(None).unwrap()); | ||
| 832 | assert!(!stored(&mut canvas).contains(&0x00ff00)); | ||
| 833 | } | ||
| 834 | |||
| 799 | #[test] | 835 | #[test] |
| 800 | fn a_picture_goes_in_at_the_caret_and_undoes() { | 836 | fn a_picture_goes_in_at_the_caret_and_undoes() { |
| 801 | let (_directory, section) = features(); | 837 | let (_directory, section) = features(); |