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
855855
856856 override func resignFirstResponder() -> Bool {
857857 guard super.resignFirstResponder() else { return false }
858 TagGrid.shown?.close()
858859 if let handle { sb_view_focus(handle, false) }
859860 dirty = true
860861 onChange?()
......@@ -937,22 +938,42 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI
937938 override func toggleItalics(_ sender: Any?) { apply(1) }
938939 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.
941950 func editMenu(for textRange: UITextRange, suggestedActions: [UIMenuElement]) -> UIMenu? {
942951 guard let handle else { return nil }
943952 let bits = sb_view_format(handle)
944953 let tag = UIMenu(
945954 title: "Tag", image: UIImage(systemName: "tag"),
946955 children: Tags.menu(bits: bits) { [weak self] in self?.apply($0) })
947 var extra: [UIMenuElement] = [tag]
956 var highlight: [UIMenuElement] = []
948957 if !textRange.isEmpty {
949 extra.insert(
950 UIAction(title: "Highlight", image: UIImage(systemName: "highlighter")) { [weak self] _ in
951 self?.apply(10)
952 }, at: 0)
958 let colors = Self.highlights.map { color, name in
959 let swatch = UIGraphicsImageRenderer(size: CGSize(width: 20, height: 20)).image { _ in
960 let dot = UIBezierPath(ovalIn: CGRect(x: 1, y: 1, width: 18, height: 18))
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])]
953974 }
954975 return UIMenu(
955 children: spellingActions() + suggestedActions + [UIMenu(options: .displayInline, children: extra)])
976 children: spellingActions() + highlight + suggestedActions + [UIMenu(options: .displayInline, children: [tag])])
956977 }
957978
958979 private struct Correction: Decodable {
......@@ -1117,6 +1138,12 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI
11171138 edit(external: true) { sb_view_apply(handle, command) }
11181139 }
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
11201147 /// Gives the selected paragraphs style `place` of OneNote's gallery, in the page's theme.
11211148 func applyStyle(_ place: UInt8) {
11221149 guard let handle else { return }
apps/ios/Snowbound/FormatBar.swift+304-58
......@@ -1,22 +1,11 @@
11import UIKit
22
3/// Formatting above the keyboard, as Notes offers it: To Do first, then text styles, lists,
4/// indentation, tags and pictures, the keyboard's dismissal at the end.
3/// Formatting above the keyboard, as Notes offers it: from iOS 26 a floating glass bar, the
4/// keyboard's dismissal always at its right.
55final 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 ]
196 private var toggles: [(UIButton, UInt8)] = []
7 private let tags = FormatBar.button("tag", "Tags")
8 private let list = FormatBar.button("list.bullet", "List")
209 private var bits: UInt64 = 0
2110 var onApply: ((UInt8) -> Void)?
2211 /// Applies a style of OneNote's gallery, by place; `stylePlace` names the selection's.
......@@ -27,20 +16,17 @@ final class FormatBar: UIInputView {
2716 "Heading 1", "Heading 2", "Heading 3", "Heading 4", "Heading 5", "Heading 6",
2817 "Page Title", "Citation", "Quote", "Code", "Normal",
2918 ]
30 /// Asks for a picture from the camera (true) or the photo library.
31 var onPicture: ((Bool) -> Void)?
19 /// What `+` offers to insert, asked for as it opens.
20 var insert: (() -> [UIMenuElement])?
21 var onPaper: (() -> Void)?
3222 var onDismiss: (() -> Void)?
3323
3424 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)
3629 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
4430 let styles = Self.button("textformat", "Styles")
4531 styles.menu = UIMenu(children: [
4632 UIDeferredMenuElement.uncached { [weak self] done in
......@@ -53,65 +39,112 @@ final class FormatBar: UIInputView {
5339 }
5440 ])
5541 styles.showsMenuAsPrimaryAction = true
56 row.addArrangedSubview(styles)
57 for (symbol, label, command) in Self.buttons {
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: [
42 tags.addAction(UIAction { [weak self] _ in self?.showTags() }, for: .primaryActionTriggered)
43 list.menu = UIMenu(children: [
6544 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 })
6753 }
6854 ])
69 tag.showsMenuAsPrimaryAction = true
70 row.addArrangedSubview(tag)
71 let picture = Self.button("photo", "Insert Picture")
72 picture.menu = UIMenu(children: [
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) },
55 list.showsMenuAsPrimaryAction = true
56 let insert = Self.button("plus", "Insert")
57 insert.menu = UIMenu(children: [
58 UIDeferredMenuElement.uncached { [weak self] done in done(self?.insert?() ?? []) }
8059 ])
81 picture.showsMenuAsPrimaryAction = true
82 row.addArrangedSubview(picture)
60 insert.showsMenuAsPrimaryAction = true
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
8371 let scroll = UIScrollView()
8472 scroll.showsHorizontalScrollIndicator = false
8573 scroll.addSubview(row)
8674 let dismiss = Self.button("keyboard.chevron.compact.down", "Hide Keyboard")
8775 dismiss.addAction(UIAction { [weak self] _ in self?.onDismiss?() }, for: .primaryActionTriggered)
8876 for view in [scroll, dismiss, row] as [UIView] { view.translatesAutoresizingMaskIntoConstraints = false }
89 addSubview(scroll)
90 addSubview(dismiss)
9177 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),
9978 row.leadingAnchor.constraint(equalTo: scroll.contentLayoutGuide.leadingAnchor),
10079 row.trailingAnchor.constraint(equalTo: scroll.contentLayoutGuide.trailingAnchor),
10180 row.topAnchor.constraint(equalTo: scroll.contentLayoutGuide.topAnchor),
10281 row.bottomAnchor.constraint(equalTo: scroll.contentLayoutGuide.bottomAnchor),
10382 row.heightAnchor.constraint(equalTo: scroll.frameLayoutGuide.heightAnchor),
10483 ])
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 }
105129 }
106130
107131 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
109142 private static func button(_ symbol: String, _ label: String) -> UIButton {
110143 var configuration = UIButton.Configuration.plain()
111144 configuration.image = UIImage(systemName: symbol)
112145 configuration.preferredSymbolConfigurationForImage = UIImage.SymbolConfiguration(pointSize: 17)
113146 configuration.baseForegroundColor = .label
114 configuration.cornerStyle = .medium
147 configuration.cornerStyle = .capsule
115148 let button = UIButton(configuration: configuration)
116149 button.accessibilityLabel = label
117150 button.widthAnchor.constraint(equalToConstant: 42).isActive = true
......@@ -123,11 +156,224 @@ final class FormatBar: UIInputView {
123156 return button
124157 }
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
126178 /// Marks what the selection has, from `sb_view_format`.
127179 func show(_ bits: UInt64) {
128180 self.bits = bits
129181 for (button, command) in toggles {
130182 button.isSelected = bits & (1 << UInt64(command)) != 0
131183 }
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
132378 }
133379}
apps/ios/Snowbound/PageViewController.swift+48-52
......@@ -29,8 +29,6 @@ final class PageViewController: UIViewController, PHPickerViewControllerDelegate
2929 private var reader: UIBarButtonItem?
3030 /// Opens another page or conflict page of the section.
3131 var onOpen: ((String) -> Void)?
32 private lazy var done = UIBarButtonItem(
33 systemItem: .done, primaryAction: UIAction { [weak self] _ in _ = self?.canvas.resignFirstResponder() })
3432 private lazy var undo = UIBarButtonItem(
3533 title: "undo", image: UIImage(systemName: "arrow.uturn.backward"), primaryAction: UIAction { [weak self] _ in self?.canvas.undo(redo: false) })
3634 private lazy var redo = UIBarButtonItem(
......@@ -52,7 +50,6 @@ final class PageViewController: UIViewController, PHPickerViewControllerDelegate
5250 self.reveal = reveal
5351 self.titleFocus = titleFocus
5452 super.init(nibName: nil, bundle: nil)
55 done.style = .plain
5653 navigationItem.largeTitleDisplayMode = .never
5754 navigationItem.rightBarButtonItems = [more, draw]
5855 showTitle(section.row(of: page)?.row.title ?? "")
......@@ -92,7 +89,12 @@ final class PageViewController: UIViewController, PHPickerViewControllerDelegate
9289 media.onPause = { [weak self] in self?.pauseMedia() }
9390 media.onStop = { [weak self] in self?.stopMedia() }
9491 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 }
9698 bar.onAction = { [weak self] in self?.conflictAction() }
9799 NotificationCenter.default.addObserver(
98100 self, selector: #selector(sectionChanged), name: Section.changed, object: section)
......@@ -134,7 +136,8 @@ final class PageViewController: UIViewController, PHPickerViewControllerDelegate
134136 navigationItem.titleView = crowded ? UIView() : nil
135137 }
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.
138141 private func editingChanged() {
139142 if !canvas.readOnly, let title = canvas.pageTitle {
140143 showTitle(title)
......@@ -152,60 +155,16 @@ final class PageViewController: UIViewController, PHPickerViewControllerDelegate
152155 if navigationItem.rightBarButtonItems != resting { navigationItem.rightBarButtonItems = resting }
153156 return
154157 }
155 let items = canvas.isFirstResponder ? [done, more, draw, redo, undo] : [more, draw, redo, undo]
156 if navigationItem.rightBarButtonItems?.first !== items.first
157 || navigationItem.rightBarButtonItems?.count != items.count
158 {
159 navigationItem.rightBarButtonItems = items
160 }
158 let editing = [more, draw, redo, undo]
159 if navigationItem.rightBarButtonItems != editing { navigationItem.rightBarButtonItems = editing }
161160 }
162161
163162 // 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.
166165 private func pageMenu() -> [UIMenuElement] {
167 let editable: UIMenuElement.Attributes = canvas.readOnly ? .disabled : []
168166 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 ])
204167 return [readingAction()] + [
205 insert,
206 UIAction(title: "Page Background", image: UIImage(systemName: "paintpalette"), attributes: editable) {
207 [weak self] _ in self?.showPaper()
208 },
209168 themeMenu(),
210169 UIAction(title: "Tags Summary", image: UIImage(systemName: "tag")) { [weak self] _ in
211170 guard let self else { return }
......@@ -232,6 +191,43 @@ final class PageViewController: UIViewController, PHPickerViewControllerDelegate
232191 ]
233192 }
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
235231 /// Reading View: the page reflowed into the screen's width where it reflows.
236232 private func readingAction() -> UIAction {
237233 let reading = canvas.reading
apps/ios/Snowbound/Script.swift+52-2
......@@ -18,11 +18,20 @@ extension CanvasView {
1818 /// `record:video` records, `pauserecording` pauses or resumes and `stoprecording` stops it,
1919 /// `wait:N` waits N seconds more before the next step, `tree` saves the view hierarchy to
2020 /// 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.
2225 func runScript() {
2326 // Only the first page shown runs it, not every page opened afterwards.
2427 guard !scriptedPage, let script = ProcessInfo.processInfo.environment["SNOWBOUND_SCRIPT"] else { return }
2528 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 }
2635 var delay = 1.0
2736 for step in script.split(separator: "|") {
2837 if step.hasPrefix("wait:") { delay += Double(step.dropFirst(5)) ?? 0 }
......@@ -95,7 +104,21 @@ extension CanvasView {
95104 let caret = caretRect(for: range.start)
96105 menu.presentEditMenu(with: UIEditMenuConfiguration(identifier: nil, sourcePoint: CGPoint(x: caret.midX, y: caret.minY)))
97106 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()
99122 case "record", "stoprecording":
100123 let page = sequence(first: self as UIResponder, next: \.next).lazy.compactMap { $0 as? PageViewController }.first
101124 if command == "record" { page?.record(video: argument == "video") } else { page?.stopRecording() }
......@@ -132,6 +155,33 @@ extension CanvasView {
132155 }
133156}
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
135185/// Logs `presenter changed` whenever a coordinated write reaches `SNOWBOUND_PRESENTER`, a
136186/// file, to check that saving tells file providers about it.
137187final class Presenter: NSObject, NSFilePresenter {
apps/ios/Snowbound/Tags.swift+16-11
......@@ -23,15 +23,16 @@ enum Tags {
2323 /// The first nine, which ⌘1 to ⌘9 apply, as Ctrl+1 to Ctrl+9 do in OneNote.
2424 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 in
29 /// `highlight` for a highlighting tag.
30 static func icon(_ shape: UInt16, checked: Bool = false, highlight: UIColor? = nil) -> UIImage? {
28 /// Tag symbol `shape` as OneNote draws it, `points` square, a check box `checked`; a
29 /// highlighter in `highlight` for a highlighting tag.
30 static func icon(_ shape: UInt16, checked: Bool = false, highlight: UIColor? = nil, points: CGFloat = 18) -> UIImage? {
3131 guard shape != 0 else {
3232 // Highlighted lines, as the desktop draws these tags.
3333 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)
3536 let card = UIBezierPath(roundedRect: CGRect(x: 1.5, y: 3.5, width: 15, height: 11), cornerRadius: 1.5)
3637 fill.setFill()
3738 card.fill()
......@@ -42,9 +43,8 @@ enum Tags {
4243 context.fill(CGRect(x: 4, y: 10, width: 7, height: 1.5))
4344 }
4445 }
45 let key = Int(shape) << 1 | (checked ? 1 : 0)
46 let key = [Int(shape), checked ? 1 : 0, Int(points)]
4647 if let icon = icons[key] { return icon }
47 let points: CGFloat = 18
4848 let scale = UIScreen.main.scale
4949 let pixels = Int(points * scale)
5050 var rgba = [UInt8](repeating: 0, count: pixels * pixels * 4)
......@@ -61,15 +61,20 @@ enum Tags {
6161 return icon
6262 }
6363
64 /// Every tag as a menu, those the selection has checked, then Remove Tag; `apply` takes
65 /// the `sb_view_apply` command.
66 static func menu(bits: UInt64, apply: @escaping (UInt8) -> Void) -> [UIMenuElement] {
67 let actions = all.enumerated().map { index, tag in
64 /// Every tag as an action, those the selection has checked; `apply` takes the
65 /// `sb_view_apply` command.
66 static func actions(bits: UInt64, apply: @escaping (UInt8) -> Void) -> [UIAction] {
67 all.enumerated().map { index, tag in
6868 UIAction(
6969 title: tag.name, image: icon(tag.shape, highlight: tag.highlight),
7070 state: bits & (1 << UInt64(16 + index)) != 0 ? .on : .off
7171 ) { _ in apply(16 + UInt8(index)) }
7272 }
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)
7378 let tagged = bits >> 16 != 0
7479 return [
7580 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);
148148bool sb_view_can_undo(const View *view, bool redo);
149149uint64_t sb_view_format(const View *view);
150150bool sb_view_apply(View *view, uint8_t command);
151bool sb_view_highlight(View *view, int64_t color);
151152bool sb_view_style(View *view, uint8_t place);
152153int32_t sb_view_style_place(const View *view);
153154char *sb_view_correction(const View *view);
crates/mobile/src/lib.rs+12
......@@ -418,6 +418,10 @@ impl Canvas {
418418 Ok(moved(self.page.format(command)?))
419419 }
420420
421 fn highlight(&mut self, color: Option<u32>) -> Result<bool> {
422 Ok(moved(self.page.format(Formatting::Highlight(color))?))
423 }
424
421425 /// The title outline, where the page has an editable one.
422426 fn title(&self) -> Option<&TextOutline> {
423427 self.page
......@@ -1408,6 +1412,14 @@ pub extern "C" fn sb_view_apply(view: &mut View, command: u8) -> bool {
14081412 view.stored(result)
14091413}
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
14111423/// Gives the selected paragraphs style `place` of OneNote's Styles gallery (Heading 1 to 6,
14121424/// Page Title, Citation, Quote, Code, Normal) in the page's theme, or OneNote 2010's.
14131425#[unsafe(no_mangle)]
crates/mobile/src/tests.rs+36
......@@ -796,6 +796,42 @@ fn find_selects_the_match_and_formatting_reports_and_toggles() {
796796 assert!(canvas.edit().unwrap().is_some());
797797}
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
799835#[test]
800836fn a_picture_goes_in_at_the_caret_and_undoes() {
801837 let (_directory, section) = features();