Files
Termind/TFI/Platform/MobileTerminalSurface.swift

165 lines
6.5 KiB
Swift

// SPDX-FileCopyrightText: 2026 Kaede Akino
// SPDX-License-Identifier: GPL-3.0-only
import GhosttyTerminal
import TermindApplication
import TermindDomain
import TermindPresentation
import UIKit
/// A workspace owns its native surface even while SwiftUI navigates away from it.
@MainActor
final class MobileTerminalSurface {
let view = MobileTerminalView(frame: .zero)
func bindFind(_ find: TerminalFindController) {
view.findController = find
}
}
/// Adds app-level terminal-find equivalents without taking ordinary terminal
/// input away from Ghostty. Escape is replaced only while Find is presented.
@MainActor
final class MobileTerminalView: TerminalView {
var uploadFiles: (() -> Void)?
var uploadPhotos: (() -> Void)?
var canUploadFiles: (() -> Bool)?
override func touchMenuItems(for context: TerminalTouchMenuContext) -> [UIMenuElement] {
customizedTouchMenuItems(super.touchMenuItems(for: context))
}
override func touchSelectionMenuItems(for context: TerminalTouchSelectionMenuContext) -> [UIMenuElement] {
customizedTouchMenuItems(super.touchSelectionMenuItems(for: context))
}
private func customizedTouchMenuItems(_ items: [UIMenuElement]) -> [UIMenuElement] {
var editingActions: [UIMenuElement] = []
var otherItems: [UIMenuElement] = []
for item in items {
guard let action = item as? UIAction else {
otherItems.append(item)
continue
}
let title: LocalizedStringResource
switch action.identifier.rawValue {
case "terminal.copy": title = AppStrings.Common.copy
case "terminal.paste": title = AppStrings.Common.paste
case "terminal.select": title = AppStrings.Common.select
case "terminal.selectAll": title = AppStrings.Common.selectAll
default:
otherItems.append(item)
continue
}
action.title = String(localized: title)
editingActions.append(action)
}
let uploadItems = uploadMenuItems()
let uploadGroups: [UIMenuElement] =
uploadItems.isEmpty ? [] : [UIMenu(options: .displayInline, children: uploadItems)]
let trailingItems = uploadGroups + otherItems
guard !editingActions.isEmpty else { return trailingItems }
let editingMenu = UIMenu(options: .displayInline, children: editingActions)
editingMenu.preferredElementSize = .medium
return [editingMenu] + trailingItems
}
private func uploadMenuItems() -> [UIMenuElement] {
guard uploadFiles != nil else { return [] }
let attributes: UIMenuElement.Attributes = canUploadFiles?() == true ? [] : .disabled
let photos = UIAction(
title: String(localized: AppStrings.Assistant.photos),
image: UIImage(systemName: "photo.on.rectangle"), attributes: attributes
) { [weak self] _ in
guard self?.canUploadFiles?() == true else { return }
self?.uploadPhotos?()
}
let files = UIAction(
title: String(localized: AppStrings.Assistant.files),
image: UIImage(systemName: "folder"), attributes: attributes
) { [weak self] _ in
guard self?.canUploadFiles?() == true else { return }
self?.uploadFiles?()
}
return [
UIMenu(
title: String(localized: AppStrings.Terminal.uploadFile),
image: UIImage(systemName: "paperclip"), children: [photos, files])
]
}
weak var findController: TerminalFindController?
private var searchLayoutSize: CGSize?
private var resumeSearchTask: Task<Void, Never>?
override func layoutSubviews() {
if searchLayoutSize != bounds.size {
searchLayoutSize = bounds.size
findController?.suspendSearchForResize()
resumeSearchTask?.cancel()
// Ghostty coalesces native resizes. Keep search stopped through
// that trailing resize and the following rendering frames.
let delay = configuration.resizeThrottleMilliseconds / 1000 + 0.1
resumeSearchTask = Task { [weak self] in
do {
try await Task.sleep(for: .seconds(delay))
} catch {
return
}
guard !Task.isCancelled else { return }
self?.findController?.resumeSearchAfterResize()
}
}
super.layoutSubviews()
}
override var keyCommands: [UIKeyCommand]? {
var commands = super.keyCommands ?? []
commands.append(findCommand(title: AppStrings.Terminal.findNextInTerminal, modifiers: []))
commands.append(findCommand(title: AppStrings.Terminal.findPreviousInTerminal, modifiers: .shift))
guard findController?.isPresented == true else { return commands }
commands.removeAll(where: isDismissFindEquivalent)
commands.append(dismissFindCommand(modifiers: []))
commands.append(dismissFindCommand(modifiers: .shift))
return commands
}
@objc private func handleFindCommand(_ command: UIKeyCommand) {
findController?.presentAndNavigate(command.modifierFlags.contains(.shift) ? .next : .previous)
}
@objc private func handleDismissFindCommand(_: UIKeyCommand) {
findController?.dismiss()
}
private func findCommand(title: LocalizedStringResource, modifiers: UIKeyModifierFlags) -> UIKeyCommand {
let command = UIKeyCommand(
title: String(localized: title),
action: #selector(handleFindCommand(_:)),
input: UIKeyCommand.f3,
modifierFlags: modifiers
)
command.wantsPriorityOverSystemBehavior = true
return command
}
private func dismissFindCommand(modifiers: UIKeyModifierFlags) -> UIKeyCommand {
let command = UIKeyCommand(
title: String(localized: AppStrings.Terminal.closeFind),
action: #selector(handleDismissFindCommand(_:)),
input: UIKeyCommand.inputEscape,
modifierFlags: modifiers
)
command.wantsPriorityOverSystemBehavior = true
return command
}
private func isDismissFindEquivalent(_ command: UIKeyCommand) -> Bool {
guard command.input == UIKeyCommand.inputEscape else { return false }
let modifiers = command.modifierFlags.intersection([.command, .control, .alternate, .shift])
return modifiers.isEmpty || modifiers == .shift
}
}