feat: editor working

This commit is contained in:
Lakr
2025-06-19 02:34:53 +08:00
parent 92887791fc
commit 876ea3a987
17 changed files with 994 additions and 263 deletions
@@ -29,6 +29,7 @@ let package = Package(
.product(name: "Apollo", package: "apollo-ios"),
.product(name: "OrderedCollections", package: "swift-collections"),
], resources: [
.process("Resources/main.metal"),
.process("Interface/View/InputBox/InputBox.xcassets"),
]),
]
@@ -35,7 +35,6 @@ class BlurTransitioningDelegate: NSObject, UIViewControllerTransitioningDelegate
class BlurTransitionAnimator: NSObject, UIViewControllerAnimatedTransitioning {
private let presenting: Bool
private let duration: TimeInterval = 0.75
private let snapshotViewTag = "snapshotView".hashValue
private let blurViewTag = "blurView".hashValue
@@ -45,23 +44,8 @@ class BlurTransitionAnimator: NSObject, UIViewControllerAnimatedTransitioning {
super.init()
}
private func performAnimation(
animations: @escaping () -> Void,
completion: @escaping (Bool) -> Void
) {
UIView.animate(
withDuration: duration,
delay: 0,
usingSpringWithDamping: 0.8,
initialSpringVelocity: 0.8,
options: [.beginFromCurrentState, .allowAnimatedContent, .curveEaseInOut],
animations: animations,
completion: completion
)
}
func transitionDuration(using _: UIViewControllerContextTransitioning?) -> TimeInterval {
duration
0.5
}
func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
@@ -108,7 +92,7 @@ class BlurTransitionAnimator: NSObject, UIViewControllerAnimatedTransitioning {
toView.layoutIfNeeded()
performAnimation(animations: {
performWithAnimation(animations: {
blurEffectView.effect = UIBlurEffect(style: .systemMaterial)
fromViewSnapshot.transform = CGAffineTransform(scaleX: 0.95, y: 0.95)
toView.alpha = 1
@@ -150,7 +134,7 @@ class BlurTransitionAnimator: NSObject, UIViewControllerAnimatedTransitioning {
return
}
performAnimation(animations: {
performWithAnimation(animations: {
fromViewSnapshot.transform = .identity
blurEffectView.effect = nil
fromView.transform = CGAffineTransform(scaleX: 1.05, y: 1.05)
@@ -5,19 +5,36 @@
// Created by on 6/19/25.
//
import PhotosUI
import UIKit
import UniformTypeIdentifiers
extension MainViewController: InputBoxDelegate {
func inputBoxDidSelectTakePhoto(_ inputBox: InputBox) {
print(#function, inputBox)
func inputBoxDidSelectTakePhoto(_: InputBox) {
let imagePickerController = UIImagePickerController()
imagePickerController.delegate = self
imagePickerController.sourceType = .camera
imagePickerController.allowsEditing = false
present(imagePickerController, animated: true)
}
func inputBoxDidSelectPhotoLibrary(_ inputBox: InputBox) {
print(#function, inputBox)
func inputBoxDidSelectPhotoLibrary(_: InputBox) {
var configuration = PHPickerConfiguration()
configuration.filter = .images
configuration.selectionLimit = 0 // 0 means no limit
let picker = PHPickerViewController(configuration: configuration)
picker.delegate = self
present(picker, animated: true)
}
func inputBoxDidSelectAttachFiles(_ inputBox: InputBox) {
print(#function, inputBox)
func inputBoxDidSelectAttachFiles(_: InputBox) {
let documentPicker = UIDocumentPickerViewController(forOpeningContentTypes: [
.pdf, .plainText, .commaSeparatedText, .data,
])
documentPicker.delegate = self
documentPicker.allowsMultipleSelection = false
present(documentPicker, animated: true)
}
func inputBoxDidSelectEmbedDocs(_ inputBox: InputBox) {
@@ -36,3 +53,71 @@ extension MainViewController: InputBoxDelegate {
print(#function, text)
}
}
// MARK: - UIImagePickerControllerDelegate
extension MainViewController: UIImagePickerControllerDelegate, UINavigationControllerDelegate {
func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any]) {
defer { picker.dismiss(animated: true) }
guard let image = info[.originalImage] as? UIImage else { return }
inputBox.addImageAttachment(image)
}
func imagePickerControllerDidCancel(_ picker: UIImagePickerController) {
picker.dismiss(animated: true)
}
}
// MARK: - PHPickerViewControllerDelegate
extension MainViewController: PHPickerViewControllerDelegate {
func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) {
defer { picker.dismiss(animated: true) }
for result in results {
if result.itemProvider.canLoadObject(ofClass: UIImage.self) {
result.itemProvider.loadObject(ofClass: UIImage.self) { [weak self] object, error in
guard let image = object as? UIImage, error == nil else { return }
DispatchQueue.main.async {
self?.inputBox.addImageAttachment(image)
}
}
}
}
}
}
// MARK: - UIDocumentPickerDelegate
extension MainViewController: UIDocumentPickerDelegate {
func documentPicker(_: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) {
for url in urls {
// Start accessing security-scoped resource
guard url.startAccessingSecurityScopedResource() else { continue }
defer { url.stopAccessingSecurityScopedResource() }
// Copy file to temporary directory
let context = IntelligentContext.shared
context.prepareTemporaryDirectory()
let tempURL = context.temporaryDirectory.appendingPathComponent(url.lastPathComponent)
do {
// Remove existing file if it exists
if FileManager.default.fileExists(atPath: tempURL.path) {
try FileManager.default.removeItem(at: tempURL)
}
// Copy file to temporary directory
try FileManager.default.copyItem(at: url, to: tempURL)
// Add file attachment using the temporary URL
inputBox.addFileAttachment(tempURL)
} catch {
print("Failed to copy file: \(error)")
}
}
}
}
@@ -6,11 +6,11 @@ import UIKit
class MainViewController: UIViewController {
// MARK: - UI Components
private lazy var headerView = MainHeaderView().then {
lazy var headerView = MainHeaderView().then {
$0.delegate = self
}
private lazy var inputBox = InputBox().then {
lazy var inputBox = InputBox().then {
$0.delegate = self
}
@@ -23,25 +23,6 @@ class MainViewController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
setupUI()
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
navigationController!.setNavigationBarHidden(true, animated: animated)
DispatchQueue.main.async {
self.inputBox.textView.becomeFirstResponder()
}
}
override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
navigationController!.setNavigationBarHidden(false, animated: animated)
}
// MARK: - Setup
private func setupUI() {
view.backgroundColor = .systemBackground
let inputBox = InputBox().then {
$0.delegate = self
@@ -61,4 +42,17 @@ class MainViewController: UIViewController {
make.bottom.equalTo(view.keyboardLayoutGuide.snp.top)
}
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
navigationController!.setNavigationBarHidden(true, animated: animated)
DispatchQueue.main.async {
self.inputBox.textView.becomeFirstResponder()
}
}
override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
navigationController!.setNavigationBarHidden(false, animated: animated)
}
}
@@ -0,0 +1,45 @@
//
// ParticleView+Removal.swift
// UIEffectKit
//
// Created by on 6/13/25.
//
import UIKit
public extension UIView {
func removeFromSuperviewWithExplodeEffect() {
guard let superview else { return }
guard let window else {
removeFromSuperview()
return
}
guard MTLCreateSystemDefaultDevice() != nil else {
removeFromSuperview()
return
}
let image = createViewSnapshot()
guard let cgImage = image.cgImage else {
removeFromSuperview()
return
}
let frameInWindow = superview.convert(frame, to: window)
let particleView = ParticleView(frame: frameInWindow)
window.addSubview(particleView)
particleView.layer.zPosition = 1000
particleView.frame = frameInWindow
particleView.setNeedsLayout()
particleView.layoutIfNeeded()
particleView.beginWith(cgImage, targetFrame: frameInWindow, onComplete: {
particleView.removeFromSuperview()
}, onFirstFrameRendered: { [weak self] in
DispatchQueue.main.async {
self?.removeFromSuperview()
}
})
}
}
@@ -0,0 +1,331 @@
//
// ParticleView+Renderer.swift
// UIEffectKit
//
// Created by on 6/13/25.
//
import MetalKit
extension ParticleView {
class Renderer: NSObject, MTKViewDelegate {
private struct Particle {
var position: simd_float2
var velocity: simd_float2
var life: simd_float1
var duration: simd_float1
}
private struct Vertex {
var position: simd_float4
var uv: simd_float2
var opacity: simd_float1
}
private var isPrepared = false
private var renderPipeline: MTLRenderPipelineState!
private var computePipeline: MTLComputePipelineState!
private var vertexBuffer: MTLBuffer!
private var particleBuffer: MTLBuffer!
private var particleCount: Int = 0
private var texture: MTLTexture!
private var targetFrameSize: simd_float2 = .zero
private var stepSize: Float = 0
private var commandQueue: MTLCommandQueue!
private var maxLife: Float = 0
private var onComplete: (() -> Void)?
private var onFirstFrameRendered: (() -> Void)?
private var hasRenderedFirstFrame = false
private var device: MTLDevice!
func prepareResources(
with device: MTLDevice,
image: CGImage,
targetFrame: CGRect,
onComplete: @escaping () -> Void,
onFirstFrameRendered: @escaping () -> Void
) {
guard !isPrepared else { return }
self.device = device
self.onComplete = onComplete
self.onFirstFrameRendered = onFirstFrameRendered
let integralTargetFrame = targetFrame.integral
DispatchQueue.global(qos: .userInitiated).async { [weak self] in
guard let self else { return }
setupPipelineStates(with: device)
setupVertexBuffer(with: device)
setupParticleSystem(targetFrame: integralTargetFrame, device: device)
setupTexture(from: image, device: device)
finalizeSetup(targetFrame: integralTargetFrame, device: device)
DispatchQueue.main.async { self.isPrepared = true }
}
}
private func setupPipelineStates(with device: MTLDevice) {
let library = try! device.makeDefaultLibrary(bundle: .module)
let particleVertexFunction = library.makeFunction(name: "PTS_ParticleVertex")!
let particleFragmentFunction = library.makeFunction(name: "PTS_ParticleFragment")!
let updateParticlesFunction = library.makeFunction(name: "PTS_UpdateParticles")!
let renderPipelineDescriptor = createRenderPipelineDescriptor(
vertexFunction: particleVertexFunction,
fragmentFunction: particleFragmentFunction
)
do {
renderPipeline = try device.makeRenderPipelineState(descriptor: renderPipelineDescriptor)
computePipeline = try device.makeComputePipelineState(function: updateParticlesFunction)
} catch {
fatalError("failed to create pipeline states: \(error)")
}
}
private func createRenderPipelineDescriptor(
vertexFunction: MTLFunction,
fragmentFunction: MTLFunction
) -> MTLRenderPipelineDescriptor {
let descriptor = MTLRenderPipelineDescriptor()
descriptor.colorAttachments[0].pixelFormat = .bgra8Unorm
descriptor.colorAttachments[0].isBlendingEnabled = true
descriptor.colorAttachments[0].rgbBlendOperation = .add
descriptor.colorAttachments[0].alphaBlendOperation = .add
descriptor.colorAttachments[0].sourceRGBBlendFactor = .sourceAlpha
descriptor.colorAttachments[0].sourceAlphaBlendFactor = .sourceAlpha
descriptor.colorAttachments[0].destinationRGBBlendFactor = .oneMinusSourceAlpha
descriptor.colorAttachments[0].destinationAlphaBlendFactor = .oneMinusSourceAlpha
descriptor.vertexFunction = vertexFunction
descriptor.fragmentFunction = fragmentFunction
return descriptor
}
func mtkView(_: MTKView, drawableSizeWillChange _: CGSize) {
// No-op since view is not subject to resize
}
func draw(in view: MTKView) {
guard isPrepared else { return }
updateParticles()
if checkAllParticlesDead() {
DispatchQueue.main.async { [weak self] in
self?.onComplete?()
}
return
}
renderParticles(in: view)
if !hasRenderedFirstFrame {
hasRenderedFirstFrame = true
DispatchQueue.main.async { [weak self] in
self?.onFirstFrameRendered?()
}
}
}
private func updateParticles() {
let maxThreadsPerThreadgroup = computePipeline.maxTotalThreadsPerThreadgroup
let threadgroupSize = min(maxThreadsPerThreadgroup, 2048)
let threadgroupCount = (particleCount + threadgroupSize - 1) / threadgroupSize
let computeCommandBuffer = commandQueue.makeCommandBuffer()!
let computeCommandEncoder = computeCommandBuffer.makeComputeCommandEncoder()!
computeCommandEncoder.setComputePipelineState(computePipeline)
computeCommandEncoder.setBuffer(particleBuffer, offset: 0, index: 0)
computeCommandEncoder.dispatchThreadgroups(
.init(width: threadgroupCount, height: 1, depth: 1),
threadsPerThreadgroup: .init(width: threadgroupSize, height: 1, depth: 1)
)
computeCommandEncoder.endEncoding()
computeCommandBuffer.commit()
}
private func checkAllParticlesDead() -> Bool {
let particleData = particleBuffer
.contents()
.bindMemory(to: Particle.self, capacity: particleCount)
for i in 0 ..< particleCount {
if particleData[i].life >= 0 {
return false
}
}
return true
}
private func renderParticles(in view: MTKView) {
let viewCGSize = view.bounds.size
var viewSize = simd_float2(Float(viewCGSize.width), Float(viewCGSize.height))
let renderCommandBuffer = commandQueue.makeCommandBuffer()!
guard let renderPassDescriptor = view.currentRenderPassDescriptor else { return }
renderPassDescriptor.colorAttachments[0].loadAction = .clear
renderPassDescriptor.colorAttachments[0].clearColor = .init(red: 0, green: 0, blue: 0, alpha: 0)
let renderCommandEncoder = renderCommandBuffer.makeRenderCommandEncoder(descriptor: renderPassDescriptor)!
renderCommandEncoder.setRenderPipelineState(renderPipeline)
renderCommandEncoder.setVertexBuffer(vertexBuffer, offset: 0, index: 0)
withUnsafeBytes(of: &viewSize) { pointer in
renderCommandEncoder.setVertexBytes(
pointer.baseAddress!,
length: MemoryLayout<simd_float2>.size,
index: 1
)
}
renderCommandEncoder.setVertexBuffer(particleBuffer, offset: 0, index: 2)
withUnsafeBytes(of: &targetFrameSize) { pointer in
renderCommandEncoder.setVertexBytes(
pointer.baseAddress!,
length: MemoryLayout<simd_float2>.size,
index: 3
)
}
withUnsafeBytes(of: &stepSize) { pointer in
renderCommandEncoder.setVertexBytes(
pointer.baseAddress!,
length: MemoryLayout<Float>.size,
index: 4
)
}
renderCommandEncoder.setFragmentTexture(texture, index: 0)
setupSampler(renderCommandEncoder: renderCommandEncoder)
renderCommandEncoder.drawPrimitives(
type: .triangleStrip,
vertexStart: 0,
vertexCount: 4,
instanceCount: particleCount
)
renderCommandEncoder.endEncoding()
renderCommandBuffer.present(view.currentDrawable!)
renderCommandBuffer.commit()
}
private func setupSampler(renderCommandEncoder: MTLRenderCommandEncoder) {
let samplerDescriptor = MTLSamplerDescriptor()
samplerDescriptor.minFilter = .linear
samplerDescriptor.magFilter = .linear
samplerDescriptor.mipFilter = .notMipmapped
samplerDescriptor.sAddressMode = .clampToEdge
samplerDescriptor.tAddressMode = .clampToEdge
let samplerState = device.makeSamplerState(descriptor: samplerDescriptor)
renderCommandEncoder.setFragmentSamplerState(samplerState, index: 0)
}
}
}
extension ParticleView.Renderer {
private func setupVertexBuffer(with device: MTLDevice) {
let vertices: [Vertex] = [
.init(position: .init(0, 0, 0, 1), uv: .init(0, 0), opacity: .zero),
.init(position: .init(1, 0, 0, 1), uv: .init(1, 0), opacity: .zero),
.init(position: .init(0, 1, 0, 1), uv: .init(0, 1), opacity: .zero),
.init(position: .init(1, 1, 0, 1), uv: .init(1, 1), opacity: .zero),
]
let vertexBuffer = vertices.withUnsafeBytes { pointer in
device.makeBuffer(
bytes: pointer.baseAddress!,
length: MemoryLayout<Vertex>.stride * vertices.count,
options: .storageModeShared
)
}
self.vertexBuffer = vertexBuffer!
}
private func setupParticleSystem(targetFrame: CGRect, device: MTLDevice) {
var particles = [Particle]()
let targetFrameHeight = Float(targetFrame.height)
let targetFrameWidth = Float(targetFrame.width)
let particleStep = 1
let estimatedParticleCount = 1
* Int(targetFrameWidth / Float(particleStep))
* Int(targetFrameHeight / Float(particleStep))
let pixelMultiplier = 1
particles.reserveCapacity(estimatedParticleCount * pixelMultiplier)
for y in stride(from: 0, to: Int(targetFrameHeight), by: particleStep) {
for x in stride(from: 0, to: Int(targetFrameWidth), by: particleStep) {
let particle = createParticle(x: x, y: y, step: particleStep)
for _ in 0 ..< pixelMultiplier {
particles.append(particle)
}
}
}
particleCount = particles.count
let particleBuffer = particles.withUnsafeBytes { pointer in
device.makeBuffer(
bytes: pointer.baseAddress!,
length: MemoryLayout<Particle>.stride * particles.count,
options: .storageModeShared
)
}
self.particleBuffer = particleBuffer!
stepSize = Float(particleStep)
}
private func createParticle(x: Int, y: Int, step: Int) -> Particle {
let particleDuration: Float = .random(in: 20 ... 60)
let initialX = Float(x) + Float(step) / 2.0
let initialY = Float(y) + Float(step) / 2.0
return .init(
position: .init(initialX, initialY),
velocity: .init(
cos(Float.random(in: 0 ... (2 * Float.pi))) * Float.random(in: 1 ... 4),
sin(Float.random(in: 0 ... (2 * Float.pi))) * Float.random(in: 1 ... 4) - 2.5
),
life: simd_float1(particleDuration),
duration: simd_float1(particleDuration)
)
}
private func setupTexture(from image: CGImage, device: MTLDevice) {
let colorSpace = CGColorSpace(name: CGColorSpace.sRGB)!
let bitmapInfo = CGBitmapInfo(rawValue: CGImageAlphaInfo.premultipliedLast.rawValue)
guard let context = CGContext(
data: nil,
width: image.width,
height: image.height,
bitsPerComponent: 8,
bytesPerRow: 0,
space: colorSpace,
bitmapInfo: bitmapInfo.rawValue
) else { return }
context.draw(image, in: CGRect(x: 0, y: 0, width: image.width, height: image.height))
guard let convertedImage = context.makeImage() else { return }
let textureLoader = MTKTextureLoader(device: device)
let textureLoaderOptions: [MTKTextureLoader.Option: Any] = [
.textureStorageMode: MTLStorageMode.private.rawValue,
.SRGB: false,
]
guard let texture = try? textureLoader.newTexture(
cgImage: convertedImage,
options: textureLoaderOptions
) else { return }
self.texture = texture
}
private func finalizeSetup(targetFrame: CGRect, device: MTLDevice) {
let targetFrameWidth = Float(targetFrame.width)
let targetFrameHeight = Float(targetFrame.height)
targetFrameSize = .init(targetFrameWidth, targetFrameHeight)
commandQueue = device.makeCommandQueue()!
}
}
@@ -0,0 +1,79 @@
//
// ParticleView.swift
// TrollNFC
//
// Created by on 6/8/25.
//
import MetalKit
import simd
import UIKit
class ParticleView: UIView {
private var device: MTLDevice!
private var metalView: MTKView!
private var renderer = Renderer()
override init(frame: CGRect) {
super.init(frame: frame)
setupMetalDevice()
setupMetalView()
setupViewProperties()
}
private func setupMetalDevice() {
guard let device = Self.createSystemDefaultDevice() else {
fatalError("failed to create Metal device")
}
self.device = device
}
private func setupMetalView() {
metalView = MTKView(frame: .zero, device: device)
configureMetalView()
addSubview(metalView)
}
private func configureMetalView() {
metalView.layer.isOpaque = false
metalView.backgroundColor = UIColor.clear
metalView.delegate = renderer
}
private func setupViewProperties() {
clipsToBounds = false
metalView.clipsToBounds = false
}
private static func createSystemDefaultDevice() -> MTLDevice? {
MTLCreateSystemDefaultDevice()
}
@available(*, unavailable)
required init?(coder _: NSCoder) {
fatalError()
}
func beginWith(
_ image: CGImage,
targetFrame: CGRect,
onComplete: @escaping () -> Void,
onFirstFrameRendered: @escaping () -> Void
) {
renderer.prepareResources(
with: device,
image: image,
targetFrame: targetFrame,
onComplete: onComplete,
onFirstFrameRendered: onFirstFrameRendered
)
metalView.draw()
}
override func layoutSubviews() {
super.layoutSubviews()
let expandedBounds = bounds.insetBy(dx: -bounds.width, dy: -bounds.height)
metalView.frame = expandedBounds
}
}
@@ -0,0 +1,22 @@
//
// UIView+createViewSnapshot.swift
// Intelligents
//
// Created by on 6/19/25.
//
import UIKit
public extension UIView {
func createViewSnapshot() -> UIImage {
let renderer = UIGraphicsImageRenderer(bounds: bounds)
return renderer.image { context in
// clear the background
context.cgContext.setFillColor(UIColor.clear.cgColor)
context.cgContext.fill(bounds)
// MUST USE DRAW HIERARCHY TO RENDER VISUAL EFFECT VIEW
self.drawHierarchy(in: bounds, afterScreenUpdates: false)
}
}
}
@@ -0,0 +1,23 @@
//
// Animation.swift
// Intelligents
//
// Created by on 6/19/25.
//
import UIKit
func performWithAnimation(
animations: @escaping () -> Void,
completion: @escaping (Bool) -> Void = { _ in }
) {
UIView.animate(
withDuration: 0.5,
delay: 0,
usingSpringWithDamping: 0.8,
initialSpringVelocity: 0.8,
options: [.beginFromCurrentState, .allowAnimatedContent, .curveEaseInOut],
animations: animations,
completion: completion
)
}
@@ -48,12 +48,15 @@ class InputBox: UIView {
$0.delegate = self
}
lazy var imageBar = InputBoxImageBar(frame: bounds)
lazy var imageBar = InputBoxImageBar().then {
$0.imageBarDelegate = self
}
lazy var mainStackView = UIStackView().then {
$0.axis = .vertical
$0.spacing = 16
$0.alignment = .fill
$0.addArrangedSubview(imageBar)
$0.addArrangedSubview(textView)
$0.addArrangedSubview(functionBar)
}
@@ -73,76 +76,13 @@ class InputBox: UIView {
override init(frame: CGRect = .zero) {
super.init(frame: frame)
setupViews()
setupConstraints()
setupBindings()
updatePlaceholderVisibility()
}
@available(*, unavailable)
required init?(coder _: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func setupViews() {
backgroundColor = .clear
addSubview(containerView)
containerView.addSubview(mainStackView)
containerView.addSubview(placeholderLabel)
imageBar.isHidden = true
imageBar.imageBarDelegate = self
}
func setupBindings() {
// ViewModel UI
viewModel.$inputText
.sink { [weak self] text in
if self?.textView.text != text {
self?.textView.text = text
self?.updatePlaceholderVisibility()
self?.updateTextViewHeight()
}
}
.store(in: &cancellables)
viewModel.$isToolEnabled
.sink { [weak self] enabled in
self?.functionBar.updateToolState(isEnabled: enabled)
}
.store(in: &cancellables)
viewModel.$isNetworkEnabled
.sink { [weak self] enabled in
self?.functionBar.updateNetworkState(isEnabled: enabled)
}
.store(in: &cancellables)
viewModel.$isDeepThinkingEnabled
.sink { [weak self] enabled in
self?.functionBar.updateDeepThinkingState(isEnabled: enabled)
}
.store(in: &cancellables)
viewModel.$canSend
.sink { [weak self] canSend in
self?.functionBar.updateSendState(canSend: canSend)
}
.store(in: &cancellables)
viewModel.$hasAttachments
.sink { [weak self] hasAttachments in
self?.updateImageBarVisibility(hasAttachments)
}
.store(in: &cancellables)
viewModel.$attachments
.sink { [weak self] attachments in
self?.updateImageBarContent(attachments)
}
.store(in: &cancellables)
}
func setupConstraints() {
containerView.snp.makeConstraints { make in
make.edges.equalToSuperview().inset(16)
}
@@ -151,6 +91,10 @@ class InputBox: UIView {
make.edges.equalToSuperview().inset(16)
}
imageBar.snp.makeConstraints { make in
make.left.right.equalToSuperview()
}
textView.snp.makeConstraints { make in
textViewHeightConstraint = make.height.equalTo(minTextViewHeight).constraint
}
@@ -159,6 +103,74 @@ class InputBox: UIView {
make.left.right.equalTo(textView)
make.top.equalTo(textView)
}
setupBindings()
updatePlaceholderVisibility()
}
@available(*, unavailable)
required init?(coder _: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func setupBindings() {
// ViewModel UI
viewModel.$inputText
.removeDuplicates()
.sink { [weak self] text in
if self?.textView.text != text {
self?.textView.text = text
self?.updatePlaceholderVisibility()
self?.updateTextViewHeight()
}
}
.store(in: &cancellables)
viewModel.$isToolEnabled
.removeDuplicates()
.sink { [weak self] enabled in
self?.functionBar.updateToolState(isEnabled: enabled)
}
.store(in: &cancellables)
viewModel.$isNetworkEnabled
.removeDuplicates()
.sink { [weak self] enabled in
self?.functionBar.updateNetworkState(isEnabled: enabled)
}
.store(in: &cancellables)
viewModel.$isDeepThinkingEnabled
.removeDuplicates()
.sink { [weak self] enabled in
self?.functionBar.updateDeepThinkingState(isEnabled: enabled)
}
.store(in: &cancellables)
viewModel.$canSend
.removeDuplicates()
.sink { [weak self] canSend in
self?.functionBar.updateSendState(canSend: canSend)
}
.store(in: &cancellables)
viewModel.$hasAttachments
.dropFirst() // for view setup
.removeDuplicates()
.sink { [weak self] hasAttachments in
performWithAnimation {
self?.updateImageBarVisibility(hasAttachments)
self?.layoutIfNeeded()
}
}
.store(in: &cancellables)
viewModel.$attachments
.removeDuplicates()
.sink { [weak self] attachments in
self?.updateImageBarContent(attachments)
}
.store(in: &cancellables)
}
func updateTextViewHeight() {
@@ -173,13 +185,7 @@ class InputBox: UIView {
if height == 0 || superview == nil || window == nil || isHidden { return }
UIView.animate(
withDuration: 0.5,
delay: 0,
usingSpringWithDamping: 0.8,
initialSpringVelocity: 1.0,
options: .curveEaseInOut
) {
performWithAnimation {
self.layoutIfNeeded()
self.superview?.layoutIfNeeded()
}
@@ -190,22 +196,11 @@ class InputBox: UIView {
}
func updateImageBarVisibility(_ hasAttachments: Bool) {
if hasAttachments, !mainStackView.arrangedSubviews.contains(imageBar) {
mainStackView.insertArrangedSubview(imageBar, at: 1)
} else if !hasAttachments, mainStackView.arrangedSubviews.contains(imageBar) {
mainStackView.removeArrangedSubview(imageBar)
imageBar.removeFromSuperview()
}
imageBar.isHidden = !hasAttachments
}
func updateImageBarContent(_ attachments: [InputAttachment]) {
imageBar.clear()
for attachment in attachments {
if attachment.type == .image, let data = attachment.data, let image = UIImage(data: data) {
imageBar.addImage(image)
}
}
imageBar.updateImageBarContent(attachments)
}
// MARK: - Public Methods
@@ -220,7 +215,26 @@ class InputBox: UIView {
size: Int64(imageData.count)
)
viewModel.addAttachment(attachment)
performWithAnimation { [self] in
viewModel.addAttachment(attachment)
layoutIfNeeded()
}
}
public func addFileAttachment(_ url: URL) {
guard let fileData = try? Data(contentsOf: url) else { return }
let attachment = InputAttachment(
type: .file,
data: fileData,
name: url.lastPathComponent,
size: Int64(fileData.count)
)
performWithAnimation { [self] in
viewModel.addAttachment(attachment)
layoutIfNeeded()
}
}
public var inputBoxData: InputBoxData {
@@ -17,8 +17,11 @@ protocol InputBoxDelegate: AnyObject {
}
extension InputBox: InputBoxImageBarDelegate {
func inputBoxImageBar(_: InputBoxImageBar, didRemoveImageAt index: Int) {
viewModel.removeAttachment(at: index)
func inputBoxImageBar(_: InputBoxImageBar, didRemoveImageWithId id: InputAttachment.ID) {
performWithAnimation { [self] in
viewModel.removeAttachment(withId: id)
layoutIfNeeded()
}
}
}
@@ -10,27 +10,34 @@ import Then
import UIKit
protocol InputBoxImageBarDelegate: AnyObject {
func inputBoxImageBar(_ imageBar: InputBoxImageBar, didRemoveImageAt index: Int)
func inputBoxImageBar(_ imageBar: InputBoxImageBar, didRemoveImageWithId id: UUID)
}
private let constantHeight: CGFloat = 108
private class AttachmentViewModel {
let attachment: InputAttachment
let imageCell: InputBoxImageBar.ImageCell
init(attachment: InputAttachment, imageCell: InputBoxImageBar.ImageCell) {
self.attachment = attachment
self.imageCell = imageCell
}
}
class InputBoxImageBar: UIScrollView {
weak var imageBarDelegate: InputBoxImageBarDelegate?
private lazy var stackView = UIStackView().then {
$0.axis = .horizontal
$0.spacing = 8
$0.alignment = .center
$0.distribution = .equalSpacing
}
private var imageCells: [ImageCell] = []
private var attachmentViewModels: [AttachmentViewModel] = []
private let cellSpacing: CGFloat = 8
private let constantHeight: CGFloat = 80
override init(frame: CGRect = .zero) {
super.init(frame: frame)
setupViews()
setupConstraints()
showsHorizontalScrollIndicator = false
showsVerticalScrollIndicator = false
snp.makeConstraints { make in
make.height.equalTo(constantHeight)
}
}
@available(*, unavailable)
@@ -38,113 +45,128 @@ class InputBoxImageBar: UIScrollView {
fatalError()
}
private func setupViews() {
showsHorizontalScrollIndicator = false
showsVerticalScrollIndicator = false
addSubview(stackView)
}
func updateImageBarContent(_ attachments: [InputAttachment]) {
let currentIds = Set(attachmentViewModels.map(\.attachment.id))
let imageAttachments = attachments.filter { $0.type == .image }
let newIds = Set(imageAttachments.map(\.id))
private func setupConstraints() {
stackView.snp.makeConstraints { make in
make.edges.equalToSuperview()
make.height.equalTo(constantHeight)
//
let idsToRemove = currentIds.subtracting(newIds)
for id in idsToRemove {
if let index = attachmentViewModels.firstIndex(where: { $0.attachment.id == id }) {
let viewModel = attachmentViewModels.remove(at: index)
viewModel.imageCell.removeFromSuperview()
}
}
snp.makeConstraints { make in
make.height.equalTo(constantHeight)
}
}
//
let idsToAdd = newIds.subtracting(currentIds)
for attachment in imageAttachments {
if idsToAdd.contains(attachment.id),
let data = attachment.data,
let image = UIImage(data: data)
{
let imageCell = ImageCell(
// for animation to work
frame: .init(x: 0, y: 0, width: constantHeight, height: constantHeight),
image: image,
attachmentId: attachment.id
)
imageCell.onRemove = { [weak self] cell in
self?.removeImageCell(cell)
}
func addImage(_ image: UIImage) {
let imageCell = ImageCell(image: image)
imageCell.onRemove = { [weak self] cell in
self?.removeImageCell(cell)
let viewModel = AttachmentViewModel(attachment: attachment, imageCell: imageCell)
attachmentViewModels.append(viewModel)
addSubview(imageCell)
}
}
imageCells.append(imageCell)
stackView.addArrangedSubview(imageCell)
updateContentSize()
layoutImageCells()
}
func removeImageCell(_ cell: ImageCell) {
if let index = imageCells.firstIndex(of: cell) {
imageCells.remove(at: index)
stackView.removeArrangedSubview(cell)
cell.removeFromSuperview()
imageBarDelegate?.inputBoxImageBar(self, didRemoveImageAt: index)
updateContentSize()
if let index = attachmentViewModels.firstIndex(where: { $0.imageCell === cell }) {
let viewModel = attachmentViewModels.remove(at: index)
viewModel.imageCell.removeFromSuperviewWithExplodeEffect()
imageBarDelegate?.inputBoxImageBar(self, didRemoveImageWithId: cell.attachmentId)
layoutImageCells()
}
}
func clear() {
for cell in imageCells {
stackView.removeArrangedSubview(cell)
cell.removeFromSuperview()
for viewModel in attachmentViewModels {
viewModel.imageCell.removeFromSuperview()
}
imageCells.removeAll()
updateContentSize()
attachmentViewModels.removeAll()
contentSize = .zero
}
private func updateContentSize() {
layoutIfNeeded()
contentSize = stackView.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize)
private func layoutImageCells() {
var xOffset: CGFloat = 0
for viewModel in attachmentViewModels {
viewModel.imageCell.frame = CGRect(x: xOffset, y: 0, width: constantHeight, height: constantHeight)
xOffset += constantHeight + cellSpacing
}
// Update content size
let totalWidth = max(0, xOffset - cellSpacing)
contentSize = CGSize(width: totalWidth, height: constantHeight)
}
}
extension InputBoxImageBar {
class ImageCell: UIView {
let attachmentId: UUID
var onRemove: ((ImageCell) -> Void)?
private lazy var imageView = UIImageView().then {
private lazy var imageView = UIImageView(frame: bounds).then {
$0.contentMode = .scaleAspectFill
$0.clipsToBounds = true
$0.layer.cornerRadius = 12
$0.layer.cornerCurve = .continuous
$0.backgroundColor = .systemGray6
}
private lazy var removeButton = UIButton(type: .system).then {
$0.backgroundColor = UIColor.black.withAlphaComponent(0.52)
$0.layer.cornerRadius = 8.5
$0.layer.borderWidth = 1
$0.layer.borderColor = UIColor(red: 0.9, green: 0.9, blue: 0.9, alpha: 1).cgColor
$0.setImage(UIImage(systemName: "xmark"), for: .normal)
$0.tintColor = .white
$0.addTarget(self, action: #selector(removeButtonTapped), for: .touchUpInside)
private lazy var removeButton = DeleteButtonView(frame: removeButtonFrame).then {
$0.onTapped = { [weak self] in
self?.removeButtonTapped()
}
}
init(image: UIImage) {
super.init(frame: .zero)
setupViews()
setupConstraints()
init(frame: CGRect, image: UIImage, attachmentId: UUID) {
self.attachmentId = attachmentId
super.init(frame: frame)
addSubview(imageView)
addSubview(removeButton)
imageView.image = image
}
var removeButtonFrame: CGRect {
let buttonSize: CGFloat = 18
let buttonInset: CGFloat = 6
return CGRect(
x: bounds.width - buttonSize - buttonInset,
y: buttonInset,
width: buttonSize,
height: buttonSize
)
}
override func layoutSubviews() {
super.layoutSubviews()
imageView.frame = bounds
removeButton.frame = removeButtonFrame
}
@available(*, unavailable)
required init?(coder _: NSCoder) {
fatalError()
}
private func setupViews() {
addSubview(imageView)
addSubview(removeButton)
}
private func setupConstraints() {
// 1:1
snp.makeConstraints { make in
make.width.height.equalTo(constantHeight)
}
imageView.snp.makeConstraints { make in
make.edges.equalToSuperview()
}
removeButton.snp.makeConstraints { make in
make.top.trailing.equalToSuperview().inset(6.5)
make.width.height.equalTo(17)
}
}
@objc private func removeButtonTapped() {
onRemove?(self)
}
@@ -10,41 +10,27 @@ import Foundation
// MARK: - Data Models
public enum AttachmentType: Equatable {
case image
case document
case video
case audio
case other(String)
public struct InputAttachment: Identifiable, Equatable, Hashable, Codable {
public var id: UUID = .init()
public var type: AttachmentType
public var data: Data?
public var url: URL?
public var name: String
public var size: Int64
public var displayName: String {
switch self {
case .image: "Image"
case .document: "Document"
case .video: "Video"
case .audio: "Audio"
case let .other(type): type
}
public enum AttachmentType: String, Equatable, Hashable, Codable {
case image
case document
case file
}
}
public struct InputAttachment {
public let id: String
public let type: AttachmentType
public let data: Data?
public let url: URL?
public let name: String
public let size: Int64
public init(
id: String = UUID().uuidString,
type: AttachmentType,
data: Data? = nil,
url: URL? = nil,
name: String,
size: Int64 = 0
) {
self.id = id
self.type = type
self.data = data
self.url = url
@@ -54,11 +40,11 @@ public struct InputAttachment {
}
public struct InputBoxData {
public let text: String
public let attachments: [InputAttachment]
public let isToolEnabled: Bool
public let isNetworkEnabled: Bool
public let isDeepThinkingEnabled: Bool
public var text: String
public var attachments: [InputAttachment]
public var isToolEnabled: Bool
public var isNetworkEnabled: Bool
public var isDeepThinkingEnabled: Bool
public init(
text: String,
@@ -146,9 +132,8 @@ public extension InputBoxViewModel {
attachments.append(attachment)
}
func removeAttachment(at index: Int) {
guard index < attachments.count else { return }
attachments.remove(at: index)
func removeAttachment(withId id: UUID) {
attachments.removeAll { $0.id == id }
}
func clearAttachments() {
@@ -48,12 +48,7 @@ public extension UIViewController {
button.stopProgress()
view.layoutIfNeeded()
UIView.animate(
withDuration: 0.5,
delay: 0,
usingSpringWithDamping: 1.0,
initialSpringVelocity: 0.8
) {
performWithAnimation {
button.alpha = 1
button.transform = .identity
button.setNeedsLayout()
@@ -78,12 +73,7 @@ public extension UIViewController {
button.stopProgress()
button.setNeedsLayout()
view.layoutIfNeeded()
UIView.animate(
withDuration: 0.5,
delay: 0,
usingSpringWithDamping: 1.0,
initialSpringVelocity: 0.8
) {
performWithAnimation {
button.alpha = 0
button.transform = .init(scaleX: 0, y: 0)
button.setNeedsLayout()
@@ -0,0 +1,49 @@
import UIKit
class DeleteButtonView: UIView {
let imageView = UIImageView(image: .init(systemName: "xmark")).then {
$0.tintColor = .white
$0.contentMode = .scaleAspectFit
}
let blur = UIVisualEffectView(
effect: UIBlurEffect(style: .systemUltraThinMaterialDark)
).then {
$0.clipsToBounds = true
}
var onTapped: () -> Void = {}
override init(frame: CGRect) {
super.init(frame: frame)
isUserInteractionEnabled = true
addSubview(blur)
addSubview(imageView)
blur.snp.makeConstraints { make in
make.edges.equalToSuperview()
}
imageView.snp.makeConstraints { make in
make.edges.equalToSuperview().inset(2)
}
let gesture = UITapGestureRecognizer(target: self, action: #selector(tapped))
addGestureRecognizer(gesture)
}
@available(*, unavailable)
required init?(coder _: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func layoutSubviews() {
super.layoutSubviews()
blur.layer.cornerRadius = min(bounds.width, bounds.height) / 2
}
@objc func tapped() {
onTapped()
}
}
@@ -15,13 +15,30 @@ public class IntelligentContext {
public var webView: WKWebView!
public lazy var temporaryDirectory: URL = {
let tempDir = FileManager.default.temporaryDirectory
return tempDir.appendingPathComponent("IntelligentContext")
}()
private init() {}
public func preparePresent(_ completion: @escaping () -> Void) {
// used to gathering information, populate content from webview, etc.
// TODO: if needed
completion()
DispatchQueue.global(qos: .userInitiated).async { [self] in
prepareTemporaryDirectory()
// TODO: used to gathering information, populate content from webview, etc.
DispatchQueue.main.async {
completion()
}
}
}
// MARK: - Input Processing
func prepareTemporaryDirectory() {
if FileManager.default.fileExists(atPath: temporaryDirectory.path) {
try? FileManager.default.removeItem(at: temporaryDirectory)
}
try? FileManager.default.createDirectory(
at: temporaryDirectory,
withIntermediateDirectories: true
)
}
}
@@ -0,0 +1,87 @@
#include <metal_stdlib>
using namespace metal;
namespace ParticleTransitionSystem {
struct TrollParticle {
float2 position;
float2 velocity;
float life;
float duration;
};
struct TrollVertex {
float4 position [[position]];
float2 uv;
float opacity;
};
}
// 顶点着色器 负责将粒子数据转换为顶点数据
vertex ParticleTransitionSystem::TrollVertex PTS_ParticleVertex(const device ParticleTransitionSystem::TrollVertex *vertices [[buffer(0)]],
const device float2 &resolution [[buffer(1)]],
const device ParticleTransitionSystem::TrollParticle *particles [[buffer(2)]],
const device float2 &targetFrameSize [[buffer(3)]],
const device float &stepSize [[buffer(4)]],
unsigned int vid [[vertex_id]],
unsigned int particleId [[instance_id]]) {
ParticleTransitionSystem::TrollVertex v = vertices[vid];
ParticleTransitionSystem::TrollParticle p = particles[particleId];
int particlesPerRow = int(targetFrameSize.x / stepSize);
int row = particleId / particlesPerRow;
int col = particleId % particlesPerRow;
float2 originalPos = float2(col * stepSize + stepSize / 2.0, row * stepSize + stepSize / 2.0);
float2 currentPos = p.position;
// 计算目标帧在屏幕中的居中偏移
float2 offset = (resolution - targetFrameSize) / 2.0;
// 设置UV坐标用于纹理采样
v.uv.x = originalPos.x / targetFrameSize.x;
v.uv.y = originalPos.y / targetFrameSize.y;
// 计算最终的屏幕位置
float particleSize = stepSize;
float2 worldPos = v.position.xy * particleSize + currentPos + offset;
// 转换到NDC坐标系 (-1到1)
v.position.x = (worldPos.x / resolution.x) * 2.0 - 1.0;
v.position.y = 1.0 - (worldPos.y / resolution.y) * 2.0;
// 逐渐消失
v.opacity = p.life / p.duration;
return v;
}
// 片段着色器 负责将顶点数据转换为像素颜色
fragment float4 PTS_ParticleFragment(ParticleTransitionSystem::TrollVertex in [[stage_in]],
const texture2d<float> texture [[texture(0)]],
const sampler textureSampler [[sampler(0)]]) {
constexpr sampler samplr;
float4 color = texture.sample(samplr, in.uv);
float a = color.a * in.opacity; // apply texture alpha
color *= in.opacity; // apply opacity from vertex shader aka pre-multiplied alpha
color.a = a;
return color;
}
// 计算粒子位置和速度的计算着色器 负责更新粒子的位置和速度
kernel void PTS_UpdateParticles(device ParticleTransitionSystem::TrollParticle *particles [[buffer(0)]],
unsigned int index [[thread_position_in_grid]]) {
if (particles[index].life >= 0) {
particles[index].position += particles[index].velocity;
// 模拟空气阻力,降低速度 x, y 分量
particles[index].velocity.x *= 0.99;
particles[index].velocity.y *= 0.99;
// 模拟重力影响,增加 y 分量
particles[index].velocity.y += 0.1;
}
particles[index].life -= 1.0;
}