| 1 | import UIKit |
| 2 | |
| 3 | /// What a touch on the page does: `sb_view_set_tool`'s tools, a pen by its place in the |
| 4 | /// section's `Pen.gallery`, a shape by its number. |
| 5 | enum InkTool: Equatable { |
| 6 | case select |
| 7 | case pen(UInt8) |
| 8 | case eraser |
| 9 | case lasso |
| 10 | case shape(UInt8) |
| 11 | |
| 12 | var code: (tool: UInt8, detail: UInt8) { |
| 13 | switch self { |
| 14 | case .select: (0, 0) |
| 15 | case .pen(let pen): (1, pen) |
| 16 | case .eraser: (2, 0) |
| 17 | case .lasso: (3, 0) |
| 18 | case .shape(let shape): (4, shape) |
| 19 | } |
| 20 | } |
| 21 | } |
| 22 | |
| 23 | /// A pen of a section's gallery, from `sb_pens`. |
| 24 | struct Pen { |
| 25 | /// Nil draws in the paper's ink. |
| 26 | let color: UIColor? |
| 27 | /// In HIMETRIC. |
| 28 | let width: Float |
| 29 | let highlighter: Bool |
| 30 | |
| 31 | /// OneNote 2010's "Use pen pressure sensitivity": the Pencil's strokes follow its pressure |
| 32 | /// unless turned off, when they keep their pen's width. |
| 33 | static var pressure: Bool { |
| 34 | get { !UserDefaults.standard.bool(forKey: "ignorePenPressure") } |
| 35 | set { UserDefaults.standard.set(!newValue, forKey: "ignorePenPressure") } |
| 36 | } |
| 37 | |
| 38 | /// The pens under a section of tab colour `section`, a COLORREF: its accent, then |
| 39 | /// OneNote 2010's favourites, as the desktop's gallery. |
| 40 | static func gallery(_ section: UInt32) -> [Pen] { |
| 41 | guard let text = take(sb_pens(section)), |
| 42 | let rows = try? JSONSerialization.jsonObject(with: Data(text.utf8)) as? [[Any]] |
| 43 | else { return [] } |
| 44 | return rows.compactMap { row in |
| 45 | guard row.count == 3, let width = row[1] as? NSNumber, let highlighter = row[2] as? Bool else { return nil } |
| 46 | return Pen( |
| 47 | color: (row[0] as? [NSNumber]).map { UIColor(rgb: $0.map(\.uint8Value)) }, width: width.floatValue, |
| 48 | highlighter: highlighter) |
| 49 | } |
| 50 | } |
| 51 | } |
| 52 | |
| 53 | /// A point a touch passed, in the view's bounds, and the Pencil's pressure there from 0 to its |
| 54 | /// greatest, or -1 for a finger, whose strokes keep their pen's width. |
| 55 | typealias InkSample = (point: CGPoint, pressure: Float) |
| 56 | |
| 57 | /// Follows one touch from the moment it lands, gathering every point the hardware sampled, |
| 58 | /// for the canvas's pens, eraser and lasso. A second finger cancels a finger's stroke, so two |
| 59 | /// fingers scroll; a hand resting beside the Pencil is ignored. |
| 60 | final class InkGesture: UIGestureRecognizer { |
| 61 | /// Points the touch passed since the action last took them. |
| 62 | var points: [InkSample] = [] |
| 63 | private var tracked: UITouch? |
| 64 | |
| 65 | private func sample(_ touch: UITouch) -> InkSample { |
| 66 | let pressure = touch.type == .pencil && Pen.pressure ? Float(touch.force / touch.maximumPossibleForce) : -1 |
| 67 | return (touch.preciseLocation(in: view), pressure) |
| 68 | } |
| 69 | |
| 70 | override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent) { |
| 71 | for touch in touches { |
| 72 | if tracked == nil { |
| 73 | tracked = touch |
| 74 | points = [sample(touch)] |
| 75 | state = .began |
| 76 | } else if tracked?.type == .direct { |
| 77 | state = .cancelled |
| 78 | } else { |
| 79 | ignore(touch, for: event) |
| 80 | } |
| 81 | } |
| 82 | } |
| 83 | |
| 84 | override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent) { |
| 85 | guard let tracked, touches.contains(tracked) else { return } |
| 86 | points += (event.coalescedTouches(for: tracked) ?? [tracked]).map(sample) |
| 87 | state = .changed |
| 88 | } |
| 89 | |
| 90 | override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent) { |
| 91 | guard let tracked, touches.contains(tracked) else { return } |
| 92 | points.append(sample(tracked)) |
| 93 | state = .ended |
| 94 | } |
| 95 | |
| 96 | override func touchesCancelled(_ touches: Set<UITouch>, with event: UIEvent) { |
| 97 | state = .cancelled |
| 98 | } |
| 99 | |
| 100 | override func reset() { |
| 101 | tracked = nil |
| 102 | points = [] |
| 103 | } |
| 104 | } |
| 105 | |
| 106 | /// The drawing tools floating over the page, as PencilKit's tool picker lays them out: Select |
| 107 | /// & Type, where a finger scrolls and selects, then pen, highlighter, eraser, lasso and |
| 108 | /// shapes, which a finger draws with too, and the pen's colour and width. |
| 109 | final class InkPicker: UIView { |
| 110 | /// A tool picked, or Select & Type as nil. |
| 111 | var onPick: ((InkTool?) -> Void)? |
| 112 | var onClose: (() -> Void)? |
| 113 | |
| 114 | /// Line, arrow, rectangle and oval, in `sb_view_set_tool`'s order. |
| 115 | private static let shapes = [ |
| 116 | ("line.diagonal", "Line"), ("arrow.up.right", "Arrow"), ("rectangle", "Rectangle"), ("oval", "Oval"), |
| 117 | ] |
| 118 | private let pens: [Pen] |
| 119 | private let backdrop = UIVisualEffectView(effect: UIBlurEffect(style: .systemChromeMaterial)) |
| 120 | private let row = UIStackView() |
| 121 | private let select = InkPicker.tool("hand.point.up.left", "Select and Type") |
| 122 | private let pen = InkPicker.tool("pencil.tip", "Pen") |
| 123 | private let highlighter = InkPicker.tool("highlighter", "Highlighter") |
| 124 | private let eraser = InkPicker.tool("eraser", "Eraser") |
| 125 | private let lasso = InkPicker.tool("lasso", "Lasso") |
| 126 | private let shape = InkPicker.tool("oval", "Shapes") |
| 127 | private let color = UIButton(configuration: .plain()) |
| 128 | /// The pen, highlighter and shape picked last, which their buttons return to. |
| 129 | private var lastPen: UInt8 = 0 |
| 130 | private var lastHighlighter: UInt8 |
| 131 | private var lastShape: UInt8 = 3 |
| 132 | |
| 133 | init(pens: [Pen]) { |
| 134 | self.pens = pens |
| 135 | lastHighlighter = UInt8(pens.firstIndex(where: \.highlighter) ?? 0) |
| 136 | super.init(frame: .zero) |
| 137 | layer.shadowColor = UIColor.black.cgColor |
| 138 | layer.shadowOpacity = 0.18 |
| 139 | layer.shadowRadius = 12 |
| 140 | layer.shadowOffset = CGSize(width: 0, height: 4) |
| 141 | backdrop.layer.cornerRadius = 22 |
| 142 | backdrop.layer.cornerCurve = .continuous |
| 143 | backdrop.clipsToBounds = true |
| 144 | backdrop.layer.borderWidth = 1 / UIScreen.main.scale |
| 145 | backdrop.layer.borderColor = UIColor.separator.cgColor |
| 146 | let picks: [(UIButton, () -> InkTool?)] = [ |
| 147 | (select, { nil }), |
| 148 | (pen, { [unowned self] in .pen(lastPen) }), |
| 149 | (highlighter, { [unowned self] in .pen(lastHighlighter) }), |
| 150 | (eraser, { .eraser }), |
| 151 | (lasso, { .lasso }), |
| 152 | ] |
| 153 | for (button, pick) in picks { |
| 154 | button.addAction(UIAction { [weak self] _ in self?.onPick?(pick()) }, for: .primaryActionTriggered) |
| 155 | row.addArrangedSubview(button) |
| 156 | } |
| 157 | shape.menu = UIMenu(children: [ |
| 158 | UIDeferredMenuElement.uncached { [weak self] done in done(self?.shapeMenu() ?? []) } |
| 159 | ]) |
| 160 | shape.showsMenuAsPrimaryAction = true |
| 161 | row.addArrangedSubview(shape) |
| 162 | row.setCustomSpacing(8, after: select) |
| 163 | row.addArrangedSubview(Self.divider()) |
| 164 | color.configuration?.preferredSymbolConfigurationForImage = UIImage.SymbolConfiguration(pointSize: 24) |
| 165 | color.widthAnchor.constraint(equalToConstant: 40).isActive = true |
| 166 | color.menu = UIMenu(children: [ |
| 167 | UIDeferredMenuElement.uncached { [weak self] done in done(self?.colorMenu() ?? []) } |
| 168 | ]) |
| 169 | color.showsMenuAsPrimaryAction = true |
| 170 | row.addArrangedSubview(color) |
| 171 | let close = UIButton(configuration: .plain()) |
| 172 | close.configuration?.image = UIImage(systemName: "xmark.circle.fill") |
| 173 | close.configuration?.baseForegroundColor = .secondaryLabel |
| 174 | close.accessibilityLabel = "Close" |
| 175 | close.widthAnchor.constraint(equalToConstant: 36).isActive = true |
| 176 | close.addAction(UIAction { [weak self] _ in self?.onClose?() }, for: .primaryActionTriggered) |
| 177 | row.addArrangedSubview(close) |
| 178 | row.spacing = 2 |
| 179 | row.alignment = .center |
| 180 | // Within a narrow column the tray scrolls rather than hide its end. |
| 181 | let scroll = UIScrollView() |
| 182 | scroll.showsHorizontalScrollIndicator = false |
| 183 | scroll.addSubview(row) |
| 184 | backdrop.contentView.addSubview(scroll) |
| 185 | addSubview(backdrop) |
| 186 | for view in [backdrop, scroll, row] as [UIView] { view.translatesAutoresizingMaskIntoConstraints = false } |
| 187 | let fit = scroll.widthAnchor.constraint(equalTo: row.widthAnchor) |
| 188 | fit.priority = .defaultHigh |
| 189 | NSLayoutConstraint.activate([ |
| 190 | backdrop.leadingAnchor.constraint(equalTo: leadingAnchor), |
| 191 | backdrop.trailingAnchor.constraint(equalTo: trailingAnchor), |
| 192 | backdrop.topAnchor.constraint(equalTo: topAnchor), |
| 193 | backdrop.bottomAnchor.constraint(equalTo: bottomAnchor), |
| 194 | scroll.leadingAnchor.constraint(equalTo: backdrop.contentView.leadingAnchor, constant: 8), |
| 195 | scroll.trailingAnchor.constraint(equalTo: backdrop.contentView.trailingAnchor, constant: -8), |
| 196 | scroll.topAnchor.constraint(equalTo: backdrop.contentView.topAnchor), |
| 197 | scroll.bottomAnchor.constraint(equalTo: backdrop.contentView.bottomAnchor), |
| 198 | scroll.heightAnchor.constraint(equalToConstant: 64), |
| 199 | fit, |
| 200 | row.leadingAnchor.constraint(equalTo: scroll.contentLayoutGuide.leadingAnchor), |
| 201 | row.trailingAnchor.constraint(equalTo: scroll.contentLayoutGuide.trailingAnchor), |
| 202 | row.topAnchor.constraint(equalTo: scroll.contentLayoutGuide.topAnchor), |
| 203 | row.bottomAnchor.constraint(equalTo: scroll.contentLayoutGuide.bottomAnchor), |
| 204 | row.heightAnchor.constraint(equalTo: scroll.frameLayoutGuide.heightAnchor), |
| 205 | ]) |
| 206 | } |
| 207 | |
| 208 | required init?(coder: NSCoder) { fatalError() } |
| 209 | |
| 210 | private var tool: InkTool = .pen(0) |
| 211 | |
| 212 | private func isHighlighter(_ tool: InkTool) -> Bool { |
| 213 | if case .pen(let place) = tool { pens[Int(place)].highlighter } else { false } |
| 214 | } |
| 215 | |
| 216 | /// Shows `tool` as the Pencil's, raised, and Select & Type chosen unless a finger draws. |
| 217 | func show(_ tool: InkTool, fingerDraws: Bool) { |
| 218 | self.tool = tool |
| 219 | switch tool { |
| 220 | case .pen(let place) where isHighlighter(tool): lastHighlighter = place |
| 221 | case .pen(let place): lastPen = place |
| 222 | case .shape(let kind): lastShape = kind |
| 223 | default: break |
| 224 | } |
| 225 | let raised: [(UIButton, Bool)] = [ |
| 226 | (select, !fingerDraws), |
| 227 | (pen, tool == .pen(lastPen)), |
| 228 | (highlighter, tool == .pen(lastHighlighter)), |
| 229 | (eraser, tool == .eraser), |
| 230 | (lasso, tool == .lasso), |
| 231 | (shape, tool == .shape(lastShape)), |
| 232 | ] |
| 233 | for (button, on) in raised { button.isSelected = on } |
| 234 | pen.configuration?.baseForegroundColor = pens[Int(lastPen)].color ?? .label |
| 235 | highlighter.configuration?.baseForegroundColor = pens[Int(lastHighlighter)].color |
| 236 | shape.configuration?.image = UIImage(systemName: Self.shapes[Int(lastShape)].0) |
| 237 | let shown = pens[Int(isHighlighter(tool) ? lastHighlighter : lastPen)] |
| 238 | color.configuration?.image = UIImage(systemName: "circle.fill") |
| 239 | color.configuration?.baseForegroundColor = shown.color ?? .label |
| 240 | color.accessibilityLabel = "Colour, \(Self.name(shown.color))" |
| 241 | } |
| 242 | |
| 243 | /// The shapes, then OneNote's Snap To Grid. |
| 244 | private func shapeMenu() -> [UIMenuElement] { |
| 245 | let shapes = Self.shapes.enumerated().map { place, shape in |
| 246 | let kind = UInt8(place) |
| 247 | let action = UIAction(title: shape.1, image: UIImage(systemName: shape.0)) { [weak self] _ in |
| 248 | self?.onPick?(.shape(kind)) |
| 249 | } |
| 250 | action.state = tool == .shape(kind) ? .on : .off |
| 251 | return action |
| 252 | } |
| 253 | let snap = UIAction(title: "Snap To Grid", image: UIImage(systemName: "grid")) { _ in |
| 254 | Editing.snapToGrid.toggle() |
| 255 | } |
| 256 | snap.state = Editing.snapToGrid ? .on : .off |
| 257 | return [UIMenu(options: .displayInline, children: shapes), UIMenu(options: .displayInline, children: [snap])] |
| 258 | } |
| 259 | |
| 260 | /// The colours of the kind of pen shown, and for a pen its two widths. |
| 261 | private func colorMenu() -> [UIMenuElement] { |
| 262 | let highlighting = isHighlighter(tool) |
| 263 | let current = highlighting ? lastHighlighter : lastPen |
| 264 | let width = pens[Int(current)].width |
| 265 | let colors = pens.indices.filter { |
| 266 | pens[$0].highlighter == highlighting && (highlighting || pens[$0].width == width) |
| 267 | } |
| 268 | let swatches = colors.map { place in |
| 269 | let color = pens[place].color ?? .label |
| 270 | let action = UIAction( |
| 271 | title: Self.name(pens[place].color), |
| 272 | image: UIImage(systemName: "circle.fill")?.withTintColor(color, renderingMode: .alwaysOriginal) |
| 273 | ) { [weak self] _ in self?.onPick?(.pen(UInt8(place))) } |
| 274 | action.state = UInt8(place) == current ? .on : .off |
| 275 | return action |
| 276 | } |
| 277 | guard !highlighting else { return swatches } |
| 278 | // The same colour in the gallery's other width, where it has one. |
| 279 | let widths = Set(pens.filter { !$0.highlighter }.map(\.width)).sorted() |
| 280 | let weights = widths.enumerated().map { step, other in |
| 281 | let match = pens.indices.first { |
| 282 | !pens[$0].highlighter && pens[$0].width == other && pens[$0].color == pens[Int(current)].color |
| 283 | } |
| 284 | let action = UIAction( |
| 285 | title: step == 0 ? "Thin" : "Thick", image: UIImage(systemName: step == 0 ? "line.3.horizontal" : "equal"), |
| 286 | attributes: match == nil ? .disabled : [] |
| 287 | ) { [weak self] _ in if let match { self?.onPick?(.pen(UInt8(match))) } } |
| 288 | action.state = other == width ? .on : .off |
| 289 | return action |
| 290 | } |
| 291 | let pressure = UIAction(title: "Use pen pressure sensitivity", image: UIImage(systemName: "hand.draw")) { _ in |
| 292 | Pen.pressure.toggle() |
| 293 | } |
| 294 | pressure.state = Pen.pressure ? .on : .off |
| 295 | return [ |
| 296 | UIMenu(options: .displayInline, children: swatches), UIMenu(options: .displayInline, children: weights), |
| 297 | UIMenu(options: .displayInline, children: [pressure]), |
| 298 | ] |
| 299 | } |
| 300 | |
| 301 | private static func name(_ color: UIColor?) -> String { color?.accessibilityName.capitalized ?? "Black" } |
| 302 | |
| 303 | private static func tool(_ symbol: String, _ label: String) -> UIButton { |
| 304 | var configuration = UIButton.Configuration.plain() |
| 305 | configuration.image = UIImage(systemName: symbol) |
| 306 | configuration.preferredSymbolConfigurationForImage = UIImage.SymbolConfiguration(pointSize: 24) |
| 307 | configuration.baseForegroundColor = .label |
| 308 | configuration.cornerStyle = .medium |
| 309 | let button = UIButton(configuration: configuration) |
| 310 | button.accessibilityLabel = label |
| 311 | button.widthAnchor.constraint(equalToConstant: 42).isActive = true |
| 312 | button.heightAnchor.constraint(equalToConstant: 52).isActive = true |
| 313 | // The chosen tool stands up out of the tray, as PencilKit's does. |
| 314 | button.configurationUpdateHandler = { button in |
| 315 | button.transform = CGAffineTransform(translationX: 0, y: button.isSelected ? -5 : 4) |
| 316 | button.configuration?.background.backgroundColor = |
| 317 | button.isSelected ? UIColor.tintColor.withAlphaComponent(0.14) : .clear |
| 318 | } |
| 319 | return button |
| 320 | } |
| 321 | |
| 322 | private static func divider() -> UIView { |
| 323 | let line = UIView() |
| 324 | line.backgroundColor = .separator |
| 325 | line.translatesAutoresizingMaskIntoConstraints = false |
| 326 | NSLayoutConstraint.activate([ |
| 327 | line.widthAnchor.constraint(equalToConstant: 1), |
| 328 | line.heightAnchor.constraint(equalToConstant: 32), |
| 329 | ]) |
| 330 | return line |
| 331 | } |
| 332 | } |