| 1 | import 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. |
| 5 | final 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. |
| 192 | private final class Overlay: UIWindow { |
| 193 | override var canBecomeKey: Bool { false } |
| 194 | } |
| 195 | |
| 196 | /// OneNote's first nine tags in a grid centred on the Tags button, so a thumb reaches each, |
| 197 | /// then the rest and Remove Tag; beside the grid rather than below it on a short screen. |
| 198 | final class TagGrid: UIView, UIGestureRecognizerDelegate { |
| 199 | private(set) static weak var shown: TagGrid? |
| 200 | private let panel: UIVisualEffectView |
| 201 | private let grid = UIStackView() |
| 202 | private let apply: (UInt8) -> Void |
| 203 | private var laidOut = CGSize.zero |
| 204 | private var overlay: UIWindow? |
| 205 | |
| 206 | init(bits: UInt64, short: Bool, apply: @escaping (UInt8) -> Void) { |
| 207 | self.apply = apply |
| 208 | if #available(iOS 26, *) { |
| 209 | panel = UIVisualEffectView(effect: UIGlassEffect()) |
| 210 | panel.cornerConfiguration = .uniformCorners(radius: .fixed(28)) |
| 211 | } else { |
| 212 | panel = UIVisualEffectView(effect: UIBlurEffect(style: .systemThickMaterial)) |
| 213 | panel.layer.cornerRadius = 20 |
| 214 | panel.clipsToBounds = true |
| 215 | } |
| 216 | super.init(frame: .zero) |
| 217 | accessibilityViewIsModal = true |
| 218 | let outside = UITapGestureRecognizer(target: self, action: #selector(close)) |
| 219 | outside.delegate = self |
| 220 | addGestureRecognizer(outside) |
| 221 | layer.shadowColor = UIColor.black.cgColor |
| 222 | layer.shadowOpacity = 0.18 |
| 223 | layer.shadowRadius = 24 |
| 224 | grid.axis = .vertical |
| 225 | let tags = Array(Tags.all.prefix(Tags.keyed).enumerated()) |
| 226 | for start in stride(from: 0, to: tags.count, by: 3) { |
| 227 | let line = UIStackView(arrangedSubviews: tags[start..<min(start + 3, tags.count)].map { index, tag in |
| 228 | tile(tag, command: 16 + UInt8(index), on: bits & 1 << UInt64(16 + index) != 0, short: short) |
| 229 | }) |
| 230 | line.distribution = .fillEqually |
| 231 | grid.addArrangedSubview(line) |
| 232 | } |
| 233 | let more = Self.row("More Tags…", "tag") |
| 234 | more.menu = UIMenu(children: Array(Tags.actions(bits: bits) { [weak self] in self?.choose($0) }.dropFirst(Tags.keyed))) |
| 235 | more.showsMenuAsPrimaryAction = true |
| 236 | let remove = Self.row("Remove Tag", "tag.slash") |
| 237 | remove.isEnabled = bits >> 16 != 0 |
| 238 | remove.addAction(UIAction { [weak self] _ in self?.choose(8) }, for: .primaryActionTriggered) |
| 239 | let separator = UIView() |
| 240 | separator.backgroundColor = .separator |
| 241 | (short ? separator.widthAnchor : separator.heightAnchor) |
| 242 | .constraint(equalToConstant: 1 / UIScreen.main.scale).isActive = true |
| 243 | let actions = UIStackView(arrangedSubviews: [more, remove]) |
| 244 | actions.distribution = short ? .fill : .fillEqually |
| 245 | actions.axis = short ? .vertical : .horizontal |
| 246 | let stack = UIStackView(arrangedSubviews: [grid, separator, actions]) |
| 247 | stack.axis = short ? .horizontal : .vertical |
| 248 | stack.alignment = short ? .top : .fill |
| 249 | separator.heightAnchor.constraint(equalTo: grid.heightAnchor).isActive = short |
| 250 | stack.spacing = 4 |
| 251 | stack.translatesAutoresizingMaskIntoConstraints = false |
| 252 | panel.contentView.addSubview(stack) |
| 253 | NSLayoutConstraint.activate([ |
| 254 | stack.leadingAnchor.constraint(equalTo: panel.contentView.leadingAnchor, constant: 8), |
| 255 | stack.trailingAnchor.constraint(equalTo: panel.contentView.trailingAnchor, constant: -8), |
| 256 | stack.topAnchor.constraint(equalTo: panel.contentView.topAnchor, constant: 8), |
| 257 | stack.bottomAnchor.constraint(equalTo: panel.contentView.bottomAnchor, constant: -8), |
| 258 | grid.widthAnchor.constraint(equalToConstant: 3 * (short ? 150 : 100)), |
| 259 | ]) |
| 260 | addSubview(panel) |
| 261 | } |
| 262 | |
| 263 | required init?(coder: NSCoder) { fatalError() } |
| 264 | |
| 265 | private func tile(_ tag: Tags.Tag, command: UInt8, on: Bool, short: Bool) -> UIButton { |
| 266 | var configuration = UIButton.Configuration.plain() |
| 267 | configuration.image = Tags.icon(tag.shape, highlight: tag.highlight, points: 24) |
| 268 | configuration.title = tag.name |
| 269 | configuration.imagePlacement = short ? .leading : .top |
| 270 | configuration.imagePadding = 6 |
| 271 | configuration.titleAlignment = short ? .leading : .center |
| 272 | configuration.titleLineBreakMode = .byWordWrapping |
| 273 | configuration.titleTextAttributesTransformer = UIConfigurationTextAttributesTransformer { |
| 274 | var attributes = $0 |
| 275 | attributes.font = .preferredFont(forTextStyle: .caption1) |
| 276 | return attributes |
| 277 | } |
| 278 | configuration.baseForegroundColor = .label |
| 279 | configuration.cornerStyle = .large |
| 280 | configuration.contentInsets = NSDirectionalEdgeInsets( |
| 281 | top: short ? 0 : 10, leading: short ? 8 : 4, bottom: short ? 0 : 8, trailing: 4) |
| 282 | let button = UIButton(configuration: configuration) |
| 283 | if short { button.contentHorizontalAlignment = .leading } |
| 284 | button.isSelected = on |
| 285 | button.configurationUpdateHandler = { button in |
| 286 | button.configuration?.background.backgroundColor = |
| 287 | button.isSelected ? UIColor.tintColor.withAlphaComponent(0.18) : .clear |
| 288 | } |
| 289 | button.heightAnchor.constraint(equalToConstant: short ? 44 : 72).isActive = true |
| 290 | button.addAction(UIAction { [weak self] _ in self?.choose(command) }, for: .primaryActionTriggered) |
| 291 | return button |
| 292 | } |
| 293 | |
| 294 | private static func row(_ title: String, _ symbol: String) -> UIButton { |
| 295 | var configuration = UIButton.Configuration.plain() |
| 296 | configuration.title = title |
| 297 | configuration.image = UIImage(systemName: symbol) |
| 298 | configuration.imagePadding = 6 |
| 299 | configuration.baseForegroundColor = .label |
| 300 | configuration.cornerStyle = .large |
| 301 | configuration.contentInsets = NSDirectionalEdgeInsets(top: 0, leading: 8, bottom: 0, trailing: 8) |
| 302 | configuration.titleLineBreakMode = .byTruncatingTail |
| 303 | configuration.titleTextAttributesTransformer = UIConfigurationTextAttributesTransformer { |
| 304 | var attributes = $0 |
| 305 | attributes.font = .preferredFont(forTextStyle: .subheadline) |
| 306 | return attributes |
| 307 | } |
| 308 | let button = UIButton(configuration: configuration) |
| 309 | button.heightAnchor.constraint(equalToConstant: 44).isActive = true |
| 310 | return button |
| 311 | } |
| 312 | |
| 313 | /// Opens with the grid's middle at `point`, in screen points, as near as it fits on screen |
| 314 | /// above `bottom`. |
| 315 | func show(in scene: UIWindowScene, around point: CGPoint, above bottom: CGFloat) { |
| 316 | Self.shown?.close() |
| 317 | Self.shown = self |
| 318 | // Over the format bar's window. |
| 319 | let window = Overlay(windowScene: scene) |
| 320 | window.windowLevel = .alert |
| 321 | let root = UIViewController() |
| 322 | root.view = self |
| 323 | window.rootViewController = root |
| 324 | window.isHidden = false |
| 325 | overlay = window |
| 326 | let size = panel.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize) |
| 327 | panel.bounds.size = size |
| 328 | panel.layoutIfNeeded() |
| 329 | let middle = grid.convert(CGPoint(x: grid.bounds.midX, y: grid.bounds.midY), to: panel) |
| 330 | var room = window.bounds.inset(by: window.safeAreaInsets).insetBy(dx: 8, dy: 8) |
| 331 | room.size.height = min(room.maxY, bottom - 8) - room.minY |
| 332 | let origin = CGPoint( |
| 333 | x: min(max(point.x - middle.x, room.minX), room.maxX - size.width), |
| 334 | y: min(max(point.y - middle.y, room.minY), room.maxY - size.height)) |
| 335 | panel.frame = CGRect(origin: origin, size: size) |
| 336 | laidOut = bounds.size |
| 337 | panel.alpha = 0 |
| 338 | if !UIAccessibility.isReduceMotionEnabled { |
| 339 | // Grows from the finger, as menus do. |
| 340 | let finger = CGPoint(x: point.x - panel.center.x, y: point.y - panel.center.y) |
| 341 | panel.transform = CGAffineTransform(translationX: finger.x, y: finger.y).scaledBy(x: 0.6, y: 0.6) |
| 342 | .translatedBy(x: -finger.x, y: -finger.y) |
| 343 | } |
| 344 | UIView.animate(withDuration: 0.35, delay: 0, usingSpringWithDamping: 0.82, initialSpringVelocity: 0) { |
| 345 | self.panel.alpha = 1 |
| 346 | self.panel.transform = .identity |
| 347 | } |
| 348 | UIAccessibility.post(notification: .screenChanged, argument: grid.arrangedSubviews.first?.subviews.first) |
| 349 | } |
| 350 | |
| 351 | override func layoutSubviews() { |
| 352 | super.layoutSubviews() |
| 353 | // Turning the device moves the button out from under the grid. |
| 354 | if laidOut != .zero, bounds.size != laidOut { close() } |
| 355 | } |
| 356 | |
| 357 | func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool { |
| 358 | touch.view === self |
| 359 | } |
| 360 | |
| 361 | private func choose(_ command: UInt8) { |
| 362 | apply(command) |
| 363 | close() |
| 364 | } |
| 365 | |
| 366 | @objc func close() { |
| 367 | guard overlay != nil else { return } |
| 368 | laidOut = .zero |
| 369 | UIView.animate(withDuration: 0.15, animations: { self.alpha = 0 }) { _ in |
| 370 | self.overlay?.isHidden = true |
| 371 | self.overlay = nil |
| 372 | } |
| 373 | } |
| 374 | |
| 375 | override func accessibilityPerformEscape() -> Bool { |
| 376 | close() |
| 377 | return true |
| 378 | } |
| 379 | } |