| 1 | #if DEBUG |
| 2 | import UIKit |
| 3 | |
| 4 | private var scriptedPage = false |
| 5 | |
| 6 | extension 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. |
| 137 | final 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 |