1import UIKit
2
3private final class MetalView: UIView {
4 override class var layerClass: AnyClass { CAMetalLayer.self }
5}
6
7/// An offset into the active outline's shown text in UTF-16 units, paragraphs joined by a
8/// newline; collapsed paragraphs are not part of it.
9private final class Position: UITextPosition {
10 let value: Int
11 init(_ value: Int) { self.value = value }
12}
13
14private final class Range: UITextRange {
15 let lo: Int
16 let hi: Int
17 init(_ a: Int, _ b: Int) { (lo, hi) = (min(a, b), max(a, b)) }
18 override var start: UITextPosition { Position(lo) }
19 override var end: UITextPosition { Position(hi) }
20 override var isEmpty: Bool { lo == hi }
21}
22
23private final class SelectionRect: UITextSelectionRect {
24 private let frame: CGRect
25 private let first: Bool
26 private let last: Bool
27 init(_ frame: CGRect, first: Bool, last: Bool) { (self.frame, self.first, self.last) = (frame, first, last) }
28 override var rect: CGRect { frame }
29 override var writingDirection: NSWritingDirection { .leftToRight }
30 override var containsStart: Bool { first }
31 override var containsEnd: Bool { last }
32 override var isVertical: Bool { false }
33}
34
35private extension UITextPosition { var offset: Int { (self as! Position).value } }
36private extension UITextRange {
37 var lower: Int { (self as! Range).lo }
38 var upper: Int { (self as! Range).hi }
39}
40
41/// The page date's fields, from `sb_view_date_request`.
42enum DateField: Int8 {
43 case date, time
44}
45
46/// What a touch lands on; see `sb_view_target`.
47private enum Target: UInt8 {
48 case page, activeText, text, grip
49}
50
51/// Routes the system's undo (shake, three-finger swipe, the keyboard's undo key) to the
52/// canvas's history.
53private final class CanvasUndoManager: UndoManager {
54 weak var canvas: CanvasView?
55 override var canUndo: Bool { canvas?.canUndo(redo: false) ?? false }
56 override var canRedo: Bool { canvas?.canUndo(redo: true) ?? false }
57 override func undo() { canvas?.undo(redo: false) }
58 override func redo() { canvas?.undo(redo: true) }
59}
60
61/// A canvas page: a scroll view whose pan and pinch drive the canvas viewport, with the page
62/// drawn into a Metal layer pinned to the visible bounds, and the active outline's text
63/// behind `UITextInput` so the system keyboard, marked text, autocorrection, dictation, text
64/// interaction, loupe and edit menu work on it.
65final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextInteractionDelegate,
66 UITextSelectionDisplayInteractionDelegate, UIGestureRecognizerDelegate, UIEditMenuInteractionDelegate,
67 UIPencilInteractionDelegate, UIScribbleInteractionDelegate
68{
69 /// The zoom a page opens at once the reader has pinched one.
70 private static let zoomKey = "zoom"
71
72 private let section: Section
73 private let page: String
74 private let metal = MetalView()
75 /// Sized to the page content at 100%, so the scroll view's zoom scale is the page zoom.
76 private let content = UIView()
77 private var handle: OpaquePointer?
78 private var link: CADisplayLink?
79 private var dirty = true
80 /// The content's corner at 100%, from which the scroll offset is measured.
81 private var origin = CGPoint.zero
82 private var syncing = false
83 private var scrolled = false
84 private var drag: Drag?
85 /// The zoom and finger spread a pinch started from.
86 private var pinchStart: (zoom: CGFloat, spread: CGFloat)?
87 private var inDetent = false
88 private let detentTick = UISelectionFeedbackGenerator()
89 /// How far right of the content's corner a page opens.
90 private var left: CGFloat = 0
91 /// The zoom a double tap returns to.
92 private var resting: CGFloat = 1
93 private var tapZooming = false
94 private lazy var tap = UITapGestureRecognizer(target: self, action: #selector(tapped))
95 private lazy var doubleTap: UITapGestureRecognizer = {
96 let recognizer = UITapGestureRecognizer(target: self, action: #selector(doubleTapped))
97 recognizer.numberOfTapsRequired = 2
98 return recognizer
99 }()
100 /// Moves or widens the focused outline from its grips.
101 private lazy var grip = UIPanGestureRecognizer(target: self, action: #selector(gripped))
102 /// Where the touch the grip and handle drags look at came down.
103 private var touchStart = CGPoint.zero
104 /// Drags a selection handle, which the text interaction leaves to the view drawing it.
105 private lazy var handleDrag = UIPanGestureRecognizer(target: self, action: #selector(handleDragged))
106 /// The selection end a handle drag keeps, and the loupe following the other.
107 private var handleAnchor = 0
108 private var loupe: UITextLoupeSession?
109 private lazy var editMenu = UIEditMenuInteraction(delegate: self)
110 private let interaction = UITextInteraction(for: .editable)
111 /// Draws the caret, selection highlight and handles, which the text interaction's
112 /// gestures move; the canvas paints neither.
113 private lazy var display = UITextSelectionDisplayInteraction(textInput: self, delegate: self)
114 private let history = CanvasUndoManager()
115 private let spaceHint = Hint()
116 /// The Pencil's strokes, and a finger's while `fingerInks`.
117 private lazy var ink = InkGesture(target: self, action: #selector(inked))
118 /// The tool the canvas has, which touches switch between.
119 private var appliedTool = InkTool.select
120 /// The tool the Pencil draws with; the Pencil's double tap swaps it with the one before.
121 private(set) var inkTool = InkTool.pen(0)
122 private var previousInkTool = InkTool.eraser
123 /// A tool is picked for a finger too, from picking it until Select & Type or closing the
124 /// picker.
125 private var fingerDraws = false
126 /// The section's tab colour as a COLORREF, which its pen gallery and shapes follow.
127 private var sectionColor: UInt32 {
128 section.tab.color.enumerated().reduce(0) { $0 | UInt32($1.element) << (8 * UInt32($1.offset)) }
129 }
130 private lazy var picker = InkPicker(pens: Pen.gallery(sectionColor))
131
132 weak var inputDelegate: UITextInputDelegate?
133 lazy var tokenizer: UITextInputTokenizer = LineTokenizer(canvas: self)
134 /// Called when editing starts or stops and after each change, for the bar buttons.
135 var onChange: (() -> Void)?
136 /// Called once the page is first on screen.
137 var onOpened: (() -> Void)?
138 /// Called when a tap on the page date asks to change it, with the date it shows.
139 var onDate: ((DateField, Date) -> Void)?
140 /// Called when a tap on a recording, or beside a note linked to one, asks to play it.
141 var onPlay: ((PlayRequest) -> Void)?
142 /// Formatting above the keyboard.
143 let formatBar = FormatBar()
144 /// Room above the page for a bar over it.
145 var topInset: CGFloat = 0 {
146 didSet {
147 contentInset.top = topInset
148 home()
149 }
150 }
151
152 /// Room below the page, above the keyboard, for a bar over its foot.
153 var bottomInset: CGFloat = 0 {
154 didSet {
155 guard bottomInset != oldValue else { return }
156 contentInset.bottom += bottomInset - oldValue
157 verticalScrollIndicatorInsets.bottom = contentInset.bottom
158 revealCaret()
159 }
160 }
161
162 init(section: Section, page: String) {
163 self.section = section
164 self.page = page
165 super.init(frame: .zero)
166 backgroundColor = .systemBackground
167 delegate = self
168 minimumZoomScale = 0.25
169 maximumZoomScale = 4
170 keyboardDismissMode = .interactive
171 // A phone on its side keeps the page clear of the sensor housing, as it keeps the top.
172 contentInsetAdjustmentBehavior = .always
173 addSubview(content)
174 insertSubview(metal, at: 0)
175 // Touches land on the scroll view itself, where the text interaction looks for them.
176 metal.isUserInteractionEnabled = false
177 content.isUserInteractionEnabled = false
178 let layer = metal.layer as! CAMetalLayer
179 layer.isOpaque = true
180 // Frames reach the screen with the transaction that moves UIKit's caret and handles.
181 layer.presentsWithTransaction = true
182 for recognizer in [tap, doubleTap, grip, handleDrag, ink] {
183 recognizer.delegate = self
184 addGestureRecognizer(recognizer)
185 }
186 // The Pencil draws; fingers, a trackpad and a mouse scroll and select.
187 let hands = [UITouch.TouchType.direct, .indirectPointer].map { NSNumber(value: $0.rawValue) }
188 let recognizers: [UIGestureRecognizer?] = [tap, doubleTap, grip, handleDrag, panGestureRecognizer, pinchGestureRecognizer]
189 for recognizer in recognizers {
190 recognizer?.allowedTouchTypes = hands
191 }
192 let pencil = UIPencilInteraction()
193 pencil.delegate = self
194 addInteraction(pencil)
195 addInteraction(UIScribbleInteraction(delegate: self))
196 picker.onPick = { [weak self] tool in self?.pick(tool) }
197 picker.onClose = { [weak self] in self?.showPicker(false) }
198 tap.require(toFail: doubleTap)
199 panGestureRecognizer.require(toFail: grip)
200 panGestureRecognizer.require(toFail: handleDrag)
201 interaction.textInput = self
202 interaction.delegate = self
203 addInteraction(interaction)
204 addInteraction(display)
205 display.isActivated = false
206 addInteraction(editMenu)
207 history.canvas = self
208 NotificationCenter.default.addObserver(
209 self, selector: #selector(keyboardChanged), name: UIResponder.keyboardWillChangeFrameNotification,
210 object: nil)
211 registerForTraitChanges([UITraitUserInterfaceStyle.self]) { (view: CanvasView, _) in view.paper() }
212 formatBar.onApply = { [weak self] command in self?.apply(command) }
213 formatBar.onStyle = { [weak self] place in self?.applyStyle(place) }
214 formatBar.stylePlace = { [weak self] in self?.stylePlace ?? -1 }
215 formatBar.onDismiss = { [weak self] in _ = self?.resignFirstResponder() }
216 }
217
218 required init?(coder: NSCoder) { fatalError() }
219
220 deinit {
221 link?.invalidate()
222 if let handle { sb_view_free(handle) }
223 }
224
225 // MARK: Drawing
226
227 override func layoutSubviews() {
228 super.layoutSubviews()
229 metal.frame = bounds
230 // Selection views go in at the back, where the opaque page would hide them.
231 sendSubviewToBack(metal)
232 let scale = window?.screen.scale ?? 3
233 metal.layer.contentsScale = scale
234 guard bounds.width > 0, bounds.height > 0 else { return }
235 let pixels = CGSize(width: (bounds.width * scale).rounded(), height: (bounds.height * scale).rounded())
236 if let handle {
237 // Scrolling lays the view out too; only a new size reconfigures the surface.
238 guard (metal.layer as! CAMetalLayer).drawableSize != pixels else { return }
239 sb_view_resize(handle, Float(bounds.width), Float(bounds.height), Float(scale))
240 transform()
241 } else {
242 handle = sb_view_new(
243 Unmanaged.passUnretained(metal.layer).toOpaque(), section.handle, page,
244 Float(bounds.width), Float(bounds.height), Float(scale))
245 guard let handle else { return }
246 sb_view_focus(handle, false)
247 paper()
248 sync()
249 let skip = margin
250 resting = openingZoom(skipping: skip)
251 zoomScale = resting
252 left = skip * resting
253 home()
254 onOpened?()
255 #if DEBUG
256 runScript()
257 #endif
258 }
259 }
260
261 override func didMoveToWindow() {
262 super.didMoveToWindow()
263 link?.invalidate()
264 link = nil
265 guard window != nil else { return }
266 let link = CADisplayLink(target: self, selector: #selector(frame(_:)))
267 link.preferredFrameRateRange = CAFrameRateRange(minimum: 60, maximum: 120, preferred: 120)
268 link.add(to: .main, forMode: .common)
269 self.link = link
270 }
271
272 @objc private func frame(_ link: CADisplayLink) {
273 guard let handle else { return }
274 if sb_view_frame_pending(handle) { dirty = true }
275 guard dirty else { return }
276 dirty = false
277 // A frame presented with a transaction shows only once one commits, which an edit
278 // that moves no UIKit view would not otherwise cause.
279 CATransaction.begin()
280 sb_view_render(handle)
281 CATransaction.commit()
282 }
283
284 /// The page follows the system's appearance, on the desktop's dark paper in dark mode.
285 private func paper() {
286 guard let handle else { return }
287 sb_view_set_dark(handle, traitCollection.userInterfaceStyle == .dark)
288 dirty = true
289 }
290
291 // MARK: Scrolling and zoom
292
293 /// Sizes the scroll content to the page's after an edit, keeping the page point at the
294 /// view's corner. The scroll view owns the offset: the canvas's own clamping and caret
295 /// reveal know neither the navigation bar nor the keyboard, so `transform` overrides them.
296 private func sync() {
297 guard let handle else { return }
298 var bounds: [Float] = [0, 0, 0, 0]
299 sb_view_content(handle, &bounds)
300 let corner = CGPoint(x: origin.x + contentOffset.x / zoomScale, y: origin.y + contentOffset.y / zoomScale)
301 origin = CGPoint(x: CGFloat(bounds[0]), y: CGFloat(bounds[1]))
302 let size = CGSize(width: CGFloat(bounds[2]) - origin.x, height: CGFloat(bounds[3]) - origin.y)
303 syncing = true
304 content.bounds = CGRect(origin: .zero, size: size)
305 content.center = CGPoint(x: size.width * zoomScale / 2, y: size.height * zoomScale / 2)
306 contentSize = CGSize(width: size.width * zoomScale, height: size.height * zoomScale)
307 contentOffset = CGPoint(x: (corner.x - origin.x) * zoomScale, y: (corner.y - origin.y) * zoomScale)
308 syncing = false
309 transform()
310 }
311
312 private func transform() {
313 guard let handle, !syncing else { return }
314 sb_view_set_transform(
315 handle, Float(zoomScale), Float(origin.x + contentOffset.x / zoomScale),
316 Float(origin.y + contentOffset.y / zoomScale))
317 metal.frame = bounds
318 dirty = true
319 }
320
321 /// The zoom the reader last pinched to, within reason, or before that 100%, where OneNote's
322 /// 11 pt body text shows at 17 points; less where that fits the page right of `skip`
323 /// across the screen.
324 private func openingZoom(skipping skip: CGFloat) -> CGFloat {
325 let pinched = UserDefaults.standard.object(forKey: Self.zoomKey) as? CGFloat ?? 1
326 let fit = bounds.inset(by: safeAreaInsets).width / max(1, content.bounds.width - skip)
327 // A page opens readable however far the last one was pinched or however wide it is.
328 return max(0.75, min(2, pinched, fit))
329 }
330
331 /// The empty page left of its first outline, which a phone skips on opening, keeping
332 /// room for the tags OneNote hangs left of the text.
333 private var margin: CGFloat {
334 guard let handle else { return 0 }
335 var block: [Float] = [0, 0, 0, 0]
336 guard sb_view_block(handle, 0, 0, &block) else { return 0 }
337 return max(0, CGFloat(block[0]) - origin.x - 34)
338 }
339
340 /// Until the reader scrolls, the page's corner stays below the navigation bar as its
341 /// insets settle.
342 override func adjustedContentInsetDidChange() {
343 super.adjustedContentInsetDidChange()
344 home()
345 }
346
347 private func home() {
348 if !scrolled { contentOffset = CGPoint(x: left - adjustedContentInset.left, y: -adjustedContentInset.top) }
349 }
350
351 /// A drag that starts within 30° of an axis sticks to it; a 24-point stretch of the
352 /// finger more than 40° off it lets go, and the page catches up over the next 48 points.
353 private struct Drag {
354 static let snap = tan(CGFloat.pi / 6)
355 static let release = tan(CGFloat.pi * 2 / 9)
356 static let stretch: CGFloat = 24
357 static let catchUp: CGFloat = 48
358
359 /// The axis the lock holds still.
360 let cross: WritableKeyPath<CGPoint, CGFloat>
361 var locked = true
362 /// The offset last shown, from which the scroll view's next step is measured.
363 var offset: CGPoint
364 /// The translation where the stretch of the gesture being judged began.
365 var stretch: CGPoint
366 /// How far the held axis trails the finger.
367 var lag: CGFloat = 0
368 }
369
370 func scrollViewWillBeginDragging(_ scrollView: UIScrollView) {
371 scrolled = true
372 let moved = panGestureRecognizer.translation(in: self)
373 let cross: WritableKeyPath<CGPoint, CGFloat>? =
374 abs(moved.x) <= abs(moved.y) * Drag.snap ? \.x : abs(moved.y) <= abs(moved.x) * Drag.snap ? \.y : nil
375 drag = cross.map { Drag(cross: $0, offset: contentOffset, stretch: moved) }
376 }
377
378 func scrollViewWillBeginZooming(_ scrollView: UIScrollView, with view: UIView?) {
379 scrolled = true
380 drag = nil
381 pinchStart = spread.map { (zoomScale, $0) }
382 inDetent = abs(log(zoomScale)) < Self.detent
383 detentTick.prepare()
384 }
385
386 func viewForZooming(in scrollView: UIScrollView) -> UIView? { content }
387
388 func scrollViewDidScroll(_ scrollView: UIScrollView) {
389 if var drag, isDragging || isDecelerating, !isZooming {
390 let cross = drag.cross
391 let along = cross == \CGPoint.x ? \CGPoint.y : \CGPoint.x
392 var offset = contentOffset
393 let step = offset[keyPath: cross] - drag.offset[keyPath: cross]
394 if drag.locked {
395 drag.lag += step
396 offset[keyPath: cross] = drag.offset[keyPath: cross]
397 let moved = panGestureRecognizer.translation(in: self)
398 let stretch = CGPoint(x: moved.x - drag.stretch.x, y: moved.y - drag.stretch.y)
399 if isTracking, hypot(stretch.x, stretch.y) >= Drag.stretch {
400 drag.locked = abs(stretch[keyPath: cross]) <= abs(stretch[keyPath: along]) * Drag.release
401 drag.stretch = moved
402 }
403 } else {
404 let travel = hypot(step, offset[keyPath: along] - drag.offset[keyPath: along])
405 let paid = drag.lag * (1 - exp(-travel / Drag.catchUp))
406 offset[keyPath: cross] += paid
407 drag.lag -= paid
408 }
409 drag.offset = offset
410 self.drag = drag.locked || abs(drag.lag) >= 0.5 ? drag : nil
411 if offset != contentOffset { contentOffset = offset }
412 }
413 transform()
414 }
415
416 /// How far apart a pinch's two fingers are.
417 private var spread: CGFloat? {
418 guard let pinch = pinchGestureRecognizer, pinch.numberOfTouches == 2 else { return nil }
419 let a = pinch.location(ofTouch: 0, in: self), b = pinch.location(ofTouch: 1, in: self)
420 return hypot(a.x - b.x, a.y - b.y)
421 }
422
423 /// How far either side of 100% a pinch sticks, as a log of the zoom.
424 private static let detent = log(CGFloat(1.1))
425
426 /// Pinching near 100% sticks there: within `detent` of it the zoom follows the fingers
427 /// on a curve flat at 100% that meets them again at the band's edges.
428 func scrollViewDidZoom(_ scrollView: UIScrollView) {
429 if let pinch = pinchGestureRecognizer, pinch.state == .changed, let pinchStart, let spread {
430 // The scroll view's pinch reports the zoom it shows, so the fingers give the raw one.
431 let raw = log(pinchStart.zoom * spread / pinchStart.spread)
432 let x = abs(raw) / Self.detent
433 if x < 1 {
434 if !inDetent { detentTick.selectionChanged() }
435 let shown = exp(Self.detent * x * x * (2 - x) * (raw < 0 ? -1 : 1))
436 if abs(shown - zoomScale) > 0.0001 {
437 let focus = pinch.location(in: content)
438 let screen = CGPoint(x: pinch.location(in: self).x - contentOffset.x, y: pinch.location(in: self).y - contentOffset.y)
439 zoomScale = shown
440 contentOffset = CGPoint(x: focus.x * shown - screen.x, y: focus.y * shown - screen.y)
441 }
442 }
443 inDetent = x < 1
444 }
445 transform()
446 // Scrolling carries the caret and handles along; zooming moves them within the page.
447 display.setNeedsSelectionUpdate()
448 }
449
450 func scrollViewDidEndZooming(_ scrollView: UIScrollView, with view: UIView?, atScale scale: CGFloat) {
451 pinchStart = nil
452 if tapZooming {
453 tapZooming = false
454 } else {
455 // A pinch let go within the detent settles on 100%.
456 resting = abs(log(scale)) < Self.detent ? 1 : scale
457 if resting != scale { setZoomScale(resting, animated: true) }
458 UserDefaults.standard.set(Double(resting), forKey: Self.zoomKey)
459 }
460 }
461
462 @objc private func doubleTapped(_ recognizer: UITapGestureRecognizer) {
463 zoom(at: recognizer.location(in: self))
464 }
465
466 /// Zooms to fit the outline nearest `point` across the view, or back out, as a double
467 /// tap does in Safari.
468 func zoom(at point: CGPoint) {
469 guard let handle else { return }
470 tapZooming = true
471 var block: [Float] = [0, 0, 0, 0]
472 let local = visible(point)
473 guard zoomScale <= resting * 1.05, sb_view_block(handle, Float(local.x), Float(local.y), &block) else {
474 setZoomScale(resting, animated: true)
475 return
476 }
477 let width = bounds.inset(by: safeAreaInsets).width
478 let margin: CGFloat = 12
479 let scale = min(maximumZoomScale, (width - 2 * margin) / (CGFloat(block[2] - block[0])))
480 guard scale > zoomScale * 1.05 else {
481 setZoomScale(resting, animated: true)
482 return
483 }
484 let x = CGFloat(block[0]) - origin.x - margin / scale
485 let y = point.y / zoomScale - bounds.height / scale / 2
486 zoom(to: CGRect(x: x, y: y, width: width / scale, height: bounds.height / scale), animated: true)
487 }
488
489 @objc private func keyboardChanged(_ notification: Notification) {
490 guard let frame = notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect,
491 let window
492 else { return }
493 let keyboard = convert(frame, from: window.screen.coordinateSpace)
494 let overlap = max(0, bounds.maxY - keyboard.minY - safeAreaInsets.bottom)
495 contentInset.bottom = overlap + bottomInset
496 verticalScrollIndicatorInsets.bottom = contentInset.bottom
497 revealCaret()
498 }
499
500 // MARK: Touch
501
502 private func target(_ point: CGPoint) -> Target {
503 guard let handle else { return .page }
504 let local = visible(point)
505 return Target(rawValue: sb_view_target(handle, Float(local.x), Float(local.y))) ?? .page
506 }
507
508 func gestureRecognizer(_ recognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool {
509 if recognizer === ink { return !readOnly && (touch.type == .pencil || fingerInks && touch.type == .direct) }
510 if recognizer === grip || recognizer === handleDrag { touchStart = touch.location(in: self) }
511 return !fingerInks || ![tap, doubleTap, grip, handleDrag].contains(recognizer)
512 }
513
514 override func gestureRecognizerShouldBegin(_ recognizer: UIGestureRecognizer) -> Bool {
515 switch recognizer {
516 case tap:
517 !readOnly && [.page, .grip].contains(target(recognizer.location(in: self)))
518 && !onHandle(recognizer.location(in: self))
519 case doubleTap: target(recognizer.location(in: self)) == .page
520 case grip: !readOnly && target(touchStart) == .grip
521 case handleDrag: onHandle(touchStart)
522 default: super.gestureRecognizerShouldBegin(recognizer)
523 }
524 }
525
526 /// The system's text interaction works in the outline taking input; a touch on another
527 /// outline's text focuses it first, as a tap there would.
528 func interactionShouldBegin(_ interaction: UITextInteraction, at point: CGPoint) -> Bool {
529 if readOnly || fingerInks || ink.state != .possible { return false }
530 if onHandle(point) { return true }
531 switch target(point) {
532 case .activeText: break
533 case .text: press(at: point)
534 case .page, .grip: return false
535 }
536 if !isFirstResponder { _ = becomeFirstResponder() }
537 return true
538 }
539
540 /// Whether `point` is on a selection handle, whose knob hangs off the text.
541 private func onHandle(_ point: CGPoint) -> Bool {
542 guard isFirstResponder, let range = selectedTextRange, !range.isEmpty else { return false }
543 return [range.start, range.end].contains {
544 caretRect(for: $0).insetBy(dx: -22, dy: -22).contains(point)
545 }
546 }
547
548 @objc private func tapped(_ recognizer: UITapGestureRecognizer) {
549 tap(at: recognizer.location(in: self))
550 }
551
552 /// A tap at `point` in the scroll view's bounds: places the caret, focuses an outline or
553 /// starts a new one, or picks a drawing.
554 func tap(at point: CGPoint) {
555 press(at: point)
556 var seconds: Int64 = 0
557 if let request = playRequest() {
558 onPlay?(request)
559 } else if let handle, let field = DateField(rawValue: sb_view_date_request(handle, &seconds)) {
560 onDate?(field, Date(timeIntervalSince1970: TimeInterval(seconds)))
561 } else if inkFrame != nil {
562 showInkMenu()
563 } else if !isFirstResponder {
564 _ = becomeFirstResponder()
565 }
566 }
567
568 /// Gives the page `date`, which the date and time under its title show.
569 func changeDate(_ date: Date) {
570 guard let handle else { return }
571 let (day, time) = titleDate(date)
572 edit(external: true) { sb_view_change_date(handle, Int64(date.timeIntervalSince1970.rounded()), day, time) }
573 }
574
575 private func press(at point: CGPoint) {
576 guard let handle else { return }
577 fingerTool(at: point)
578 let point = visible(point)
579 edit(external: true) {
580 let pressed = sb_view_press(handle, Float(point.x), Float(point.y))
581 return sb_view_release(handle) || pressed
582 }
583 }
584
585 @objc private func handleDragged(_ recognizer: UIPanGestureRecognizer) {
586 guard let range = selectedTextRange else { return }
587 // The finger sits below the knob; aim at the text line above it.
588 let point = recognizer.location(in: self)
589 let aim = CGPoint(x: point.x, y: point.y - 16)
590 switch recognizer.state {
591 case .began:
592 let start = caretRect(for: range.start)
593 let end = caretRect(for: range.end)
594 let nearStart = hypot(start.midX - touchStart.x, start.midY - touchStart.y)
595 < hypot(end.midX - touchStart.x, end.midY - touchStart.y)
596 handleAnchor = nearStart ? range.upper : range.lower
597 let handle = display.handleViews.min {
598 hypot($0.center.x - touchStart.x, $0.center.y - touchStart.y)
599 < hypot($1.center.x - touchStart.x, $1.center.y - touchStart.y)
600 }
601 loupe = UITextLoupeSession.begin(at: aim, fromSelectionWidgetView: handle, in: self)
602 editMenu.dismissMenu()
603 fallthrough
604 case .changed:
605 guard let position = closestPosition(to: aim) else { return }
606 // A handle stops one character short of the other.
607 let step = position.offset < handleAnchor || (position.offset == handleAnchor && handleAnchor > 0) ? -1 : 1
608 let end = position.offset == handleAnchor ? handleAnchor + step : position.offset
609 let moved = Range(handleAnchor, min(max(end, 0), endOfDocument.offset))
610 edit(external: true) { choose(moved) }
611 let caret = caretRect(for: position)
612 loupe?.move(to: CGPoint(x: caret.midX, y: caret.midY), withCaretRect: caret, trackingCaret: false)
613 default:
614 loupe?.invalidate()
615 loupe = nil
616 let end = caretRect(for: range.end)
617 editMenu.presentEditMenu(with: UIEditMenuConfiguration(
618 identifier: nil, sourcePoint: CGPoint(x: end.midX, y: caretRect(for: range.start).minY)))
619 }
620 }
621
622 @objc private func gripped(_ recognizer: UIPanGestureRecognizer) {
623 guard let handle else { return }
624 let point = visible(recognizer.location(in: self))
625 switch recognizer.state {
626 case .began:
627 fingerTool(at: touchStart)
628 let start = visible(touchStart)
629 _ = sb_view_press(handle, Float(start.x), Float(start.y))
630 _ = sb_view_drag(handle, Float(point.x), Float(point.y))
631 // The caret and handles stay behind until the outline lands.
632 display.isActivated = false
633 editMenu.dismissMenu()
634 dirty = true
635 case .changed:
636 _ = sb_view_drag(handle, Float(point.x), Float(point.y))
637 dirty = true
638 default:
639 display.isActivated = isFirstResponder
640 edit(external: true) { sb_view_release(handle) }
641 spaceHint.hide()
642 }
643 }
644
645 // MARK: Drawing tools
646
647 /// Gives the canvas `tool` for the touch starting, where it has another: switching lets
648 /// picked drawings go.
649 private func use(_ tool: InkTool) {
650 guard let handle, tool != appliedTool else { return }
651 appliedTool = tool
652 sb_view_set_tool(handle, tool.code.tool, tool.code.detail, sectionColor)
653 dirty = true
654 }
655
656 /// A finger draws with the picked tool, unless the system keeps drawing to the Pencil.
657 private var fingerInks: Bool { fingerDraws && !UIPencilInteraction.prefersPencilOnlyDrawing }
658
659 /// A finger scrolls, selects and types, and drags what the lasso picked with the lasso.
660 private func fingerTool(at point: CGPoint) {
661 if inkFrame?.contains(point) != true { use(.select) }
662 }
663
664 /// The frame of the drawings the lasso or a tap picked, in the scroll view's bounds.
665 private var inkFrame: CGRect? {
666 guard let handle else { return nil }
667 var rect: [Float] = [0, 0, 0, 0]
668 return sb_view_ink_selection(handle, &rect) ? bounded(rect) : nil
669 }
670
671 /// The Pencil's tool, and a finger's while `fingerInks`.
672 func setInkTool(_ tool: InkTool) {
673 guard tool != .select else { return }
674 if tool != inkTool { previousInkTool = inkTool }
675 inkTool = tool
676 picker.show(inkTool, fingerDraws: fingerInks)
677 }
678
679 /// A tool picked in the picker, which a finger draws with too, or Select & Type as nil.
680 private func pick(_ tool: InkTool?) {
681 if let tool { setInkTool(tool) }
682 setFingerDraws(tool != nil)
683 }
684
685 private func setFingerDraws(_ draws: Bool) {
686 fingerDraws = draws
687 // Two fingers scroll while one draws.
688 panGestureRecognizer.minimumNumberOfTouches = fingerInks ? 2 : 1
689 if fingerInks { _ = resignFirstResponder() }
690 picker.show(inkTool, fingerDraws: fingerInks)
691 onChange?()
692 }
693
694 var pickerShown: Bool { picker.superview != nil }
695
696 /// Shows the drawing tools over the bottom of the page, or puts them away. Showing them from
697 /// the bar picks the Pencil's tool for a finger too; closing them returns it to Select & Type.
698 func showPicker(_ shown: Bool, fingerDraws draws: Bool = true) {
699 guard shown != pickerShown, let host = superview else { return }
700 if shown {
701 picker.translatesAutoresizingMaskIntoConstraints = false
702 host.addSubview(picker)
703 NSLayoutConstraint.activate([
704 picker.centerXAnchor.constraint(equalTo: host.centerXAnchor),
705 picker.widthAnchor.constraint(lessThanOrEqualTo: host.safeAreaLayoutGuide.widthAnchor, constant: -16),
706 picker.bottomAnchor.constraint(equalTo: host.keyboardLayoutGuide.topAnchor, constant: -12),
707 ])
708 setFingerDraws(draws)
709 } else {
710 picker.removeFromSuperview()
711 setFingerDraws(false)
712 }
713 }
714
715 /// The Pencil's double tap, as the reader set it in Settings.
716 func pencilInteractionDidTap(_ interaction: UIPencilInteraction) {
717 switch UIPencilInteraction.preferredTapAction {
718 case .switchEraser: setInkTool(inkTool == .eraser ? previousInkTool : .eraser)
719 case .switchPrevious: setInkTool(previousInkTool)
720 case .showColorPalette, .showInkAttributes: showPicker(!pickerShown, fingerDraws: fingerDraws)
721 default: break
722 }
723 }
724
725 /// Writing with the Pencil draws ink on the page rather than typing.
726 func scribbleInteraction(_ interaction: UIScribbleInteraction, shouldBeginAt location: CGPoint) -> Bool { false }
727
728 @objc private func inked(_ gesture: InkGesture) {
729 let points = gesture.points
730 gesture.points = []
731 switch gesture.state {
732 case .began:
733 inkPressed(points[0])
734 inkMoved(points.dropFirst())
735 case .changed: inkMoved(points[...])
736 case .ended:
737 inkMoved(points[...])
738 inkReleased()
739 default:
740 // A second finger scrolls instead: the gesture under way goes, storing nothing.
741 guard let handle else { return }
742 edit { sb_view_cancel(handle) }
743 }
744 }
745
746 /// The Pencil, or a drawing finger, comes down at `sample`'s point in the scroll view's bounds.
747 func inkPressed(_ sample: InkSample) {
748 guard let handle else { return }
749 // Drawing puts the keyboard away, as Notes does.
750 if isFirstResponder { _ = resignFirstResponder() }
751 use(inkTool)
752 editMenu.dismissMenu()
753 let point = visible(sample.point)
754 sb_view_pressure(handle, sample.pressure)
755 edit { sb_view_press(handle, Float(point.x), Float(point.y)) }
756 }
757
758 func inkMoved(_ samples: ArraySlice<InkSample>) {
759 guard let handle else { return }
760 for sample in samples {
761 let point = visible(sample.point)
762 sb_view_pressure(handle, sample.pressure)
763 _ = sb_view_drag(handle, Float(point.x), Float(point.y))
764 }
765 dirty = true
766 }
767
768 /// The stroke, erasure, lasso or drag ends: one edit, and the menu for what the lasso picked.
769 func inkReleased() {
770 guard let handle else { return }
771 edit { sb_view_release(handle) }
772 showInkMenu()
773 }
774
775 private static let inkMenu = "ink" as NSString
776
777 private func showInkMenu() {
778 guard let frame = inkFrame else { return }
779 editMenu.presentEditMenu(
780 with: UIEditMenuConfiguration(identifier: Self.inkMenu, sourcePoint: CGPoint(x: frame.midX, y: frame.minY)))
781 }
782
783 func editMenuInteraction(
784 _ interaction: UIEditMenuInteraction, menuFor configuration: UIEditMenuConfiguration,
785 suggestedActions: [UIMenuElement]
786 ) -> UIMenu? {
787 guard configuration.identifier as? NSString == Self.inkMenu else {
788 return selectedTextRange.flatMap { editMenu(for: $0, suggestedActions: suggestedActions) }
789 ?? UIMenu(children: suggestedActions)
790 }
791 return UIMenu(children: [
792 UIAction(title: "Delete", image: UIImage(systemName: "trash"), attributes: .destructive) { [weak self] _ in
793 self?.deleteInk()
794 }
795 ])
796 }
797
798 func editMenuInteraction(
799 _ interaction: UIEditMenuInteraction, targetRectFor configuration: UIEditMenuConfiguration
800 ) -> CGRect {
801 configuration.identifier as? NSString == Self.inkMenu ? inkFrame ?? .null : .null
802 }
803
804 /// Deletes the drawings the lasso or a tap picked.
805 func deleteInk() {
806 guard let handle, inkFrame != nil else { return }
807 edit(external: true) { sb_delete_backward(handle) }
808 }
809
810 /// Runs a change to the page, telling the system when it did not ask for it; returns
811 /// whether it changed.
812 @discardableResult
813 private func edit(external: Bool = false, _ change: () -> Bool) -> Bool {
814 if external {
815 inputDelegate?.selectionWillChange(self)
816 inputDelegate?.textWillChange(self)
817 }
818 let changed = change()
819 if external {
820 inputDelegate?.textDidChange(self)
821 inputDelegate?.selectionDidChange(self)
822 }
823 dirty = true
824 if changed {
825 sync()
826 revealCaret()
827 }
828 display.setNeedsSelectionUpdate()
829 changedSelection()
830 return changed
831 }
832
833 /// Tells the bar buttons and the format bar what the selection now has.
834 private func changedSelection() {
835 formatBar.show(format)
836 onChange?()
837 }
838
839 private func revealCaret() {
840 guard handle != nil, isFirstResponder, let range = selectedTextRange else { return }
841 reveal(caretRect(for: range.end))
842 }
843
844 private func reveal(_ rect: CGRect) {
845 scrollRectToVisible(rect.insetBy(dx: -8, dy: -16), animated: false)
846 }
847
848 // MARK: Responder
849
850 override var canBecomeFirstResponder: Bool { handle != nil && !readOnly }
851
852 override var inputAccessoryView: UIView? { formatBar }
853
854 override func becomeFirstResponder() -> Bool {
855 if Prototype.welcome, Author.name == nil, let controller = window?.rootViewController {
856 Prototype.askName(from: controller) { [weak self] in _ = self?.becomeFirstResponder() }
857 return false
858 }
859 guard super.becomeFirstResponder() else { return false }
860 if let handle { sb_view_focus(handle, true) }
861 display.isActivated = true
862 dirty = true
863 changedSelection()
864 return true
865 }
866
867 override func resignFirstResponder() -> Bool {
868 guard super.resignFirstResponder() else { return false }
869 if let handle { sb_view_focus(handle, false) }
870 display.isActivated = false
871 dirty = true
872 onChange?()
873 return true
874 }
875
876 override var undoManager: UndoManager? { history }
877
878 func canUndo(redo: Bool) -> Bool { handle.map { sb_view_can_undo($0, redo) } ?? false }
879
880 func undo(redo: Bool) {
881 guard let handle else { return }
882 edit(external: true) { sb_view_undo(handle, redo) }
883 }
884
885 private var selection: (lo: Int, hi: Int) {
886 guard let range = selectedTextRange else { return (0, 0) }
887 return (range.lower, range.upper)
888 }
889
890 override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool {
891 let (lo, hi) = selection
892 switch action {
893 case #selector(copy(_:)), #selector(cut(_:)), #selector(delete(_:)),
894 #selector(toggleBoldface(_:)), #selector(toggleItalics(_:)), #selector(toggleUnderline(_:)):
895 return lo < hi
896 case #selector(paste(_:)): return UIPasteboard.general.hasStrings || UIPasteboard.general.hasImages
897 case #selector(select(_:)): return lo == hi && hasText
898 case #selector(selectAll(_:)): return hasText && (lo > 0 || hi < endOfDocument.offset)
899 default: return super.canPerformAction(action, withSender: sender)
900 }
901 }
902
903 /// A page selection copies every outline, as OneNote 2010 copies it.
904 override func copy(_ sender: Any?) {
905 guard let handle, let text = take(sb_view_copy(handle, false)) else { return }
906 UIPasteboard.general.string = text
907 }
908
909 override func cut(_ sender: Any?) {
910 guard let handle else { return }
911 var text: String?
912 edit(external: true) {
913 text = take(sb_view_copy(handle, true))
914 return text != nil
915 }
916 if let text { UIPasteboard.general.string = text }
917 }
918
919 override func delete(_ sender: Any?) {
920 guard let range = selectedTextRange else { return }
921 edit(external: true) { replacing(range, with: "") }
922 }
923
924 /// Pasted text takes the keyboard's language, as Windows gives the clipboard. Text wins
925 /// over a picture, which apps put beside copied text.
926 override func paste(_ sender: Any?) {
927 guard let handle else { return }
928 if let text = UIPasteboard.general.string {
929 edit(external: true) { sb_paste(handle, text, textInputMode?.primaryLanguage ?? "") }
930 } else if let image = UIPasteboard.general.image {
931 insertPicture(image)
932 }
933 }
934
935 override func select(_ sender: Any?) {
936 guard let caret = selectedTextRange?.start,
937 let word = tokenizer.rangeEnclosingPosition(caret, with: .word, inDirection: .storage(.backward))
938 ?? tokenizer.rangeEnclosingPosition(caret, with: .word, inDirection: .storage(.forward))
939 else { return }
940 edit(external: true) { choose(word) }
941 }
942
943 override func selectAll(_ sender: Any?) {
944 guard let handle else { return }
945 edit(external: true) { sb_select_more(handle) }
946 }
947
948 override func toggleBoldface(_ sender: Any?) { apply(0) }
949 override func toggleItalics(_ sender: Any?) { apply(1) }
950 override func toggleUnderline(_ sender: Any?) { apply(2) }
951
952 /// Tags and the highlighter join the system's text actions.
953 func editMenu(for textRange: UITextRange, suggestedActions: [UIMenuElement]) -> UIMenu? {
954 guard let handle else { return nil }
955 let bits = sb_view_format(handle)
956 let tag = UIMenu(
957 title: "Tag", image: UIImage(systemName: "tag"),
958 children: Tags.menu(bits: bits) { [weak self] in self?.apply($0) })
959 var extra: [UIMenuElement] = [tag]
960 if !textRange.isEmpty {
961 extra.insert(
962 UIAction(title: "Highlight", image: UIImage(systemName: "highlighter")) { [weak self] _ in
963 self?.apply(10)
964 }, at: 0)
965 }
966 return UIMenu(
967 children: spellingActions() + suggestedActions + [UIMenu(options: .displayInline, children: extra)])
968 }
969
970 private struct Correction: Decodable {
971 let word: String
972 let suggestions: [String]
973 let repeated: Bool
974 }
975
976 /// OneNote's corrections for the marked word at the selection, first in the menu.
977 private func spellingActions() -> [UIMenuElement] {
978 guard let handle, let correction = decode(Correction.self, sb_view_correction(handle)) else { return [] }
979 let correct = { [weak self] (command: UInt8, text: String?) in
980 guard let self, let handle = self.handle else { return }
981 self.edit(external: true) { sb_view_correct(handle, command, text) }
982 }
983 let replacements =
984 correction.repeated
985 ? [UIAction(title: "Delete Repeated Word") { _ in correct(0, "") }]
986 : correction.suggestions.map { word in UIAction(title: word) { _ in correct(0, word) } }
987 let ignore = UIAction(title: "Ignore") { _ in correct(1, nil) }
988 let learn = UIAction(title: "Add to Dictionary") { _ in correct(2, nil) }
989 return [
990 UIMenu(options: .displayInline, children: replacements + (correction.repeated ? [ignore] : [ignore, learn]))
991 ]
992 }
993
994 // MARK: Page
995
996 /// A conflict page, which shows what is stored and takes no edits.
997 var readOnly: Bool { handle.map(sb_view_read_only) ?? false }
998
999 /// The column a reading view fills: the screen's width less the room tags hang in.
1000 private var readingWidth: Float { Float(bounds.inset(by: safeAreaInsets).width - 34 - 12) }
1001
1002 /// Whether the page has a reading view here, and how it reads or why not.
1003 var reading: (offered: Bool, verdict: String)? {
1004 struct Reading: Decodable {
1005 let offered: Bool
1006 let verdict: String
1007 }
1008 guard let handle, let read = decode(Reading.self, sb_view_reading(handle, readingWidth)) else { return nil }
1009 return (read.offered, read.verdict)
1010 }
1011
1012 /// Whether the page shows reflowed, as `showReading` left it.
1013 private(set) var readingShown = false
1014
1015 /// Shows the page reflowed into the screen's width at 100%, read-only, or as laid out.
1016 func showReading(_ on: Bool) {
1017 guard let handle else { return }
1018 _ = resignFirstResponder()
1019 readingShown = sb_view_set_reading(handle, on ? readingWidth : 0)
1020 zoomScale = 1
1021 resting = 1
1022 scrolled = false
1023 sync()
1024 left = margin
1025 home()
1026 dirty = true
1027 onChange?()
1028 }
1029
1030 /// The title as typed; nil on a page without an editable title.
1031 var pageTitle: String? { handle.flatMap { take(sb_view_title($0)) } }
1032
1033 var pageText: String { handle.flatMap { take(sb_view_page_text($0)) } ?? "" }
1034
1035 /// The page on `paper`, in points, as OneNote 2010 prints it, each sheet's footer naming
1036 /// `section`.
1037 func pdf(paper: CGSize, section: String) -> Data? {
1038 guard let handle else { return nil }
1039 var length = 0
1040 guard let bytes = sb_view_pdf(handle, Float(paper.width), Float(paper.height), section, &length) else {
1041 return nil
1042 }
1043 defer { sb_bytes_free(bytes, length) }
1044 return Data(bytes: bytes, count: length)
1045 }
1046
1047 /// The selection's formatting, from `sb_view_format`.
1048 var format: UInt64 { handle.map(sb_view_format) ?? 0 }
1049
1050 var pagePaper: Paper? { handle.flatMap { decode(Paper.self, sb_view_paper($0)) } }
1051
1052 /// Gives the page colour `color` and rule lines `ruled` of `paper.rules`, or none.
1053 func setPaper(color: [UInt8]?, ruled: Int?) {
1054 guard let handle else { return }
1055 edit(external: true) {
1056 sb_view_set_paper(
1057 handle, color.map { Int16($0[0]) } ?? -1, color?[1] ?? 0, color?[2] ?? 0, Int8(ruled ?? -1))
1058 }
1059 }
1060
1061 /// Puts template `name`'s art behind the page, or none.
1062 func setArt(_ name: String?) {
1063 guard let handle else { return }
1064 edit(external: true) { sb_view_set_art(handle, name) }
1065 }
1066
1067 /// Insert Space: the next drag moves what lies below or right of where it starts.
1068 func insertSpace() {
1069 guard let handle else { return }
1070 sb_view_insert_space(handle)
1071 spaceHint.show("Drag down or right to add space", in: self)
1072 }
1073
1074 /// Types the date, time or both as the system writes them short, and a space, as
1075 /// OneNote's Insert Date and Time do.
1076 func insertDate(_ date: Bool, time: Bool) {
1077 let now = Date()
1078 let text = DateFormatter.localizedString(
1079 from: now, dateStyle: date ? .short : .none, timeStyle: time ? .short : .none)
1080 if !isFirstResponder { _ = becomeFirstResponder() }
1081 insertText(text + " ")
1082 }
1083
1084 /// Selects paragraph `id` and brings it into view.
1085 func selectParagraph(_ id: String) -> Bool {
1086 guard let handle else { return false }
1087 let found = edit(external: true) { sb_view_select_paragraph(handle, id) }
1088 if found { reveal(selectedTextRange.map { firstRect(for: $0) } ?? .zero) }
1089 return found
1090 }
1091
1092 /// Applies `sb_view_apply` formatting to the selection.
1093 func apply(_ command: UInt8) {
1094 guard let handle else { return }
1095 edit(external: true) { sb_view_apply(handle, command) }
1096 }
1097
1098 /// Gives the selected paragraphs style `place` of OneNote's gallery, in the page's theme.
1099 func applyStyle(_ place: UInt8) {
1100 guard let handle else { return }
1101 edit(external: true) { sb_view_style(handle, place) }
1102 }
1103
1104 /// The selection's style's place in the gallery, or -1.
1105 var stylePlace: Int32 { handle.map(sb_view_style_place) ?? -1 }
1106
1107 /// Shows the page as stored; with `discard`, after an edit was refused.
1108 func reload(discard: Bool) {
1109 guard let handle else { return }
1110 edit(external: true) { sb_view_reload(handle, discard) }
1111 }
1112
1113 /// Selects the first place `query` occurs and brings it into view.
1114 func find(_ query: String) -> Bool {
1115 guard let handle else { return false }
1116 let found = edit(external: true) { sb_view_find(handle, query) }
1117 if found { reveal(selectedTextRange.map { firstRect(for: $0) } ?? .zero) }
1118 return found
1119 }
1120
1121 /// Puts the caret at the end of the page title.
1122 func focusTitle() -> Bool {
1123 guard let handle else { return false }
1124 return edit(external: true) { sb_view_focus_title(handle) }
1125 }
1126
1127 /// A photo as OneNote 2010 can read it: JPEG, at most 2048 pixels across, shown at most
1128 /// 220 points wide.
1129 func insertPicture(_ image: UIImage) {
1130 let longest: CGFloat = 2048
1131 let scale = min(1, longest / max(image.size.width * image.scale, image.size.height * image.scale))
1132 let pixels = CGSize(
1133 width: (image.size.width * image.scale * scale).rounded(),
1134 height: (image.size.height * image.scale * scale).rounded())
1135 let format = UIGraphicsImageRendererFormat()
1136 format.scale = 1
1137 let drawn = UIGraphicsImageRenderer(size: pixels, format: format).image { _ in
1138 image.draw(in: CGRect(origin: .zero, size: pixels))
1139 }
1140 guard let data = drawn.jpegData(compressionQuality: 0.85) else { return }
1141 // OneNote shows a picture's pixels at 96 per inch.
1142 var size = CGSize(width: pixels.width * 0.75, height: pixels.height * 0.75)
1143 if size.width > 220 { size = CGSize(width: 220, height: size.height * 220 / size.width) }
1144 insertPicture(data, size: size)
1145 }
1146
1147 /// Puts a JPEG or PNG at the caret at `size` points.
1148 func insertPicture(_ data: Data, size: CGSize) {
1149 guard let handle else { return }
1150 edit(external: true) {
1151 data.withUnsafeBytes { bytes in
1152 sb_view_insert_picture(
1153 handle, bytes.bindMemory(to: UInt8.self).baseAddress, bytes.count, Float(size.width),
1154 Float(size.height))
1155 }
1156 }
1157 }
1158
1159 // MARK: Recordings
1160
1161 /// Starts recording at the caret, under a line saying when; whether it started.
1162 func startRecording(video: Bool) -> Bool {
1163 guard let handle else { return false }
1164 let (day, time) = titleDate()
1165 return edit(external: true) { sb_view_start_recording(handle, video, day, time) }
1166 }
1167
1168 /// How far the recording has got in milliseconds, without its pauses, and whether it is
1169 /// paused; nil when nothing records.
1170 var recording: (ms: Int64, paused: Bool)? {
1171 guard let handle else { return nil }
1172 var paused = false
1173 let ms = sb_view_recording(handle, &paused)
1174 return ms < 0 ? nil : (ms, paused)
1175 }
1176
1177 /// Nothing typed while the recording is paused links to it.
1178 func pauseRecording(_ paused: Bool) {
1179 guard let handle else { return }
1180 sb_view_pause_recording(handle, paused)
1181 }
1182
1183 /// Puts the recording made where it started: a PCM WAV file, or with `video` the AVI
1184 /// file of `sb_movie_finish`. Nil forgets a recording whose file never came.
1185 func finishRecording(_ file: Data?, video: Bool) {
1186 guard let handle else { return }
1187 _ = edit(external: true) {
1188 guard let file else { return sb_view_finish_recording(handle, nil, 0, video) }
1189 return file.withUnsafeBytes { bytes in
1190 sb_view_finish_recording(handle, bytes.bindMemory(to: UInt8.self).baseAddress, bytes.count, video)
1191 }
1192 }
1193 }
1194
1195 /// The recording the last tap asked to play, with its files written out to play from.
1196 private func playRequest() -> PlayRequest? {
1197 let folder = FileManager.default.temporaryDirectory.appendingPathComponent("Playback")
1198 guard let handle, let json = sb_view_play_request(handle, folder.path) else { return nil }
1199 defer { sb_string_free(json) }
1200 let decoder = JSONDecoder()
1201 decoder.keyDecodingStrategy = .convertFromSnakeCase
1202 return try? decoder.decode(PlayRequest.self, from: Data(String(cString: json).utf8))
1203 }
1204
1205 /// See Playback: the note linked at `ms` into the recording playing is highlighted; nil
1206 /// ends playback.
1207 func played(at ms: Int64?) {
1208 guard let handle, sb_view_played(handle, ms ?? -1) else { return }
1209 dirty = true
1210 }
1211
1212 /// Commits marked text, so it is stored before the app leaves the screen.
1213 func commitComposition() {
1214 guard markedTextRange != nil else { return }
1215 inputDelegate?.textWillChange(self)
1216 unmarkText()
1217 inputDelegate?.textDidChange(self)
1218 }
1219
1220 override var keyCommands: [UIKeyCommand]? {
1221 guard isFirstResponder else { return nil }
1222 // Ctrl+1 to Ctrl+9 in OneNote, where the Mac's Command stands for Control.
1223 let tags = Tags.all.prefix(Tags.keyed).enumerated().map { index, tag in
1224 UIKeyCommand(
1225 title: tag.name, action: #selector(formatted), input: "\(index + 1)", modifierFlags: .command,
1226 propertyList: 16 + index)
1227 }
1228 // The desktop's chords, as OneNote for Mac's.
1229 let formats: [(String, String, UIKeyModifierFlags, Int)] = [
1230 ("Strikethrough", "x", [.command, .shift], 3),
1231 ("Bullets", ".", .command, 4),
1232 ("Numbering", "/", .command, 5),
1233 ("Increase Indent", "]", .command, 6),
1234 ("Decrease Indent", "[", .command, 7),
1235 ("Remove Tag", "0", [.command, .control], 8),
1236 ("Clear Formatting", "n", [.command, .shift], 9),
1237 ("Highlight", "h", [.command, .control], 10),
1238 ("Decrease Indent", "\t", .shift, 7),
1239 ]
1240 let commands = tags + formats.map { title, input, modifiers, command in
1241 UIKeyCommand(
1242 title: title, action: #selector(formatted), input: input, modifierFlags: modifiers,
1243 propertyList: command)
1244 }
1245 for command in commands { command.wantsPriorityOverSystemBehavior = true }
1246 let done = UIKeyCommand(title: "Done", action: #selector(finished), input: UIKeyCommand.inputEscape)
1247 return commands + [done]
1248 }
1249
1250 @objc private func formatted(_ command: UIKeyCommand) {
1251 guard let command = command.propertyList as? Int else { return }
1252 apply(UInt8(command))
1253 }
1254
1255 @objc private func finished() { _ = resignFirstResponder() }
1256
1257 // MARK: Coordinates
1258
1259 /// A point in the scroll view's bounds as the canvas view's points from its corner.
1260 private func visible(_ point: CGPoint) -> CGPoint {
1261 CGPoint(x: point.x - contentOffset.x, y: point.y - contentOffset.y)
1262 }
1263
1264 private func bounded(_ rect: [Float]) -> CGRect {
1265 CGRect(
1266 x: CGFloat(rect[0]) + contentOffset.x, y: CGFloat(rect[1]) + contentOffset.y,
1267 width: CGFloat(rect[2]), height: CGFloat(rect[3]))
1268 }
1269
1270 // MARK: UIKeyInput
1271
1272 var hasText: Bool { handle.map { sb_text_length($0) > 0 } ?? false }
1273
1274 func insertText(_ text: String) {
1275 guard let handle else { return }
1276 edit { sb_insert(handle, text) }
1277 }
1278
1279 func deleteBackward() {
1280 guard let handle else { return }
1281 edit { sb_delete_backward(handle) }
1282 }
1283
1284 // MARK: UITextInput
1285
1286 func text(in range: UITextRange) -> String? {
1287 guard let handle, let text = sb_text(handle, UInt32(range.lower), UInt32(range.upper)) else { return nil }
1288 defer { sb_string_free(text) }
1289 return String(cString: text)
1290 }
1291
1292 private func replacing(_ range: UITextRange, with text: String) -> Bool {
1293 guard let handle else { return false }
1294 return sb_replace(handle, UInt32(range.lower), UInt32(range.upper), text)
1295 }
1296
1297 func replace(_ range: UITextRange, withText text: String) {
1298 edit { replacing(range, with: text) }
1299 }
1300
1301 private func choose(_ range: UITextRange) -> Bool {
1302 guard let handle else { return false }
1303 return sb_select(handle, UInt32(range.lower), UInt32(range.upper))
1304 }
1305
1306 var selectedTextRange: UITextRange? {
1307 get {
1308 guard let handle else { return nil }
1309 var range: [UInt32] = [0, 0]
1310 sb_selection(handle, &range)
1311 return Range(Int(range[0]), Int(range[1]))
1312 }
1313 set {
1314 guard let newValue else { return }
1315 _ = choose(newValue)
1316 display.setNeedsSelectionUpdate()
1317 dirty = true
1318 changedSelection()
1319 }
1320 }
1321
1322 var markedTextRange: UITextRange? {
1323 guard let handle else { return nil }
1324 var range: [UInt32] = [0, 0]
1325 return sb_marked(handle, &range) ? Range(Int(range[0]), Int(range[1])) : nil
1326 }
1327
1328 var markedTextStyle: [NSAttributedString.Key: Any]?
1329
1330 func setMarkedText(_ markedText: String?, selectedRange: NSRange) {
1331 guard let handle else { return }
1332 edit {
1333 sb_set_marked(
1334 handle, markedText ?? "", UInt32(selectedRange.location),
1335 UInt32(selectedRange.location + selectedRange.length))
1336 }
1337 }
1338
1339 func unmarkText() {
1340 guard let handle else { return }
1341 sb_unmark(handle)
1342 dirty = true
1343 }
1344
1345 var beginningOfDocument: UITextPosition { Position(0) }
1346 var endOfDocument: UITextPosition { Position(handle.map { Int(sb_text_length($0)) } ?? 0) }
1347
1348 func textRange(from fromPosition: UITextPosition, to toPosition: UITextPosition) -> UITextRange? {
1349 Range(fromPosition.offset, toPosition.offset)
1350 }
1351
1352 func position(from position: UITextPosition, offset: Int) -> UITextPosition? {
1353 let target = position.offset + offset
1354 guard target >= 0, target <= endOfDocument.offset else { return nil }
1355 return Position(target)
1356 }
1357
1358 func position(
1359 from position: UITextPosition, in direction: UITextLayoutDirection, offset: Int
1360 ) -> UITextPosition? {
1361 switch direction {
1362 case .left: return self.position(from: position, offset: -offset)
1363 case .right: return self.position(from: position, offset: offset)
1364 default:
1365 let caret = caretRect(for: position)
1366 let step = (direction == .up ? -1 : 1) * CGFloat(offset) * caret.height
1367 return closestPosition(to: CGPoint(x: caret.midX, y: caret.midY + step))
1368 }
1369 }
1370
1371 func compare(_ position: UITextPosition, to other: UITextPosition) -> ComparisonResult {
1372 position.offset < other.offset
1373 ? .orderedAscending : position.offset > other.offset ? .orderedDescending : .orderedSame
1374 }
1375
1376 func offset(from: UITextPosition, to toPosition: UITextPosition) -> Int { toPosition.offset - from.offset }
1377
1378 func position(within range: UITextRange, farthestIn direction: UITextLayoutDirection) -> UITextPosition? {
1379 direction == .left || direction == .up ? range.start : range.end
1380 }
1381
1382 func characterRange(byExtending position: UITextPosition, in direction: UITextLayoutDirection)
1383 -> UITextRange?
1384 {
1385 direction == .left || direction == .up
1386 ? Range(0, position.offset) : Range(position.offset, endOfDocument.offset)
1387 }
1388
1389 func baseWritingDirection(
1390 for position: UITextPosition, in direction: UITextStorageDirection
1391 ) -> NSWritingDirection { .leftToRight }
1392
1393 func setBaseWritingDirection(_ writingDirection: NSWritingDirection, for range: UITextRange) {}
1394
1395 func firstRect(for range: UITextRange) -> CGRect {
1396 selectionRects(for: range).first?.rect ?? caretRect(for: range.start)
1397 }
1398
1399 func caretRect(for position: UITextPosition) -> CGRect {
1400 guard let handle else { return .zero }
1401 var rect: [Float] = [0, 0, 0, 0]
1402 guard sb_caret_rect(handle, UInt32(position.offset), &rect) else { return .zero }
1403 var caret = bounded(rect)
1404 caret.origin.x -= 1
1405 caret.size.width = 2
1406 return caret
1407 }
1408
1409 func selectionRects(for range: UITextRange) -> [UITextSelectionRect] {
1410 guard let handle else { return [] }
1411 let capacity = 256
1412 var rects = [(Float, Float, Float, Float)](repeating: (0, 0, 0, 0), count: capacity)
1413 let count = min(capacity, sb_range_rects(handle, UInt32(range.lower), UInt32(range.upper), &rects, capacity))
1414 return (0..<count).map {
1415 let rect = rects[$0]
1416 return SelectionRect(
1417 bounded([rect.0, rect.1, rect.2, rect.3]), first: $0 == 0, last: $0 == count - 1)
1418 }
1419 }
1420
1421 func closestPosition(to point: CGPoint) -> UITextPosition? {
1422 guard let handle else { return nil }
1423 let local = visible(point)
1424 return Position(Int(sb_closest(handle, Float(local.x), Float(local.y))))
1425 }
1426
1427 func closestPosition(to point: CGPoint, within range: UITextRange) -> UITextPosition? {
1428 let offset = closestPosition(to: point)?.offset ?? range.lower
1429 return Position(min(max(offset, range.lower), range.upper))
1430 }
1431
1432 func characterRange(at point: CGPoint) -> UITextRange? {
1433 guard let position = closestPosition(to: point) else { return nil }
1434 return Range(position.offset, min(position.offset + 1, endOfDocument.offset))
1435 }
1436}
1437
1438/// Words and sentences come from the text; lines come from the canvas's layout, which the
1439/// string tokenizer cannot see, so a tap after a line's last word stays on that line.
1440private final class LineTokenizer: UITextInputStringTokenizer {
1441 private unowned let canvas: CanvasView
1442
1443 init(canvas: CanvasView) {
1444 self.canvas = canvas
1445 super.init(textInput: canvas)
1446 }
1447
1448 private func forward(_ direction: UITextDirection) -> Bool {
1449 [UITextStorageDirection.forward.rawValue, UITextLayoutDirection.right.rawValue,
1450 UITextLayoutDirection.down.rawValue].contains(direction.rawValue)
1451 }
1452
1453 private func line(_ offset: Int) -> CGFloat { canvas.caretRect(for: Position(offset)).minY.rounded() }
1454
1455 private func atEdge(_ offset: Int, forward: Bool) -> Bool {
1456 let end = canvas.endOfDocument.offset
1457 return forward
1458 ? offset >= end || line(offset + 1) != line(offset)
1459 : offset <= 0 || line(offset - 1) != line(offset)
1460 }
1461
1462 private func edge(_ offset: Int, forward: Bool) -> Int {
1463 var offset = offset
1464 while !atEdge(offset, forward: forward) { offset += forward ? 1 : -1 }
1465 return offset
1466 }
1467
1468 override func isPosition(
1469 _ position: UITextPosition, atBoundary granularity: UITextGranularity, inDirection direction: UITextDirection
1470 ) -> Bool {
1471 guard granularity == .line else {
1472 return super.isPosition(position, atBoundary: granularity, inDirection: direction)
1473 }
1474 return atEdge(position.offset, forward: forward(direction))
1475 }
1476
1477 override func position(
1478 from position: UITextPosition, toBoundary granularity: UITextGranularity, inDirection direction: UITextDirection
1479 ) -> UITextPosition? {
1480 guard granularity == .line else {
1481 return super.position(from: position, toBoundary: granularity, inDirection: direction)
1482 }
1483 return Position(edge(position.offset, forward: forward(direction)))
1484 }
1485
1486 override func rangeEnclosingPosition(
1487 _ position: UITextPosition, with granularity: UITextGranularity, inDirection direction: UITextDirection
1488 ) -> UITextRange? {
1489 guard granularity == .line else {
1490 return super.rangeEnclosingPosition(position, with: granularity, inDirection: direction)
1491 }
1492 return Range(edge(position.offset, forward: false), edge(position.offset, forward: true))
1493 }
1494
1495 override func isPosition(
1496 _ position: UITextPosition, withinTextUnit granularity: UITextGranularity, inDirection direction: UITextDirection
1497 ) -> Bool {
1498 granularity == .line || super.isPosition(position, withinTextUnit: granularity, inDirection: direction)
1499 }
1500}
1501
1502/// A short instruction floating at the bottom of the page until the gesture it asks for.
1503private final class Hint: UIVisualEffectView {
1504 private let label = UILabel()
1505
1506 init() {
1507 super.init(effect: UIBlurEffect(style: .systemThickMaterial))
1508 label.font = .preferredFont(forTextStyle: .subheadline)
1509 label.textAlignment = .center
1510 label.translatesAutoresizingMaskIntoConstraints = false
1511 contentView.addSubview(label)
1512 NSLayoutConstraint.activate([
1513 label.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 16),
1514 label.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -16),
1515 label.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 10),
1516 label.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -10),
1517 ])
1518 layer.cornerRadius = 18
1519 clipsToBounds = true
1520 isUserInteractionEnabled = false
1521 }
1522
1523 required init?(coder: NSCoder) { fatalError() }
1524
1525 /// Shows `text` over the bottom of `view`'s visible area, and says it.
1526 func show(_ text: String, in view: UIScrollView) {
1527 label.text = text
1528 guard let host = view.superview else { return }
1529 translatesAutoresizingMaskIntoConstraints = false
1530 host.addSubview(self)
1531 NSLayoutConstraint.activate([
1532 centerXAnchor.constraint(equalTo: host.centerXAnchor),
1533 bottomAnchor.constraint(equalTo: host.keyboardLayoutGuide.topAnchor, constant: -16),
1534 ])
1535 alpha = 0
1536 UIView.animate(withDuration: 0.2) { self.alpha = 1 }
1537 UIAccessibility.post(notification: .announcement, argument: text)
1538 }
1539
1540 func hide() {
1541 guard superview != nil else { return }
1542 UIView.animate(withDuration: 0.2, animations: { self.alpha = 0 }) { _ in self.removeFromSuperview() }
1543 }
1544}