Files
byre/mac/Sources/Byre/Composer.swift
T

397 lines
15 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import AppKit
import OpenAPIRuntime
import SwiftUI
import UniformTypeIdentifiers
let maxImageBytes = 5 << 20
struct PendingImage: Identifiable {
let id = UUID()
let data: Data
let image: NSImage
}
func imageType(_ data: Data) -> String? {
let b = [UInt8](data.prefix(12))
if b.starts(with: [0x89, 0x50, 0x4E, 0x47]) {
return "png"
}
if b.starts(with: [0xFF, 0xD8, 0xFF]) {
return "jpeg"
}
if b.starts(with: Array("GIF8".utf8)) {
return "gif"
}
if b.count == 12, b.starts(with: Array("RIFF".utf8)), Array(b[8..<12]) == Array("WEBP".utf8) {
return "webp"
}
return nil
}
func pngFromTIFF(_ data: Data) -> Data? {
NSBitmapImageRep(data: data)?.representation(using: .png, properties: [:])
}
@MainActor
@Observable
final class Attachments {
var images: [PendingImage] = []
let spike: SpikeLog
let api: API
let name: String
init(spike: SpikeLog, api: API, name: String) {
self.spike = spike
self.api = api
self.name = name
}
func add(_ data: Data, from source: String) {
guard let kind = imageType(data), let image = NSImage(data: data) else {
spike.log("\(name): \(source) gave \(data.count) bytes that aren't PNG, JPEG, GIF or WebP")
return
}
if data.count > maxImageBytes {
spike.log("\(name): \(source) image is over \(maxImageBytes >> 20) MiB")
return
}
spike.log("\(name): \(source) attached a \(kind), \(data.count) bytes")
images.append(PendingImage(data: data, image: image))
}
func add(pasteboard pb: NSPasteboard, from source: String) -> Bool {
if let urls = pb.readObjects(forClasses: [NSURL.self], options: [.urlReadingFileURLsOnly: true]) as? [URL], !urls.isEmpty {
var any = false
for url in urls {
if let data = try? Data(contentsOf: url), imageType(data) != nil || pngFromTIFF(data) != nil && NSImage(data: data) != nil {
add(imageType(data) != nil ? data : pngFromTIFF(data)!, from: "\(source) file \(url.lastPathComponent)")
any = true
}
}
return any
}
for t in [NSPasteboard.PasteboardType.png, NSPasteboard.PasteboardType(UTType.jpeg.identifier), NSPasteboard.PasteboardType(UTType.gif.identifier), NSPasteboard.PasteboardType(UTType.webP.identifier)] {
if let data = pb.data(forType: t) {
add(data, from: "\(source) \(t.rawValue)")
return true
}
}
if let tiff = pb.data(forType: .tiff), let png = pngFromTIFF(tiff) {
add(png, from: "\(source) public.tiff converted to PNG")
return true
}
return false
}
func add(providers: [NSItemProvider], from source: String) {
spike.log("\(name): \(source) with types \(providers.map { $0.registeredTypeIdentifiers })")
for p in providers {
let types = [UTType.png, .jpeg, .gif, .webP, .tiff, .fileURL].filter { p.hasItemConformingToTypeIdentifier($0.identifier) }
guard let t = types.first else {
continue
}
_ = p.loadDataRepresentation(for: t) { data, error in
Task { @MainActor in
guard let data else {
self.spike.log("\(self.name): \(source) load failed: \(String(describing: error))")
return
}
if t == .fileURL, let url = URL(dataRepresentation: data, relativeTo: nil), let file = try? Data(contentsOf: url) {
self.add(imageType(file) != nil ? file : (pngFromTIFF(file) ?? file), from: "\(source) file \(url.lastPathComponent)")
} else if t == .tiff, let png = pngFromTIFF(data) {
self.add(png, from: "\(source) tiff converted to PNG")
} else {
self.add(data, from: "\(source) \(t.identifier)")
}
}
}
}
}
func send(_ text: String) {
let images = self.images
self.images = []
spike.log("\(name): send \(text.debugDescription) with \(images.count) images")
Task {
for img in images {
do {
spike.log("\(name): uploaded blob \(try await upload(img.data))")
} catch {
spike.log("\(name): upload failed: \(errorText(error))")
}
}
}
}
private func upload(_ data: Data) async throws -> String {
let body = HTTPBody(data)
let typed: Operations.UploadBlob.Input.Body = switch imageType(data) {
case "png": .png(body)
case "jpeg": .jpeg(body)
case "gif": .imageGif(body)
default: .imageWebp(body)
}
return try await api.client.uploadBlob(body: typed).created.body.json.id
}
}
struct Thumbnails: View {
@Bindable var attachments: Attachments
var body: some View {
HStack {
ForEach(attachments.images) { img in
ZStack(alignment: .topTrailing) {
Image(nsImage: img.image)
.resizable()
.scaledToFit()
.frame(width: 64, height: 64)
Button("×") {
attachments.images.removeAll { $0.id == img.id }
}
}
}
}
.frame(height: attachments.images.isEmpty ? 0 : 64)
}
}
struct ComposerSpike: View {
@Environment(Store.self) private var store
@Environment(SpikeLog.self) private var spike
var body: some View {
HStack(alignment: .top, spacing: 16) {
SwiftUIComposer(attachments: Attachments(spike: spike, api: store.api, name: "TextEditor"))
AppKitComposerPanel(attachments: Attachments(spike: spike, api: store.api, name: "NSTextView"))
RoutedComposer(attachments: Attachments(spike: spike, api: store.api, name: "Routed"))
}
.padding(8)
}
}
struct ComposerFocusKey: FocusedValueKey {
typealias Value = Attachments
}
extension FocusedValues {
var composer: Attachments? {
get {
self[ComposerFocusKey.self]
}
set {
self[ComposerFocusKey.self] = newValue
}
}
}
struct PasteboardCommands: Commands {
@FocusedValue(\.composer) private var composer
var body: some Commands {
CommandGroup(replacing: .pasteboard) {
Button("Cut") {
NSApp.sendAction(#selector(NSText.cut(_:)), to: nil, from: nil)
}
.keyboardShortcut("x")
Button("Copy") {
NSApp.sendAction(#selector(NSText.copy(_:)), to: nil, from: nil)
}
.keyboardShortcut("c")
Button("Paste") {
if let composer, composer.add(pasteboard: .general, from: "Edit ▸ Paste") {
return
}
NSApp.sendAction(#selector(NSText.paste(_:)), to: nil, from: nil)
}
.keyboardShortcut("v")
Button("Select All") {
NSApp.sendAction(#selector(NSText.selectAll(_:)), to: nil, from: nil)
}
.keyboardShortcut("a")
}
}
}
struct RoutedComposer: View {
@ViewState var attachments: Attachments
@ViewState private var text = ""
@ViewState private var overlayHits = false
@ViewState private var targeted = false
var body: some View {
VStack(alignment: .leading) {
Text("SwiftUI TextEditor, paste through Edit ▸ Paste, drop overlay")
Toggle("Drop overlay takes hits (off: clicks pass through to the editor)", isOn: $overlayHits)
Thumbnails(attachments: attachments)
TextEditor(text: $text)
.frame(minHeight: 160)
.border(targeted ? Color.accentColor : Color.secondary.opacity(0.4))
.focusedValue(\.composer, attachments)
.onKeyPress(.return, phases: .down) { press in
let marked = (NSApp.keyWindow?.firstResponder as? NSTextView)?.hasMarkedText() ?? false
attachments.spike.log("Routed: onKeyPress return, modifiers \(press.modifiers.rawValue), hasMarkedText \(marked)")
if press.modifiers.contains(.shift) || marked {
return .ignored
}
attachments.send(text)
text = ""
return .handled
}
.overlay {
Color.clear
.contentShape(Rectangle())
.allowsHitTesting(overlayHits)
.onDrop(of: [.image, .fileURL], isTargeted: $targeted) { providers in
attachments.add(providers: providers, from: "overlay onDrop")
return true
}
}
.onChange(of: targeted) {
attachments.spike.log("Routed: overlay drop targeted \(targeted)")
}
}
}
}
struct SwiftUIComposer: View {
@ViewState var attachments: Attachments
@ViewState private var text = ""
@ViewState private var checkMarked = false
var body: some View {
VStack(alignment: .leading) {
Text("SwiftUI TextEditor + onKeyPress, onPasteCommand, onDrop")
Toggle("In onKeyPress, also ignore Return while the first responder hasMarkedText()", isOn: $checkMarked)
Thumbnails(attachments: attachments)
TextEditor(text: $text)
.frame(minHeight: 160)
.border(Color.secondary.opacity(0.4))
.onKeyPress(.return, phases: .down) { press in
let marked = (NSApp.keyWindow?.firstResponder as? NSTextView)?.hasMarkedText() ?? false
attachments.spike.log("TextEditor: onKeyPress return, modifiers \(press.modifiers.rawValue), hasMarkedText \(marked)")
if press.modifiers.contains(.shift) {
return .ignored
}
if checkMarked && marked {
return .ignored
}
attachments.send(text)
text = ""
return .handled
}
.onPasteCommand(of: [.png, .jpeg, .gif, .webP, .tiff, .fileURL]) { providers in
attachments.add(providers: providers, from: "onPasteCommand")
}
.onDrop(of: [.image, .fileURL], isTargeted: nil) { providers in
attachments.add(providers: providers, from: "onDrop")
return true
}
}
}
}
struct AppKitComposerPanel: View {
@ViewState var attachments: Attachments
var body: some View {
VStack(alignment: .leading) {
Text("NSTextView in NSViewRepresentable")
Text("doCommand(insertNewline:), paste(_:), performDragOperation")
Thumbnails(attachments: attachments)
AppKitComposer(attachments: attachments)
.frame(minHeight: 160)
.border(Color.secondary.opacity(0.4))
}
}
}
final class ComposerTextView: NSTextView {
var attachments: Attachments!
override func keyDown(with event: NSEvent) {
if event.keyCode == 36 || event.keyCode == 76 {
attachments.spike.log("NSTextView: keyDown return, modifiers \(event.modifierFlags.intersection(.deviceIndependentFlagsMask).rawValue), hasMarkedText \(hasMarkedText())")
}
super.keyDown(with: event)
}
override func doCommand(by selector: Selector) {
let shift = NSApp.currentEvent?.modifierFlags.contains(.shift) ?? false
if selector == #selector(insertNewline(_:)) && !shift {
attachments.send(string)
string = ""
return
}
if selector == #selector(insertNewline(_:)) {
insertNewlineIgnoringFieldEditor(nil)
return
}
super.doCommand(by: selector)
}
override func paste(_ sender: Any?) {
if attachments.add(pasteboard: .general, from: "paste(_:)") {
return
}
super.paste(sender)
}
override var readablePasteboardTypes: [NSPasteboard.PasteboardType] {
super.readablePasteboardTypes + [.png, .tiff, .fileURL]
}
override func draggingEntered(_ sender: any NSDraggingInfo) -> NSDragOperation {
if hasImage(sender.draggingPasteboard) {
return .copy
}
return super.draggingEntered(sender)
}
override func draggingUpdated(_ sender: any NSDraggingInfo) -> NSDragOperation {
if hasImage(sender.draggingPasteboard) {
return .copy
}
return super.draggingUpdated(sender)
}
override func performDragOperation(_ sender: any NSDraggingInfo) -> Bool {
if hasImage(sender.draggingPasteboard) {
return attachments.add(pasteboard: sender.draggingPasteboard, from: "drop")
}
return super.performDragOperation(sender)
}
private func hasImage(_ pb: NSPasteboard) -> Bool {
if let urls = pb.readObjects(forClasses: [NSURL.self], options: [.urlReadingFileURLsOnly: true]) as? [URL], !urls.isEmpty {
return urls.contains { (try? Data(contentsOf: $0)).map { imageType($0) != nil || NSImage(data: $0) != nil } ?? false }
}
return pb.availableType(from: [.png, .tiff, NSPasteboard.PasteboardType(UTType.jpeg.identifier), NSPasteboard.PasteboardType(UTType.gif.identifier), NSPasteboard.PasteboardType(UTType.webP.identifier)]) != nil
}
}
struct AppKitComposer: NSViewRepresentable {
let attachments: Attachments
func makeNSView(context: Context) -> NSScrollView {
let scroll = NSScrollView()
scroll.hasVerticalScroller = true
let tv = ComposerTextView(frame: .zero)
tv.attachments = attachments
tv.isRichText = false
tv.allowsUndo = true
tv.font = Fonts.ct(13) as NSFont
tv.minSize = .zero
tv.maxSize = NSSize(width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude)
tv.isVerticallyResizable = true
tv.isHorizontallyResizable = false
tv.autoresizingMask = [.width]
tv.textContainer?.widthTracksTextView = true
tv.registerForDraggedTypes([.png, .tiff, .fileURL])
scroll.documentView = tv
return scroll
}
func updateNSView(_ nsView: NSScrollView, context: Context) {}
}