1import UIKit
2
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.
5final class FormatBar: UIInputView {
6 private var toggles: [(UIButton, UInt8)] = []
7 private let tags = FormatBar.button("tag", "Tags")
8 private let list = FormatBar.button("list.bullet", "List")
9 private var bits: UInt64 = 0
10 var onApply: ((UInt8) -> Void)?
11 /// Applies a style of OneNote's gallery, by place; `stylePlace` names the selection's.
12 var onStyle: ((UInt8) -> Void)?
13 var stylePlace: (() -> Int32)?
14 /// OneNote 2010's Styles gallery, in its order.
15 private static let styles = [
16 "Heading 1", "Heading 2", "Heading 3", "Heading 4", "Heading 5", "Heading 6",
17 "Page Title", "Citation", "Quote", "Code", "Normal",
18 ]
19 /// What `+` offers to insert, asked for as it opens.
20 var insert: (() -> [UIMenuElement])?
21 var onPaper: (() -> Void)?
22 var onDismiss: (() -> Void)?
23
24 init() {
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)
29 allowsSelfSizing = true
30 let styles = Self.button("textformat", "Styles")
31 styles.menu = UIMenu(children: [
32 UIDeferredMenuElement.uncached { [weak self] done in
33 let current = self?.stylePlace?() ?? -1
34 done(Self.styles.enumerated().map { place, name in
35 UIAction(title: name, state: Int32(place) == current ? .on : .off) { _ in
36 self?.onStyle?(UInt8(place))
37 }
38 })
39 }
40 ])
41 styles.showsMenuAsPrimaryAction = true
42 tags.addAction(UIAction { [weak self] _ in self?.showTags() }, for: .primaryActionTriggered)
43 list.menu = UIMenu(children: [
44 UIDeferredMenuElement.uncached { [weak self] done in
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 })
53 }
54 ])
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?() ?? []) }
59 ])
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
71 let scroll = UIScrollView()
72 scroll.showsHorizontalScrollIndicator = false
73 scroll.addSubview(row)
74 let dismiss = Self.button("keyboard.chevron.compact.down", "Hide Keyboard")
75 dismiss.addAction(UIAction { [weak self] _ in self?.onDismiss?() }, for: .primaryActionTriggered)
76 for view in [scroll, dismiss, row] as [UIView] { view.translatesAutoresizingMaskIntoConstraints = false }
77 NSLayoutConstraint.activate([
78 row.leadingAnchor.constraint(equalTo: scroll.contentLayoutGuide.leadingAnchor),
79 row.trailingAnchor.constraint(equalTo: scroll.contentLayoutGuide.trailingAnchor),
80 row.topAnchor.constraint(equalTo: scroll.contentLayoutGuide.topAnchor),
81 row.bottomAnchor.constraint(equalTo: scroll.contentLayoutGuide.bottomAnchor),
82 row.heightAnchor.constraint(equalTo: scroll.frameLayoutGuide.heightAnchor),
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 }
129 }
130
131 required init?(coder: NSCoder) { fatalError() }
132
133 @available(iOS 26, *)
134 private static func glass(_ content: UIView) -> UIVisualEffectView {
135 let glass = UIVisualEffectView(effect: UIGlassEffect())
136 glass.cornerConfiguration = .capsule()
137 glass.translatesAutoresizingMaskIntoConstraints = false
138 glass.contentView.addSubview(content)
139 return glass
140 }
141
142 private static func button(_ symbol: String, _ label: String) -> UIButton {
143 var configuration = UIButton.Configuration.plain()
144 configuration.image = UIImage(systemName: symbol)
145 configuration.preferredSymbolConfigurationForImage = UIImage.SymbolConfiguration(pointSize: 17)
146 configuration.baseForegroundColor = .label
147 configuration.cornerStyle = .capsule
148 let button = UIButton(configuration: configuration)
149 button.accessibilityLabel = label
150 button.widthAnchor.constraint(equalToConstant: 42).isActive = true
151 button.configurationUpdateHandler = { button in
152 button.configuration?.background.backgroundColor =
153 button.isSelected ? UIColor.tintColor.withAlphaComponent(0.18) : .clear
154 button.configuration?.baseForegroundColor = button.isSelected ? .tintColor : .label
155 }
156 return button
157 }
158
159 /// A button for `sb_view_apply` `command`, marked while the selection has it.
160 private func command(_ symbol: String, _ label: String, _ command: UInt8) -> UIButton {
161 let button = Self.button(symbol, label)
162 button.addAction(UIAction { [weak self] _ in self?.onApply?(command) }, for: .primaryActionTriggered)
163 toggles.append((button, command))
164 return button
165 }
166
167 private func showTags() {
168 guard let scene = window?.windowScene else { return }
169 let grid = TagGrid(bits: bits, short: traitCollection.verticalSizeClass == .compact) { [weak self] in
170 self?.onApply?($0)
171 }
172 // Over the bar but never the keyboard, which covers every window of the app.
173 grid.show(
174 in: scene, around: tags.convert(CGPoint(x: tags.bounds.midX, y: tags.bounds.midY), to: nil),
175 above: convert(CGPoint(x: 0, y: bounds.maxY), to: nil).y)
176 }
177
178 /// Marks what the selection has, from `sb_view_format`.
179 func show(_ bits: UInt64) {
180 self.bits = bits
181 for (button, command) in toggles {
182 button.isSelected = bits & (1 << UInt64(command)) != 0
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
378 }
379}