diff --git a/Sources/SwiftTerm/TerminalTapPolicy.swift b/Sources/SwiftTerm/TerminalTapPolicy.swift new file mode 100644 index 00000000..73bfe4a2 --- /dev/null +++ b/Sources/SwiftTerm/TerminalTapPolicy.swift @@ -0,0 +1,54 @@ +// +// TerminalTapPolicy.swift +// SwiftTerm +// +// Pure, platform-independent routing for tap gestures over the terminal grid. The iOS gesture +// handlers only run on a device or simulator, so keeping the decision here lets the behaviour be +// covered by the macOS/Linux test suite without standing up a UIKit gesture pipeline. +// + +import Foundation + +/// The action a tap over the terminal grid resolves to. +enum TerminalTapAction: Equatable { + /// Double tap: select the word (or balanced expression) under the tap. + case selectWord + /// Triple tap: select the whole line under the tap. + case selectLine + /// Single tap that clears an existing selection (and re-enables scroll forwarding). + case dismissSelection + /// Single tap forwarded to the application as a mouse click (mouse reporting is on). + case forwardClick + /// Single tap with no selection and no mouse reporting: handled locally (e.g. cursor menu). + case localSingleTap +} + +enum TerminalTapPolicy { + /// Resolves a tap to an action. + /// + /// Previously every tap was forwarded to the application whenever it had mouse reporting on, + /// so a word or line could never be selected inside a full-screen app (vim, htop, a TUI) and + /// an existing selection could not be cleared by tapping. This policy lets a double or triple + /// tap select locally regardless of mouse reporting, and lets a single tap dismiss a live + /// selection before any click is forwarded. A single tap with nothing selected still forwards + /// the click, so interaction with mouse-reporting applications stays intact. + /// + /// - Parameters: + /// - tapCount: number of taps in the gesture (1, 2 or 3). + /// - hasActiveSelection: whether a text selection is currently live. + /// - mouseReportingActive: the application is capturing the mouse (reporting is on and the + /// gesture is not bypassing it, for example via a hardware shift key). + static func action(tapCount: Int, hasActiveSelection: Bool, mouseReportingActive: Bool) -> TerminalTapAction { + switch tapCount { + case 3: + return .selectLine + case 2: + return .selectWord + default: + if hasActiveSelection { + return .dismissSelection + } + return mouseReportingActive ? .forwardClick : .localSingleTap + } + } +} diff --git a/Sources/SwiftTerm/iOS/iOSTerminalView.swift b/Sources/SwiftTerm/iOS/iOSTerminalView.swift index a47d8729..81789c91 100644 --- a/Sources/SwiftTerm/iOS/iOSTerminalView.swift +++ b/Sources/SwiftTerm/iOS/iOSTerminalView.swift @@ -719,6 +719,13 @@ open class TerminalView: UIScrollView, UITextInputTraits, UIKeyInput, UIScrollVi gestureRecognizer.modifierFlags.contains(.shift) && !terminal.mouseShiftCapture } + /// Whether a tap should be forwarded to the application as a mouse event: mouse reporting is + /// enabled, the application sends button presses, and the gesture is not bypassing reporting + /// (for example via a hardware shift key). + private func tapForwardsToApplication(for gestureRecognizer: UIGestureRecognizer) -> Bool { + allowMouseReporting && !shiftBypassesMouseReporting(for: gestureRecognizer) && terminal.mouseMode.sendButtonPress() + } + @objc func singleTap (_ gestureRecognizer: UITapGestureRecognizer) { if isFirstResponder { @@ -734,17 +741,22 @@ open class TerminalView: UIScrollView, UITextInputTraits, UIKeyInput, UIScrollVi return } - if allowMouseReporting && !shiftBypassesMouseReporting(for: gestureRecognizer) && terminal.mouseMode.sendButtonPress() { + // An active selection is always dismissable by a tap, like the basic shell and even + // under mouse reporting, which clears it (and re-enables scroll forwarding in the host) + // instead of forwarding a click. With no selection, taps forward clicks as before. + switch TerminalTapPolicy.action(tapCount: 1, + hasActiveSelection: selection.active, + mouseReportingActive: tapForwardsToApplication(for: gestureRecognizer)) { + case .dismissSelection: + selection.selectNone() + disableSelectionPanGesture() + if UIMenuController.shared.isMenuVisible { UIMenuController.shared.hideMenu() } + case .forwardClick: sharedMouseEvent(gestureRecognizer: gestureRecognizer, release: false) - if terminal.mouseMode.sendButtonRelease() { sharedMouseEvent(gestureRecognizer: gestureRecognizer, release: true) } - } else { - if selection.active { - selection.selectNone() - disableSelectionPanGesture() - } + case .localSingleTap: if UIMenuController.shared.isMenuVisible { UIMenuController.shared.hideMenu() } else { @@ -756,6 +768,8 @@ open class TerminalView: UIScrollView, UITextInputTraits, UIKeyInput, UIScrollVi showContextMenu (forRegion: makeContextMenuRegionForTap (point: location), pos: tapLoc) } } + case .selectWord, .selectLine: + break } queuePendingDisplay() } else { @@ -771,21 +785,19 @@ open class TerminalView: UIScrollView, UITextInputTraits, UIKeyInput, UIScrollVi return } - if allowMouseReporting && !shiftBypassesMouseReporting(for: gestureRecognizer) && terminal.mouseMode.sendButtonPress() { - sharedMouseEvent(gestureRecognizer: gestureRecognizer, release: false) - - if terminal.mouseMode.sendButtonRelease() { - sharedMouseEvent(gestureRecognizer: gestureRecognizer, release: true) - } + // Double tap selects a word even when the application captures the mouse (alt buffer or + // mouse reporting). Single tap still forwards a click, so TUI interaction stays intact. + guard TerminalTapPolicy.action(tapCount: 2, + hasActiveSelection: selection.active, + mouseReportingActive: tapForwardsToApplication(for: gestureRecognizer)) == .selectWord else { return - } else { - let hit = calculateTapHit(gesture: gestureRecognizer).grid - selection.selectWordOrExpression(at: hit, in: terminal.displayBuffer) - selection.selectionMode = .character - enableSelectionPanGesture() - showContextMenu (forRegion: makeContextMenuRegionForSelection(), pos: hit) - queuePendingDisplay() } + let hit = calculateTapHit(gesture: gestureRecognizer).grid + selection.selectWordOrExpression(at: hit, in: terminal.displayBuffer) + selection.selectionMode = .character + enableSelectionPanGesture() + showContextMenu (forRegion: makeContextMenuRegionForSelection(), pos: hit) + queuePendingDisplay() } @objc func tripleTap (_ gestureRecognizer: UITapGestureRecognizer) @@ -796,20 +808,17 @@ open class TerminalView: UIScrollView, UITextInputTraits, UIKeyInput, UIScrollVi return } - if allowMouseReporting && !shiftBypassesMouseReporting(for: gestureRecognizer) && terminal.mouseMode.sendButtonPress() { - sharedMouseEvent(gestureRecognizer: gestureRecognizer, release: false) - - if terminal.mouseMode.sendButtonRelease() { - sharedMouseEvent(gestureRecognizer: gestureRecognizer, release: true) - } + // Triple tap selects a line even under mouse reporting (same rationale as doubleTap). + guard TerminalTapPolicy.action(tapCount: 3, + hasActiveSelection: selection.active, + mouseReportingActive: tapForwardsToApplication(for: gestureRecognizer)) == .selectLine else { return - } else { - let hit = calculateTapHit(gesture: gestureRecognizer).grid - selection.select(row: hit.row) - enableSelectionPanGesture() - showContextMenu (forRegion: makeContextMenuRegionForSelection(), pos: hit) - queuePendingDisplay() } + let hit = calculateTapHit(gesture: gestureRecognizer).grid + selection.select(row: hit.row) + enableSelectionPanGesture() + showContextMenu (forRegion: makeContextMenuRegionForSelection(), pos: hit) + queuePendingDisplay() } var directionView: UIView? diff --git a/Tests/SwiftTermTests/TerminalTapPolicyTests.swift b/Tests/SwiftTermTests/TerminalTapPolicyTests.swift new file mode 100644 index 00000000..6d2bb64e --- /dev/null +++ b/Tests/SwiftTermTests/TerminalTapPolicyTests.swift @@ -0,0 +1,35 @@ +import Testing +@testable import SwiftTerm + +struct TerminalTapPolicyTests { + /// Double tap selects a word even when the application is capturing the mouse: the regression + /// that prevented any selection inside a TUI (vim, htop, a full-screen app). + @Test func doubleTapSelectsWordRegardlessOfMouseReporting() { + #expect(TerminalTapPolicy.action(tapCount: 2, hasActiveSelection: false, mouseReportingActive: true) == .selectWord) + #expect(TerminalTapPolicy.action(tapCount: 2, hasActiveSelection: false, mouseReportingActive: false) == .selectWord) + } + + /// Triple tap selects a line even under mouse reporting (same rationale as double tap). + @Test func tripleTapSelectsLineRegardlessOfMouseReporting() { + #expect(TerminalTapPolicy.action(tapCount: 3, hasActiveSelection: false, mouseReportingActive: true) == .selectLine) + #expect(TerminalTapPolicy.action(tapCount: 3, hasActiveSelection: false, mouseReportingActive: false) == .selectLine) + } + + /// A single tap dismisses a live selection before any click is forwarded, even under reporting, + /// matching the basic-shell behaviour. + @Test func singleTapDismissesActiveSelectionEvenUnderMouseReporting() { + #expect(TerminalTapPolicy.action(tapCount: 1, hasActiveSelection: true, mouseReportingActive: true) == .dismissSelection) + #expect(TerminalTapPolicy.action(tapCount: 1, hasActiveSelection: true, mouseReportingActive: false) == .dismissSelection) + } + + /// With nothing selected and reporting on, a single tap forwards the click so the application + /// stays usable. + @Test func singleTapForwardsClickWhenReportingAndNoSelection() { + #expect(TerminalTapPolicy.action(tapCount: 1, hasActiveSelection: false, mouseReportingActive: true) == .forwardClick) + } + + /// With nothing selected and no reporting, a single tap is handled locally. + @Test func singleTapIsLocalWhenNoReportingAndNoSelection() { + #expect(TerminalTapPolicy.action(tapCount: 1, hasActiveSelection: false, mouseReportingActive: false) == .localSingleTap) + } +}