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 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.
109final 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}