feat(terminal): add inline selection and native menus
This commit is contained in:
1 parent
c98ddd0ffb
commit
a8e00e5122
11 files changed
+160
-15
No files matched your search
@@ -4,3 +4,7 @@
|
||||
[submodule "libssh2-spm"]
|
||||
path = libssh2-spm
|
||||
url = https://github.com/AkinoKaede/libssh2-spm.git
|
||||
[submodule "libghostty-spm"]
|
||||
path = libghostty-spm
|
||||
url = https://github.com/AkinoKaede/libghostty-spm.git
|
||||
branch = termind
|
||||
@@ -17,6 +17,19 @@ The agent runtime is `AgentKit`, a separate open-source package developed in the
|
||||
|
||||
Xcode builds the pinned tag, not the sibling working tree, so an uncommitted change in `../AgentKit` is invisible to the app. A change that spans both sides takes three steps: make and test the package change, tag and push a release from `../AgentKit`, then bump `minimumVersion` in `project.pbxproj` and re-resolve so `Package.resolved` records the new revision. Both files belong in the same commit as the app-side change that needs them.
|
||||
|
||||
`libghostty-spm/` is a maintenance submodule of the public
|
||||
`AkinoKaede/libghostty-spm` fork, tracking `termind`; upstream is
|
||||
`Lakr233/libghostty-spm`. Xcode consumes the fork as a remote SPM dependency
|
||||
pinned to an exact commit, not the submodule working tree. Push package changes
|
||||
to `termind`, then update the submodule gitlink, `project.pbxproj` revision and
|
||||
`Package.resolved` together. Run `git submodule update --init libghostty-spm`
|
||||
to obtain the maintenance checkout. Changes to `Patches/ghostty/` require a new
|
||||
fork XCFramework release and a matching URL/checksum in `Package.swift` before
|
||||
advancing the app's pin. The package's native iOS touch menus expose
|
||||
separate subclass hooks, `touchMenuItems(for:)` and
|
||||
`touchSelectionMenuItems(for:)`, before and after selection. Override them on
|
||||
the platform view, with grouping and localization owned by Termind.
|
||||
|
||||
## Architecture Records
|
||||
|
||||
Treat `openwiki/` as durable just-in-time engineering context. Start with `openwiki/quickstart.md` before changing dependencies, navigation ownership, state lifetimes, persistence, agent execution, permissions, secret handling, or release operations, then follow its topic-specific page to current source and tests. Source code and tests remain authoritative. Refresh invalidated OpenWiki content through the OpenWiki lifecycle and keep `ArchitectureBoundaryTests` aligned; do not maintain a parallel refactoring log.
|
||||
|
||||
@@ -24823,6 +24823,23 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Select" : {
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
"zh-Hans" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "选择"
|
||||
}
|
||||
},
|
||||
"zh-Hant" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "選取"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Select %@ for batch actions" : {
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
|
||||
@@ -230,6 +230,10 @@ extension AppStrings {
|
||||
"Search"
|
||||
}
|
||||
|
||||
public static var select: LocalizedStringResource {
|
||||
"Select"
|
||||
}
|
||||
|
||||
public static var selectAll: LocalizedStringResource {
|
||||
"Select All"
|
||||
}
|
||||
|
||||
@@ -7,7 +7,17 @@ import UIKit
|
||||
/// A workspace owns its native surface even while SwiftUI navigates away from it.
|
||||
@MainActor
|
||||
final class MobileTerminalSurface {
|
||||
let view = MobileTerminalView(frame: .zero)
|
||||
let view: MobileTerminalView = {
|
||||
let view = MobileTerminalView(frame: .zero)
|
||||
view.usesInlineTextSelection = true
|
||||
view.touchSelectionTitles = TerminalTouchSelectionTitles(
|
||||
paste: String(localized: AppStrings.Common.paste),
|
||||
select: String(localized: AppStrings.Common.select),
|
||||
selectAll: String(localized: AppStrings.Common.selectAll),
|
||||
copy: String(localized: AppStrings.Common.copy)
|
||||
)
|
||||
return view
|
||||
}()
|
||||
|
||||
func bindFind(_ find: TerminalFindController) {
|
||||
view.findController = find
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import GhosttyTerminal
|
||||
import SwiftUI
|
||||
import TermindApplication
|
||||
import TermindPresentation
|
||||
|
||||
/// Mounts one long-lived Ghostty view into whichever pane currently owns it.
|
||||
///
|
||||
@@ -194,11 +195,7 @@
|
||||
if pastes {
|
||||
// An empty paste must not arm a deferred broadcast ticket that
|
||||
// could consume the next unrelated mouse or protocol report.
|
||||
let pasteboard = NSPasteboard.general
|
||||
guard
|
||||
pasteboard.string(forType: .string)?.isEmpty == false
|
||||
|| pasteboard.canReadObject(forClasses: [NSURL.self], options: nil)
|
||||
else { return }
|
||||
guard canPasteFromClipboard else { return }
|
||||
captureInteraction { _ = performBindingAction("paste_from_clipboard") }
|
||||
} else {
|
||||
super.rightMouseUp(with: event)
|
||||
@@ -206,6 +203,7 @@
|
||||
}
|
||||
|
||||
override func menu(for event: NSEvent) -> NSMenu? {
|
||||
guard !isMouseCaptured else { return nil }
|
||||
if event.type == .rightMouseDown || event.type == .rightMouseUp,
|
||||
rightClickPastes ?? shouldPasteOnRightClick(event)
|
||||
{
|
||||
@@ -214,6 +212,46 @@
|
||||
return super.menu(for: event)
|
||||
}
|
||||
|
||||
override func selectionMenuPoint(at point: CGPoint) -> CGPoint? {
|
||||
// The superclass uses this hook to decide whether a right-click
|
||||
// presents a menu. Offer the app's default menu without a selection too.
|
||||
isMouseCaptured ? nil : point
|
||||
}
|
||||
|
||||
override func selectionContextMenu() -> NSMenu {
|
||||
let menu: NSMenu
|
||||
if super.selectionMenuPoint(at: .zero) != nil {
|
||||
menu = super.selectionContextMenu()
|
||||
menu.items.first?.title = String(localized: AppStrings.Common.copy)
|
||||
} else {
|
||||
menu = NSMenu()
|
||||
}
|
||||
menu.autoenablesItems = false
|
||||
let pasteItem = NSMenuItem(
|
||||
title: String(localized: AppStrings.Common.paste),
|
||||
action: #selector(pasteFromContextMenu(_:)), keyEquivalent: "")
|
||||
pasteItem.target = self
|
||||
pasteItem.isEnabled = canPasteFromClipboard
|
||||
menu.addItem(pasteItem)
|
||||
let selectAllItem = NSMenuItem(
|
||||
title: String(localized: AppStrings.Common.selectAll),
|
||||
action: #selector(selectAll(_:)), keyEquivalent: "")
|
||||
selectAllItem.target = self
|
||||
menu.addItem(selectAllItem)
|
||||
return menu
|
||||
}
|
||||
|
||||
private var canPasteFromClipboard: Bool {
|
||||
let pasteboard = NSPasteboard.general
|
||||
return pasteboard.string(forType: .string)?.isEmpty == false
|
||||
|| pasteboard.canReadObject(forClasses: [NSURL.self], options: nil)
|
||||
}
|
||||
|
||||
@objc private func pasteFromContextMenu(_: Any?) {
|
||||
guard canPasteFromClipboard else { return }
|
||||
captureInteraction { _ = performBindingAction("paste_from_clipboard") }
|
||||
}
|
||||
|
||||
private func shouldPasteOnRightClick(_ event: NSEvent) -> Bool {
|
||||
pasteOnRightClick && !isMouseCaptured && !event.modifierFlags.contains(.option)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import AppKit
|
||||
import TermindPresentation
|
||||
import Testing
|
||||
|
||||
@testable import Termind
|
||||
|
||||
@Suite @MainActor
|
||||
struct TerminalContextMenuTests {
|
||||
@Test func defaultMenuValidatesPasteAndOptionBypassesAutomaticPaste() throws {
|
||||
let pasteboard = NSPasteboard.general
|
||||
let saved = (pasteboard.pasteboardItems ?? []).map { item in
|
||||
Dictionary(
|
||||
uniqueKeysWithValues: item.types.compactMap { type in
|
||||
item.data(forType: type).map { (type, $0) }
|
||||
})
|
||||
}
|
||||
defer {
|
||||
pasteboard.clearContents()
|
||||
pasteboard.writeObjects(
|
||||
saved.map { values in
|
||||
let item = NSPasteboardItem()
|
||||
for (type, data) in values { item.setData(data, forType: type) }
|
||||
return item
|
||||
})
|
||||
}
|
||||
let view = TermindTerminalView(frame: .zero)
|
||||
let event = try #require(
|
||||
NSEvent.mouseEvent(
|
||||
with: .rightMouseDown, location: .zero, modifierFlags: [], timestamp: 1,
|
||||
windowNumber: 0, context: nil, eventNumber: 0, clickCount: 1, pressure: 1))
|
||||
pasteboard.clearContents()
|
||||
let emptyMenu = try #require(view.menu(for: event))
|
||||
let pasteTitle = String(localized: AppStrings.Common.paste)
|
||||
#expect(emptyMenu.item(withTitle: pasteTitle)?.isEnabled == false)
|
||||
#expect(emptyMenu.items.contains { $0.action == #selector(view.selectAll(_:)) && $0.isEnabled })
|
||||
#expect(!emptyMenu.items.contains { $0.action == #selector(view.copy(_:)) })
|
||||
|
||||
pasteboard.setString("echo hello", forType: .string)
|
||||
#expect(view.menu(for: event)?.item(withTitle: pasteTitle)?.isEnabled == true)
|
||||
view.pasteOnRightClick = true
|
||||
#expect(view.menu(for: event) == nil)
|
||||
let optionEvent = try #require(
|
||||
NSEvent.mouseEvent(
|
||||
with: .rightMouseDown, location: .zero, modifierFlags: .option, timestamp: 1,
|
||||
windowNumber: 0, context: nil, eventNumber: 0, clickCount: 1, pressure: 1))
|
||||
#expect(view.menu(for: optionEvent)?.item(withTitle: pasteTitle)?.isEnabled == true)
|
||||
}
|
||||
}
|
||||
@@ -208,7 +208,8 @@ struct TerminalMouseInteractionTests {
|
||||
#expect(fixture.firstWrites.isEmpty)
|
||||
fixture.setClipboard("do not paste")
|
||||
fixture.view.rightMouseDown(with: try fixture.event(.rightMouseDown, modifiers: .option))
|
||||
fixture.view.rightMouseUp(with: try fixture.event(.rightMouseUp))
|
||||
// Inspect the Option menu without entering AppKit's modal tracking loop.
|
||||
#expect(fixture.view.menu(for: try fixture.event(.rightMouseDown, modifiers: .option)) != nil)
|
||||
try await fixture.flushWrites()
|
||||
#expect(fixture.firstWrites.isEmpty)
|
||||
fixture.feed("selection")
|
||||
@@ -260,7 +261,8 @@ struct TerminalMouseInteractionTests {
|
||||
fixture.setClipboard("paste once")
|
||||
fixture.view.rightMouseDown(with: try fixture.event(.rightMouseDown))
|
||||
fixture.view.pasteOnRightClick = true
|
||||
fixture.view.rightMouseUp(with: try fixture.event(.rightMouseUp))
|
||||
// The click already chose the menu route; avoid presenting it in this test.
|
||||
#expect(fixture.view.menu(for: try fixture.event(.rightMouseDown)) != nil)
|
||||
try await fixture.flushWrites()
|
||||
#expect(fixture.firstWrites.isEmpty)
|
||||
try fixture.rightClick()
|
||||
|
||||
@@ -2940,10 +2940,10 @@
|
||||
};
|
||||
TRMD00000000000000100022 /* XCRemoteSwiftPackageReference "libghostty-spm" */ = {
|
||||
isa = XCRemoteSwiftPackageReference;
|
||||
repositoryURL = "https://github.com/Lakr233/libghostty-spm";
|
||||
repositoryURL = "https://github.com/AkinoKaede/libghostty-spm.git";
|
||||
requirement = {
|
||||
kind = upToNextMajorVersion;
|
||||
minimumVersion = 1.6.20260922;
|
||||
kind = revision;
|
||||
revision = "21d9f81178fbb9a7d115a3a19cd9956fd5259f70";
|
||||
};
|
||||
};
|
||||
TRMD00000000000000100023 /* XCRemoteSwiftPackageReference "openssl-spm" */ = {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"originHash" : "b4d0d2a0d4dd48bc17305a7633056c1a5aeddd04bed354f9616d8b1b31e4e888",
|
||||
"originHash" : "2386f4ec13a24a310cbe47f5923c524e937b76e0eb156a380e35eca9e70ec044",
|
||||
"pins" : [
|
||||
{
|
||||
"identity" : "abseil-cpp-binary",
|
||||
@@ -46,6 +46,15 @@
|
||||
"version" : "5.9.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "displaylink",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/Lakr233/DisplayLink.git",
|
||||
"state" : {
|
||||
"revision" : "41bea2661e04bebcb1c4f15fc0b6197850100fdd",
|
||||
"version" : "3.0.1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "firebase-ios-sdk",
|
||||
"kind" : "remoteSourceControl",
|
||||
@@ -157,10 +166,9 @@
|
||||
{
|
||||
"identity" : "libghostty-spm",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/Lakr233/libghostty-spm",
|
||||
"location" : "https://github.com/AkinoKaede/libghostty-spm.git",
|
||||
"state" : {
|
||||
"revision" : "b7f888e3baf8585ea9d590ab1a45b49475e00d1c",
|
||||
"version" : "1.6.20260922"
|
||||
"revision" : "21d9f81178fbb9a7d115a3a19cd9956fd5259f70"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
Submodule
+1
Submodule libghostty-spm added at 21d9f81178.
Reference in new issue
Block a user