authorclover caruso <git@paperclover.net> 2026-10-16 00:26:39-07:00
committerclover caruso <git@paperclover.net> 2026-10-16 01:38:58-07:00
log5e2a514da35d4f367f7084d85bdbe75af7584177
tree801f3536a1f0af289daae64c5c0e99249564c2f8
parent486eeeb292a5b37fb9af763e9ee014c61bcbdd9c
signature Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU (~clover)

feat(ios): float the format bar as Liquid Glass and regroup its menus

On iOS 26 the bar above the keyboard is a floating glass capsule, as in Notes, with Hide Keyboard in its own glass circle at the right; earlier systems keep the full-width bar. The top bar loses its checkmark, since the bar and Escape both end editing. Tags takes the To Do button's place and opens a 3 by 3 grid of the first nine tags over the bar, centred on the button as far as the keyboard allows, with More Tags and Remove Tag beneath it, or beside it on a short screen. Bullets and Numbering become one List button with a small menu. A plus button holds everything the page menu used to insert, and Page Background moves onto the bar. Highlight leaves the bar for the selection menu, where it offers OneNote's fifteen highlighter colours and No Highlight through a new sb_view_highlight call. Assisted-by: claude-opus-5.5

8 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
855855
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 = true860 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) }
939940
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] _ in959 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 }
957978
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 }
11191140
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 @@
1import UIKit1import UIKit
22
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.
5final class FormatBar: UIInputView {5final 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 = 09 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)?
3323
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 = true29 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 in32 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 = true41 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 in44 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 = true55 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 = true60 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 = false72 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 }
106130
107 required init?(coder: NSCoder) { fatalError() }131 required init?(coder: NSCoder) { fatalError() }
108132
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 = .label146 configuration.baseForegroundColor = .label
114 configuration.cornerStyle = .medium147 configuration.cornerStyle = .capsule
115 let button = UIButton(configuration: configuration)148 let button = UIButton(configuration: configuration)
116 button.accessibilityLabel = label149 button.accessibilityLabel = label
117 button.widthAnchor.constraint(equalToConstant: 42).isActive = true150 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 button156 return button
124 }157 }
125158
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 = bits180 self.bits = bits
129 for (button, command) in toggles {181 for (button, command) in toggles {
130 button.isSelected = bits & (1 << UInt64(command)) != 0182 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.
192private 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.
198final 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 = reveal50 self.reveal = reveal
53 self.titleFocus = titleFocus51 self.titleFocus = titleFocus
54 super.init(nibName: nil, bundle: nil)52 super.init(nibName: nil, bundle: nil)
55 done.style = .plain
56 navigationItem.largeTitleDisplayMode = .never53 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() : nil136 navigationItem.titleView = crowded ? UIView() : nil
135 }137 }
136138
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 return156 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.first159 if navigationItem.rightBarButtonItems != editing { navigationItem.rightBarButtonItems = editing }
157 || navigationItem.rightBarButtonItems?.count != items.count
158 {
159 navigationItem.rightBarButtonItems = items
160 }
161 }160 }
162161
163 // MARK: Page menu162 // MARK: Page menu
164163
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? SceneDelegate166 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] _ in169 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 }
234193
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.reading233 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 to19 /// `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 no20 /// 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 = true28 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.035 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": break107 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 }.first123 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}
134157
158extension 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`, a185/// 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.
137final class Presenter: NSObject, NSFilePresenter {187final 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 = 924 static let keyed = 9
2525
26 private static var icons: [Int: UIImage] = [:]26 private static var icons: [[Int]: UIImage] = [:]
2727
28 /// Tag symbol `shape` as OneNote draws it, a check box `checked`; a highlighter in28 /// 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 ?? .systemYellow33 let fill = highlight ?? .systemYellow
34 return UIGraphicsImageRenderer(size: CGSize(width: 18, height: 18)).image { context in34 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.scale48 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 icon61 return icon
62 }62 }
6363
64 /// Every tag as a menu, those the selection has checked, then Remove Tag; `apply` takes64 /// 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 in67 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 : .off70 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 != 078 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);
148bool sb_view_can_undo(const View *view, bool redo);148bool sb_view_can_undo(const View *view, bool redo);
149uint64_t sb_view_format(const View *view);149uint64_t sb_view_format(const View *view);
150bool sb_view_apply(View *view, uint8_t command);150bool sb_view_apply(View *view, uint8_t command);
151bool sb_view_highlight(View *view, int64_t color);
151bool sb_view_style(View *view, uint8_t place);152bool sb_view_style(View *view, uint8_t place);
152int32_t sb_view_style_place(const View *view);153int32_t sb_view_style_place(const View *view);
153char *sb_view_correction(const View *view);154char *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 }
420420
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.page427 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}
14101414
1415/// Highlights the selection in COLORREF `color`, or takes its highlight away where `color`
1416/// is negative.
1417#[unsafe(no_mangle)]
1418pub 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}
798798
799#[test]
800fn 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]
800fn a_picture_goes_in_at_the_caret_and_undoes() {836fn a_picture_goes_in_at_the_caret_and_undoes() {
801 let (_directory, section) = features();837 let (_directory, section) = features();