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