1#if DEBUG
2import UIKit
3
4private var scriptedPage = false
5
6extension CanvasView {
7 /// Replays `SNOWBOUND_SCRIPT` through the calls touch and the keyboard make, to check
8 /// input without a finger: steps joined by `|`, such as
9 /// `tap:120,300|type:hi|mark:かな|unmark|return|delete|select:2,9|scroll:0,600|zoom:1.5|shot:a`.
10 /// `tap` and `doubletap` take points from the view's corner, `select` text offsets,
11 /// `scroll` a content offset; `format:N` applies `sb_view_apply` formatting, `find:word`
12 /// selects a match, `title` edits the title, `picture` inserts a drawn picture, `selectall`
13 /// is the Select All command; `pencil:x,y;x,y;…` draws through points as the Pencil does,
14 /// `tool:pen:N`, `tool:shape:N`, `tool:eraser` or `tool:lasso` gives it a tool, `picker` shows or hides the
15 /// drawing tools, `pencildoubletap` is the Pencil's double tap, and `deleteink` deletes
16 /// what the lasso picked; `rotate:landscape` or `rotate:portrait` turns the device; `done`
17 /// ends editing, `reading` turns the reading view on or off, `record:audio` or
18 /// `record:video` records, `pauserecording` pauses or resumes and `stoprecording` stops it,
19 /// `wait:N` waits N seconds more before the next step, `tree` saves the view hierarchy to
20 /// Documents/tree.txt, and `shot:a` the window to Documents/a.png, as a device has no
21 /// screenshot command.
22 func runScript() {
23 // Only the first page shown runs it, not every page opened afterwards.
24 guard !scriptedPage, let script = ProcessInfo.processInfo.environment["SNOWBOUND_SCRIPT"] else { return }
25 scriptedPage = true
26 var delay = 1.0
27 for step in script.split(separator: "|") {
28 if step.hasPrefix("wait:") { delay += Double(step.dropFirst(5)) ?? 0 }
29 DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in
30 self?.perform(String(step))
31 }
32 delay += 0.4
33 }
34 }
35
36 private func perform(_ step: String) {
37 let (command, argument) = step.firstIndex(of: ":").map {
38 (String(step[..<$0]), String(step[step.index(after: $0)...]))
39 } ?? (step, "")
40 NSLog("script \(command) \(argument)")
41 switch command {
42 case "tap":
43 let values = argument.split(separator: ",").compactMap { Double($0) }
44 tap(at: CGPoint(x: values[0] + contentOffset.x, y: values[1] + contentOffset.y))
45 case "pencil":
46 // Points as `x,y`, or `x,y,pressure` for the Pencil's pressure from 0 to 1.
47 let points = argument.split(separator: ";").map {
48 let values = $0.split(separator: ",").compactMap { Double($0) }
49 let point = CGPoint(x: values[0] + contentOffset.x, y: values[1] + contentOffset.y)
50 return (point, values.count > 2 ? Float(values[2]) : -1) as InkSample
51 }
52 inkPressed(points[0])
53 inkMoved(points.dropFirst())
54 inkReleased()
55 case "tool":
56 let parts = argument.split(separator: ":")
57 switch parts.first {
58 case "pen": setInkTool(.pen(UInt8(parts.last ?? "") ?? 0))
59 case "eraser": setInkTool(.eraser)
60 case "lasso": setInkTool(.lasso)
61 case "shape": setInkTool(.shape(UInt8(parts.last ?? "") ?? 0))
62 default: break
63 }
64 case "picker": showPicker(!pickerShown)
65 case "pencildoubletap": pencilInteractionDidTap(UIPencilInteraction())
66 case "deleteink": deleteInk()
67 case "type": insertText(argument)
68 case "return": insertText("\n")
69 case "delete": deleteBackward()
70 case "mark": setMarkedText(argument, selectedRange: NSRange(location: argument.utf16.count, length: 0))
71 case "unmark": unmarkText()
72 case "select":
73 let values = argument.split(separator: ",").compactMap { Int($0) }
74 // As a gesture would, the change reaches the system's text interaction.
75 inputDelegate?.selectionWillChange(self)
76 defer { inputDelegate?.selectionDidChange(self) }
77 selectedTextRange = textRange(
78 from: position(from: beginningOfDocument, offset: values[0])!,
79 to: position(from: beginningOfDocument, offset: values[1])!)
80 case "scroll":
81 // `scroll:,Y` keeps the horizontal offset.
82 let values = argument.split(separator: ",", omittingEmptySubsequences: false).map { Double($0) }
83 setContentOffset(CGPoint(x: values[0] ?? contentOffset.x, y: values[1] ?? contentOffset.y), animated: true)
84 case "zoom": setZoomScale(Double(argument) ?? 1, animated: false)
85 case "doubletap":
86 let values = argument.split(separator: ",").compactMap { Double($0) }
87 zoom(at: CGPoint(x: values[0] + contentOffset.x, y: values[1] + contentOffset.y))
88 case "rotate":
89 let orientation: UIInterfaceOrientationMask = argument == "portrait" ? .portrait : .landscapeRight
90 window?.windowScene?.requestGeometryUpdate(.iOS(interfaceOrientations: orientation))
91 case "menu":
92 guard let range = selectedTextRange,
93 let menu = interactions.lazy.compactMap({ $0 as? UIEditMenuInteraction }).first(where: { $0.delegate === self })
94 else { break }
95 let caret = caretRect(for: range.start)
96 menu.presentEditMenu(with: UIEditMenuConfiguration(identifier: nil, sourcePoint: CGPoint(x: caret.midX, y: caret.minY)))
97 case "done": _ = resignFirstResponder()
98 case "wait": break
99 case "record", "stoprecording":
100 let page = sequence(first: self as UIResponder, next: \.next).lazy.compactMap { $0 as? PageViewController }.first
101 if command == "record" { page?.record(video: argument == "video") } else { page?.stopRecording() }
102 case "pauserecording": Recorder.current.map { $0.pause(!($0.elapsed?.paused ?? false)) }
103 case "selectall": selectAll(nil)
104 case "reading": showReading(!readingShown)
105 case "format": apply(UInt8(argument) ?? 0)
106 case "find": _ = find(argument)
107 case "title": _ = focusTitle()
108 case "picture":
109 let image = UIGraphicsImageRenderer(size: CGSize(width: 320, height: 200)).image { context in
110 UIColor.systemTeal.setFill()
111 context.fill(CGRect(x: 0, y: 0, width: 320, height: 200))
112 UIColor.systemYellow.setFill()
113 context.cgContext.fillEllipse(in: CGRect(x: 110, y: 50, width: 100, height: 100))
114 }
115 if let data = image.jpegData(compressionQuality: 0.9) { insertPicture(data, size: CGSize(width: 240, height: 150)) }
116 case "tree":
117 let documents = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
118 try? "\(value(forKey: "recursiveDescription") ?? "")".write(
119 to: documents.appendingPathComponent("tree.txt"), atomically: true, encoding: .utf8)
120 case "shot":
121 guard let window else { break }
122 let image = UIGraphicsImageRenderer(bounds: window.bounds).image { _ in
123 window.drawHierarchy(in: window.bounds, afterScreenUpdates: false)
124 }
125 let documents = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
126 try? image.pngData()?.write(to: documents.appendingPathComponent("\(argument).png"))
127 default: NSLog("script: unknown step \(step)")
128 }
129 if let range = selectedTextRange, let all = textRange(from: beginningOfDocument, to: endOfDocument) {
130 NSLog("script selection \(offset(from: beginningOfDocument, to: range.start))..\(offset(from: beginningOfDocument, to: range.end)) text \(text(in: all) ?? "nil")")
131 }
132 }
133}
134
135/// Logs `presenter changed` whenever a coordinated write reaches `SNOWBOUND_PRESENTER`, a
136/// file, to check that saving tells file providers about it.
137final class Presenter: NSObject, NSFilePresenter {
138 private static var watching: Presenter?
139 let presentedItemURL: URL?
140 let presentedItemOperationQueue = OperationQueue()
141
142 private init(_ url: URL) { presentedItemURL = url }
143
144 static func watch() {
145 guard let path = ProcessInfo.processInfo.environment["SNOWBOUND_PRESENTER"] else { return }
146 let presenter = Presenter(URL(fileURLWithPath: path))
147 NSFileCoordinator.addFilePresenter(presenter)
148 watching = presenter
149 }
150
151 func presentedItemDidChange() { NSLog("presenter changed") }
152}
153#endif