Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 54 additions & 0 deletions Sources/SwiftTerm/TerminalTapPolicy.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
}
73 changes: 41 additions & 32 deletions Sources/SwiftTerm/iOS/iOSTerminalView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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 {
Expand All @@ -756,6 +768,8 @@ open class TerminalView: UIScrollView, UITextInputTraits, UIKeyInput, UIScrollVi
showContextMenu (forRegion: makeContextMenuRegionForTap (point: location), pos: tapLoc)
}
}
case .selectWord, .selectLine:
break
}
queuePendingDisplay()
} else {
Expand All @@ -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)
Expand All @@ -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?
Expand Down
35 changes: 35 additions & 0 deletions Tests/SwiftTermTests/TerminalTapPolicyTests.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading