Skip to content

iOS: allow text selection inside mouse-reporting apps (TUIs)#580

Open
AlexDemzz wants to merge 1 commit into
migueldeicaza:mainfrom
AlexDemzz:native-selection
Open

iOS: allow text selection inside mouse-reporting apps (TUIs)#580
AlexDemzz wants to merge 1 commit into
migueldeicaza:mainfrom
AlexDemzz:native-selection

Conversation

@AlexDemzz

@AlexDemzz AlexDemzz commented Jun 23, 2026

Copy link
Copy Markdown
Contributor

What

On iOS, tap-to-select already works in a normal shell (mouse reporting off): a double tap selects a word, a triple tap selects a line, and a single tap clears the selection. The same gestures do nothing inside an application that captures the mouse (full-screen apps with mouse reporting on, such as vim, htop, or any TUI), because every tap is forwarded to the application as a mouse event before selection can run.

Concretely, each tap handler today branches on mouse reporting:

if allowMouseReporting && ... mouseMode.sendButtonPress() {
    // forward the tap to the application as a click, then return
} else {
    // select word / line, or clear the selection
}

So the selection path is only ever reached in a normal shell. Under mouse reporting the early return wins, which means a word or line can never be selected inside a TUI, and an existing selection cannot be cleared by tapping. The only existing bypass is a hardware shift key, which touch devices do not have.

The existing normal-shell selection code

The selection logic already lives in the else branch of each handler on main (Sources/SwiftTerm/iOS/iOSTerminalView.swift); it is simply unreachable under mouse reporting.

doubleTap (word):

} else {
    let hit = calculateTapHit(gesture: gestureRecognizer).grid
    selection.selectWordOrExpression(at: hit, in: terminal.displayBuffer)
    selection.selectionMode = .character
    enableSelectionPanGesture()
    showContextMenu (forRegion: makeContextMenuRegionForSelection(), pos: hit)
    queuePendingDisplay()
}

tripleTap (line):

} else {
    let hit = calculateTapHit(gesture: gestureRecognizer).grid
    selection.select(row: hit.row)
    enableSelectionPanGesture()
    showContextMenu (forRegion: makeContextMenuRegionForSelection(), pos: hit)
    queuePendingDisplay()
}

singleTap (clear an active selection):

} else {
    if selection.active {
        selection.selectNone()
        disableSelectionPanGesture()
    }
    ...
}

This change does not add new selection behaviour; it lets these existing paths run under mouse reporting too.

Change

The three tap handlers now route through a small pure policy so the existing normal-shell selection behaviour also applies under mouse reporting:

  • double tap selects the word (or balanced expression) under the tap, regardless of mouse reporting;
  • triple tap selects the line, regardless of mouse reporting;
  • single tap with an active selection dismisses it and re-enables scroll forwarding, even under mouse reporting (matching the normal-shell behaviour). A single tap with no selection still forwards the click, so interaction with mouse-reporting applications is unchanged.

The decision is factored into TerminalTapPolicy.action(tapCount:hasActiveSelection:mouseReportingActive:), a pure, platform-independent function. The gesture handlers only run on a device or simulator, so keeping the decision separate lets it be unit-tested in the regular swift test suite without standing up a UIKit gesture pipeline.

Tests

TerminalTapPolicyTests covers the full routing table:

  • double/triple tap select regardless of mouse reporting,
  • single tap dismisses a live selection even under mouse reporting,
  • single tap with no selection forwards the click when reporting is on,
  • single tap with no selection is handled locally when reporting is off.

Notes

  • Normal-shell behaviour (mouse reporting off) is unchanged; this only adds selection inside mouse-reporting apps and lets a tap clear a selection there.
  • Long-press selection is a separate, pre-existing issue (its menu path is unrelated) and is out of scope here.
  • If you would prefer this gated behind an opt-in property (for example allowSelectionUnderMouseReporting) rather than being the default, happy to adjust.

Double tap (word) and triple tap (line) now select even when the
application has mouse reporting on (alt buffer or normal buffer with
tracking), instead of forwarding the tap as a click. A single tap with
an active selection dismisses it and re-enables scroll forwarding,
matching the basic-shell behaviour; a single tap with no selection still
forwards a click so interaction with mouse-reporting apps stays intact.

The tap routing is factored into a pure, platform-independent
TerminalTapPolicy so the behaviour is covered by the test suite without a
UIKit gesture pipeline.
@AlexDemzz

Copy link
Copy Markdown
Contributor Author

Up @migueldeicaza. I've pushed my app with the npm patch but it would be cool if it can be merged

@migueldeicaza

Copy link
Copy Markdown
Owner

Hello!

One challenge with this, is that I think that the way to do this on Mac is to call an API to flip this (I think it is Command-R on Terminal.app), and I think that generally, we need a gesture where the user would do this on their app (like "Toggle reporting").

Your idea has merit, but it immediately triggers the "Let us think about whether there are any downsides or this causes problems downstream). So I do not really have an objection to it in principle, but I have to cross the t's and dot the i's. That is the sole reason for my delay.

But since I have you here, do you have thoughts on having a UI button to flip this on/off?

@AlexDemzz

Copy link
Copy Markdown
Contributor Author

Hello @migueldeicaza, i think a toggle button will be weird, as this PR report the actual SwiftTerm text selection behavior on TUI interface.

Here SwiftTerm behavior for non TUI (double tap + drag)
https://github.com/user-attachments/assets/efd0987d-191a-4566-8d51-663156d87d1e

Here with the PR on TUI. (double tap + drag)
https://github.com/user-attachments/assets/530ef034-8fe3-44e0-a966-a37e37415631

@migueldeicaza

Copy link
Copy Markdown
Owner

Ok, since we are adding a policy enumeration, we could have a flag that drives the policy "Should the selection toggle be driven by an API call, or can the user use these gestures".

Additionally I had Claude do a code review, and these are worth addressing:

  1. Real regression — clearing a selection no longer pops the cursor context menu · iOSTerminalView.swift:757

Old non-reporting path (.orig:750-765): clear the selection and then fall through to the near-cursor check, which could showContextMenu. New .dismissSelection case (757-760) only clears +
hideMenu() — it never reaches the showContextMenu branch. So a single tap that clears an active selection loses the cursor-menu affordance.

This is the one genuinely-undocumented behavior loss. It's narrow (usually the copy/paste menu is visible during a selection, so the old code took the hideMenu() path anyway — the
showContextMenu path only fired when a selection was live with no menu showing), but it is a real change and nothing in the comments says it was intended. Worth a decision.

2 & 3. Framed as regressions, but your own docs say they're intentional

  • Single tap under mouse reporting with a selection → dismisses instead of forwarding the click (:757). The workflow flagged this CONFIRMED, but the comment at 751-753 and the TerminalTapPolicy
    doc (lines 31-34) explicitly state this is the goal: "An active selection is always dismissable by a tap … even under mouse reporting, which clears it instead of forwarding a click." That's a
    deliberate one-tap-to-clear tradeoff, not a bug.
  • Double/triple tap no longer forward to the app under mouse reporting (:799, PLAUSIBLE). Same story — line 795-796 and the policy doc say double/triple tap select locally regardless of mouse
    reporting, by design.

I'm calling these out only so you can confirm the tradeoffs are what you want; they match the intent you wrote down, so I would not treat them as defects.

  1. Cleanup — dead guards on doubleTap/tripleTap · :797 and :819

action(tapCount: 2, …) unconditionally returns .selectWord (and 3 → .selectLine), so guard … == .selectWord else { return } is always true. The hasActiveSelection / mouseReportingActive
arguments — including the tapForwardsToApplication call that reads terminal.mouseMode and shift state — are computed on every tap and can never affect the outcome. Dead, and misleading to a
future reader. Drop the guards and just do the selection directly.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants