feat(android): close the audit's remaining parity gaps
Seven items the earlier repair waves deliberately left alone, so that the
blocker fixes could land with a working safety net first.
CERTIFICATE LIFECYCLE (the audit's only "high"). iOS ships a
CertificateRotationScheduler; Android had no counterpart, so a device
certificate simply expired and needed a manual re-enroll. The decision is a
pure total function over five answers, with RE_ENROLL_REQUIRED checked first so
it outranks any backoff. Renewal is single-flight — a second trigger coalesces,
and a cancelled leader releases the slot so cancellation cannot wedge rotation
for the process lifetime. Failure leaves the prior identity fully live (the
existing atomic ping-pong flip already guaranteed that) and is published rather
than swallowed.
Two real defects surfaced while building it:
renew() decoded its 201 with EnrollResponseDto, whose deviceId is required —
but /device/:id/renew returns only {cert, caChain, notAfter}; only
/device/enroll echoes deviceId. Every silent renewal would have thrown
MalformedResponse. Fixed with a RenewResponseDto plus the deviceId from the
path segment we addressed.
And because no renew 201 carries renewAfter, a client that persisted it would
rotate exactly once and then report not-due until the cert died. That is why
renewAfter is re-derived from the live leaf as notBefore + 2/3·lifetime — the
control plane's own formula. This is a latent iOS defect that Android now
avoids rather than inherits.
/device/:id/recover was confirmed real (control-plane/src/api/renew.ts:443,
contract pinned by that suite's CP6e) and is now wired. It goes over a separate
NON-mTLS client that throws rather than falling back, because an expired client
certificate cannot authenticate its own recovery — the deadlock a previous
session already hit and recorded.
Asked explicitly about step-up: rotation is unaffected. stepUp appears nowhere
in control-plane/src; it lives only on the relay's WebSocket upgrade. On a
step-up host the SESSION is denied, not the rotation, so the certificate still
stays alive and that principal gap is orthogonal.
FOLLOW-UP QUEUE (W2). The queue frame decoded but the three routes managing it
were missing, so Android could see "N queued" and not queue anything. One
outcome union covers both guarded writes and distinguishes full / too-large /
disabled / session-gone / rate-limited, because they need different copy. 429 is
surfaced and never auto-retried — POST and DELETE share one 20/min bucket.
Queued text is raw shell input and travels verbatim, proven with control
characters and CJK.
UNREAD WATERMARK now persists, so the unread state survives process death —
which is exactly when it matters, since the product premise is walking away and
coming back. It stores a plain map rather than an UnreadLedger: the reducer
lives in :session-core, which :host-registry may not depend on, so the logic is
not restated anywhere.
COPY-OUT. Text selection was a recorded feature gap: the stock ActionMode's own
Copy handler dereferences the absent TerminalSession, so making it reachable
puts an NPE on the button — worse than no selection. The refusal was
re-verified from the bytecode, and the same disassembly showed the way out:
TerminalBuffer.getSelectedText touches only mLines/TerminalRow, no session and
no view. A first-party selection layer now builds on that, with a pure
row-major model so a backwards drag normalises correctly. Terminal content
reaches the clipboard only on an explicit user copy; OSC 52 stays declined.
HOST REMOVAL. PushRegistrar.unregisterHost had zero call sites, so a removed
host kept receiving pushes. Removal is now confirm-gated and cleans everything
keyed by host id — a half-removed host is worse than none.
/config/ui is finally honoured. It was implemented and never called, so
allowAutoMode was ignored and the plan gate offered "approve + auto" even where
the operator had disabled it. It fails CLOSED: an unfetchable config treats auto
as disabled, because the permissive default is the dangerous one.
Verified: ./gradlew test :app:assembleDebug :app:assembleDebugAndroidTest
:macrobenchmark:assembleBenchmark koverVerify -> BUILD SUCCESSFUL, 1150 JVM
tests, 0 failures (903 -> 1150). Instrumented suite re-run on emulator-5554
against live servers: 67 tests, 0 failures, 0 skipped — no device regression.
This commit is contained in:
@@ -1,7 +1,9 @@
|
||||
package wang.yaojia.webterm.terminalview
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Canvas
|
||||
import android.util.TypedValue
|
||||
import android.view.ActionMode
|
||||
import android.view.InputDevice
|
||||
import android.view.KeyEvent
|
||||
import android.view.MotionEvent
|
||||
@@ -49,8 +51,11 @@ import kotlin.math.roundToInt
|
||||
* 4. **text selection** — `startTextSelectionMode` arms an `ActionMode` whose Copy
|
||||
* (`TextSelectionCursorController$1.onActionItemClicked` offsets 89-100) is
|
||||
* `mTermSession.onCopyTextToClipboard(...)` and whose Paste (offsets 126-136) is
|
||||
* `mTermSession.onPasteTextFromClipboard()`. → [WebTermTerminalViewClient.onLongPress] returns true,
|
||||
* which is a **recorded deviation from plan §6.5** (see that method for the full rationale).
|
||||
* `mTermSession.onPasteTextFromClipboard()`. → [WebTermTerminalViewClient.onLongPress] returns true so
|
||||
* the mode can never arm, and the press starts the **first-party** selection layer instead
|
||||
* ([TerminalSelection] for the design; [beginSelectionAt] / [copySelection] here). Only the stock
|
||||
* controller is replaced — the affordance is still an Android `ActionMode` over `ClipboardManager`, as
|
||||
* plan §6.5 specifies.
|
||||
*
|
||||
* ### Stock-method audit (verified with `javap -p -c`; keep it current when the pinned version moves)
|
||||
* | stock member | reachable here? | verdict |
|
||||
@@ -68,6 +73,7 @@ import kotlin.math.roundToInt
|
||||
* | `updateSize` | no | early-returns without a session; [TerminalResizeDriver] replaces it |
|
||||
* | `attachSession` | no | never called — it is the process-forking path we replace |
|
||||
* | `startTextSelectionMode` / `TextSelectionCursorController` | **no** | long press consumed (4) |
|
||||
* | `mDefaultSelectors` (the renderer's own highlight channel) | no (package-private) | our overlay draws it |
|
||||
* | `setTerminalCursorBlinker*` | never invoked | cosmetic gap vs stock: the cursor does not blink |
|
||||
*
|
||||
* ### The latent renderer crash this also fixes
|
||||
@@ -96,7 +102,10 @@ import kotlin.math.roundToInt
|
||||
* delegates to ([TerminalKeyDecoder], [TerminalInputBytes], [TerminalResizeDriver], [TerminalScrollGesture])
|
||||
* is what the JVM tests pin.
|
||||
*/
|
||||
public class RemoteTerminalHostView internal constructor(context: Context) : FrameLayout(context) {
|
||||
public class RemoteTerminalHostView internal constructor(
|
||||
context: Context,
|
||||
clipboard: TerminalClipboard = SystemTerminalClipboard(context),
|
||||
) : FrameLayout(context) {
|
||||
|
||||
/** The stock Termux view. Rendering is entirely its own; we only feed it an emulator and input. */
|
||||
internal val stockView: TerminalView = TerminalView(context, null)
|
||||
@@ -124,6 +133,11 @@ public class RemoteTerminalHostView internal constructor(context: Context) : Fra
|
||||
val step = if (up) -1 else 1
|
||||
stockView.topRow = min(NEWEST_ROW, max(-transcriptRows, stockView.topRow + step))
|
||||
stockView.invalidate()
|
||||
// The selection highlight is recorded in THIS view's display list and is positioned
|
||||
// relative to `topRow`, so invalidating only the child would re-render scrolled glyphs
|
||||
// under a stale band. Reachable with a selection alive: an external mouse wheel still
|
||||
// scrolls (a selection lives in external rows, so moving the viewport is harmless).
|
||||
this@RemoteTerminalHostView.invalidate()
|
||||
}
|
||||
|
||||
override fun sendKeys(data: String) {
|
||||
@@ -134,6 +148,36 @@ public class RemoteTerminalHostView internal constructor(context: Context) : Fra
|
||||
},
|
||||
)
|
||||
|
||||
// ── Text selection (the first-party replacement for the stock, crashing one) ──────────────────────
|
||||
|
||||
private val selectionBuffer = TerminalEmulatorSelectionBuffer { stockView.mEmulator }
|
||||
|
||||
private val selection = TerminalSelectionController(selectionBuffer, clipboard) { isActive ->
|
||||
if (isActive) {
|
||||
showCopyAffordance()
|
||||
} else {
|
||||
// A selection can be dropped mid-drag (a resize, a rebind), so the drag flag has to die with
|
||||
// it — a stale "true" would make the next pinch look like an extend.
|
||||
isExtendingSelection = false
|
||||
hideCopyAffordance()
|
||||
}
|
||||
// The highlight is painted by THIS view (dispatchDraw), so the frame — not just the child — has to
|
||||
// be invalidated for a span change to appear.
|
||||
invalidate()
|
||||
onSelectionChanged?.invoke(isActive)
|
||||
}
|
||||
|
||||
/** Set by [RemoteTerminalView] so `:app` can mirror the state in its own toolbar. */
|
||||
internal var onSelectionChanged: ((isActive: Boolean) -> Unit)? = null
|
||||
|
||||
private val selectionPainter = TerminalSelectionPainter()
|
||||
|
||||
/** The live floating toolbar, or null when nothing is selected (or the platform declined to start one). */
|
||||
private var copyAffordance: ActionMode? = null
|
||||
|
||||
/** True between the first extend MOVE and the lift, so the UP of a drag we own is reported as ours. */
|
||||
private var isExtendingSelection = false
|
||||
|
||||
init {
|
||||
addView(stockView, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT))
|
||||
// Builds TerminalRenderer → onDraw is safe and the cell metrics become readable.
|
||||
@@ -159,13 +203,140 @@ public class RemoteTerminalHostView internal constructor(context: Context) : Fra
|
||||
internal fun bindEmulator(emulator: TerminalEmulator) {
|
||||
stockView.mEmulator = emulator
|
||||
// A new emulator means a new transcript: drop any half-finished drag rather than let it resolve
|
||||
// rows against a buffer it did not start in.
|
||||
// rows against a buffer it did not start in, and drop any selection for the same reason — its rows
|
||||
// name content that no longer exists.
|
||||
scrollGesture.reset()
|
||||
selection.clear()
|
||||
}
|
||||
|
||||
/** Invalidate/redraw the stock view for the current emulator state (main thread). */
|
||||
/**
|
||||
* Invalidate/redraw the stock view for the current emulator state (main thread), keeping a live
|
||||
* selection pointing at the text it was made on.
|
||||
*
|
||||
* `TerminalView.onScreenUpdated` maintains a selection across incoming output — it shifts `mTopRow` by
|
||||
* `getScrollCounter()` and decrements its selection cursors — but only when `isSelectingText()` is true,
|
||||
* which for us is permanently false (we never arm the stock controller). What it does instead is reset
|
||||
* `mTopRow` to 0 and CLEAR the scroll counter, so both halves of that bookkeeping have to happen here:
|
||||
* read the counter first (nothing else can, afterwards), then re-pin the viewport and shift the span.
|
||||
* Skipping it would leave the user's highlight over text that scrolled away — and Copy would then put
|
||||
* something the user never selected on the clipboard.
|
||||
*/
|
||||
internal fun requestScreenUpdate() {
|
||||
val isSelecting = selection.isActive
|
||||
val scrolledRows = if (isSelecting) stockView.mEmulator?.scrollCounter ?: NO_SCROLL else NO_SCROLL
|
||||
val pinnedTopRow = if (isSelecting) stockView.topRow - scrolledRows else null
|
||||
|
||||
stockView.onScreenUpdated()
|
||||
|
||||
if (pinnedTopRow != null) {
|
||||
val oldestRow = -(stockView.mEmulator?.screen?.activeTranscriptRows ?: 0)
|
||||
stockView.topRow = pinnedTopRow.coerceIn(oldestRow, NEWEST_ROW)
|
||||
}
|
||||
selection.onContentScrolled(scrolledRows)
|
||||
invalidate()
|
||||
}
|
||||
|
||||
// ── Text-selection API (used by [RemoteTerminalView], which exposes it to `:app`) ──────────────────
|
||||
|
||||
internal val isSelectingText: Boolean get() = selection.isActive
|
||||
|
||||
/**
|
||||
* Start a selection at these view pixels, snapped to the word under them. Answering false means there
|
||||
* was no measured grid to resolve the pixels against, so the press is ignored.
|
||||
*/
|
||||
internal fun beginSelectionAt(xPx: Float, yPx: Float): Boolean {
|
||||
val cell = cellAt(xPx, yPx) ?: return false
|
||||
if (!selection.beginAt(cell)) return false
|
||||
// `getScrollCounter()` accumulates until something clears it, and the only thing that does is
|
||||
// `onScreenUpdated()`. A multi-chunk append posts one screen update for several main-thread appends,
|
||||
// so a long press can land between a scroll and its update — and those rows scrolled BEFORE this
|
||||
// selection existed. Counting them would shift the brand-new span by rows it never saw (measured: a
|
||||
// selection two rows of stale counter old landed on the wrong line). Zeroing here makes the shift in
|
||||
// [requestScreenUpdate] mean exactly "scrolled since the selection was made"; nothing else in the
|
||||
// pinned artifact reads the counter (stock only reads it inside `isSelectingText()`, always false).
|
||||
stockView.mEmulator?.clearScrollCounter()
|
||||
return true
|
||||
}
|
||||
|
||||
/** THE explicit copy (plan §8): nothing else in this module writes terminal text to the clipboard. */
|
||||
internal fun copySelection(): TerminalCopyResult = selection.copy()
|
||||
|
||||
internal fun clearSelection() {
|
||||
selection.clear()
|
||||
}
|
||||
|
||||
/** The text currently highlighted — for tests and for `:app` to preview; reads the buffer, mutates nothing. */
|
||||
internal fun selectedText(): String = selection.selectedText()
|
||||
|
||||
/** The highlight bands for the current selection, in this frame's pixel space. */
|
||||
internal fun highlightRects(): List<TerminalSelectionRect> {
|
||||
val span = selection.selection?.span ?: return emptyList()
|
||||
val bounds = selectionBuffer.bounds() ?: return emptyList()
|
||||
val metrics = cellMetrics() ?: return emptyList()
|
||||
return TerminalSelectionGeometry.highlightRects(span, bounds, metrics, stockView.topRow)
|
||||
}
|
||||
|
||||
/**
|
||||
* A tap: dismiss a live selection, otherwise raise the soft keyboard.
|
||||
*
|
||||
* Dismissing takes precedence because a selection makes every drag an extend — the tap is how the user
|
||||
* gets scrolling back, and it is the platform-standard way to end a selection.
|
||||
*/
|
||||
internal fun onTapped() {
|
||||
if (selection.isActive) {
|
||||
selection.clear()
|
||||
return
|
||||
}
|
||||
showSoftKeyboard()
|
||||
}
|
||||
|
||||
private fun cellAt(xPx: Float, yPx: Float): TerminalCell? {
|
||||
val metrics = cellMetrics() ?: return null
|
||||
return TerminalSelectionGeometry.cellAt(xPx, yPx, metrics, stockView.topRow)
|
||||
}
|
||||
|
||||
private fun showCopyAffordance() {
|
||||
val existing = copyAffordance
|
||||
if (existing != null) {
|
||||
// The span moved: let the floating toolbar re-anchor rather than stacking a second mode.
|
||||
existing.invalidateContentRect()
|
||||
return
|
||||
}
|
||||
copyAffordance = startActionMode(
|
||||
TerminalSelectionActionMode(
|
||||
contentRect = { highlightRects().firstOrNull() },
|
||||
onCopy = {
|
||||
copySelection()
|
||||
selection.clear()
|
||||
},
|
||||
onDismissed = {
|
||||
copyAffordance = null
|
||||
selection.clear()
|
||||
},
|
||||
),
|
||||
ActionMode.TYPE_FLOATING,
|
||||
)
|
||||
}
|
||||
|
||||
private fun hideCopyAffordance() {
|
||||
// Null the field BEFORE finishing: finish() calls onDestroyActionMode, which calls back into
|
||||
// selection.clear(). The controller's own "already clear" guard stops the recursion; this stops a
|
||||
// second finish() on a dead mode.
|
||||
val mode = copyAffordance ?: return
|
||||
copyAffordance = null
|
||||
mode.finish()
|
||||
}
|
||||
|
||||
/**
|
||||
* Draw the children (the stock view paints every glyph) and then the selection highlight ON TOP.
|
||||
*
|
||||
* An overlay, not a renderer patch: plan §6.1 keeps rendering stock, and the renderer's own selection
|
||||
* channel (`TerminalView.mDefaultSelectors`) is package-private to `com.termux.view` — see
|
||||
* [TerminalSelectionPainter] for why reaching it was rejected.
|
||||
*/
|
||||
override fun dispatchDraw(canvas: Canvas) {
|
||||
super.dispatchDraw(canvas)
|
||||
selectionPainter.draw(canvas, highlightRects())
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -287,12 +458,29 @@ public class RemoteTerminalHostView internal constructor(context: Context) : Fra
|
||||
return super.dispatchGenericMotionEvent(event)
|
||||
}
|
||||
|
||||
override fun onSizeChanged(width: Int, height: Int, oldWidth: Int, oldHeight: Int) {
|
||||
/**
|
||||
* Widened to public (the `View` callback is `protected`) so the size-change behaviour — including the
|
||||
* selection drop below — is driven directly by a JVM test, exactly like the other overrides on this
|
||||
* frame. Nothing in production calls it; the platform does.
|
||||
*/
|
||||
public override fun onSizeChanged(width: Int, height: Int, oldWidth: Int, oldHeight: Int) {
|
||||
super.onSizeChanged(width, height, oldWidth, oldHeight)
|
||||
// A new size means a new grid and an emulator reflow, so the cells the span names are about to hold
|
||||
// different text. Dropping the selection is the honest response — re-mapping it across a reflow is
|
||||
// guesswork, and keeping it would let Copy return text the user never highlighted.
|
||||
selection.clear()
|
||||
sink?.onViewSize(width, height)
|
||||
}
|
||||
|
||||
/**
|
||||
* With a live selection every drag EXTENDS it; otherwise the drag goes to the scroll gesture.
|
||||
*
|
||||
* The branch is what makes the feature usable on a touch screen: without it the very drag that should
|
||||
* grow the selection would instead be claimed by [TerminalScrollGesture] and — inside an
|
||||
* alternate-screen TUI — typed into the shell as arrow keys.
|
||||
*/
|
||||
private fun routeTouch(event: MotionEvent): Boolean {
|
||||
if (selection.isActive) return routeSelectionTouch(event)
|
||||
eventInFlight = event
|
||||
try {
|
||||
return scrollGesture.onTouch(TouchFacts.of(event))
|
||||
@@ -301,6 +489,35 @@ public class RemoteTerminalHostView internal constructor(context: Context) : Fra
|
||||
}
|
||||
}
|
||||
|
||||
private fun routeSelectionTouch(event: MotionEvent): Boolean = when (event.actionMasked) {
|
||||
MotionEvent.ACTION_MOVE -> {
|
||||
// A second finger is a pinch, which belongs to the stock ScaleGestureDetector (safe: no session
|
||||
// deref) and is where our onScale veto is enforced.
|
||||
if (event.pointerCount > SINGLE_POINTER) {
|
||||
isExtendingSelection
|
||||
} else {
|
||||
isExtendingSelection = true
|
||||
extendSelectionTo(event.x, event.y)
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
|
||||
val wasExtending = isExtendingSelection
|
||||
isExtendingSelection = false
|
||||
wasExtending
|
||||
}
|
||||
|
||||
// ACTION_DOWN is deliberately NOT claimed: the stock gesture detector has to keep seeing it so it
|
||||
// can still resolve the gesture into a tap (which dismisses the selection) or another long press
|
||||
// (which re-anchors it). Claiming it would make both impossible.
|
||||
else -> false
|
||||
}
|
||||
|
||||
private fun extendSelectionTo(xPx: Float, yPx: Float) {
|
||||
cellAt(xPx, yPx)?.let(selection::extendTo)
|
||||
}
|
||||
|
||||
/**
|
||||
* Stock `sendMouseEventCode(event, MOUSE_WHEEL{UP,DOWN}_BUTTON, true)`, offsets 0-100: report the
|
||||
* wheel at the cell the gesture started on (one anchor per down-time) so a program tracking the mouse
|
||||
@@ -349,6 +566,12 @@ public class RemoteTerminalHostView internal constructor(context: Context) : Fra
|
||||
/** No wheel gesture has been anchored yet (stock initialises `mMouseStartDownTime` to -1). */
|
||||
const val NO_DOWN_TIME: Long = -1L
|
||||
|
||||
/** Nothing scrolled off the top since the last screen update. */
|
||||
const val NO_SCROLL: Int = 0
|
||||
|
||||
/** A second pointer means a pinch, not a drag. */
|
||||
const val SINGLE_POINTER: Int = 1
|
||||
|
||||
/** `getColumnAndRow` returns `[column, row]`, both 0-based; the wire protocol is 1-based. */
|
||||
const val COLUMN_INDEX: Int = 0
|
||||
const val ROW_INDEX: Int = 1
|
||||
|
||||
@@ -17,9 +17,10 @@ import com.termux.terminal.TerminalEmulator
|
||||
*
|
||||
* **Glyph rendering and the cursor stay 100% stock. Scrolling and text selection do not** — both stock
|
||||
* implementations dereference the absent `TerminalSession`, so [RemoteTerminalHostView] owns vertical
|
||||
* drags and the long press is consumed ([WebTermTerminalViewClient.onLongPress], a recorded §6.5
|
||||
* deviation). [RemoteTerminalHostView]'s KDoc holds the enumerated audit of every stock member: which are
|
||||
* reachable, which are proven safe, and which had to be intercepted.
|
||||
* drags, and the long press is consumed ([WebTermTerminalViewClient.onLongPress]) and re-spent on a
|
||||
* first-party selection layer ([TerminalSelectionController]) rather than on the stock `ActionMode`, whose
|
||||
* Copy button is an NPE. [RemoteTerminalHostView]'s KDoc holds the enumerated audit of every stock member:
|
||||
* which are reachable, which are proven safe, and which had to be intercepted.
|
||||
*
|
||||
* ### What this class owns
|
||||
* It is the single place that knows which [RemoteTerminalSession] is bound, so it implements the
|
||||
@@ -33,9 +34,12 @@ import com.termux.terminal.TerminalEmulator
|
||||
* - **resize** — [RemoteTerminalHostView.onSizeChanged] → [TerminalResizeDriver] →
|
||||
* [RemoteTerminalSession.updateSize], which is the ONLY reason the server PTY ever learns the real
|
||||
* grid: `TerminalView.updateSize()` early-returns without a session (offsets 18–25).
|
||||
* - **selection** — [WebTermTerminalViewClient.onLongPress] → [RemoteTerminalHostView.beginSelectionAt],
|
||||
* then [copySelection] → `ClipboardManager`. Paste is untouched: it arrives through the IME.
|
||||
*
|
||||
* DEVICE-QA (plan §7): rendering, IME/CJK composition, selection, link taps, focus behaviour and grid
|
||||
* parity against web/iOS on the same physical size are verified on a device (risk R5).
|
||||
* DEVICE-QA (plan §7): rendering, IME/CJK composition, link taps, focus behaviour and grid parity against
|
||||
* web/iOS on the same physical size are verified on a device (risk R5) — and for selection specifically,
|
||||
* the floating Copy toolbar, the drag feel and the highlight's alignment with the glyphs.
|
||||
*/
|
||||
public class RemoteTerminalView(context: Context) {
|
||||
|
||||
@@ -55,7 +59,11 @@ public class RemoteTerminalView(context: Context) {
|
||||
}
|
||||
|
||||
override fun onTapped() {
|
||||
terminalView.showSoftKeyboard()
|
||||
terminalView.onTapped()
|
||||
}
|
||||
|
||||
override fun onLongPressAt(xPx: Float, yPx: Float) {
|
||||
terminalView.beginSelectionAt(xPx, yPx)
|
||||
}
|
||||
|
||||
override fun onViewSize(widthPx: Int, heightPx: Int) {
|
||||
@@ -107,11 +115,38 @@ public class RemoteTerminalView(context: Context) {
|
||||
/** Layout-change outlet (A17/A21 install point) — the §6.4 device-switch reclaim hook. */
|
||||
public var onViewSizeChanged: ((widthPx: Int, heightPx: Int) -> Unit)? = null
|
||||
|
||||
/**
|
||||
* Fires whenever a text selection appears or disappears, so `:app` can mirror it (e.g. enable a Copy
|
||||
* button in the terminal toolbar). Purely optional: the module already shows its own floating Copy
|
||||
* affordance, so a host that ignores this still has working copy-out.
|
||||
*/
|
||||
public var onSelectionChanged: ((isSelecting: Boolean) -> Unit)? = null
|
||||
|
||||
init {
|
||||
terminalView.sink = sink
|
||||
// Installed BEFORE any emulator binds: touch, focus and the IME can all arrive first, and every
|
||||
// one of those stock paths dereferences `mClient` without a null check.
|
||||
terminalView.installClient(WebTermTerminalViewClient(sink))
|
||||
terminalView.onSelectionChanged = { isSelecting -> onSelectionChanged?.invoke(isSelecting) }
|
||||
}
|
||||
|
||||
// ── Copy-out (plan §6.5). The gesture path is self-contained; these are for `:app`'s own UI ────────
|
||||
|
||||
/** True while text is highlighted. */
|
||||
public val isSelectingText: Boolean get() = terminalView.isSelectingText
|
||||
|
||||
/**
|
||||
* Copy the highlighted text to the device clipboard. This is the ONLY path from terminal content to the
|
||||
* clipboard (plan §8): selecting copies nothing, and OSC 52 host-clipboard writes stay declined.
|
||||
*
|
||||
* Safe to call with nothing selected — it answers [TerminalCopyResult.NOTHING_SELECTED] rather than
|
||||
* clearing whatever the user already had.
|
||||
*/
|
||||
public fun copySelection(): TerminalCopyResult = terminalView.copySelection()
|
||||
|
||||
/** Drop the selection (also what a tap on the terminal does). */
|
||||
public fun clearSelection() {
|
||||
terminalView.clearSelection()
|
||||
}
|
||||
|
||||
/** Point the stock renderer at the forked emulator, then re-assert the measured grid on it. */
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
package wang.yaojia.webterm.terminalview
|
||||
|
||||
import android.content.ClipData
|
||||
import android.content.ClipDescription
|
||||
import android.content.ClipboardManager
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.os.PersistableBundle
|
||||
import android.util.Log
|
||||
|
||||
/**
|
||||
* The device clipboard, behind a one-method seam so the copy DECISION is unit-testable without a framework
|
||||
* clipboard and so there is exactly one place in the module that can write terminal text out of the app.
|
||||
*/
|
||||
internal interface TerminalClipboard {
|
||||
|
||||
/** @return true when [text] is now on the clipboard; false when the platform refused. */
|
||||
fun put(text: String): Boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* [TerminalClipboard] over `android.content.ClipboardManager`.
|
||||
*
|
||||
* ### Security posture (plan §8, §6.5)
|
||||
* Terminal output is untrusted and may be a secret — an API key echoed by a script, a token in a log line.
|
||||
* Two rules follow and both are enforced here rather than trusted to callers:
|
||||
*
|
||||
* - **Only an explicit user copy reaches this class.** There is no automatic path: OSC 52 host-clipboard
|
||||
* writes stay declined in [RemoteTerminalSession] (`onCopyTextToClipboard` is a no-op), and
|
||||
* [TerminalSelectionController] refuses to write without a live selection. Selecting text writes nothing.
|
||||
* - **The copied text is never logged.** Failures log the throwable TYPE only, matching
|
||||
* [RemoteTerminalSession]'s precedent; the payload never reaches logcat.
|
||||
*
|
||||
* The clip is also marked `EXTRA_IS_SENSITIVE` on API 33+, which suppresses the system clipboard preview
|
||||
* overlay. The user asked for the text, but nobody asked for it to be rendered over their screen for a
|
||||
* bystander — and this class cannot tell a filename from a password, so it treats every terminal copy as
|
||||
* sensitive.
|
||||
*/
|
||||
internal class SystemTerminalClipboard(private val context: Context) : TerminalClipboard {
|
||||
|
||||
override fun put(text: String): Boolean {
|
||||
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager
|
||||
if (clipboard == null) {
|
||||
Log.w(LOG_TAG, "no clipboard service available; nothing copied")
|
||||
return false
|
||||
}
|
||||
val clip = ClipData.newPlainText(CLIP_LABEL, text).apply { markSensitive() }
|
||||
return try {
|
||||
clipboard.setPrimaryClip(clip)
|
||||
true
|
||||
} catch (e: RuntimeException) {
|
||||
// OEM clipboard services throw here (SecurityException when not the focused app,
|
||||
// IllegalStateException / TransactionTooLargeException on a very large clip). A refused copy is
|
||||
// a reportable outcome, never a crash. Type only — the clip content must not reach logcat.
|
||||
Log.w(LOG_TAG, "clipboard refused the copy: ${e.javaClass.simpleName}")
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
private fun ClipData.markSensitive() {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return
|
||||
description.extras = PersistableBundle().apply {
|
||||
putBoolean(ClipDescription.EXTRA_IS_SENSITIVE, true)
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
/**
|
||||
* Shown by the system clipboard UI. Deliberately generic: it must not describe WHICH session or
|
||||
* host the text came from.
|
||||
*/
|
||||
const val CLIP_LABEL: String = "Terminal selection"
|
||||
|
||||
const val LOG_TAG: String = "TerminalSelection"
|
||||
}
|
||||
}
|
||||
@@ -26,9 +26,23 @@ internal interface TerminalInputSink {
|
||||
/** Raw bytes onto the wire, verbatim (invariant #9 — never filtered). */
|
||||
fun sendInput(data: String)
|
||||
|
||||
/** The user tapped the terminal: take focus and raise the soft keyboard. */
|
||||
/**
|
||||
* The user tapped the terminal. Normally: take focus and raise the soft keyboard — but with a live
|
||||
* text selection the tap dismisses it instead (the standard platform gesture), which is why this is a
|
||||
* "tapped" event for the view to interpret rather than a "show the keyboard" command.
|
||||
*/
|
||||
fun onTapped()
|
||||
|
||||
/**
|
||||
* The user long-pressed at these view pixels: start a text selection there
|
||||
* ([RemoteTerminalHostView.beginSelectionAt]).
|
||||
*
|
||||
* The long press stays CONSUMED by [WebTermTerminalViewClient] either way — this is a notification, not
|
||||
* a decision. Stock text selection must remain unreachable (its Copy button dereferences the absent
|
||||
* `TerminalSession`), so the press is spent on the first-party layer instead of being handed back.
|
||||
*/
|
||||
fun onLongPressAt(xPx: Float, yPx: Float)
|
||||
|
||||
/** The host view was laid out at this pixel size (drives the §6.4 resize path). */
|
||||
fun onViewSize(widthPx: Int, heightPx: Int)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
package wang.yaojia.webterm.terminalview
|
||||
|
||||
import android.graphics.Rect
|
||||
import android.view.ActionMode
|
||||
import android.view.Menu
|
||||
import android.view.MenuItem
|
||||
import android.view.View
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/**
|
||||
* The explicit Copy affordance: a floating `ActionMode` over the selection.
|
||||
*
|
||||
* This is plan §6.5's "Android `ActionMode` → `ClipboardManager`" with the one part that could not be kept —
|
||||
* `TextSelectionCursorController` — replaced. It is our own `ActionMode.Callback2` on our own frame, so the
|
||||
* menu it builds has one item that calls [onCopy], and the stock callback whose Copy is
|
||||
* `mTermSession.onCopyTextToClipboard(...)` is never constructed. `TYPE_FLOATING` + [onGetContentRect] is the
|
||||
* same shape stock uses (`startActionMode(callback, 1)`), which is what makes the toolbar hover over the
|
||||
* selected text rather than take over the app bar.
|
||||
*
|
||||
* Paste is deliberately absent: it already works through the IME
|
||||
* ([RemoteTerminalInputConnection] → `writeInput`), and a paste item here would only duplicate it.
|
||||
*
|
||||
* @param contentRect the selection's bounding box in view pixels, or null when nothing is highlighted.
|
||||
* @param onCopy fired for the Copy item. The caller both copies and dismisses.
|
||||
* @param onDismissed fired when the mode ends for ANY reason (Copy, back, tapping away).
|
||||
*/
|
||||
internal class TerminalSelectionActionMode(
|
||||
private val contentRect: () -> TerminalSelectionRect?,
|
||||
private val onCopy: () -> Unit,
|
||||
private val onDismissed: () -> Unit,
|
||||
) : ActionMode.Callback2() {
|
||||
|
||||
override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean {
|
||||
// android.R.string.copy is the platform's own localised "Copy", so :terminal-view needs no
|
||||
// resources of its own for this.
|
||||
menu.add(Menu.NONE, COPY_ITEM_ID, Menu.NONE, android.R.string.copy)
|
||||
.setShowAsAction(MenuItem.SHOW_AS_ACTION_ALWAYS)
|
||||
return true
|
||||
}
|
||||
|
||||
/** Nothing to re-prepare: the single item is always enabled while a selection exists. */
|
||||
override fun onPrepareActionMode(mode: ActionMode, menu: Menu): Boolean = false
|
||||
|
||||
override fun onActionItemClicked(mode: ActionMode, item: MenuItem): Boolean {
|
||||
if (item.itemId != COPY_ITEM_ID) return false
|
||||
onCopy()
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onDestroyActionMode(mode: ActionMode) {
|
||||
onDismissed()
|
||||
}
|
||||
|
||||
/**
|
||||
* Where the floating toolbar should point. Falls back to the platform default (the whole view) when the
|
||||
* selection has scrolled out of sight, which is better than anchoring the toolbar off-screen.
|
||||
*/
|
||||
override fun onGetContentRect(mode: ActionMode, view: View, outRect: Rect) {
|
||||
val rect = contentRect()
|
||||
if (rect == null) {
|
||||
super.onGetContentRect(mode, view, outRect)
|
||||
return
|
||||
}
|
||||
outRect.set(
|
||||
rect.left.roundToInt(),
|
||||
rect.top.roundToInt(),
|
||||
rect.right.roundToInt(),
|
||||
rect.bottom.roundToInt(),
|
||||
)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
/** Any non-zero id; there is only ever one item. */
|
||||
const val COPY_ITEM_ID: Int = 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
package wang.yaojia.webterm.terminalview
|
||||
|
||||
import android.util.Log
|
||||
import com.termux.terminal.TerminalEmulator
|
||||
|
||||
/**
|
||||
* Read-only access to whatever the emulator currently holds — the ONE seam the selection layer reads the
|
||||
* terminal through.
|
||||
*
|
||||
* Two operations, both pure reads: what grid exists right now, and what text a span covers. Keeping it to
|
||||
* an interface is what lets the state machine ([TerminalSelectionController]) be driven by a fake, while
|
||||
* the real implementation is pinned against a real `TerminalEmulator` in the same JVM suite.
|
||||
*/
|
||||
internal interface TerminalSelectionBuffer {
|
||||
|
||||
/** The live grid, or null before an emulator is bound. */
|
||||
fun bounds(): TerminalGridBounds?
|
||||
|
||||
/**
|
||||
* The text inside [span]. Never throws: an off-grid span is clamped and a missing emulator yields "".
|
||||
* Implementations MUST NOT mutate anything (see [TerminalEmulatorSelectionBuffer] for why).
|
||||
*/
|
||||
fun textIn(span: TerminalSelectionSpan): String
|
||||
}
|
||||
|
||||
/**
|
||||
* [TerminalSelectionBuffer] over the bound Termux emulator.
|
||||
*
|
||||
* ### Threading — how a read is kept off a concurrent mutation (§6.2)
|
||||
* Every method here runs on the **main (render) thread**: the callers are touch/long-press/ActionMode
|
||||
* callbacks and `dispatchDraw`. Since the 2026-07-30 repair, EVERY emulator mutation also runs on the main
|
||||
* thread — [RemoteTerminalSession]'s single confined consumer hops to `mainDispatcher` for the `append` and
|
||||
* the `resize` and does nothing to the emulator anywhere else. So a read and a mutation are two main-thread
|
||||
* work items and **cannot interleave at all**. That is the same guarantee upstream Termux relies on
|
||||
* (`TerminalSession$MainThreadHandler.handleMessage` calls `append`), and it is the only one available:
|
||||
* `monitorenter` appears nowhere in `TerminalEmulator`, `TerminalBuffer`, `TerminalRow` or
|
||||
* `TerminalRenderer`, so there is no lock to take.
|
||||
*
|
||||
* It is worth being explicit about why "read it anyway and let a torn read self-correct" is not on the
|
||||
* table: that exact assumption was in this module's own KDoc until 2026-07-30 and it was wrong — a draw
|
||||
* landing inside `TerminalEmulator.resize`'s window (new `mColumns` published at offsets 97-106, rows
|
||||
* reallocated at offset 160) threw `ArrayIndexOutOfBoundsException` out of `TerminalRow.getStyle` and
|
||||
* killed the process on a real emulator. `getSelectedText` indexes the same `mText`/`mLines` arrays through
|
||||
* the same unsynchronised path, so a torn read here would be the same crash with the same lack of a
|
||||
* self-correcting next frame. **Do not move emulator mutation off the main thread**, and do not call these
|
||||
* methods from anywhere but the main thread.
|
||||
*
|
||||
* ### Why extraction is delegated rather than reimplemented
|
||||
* `TerminalBuffer.getSelectedText(x1, y1, x2, y2)` is the one part of the stock selection stack that never
|
||||
* touches `TerminalSession` — it walks rows via `TerminalRow.findStartOfColumn`, which is `WcWidth`-aware
|
||||
* (so a wide CJK cell copies whole from either of its two columns), trims each line's trailing blanks, and
|
||||
* joins soft-wrapped rows while preserving real line breaks. Re-deriving that in Kotlin would duplicate
|
||||
* subtle logic that must agree with what the renderer draws. `TerminalSelectionBufferTest` pins the
|
||||
* behaviour so a Termux bump that changes it fails loudly instead of silently copying the wrong bytes.
|
||||
*/
|
||||
internal class TerminalEmulatorSelectionBuffer(
|
||||
private val emulator: () -> TerminalEmulator?,
|
||||
) : TerminalSelectionBuffer {
|
||||
|
||||
override fun bounds(): TerminalGridBounds? {
|
||||
val emulator = emulator() ?: return null
|
||||
return TerminalGridBounds(
|
||||
columns = emulator.mColumns,
|
||||
screenRows = emulator.mRows,
|
||||
transcriptRows = emulator.screen.activeTranscriptRows,
|
||||
)
|
||||
}
|
||||
|
||||
override fun textIn(span: TerminalSelectionSpan): String {
|
||||
val emulator = emulator() ?: return ""
|
||||
val bounds = bounds() ?: return ""
|
||||
// Clamping INSIDE the port is what makes the two unguarded stock failures unreachable for every
|
||||
// caller: TerminalBuffer.externalToInternalRow throws IllegalArgumentException outside
|
||||
// -activeTranscriptRows..screenRows, and TerminalRow.findStartOfColumn walks mText with no bound
|
||||
// check for any column > mColumns. See TerminalSelectionSpan.clampedTo.
|
||||
val clamped = span.clampedTo(bounds) ?: return ""
|
||||
return try {
|
||||
emulator.getSelectedText(
|
||||
clamped.start.column,
|
||||
clamped.start.row,
|
||||
clamped.end.column,
|
||||
clamped.end.row,
|
||||
)
|
||||
} catch (e: IllegalArgumentException) {
|
||||
// Should be unreachable after the clamp; a throw here would otherwise kill the process on the
|
||||
// UI thread. Log the throwable TYPE only — never the message, which can carry row content.
|
||||
logDroppedRead(e)
|
||||
""
|
||||
} catch (e: IndexOutOfBoundsException) {
|
||||
logDroppedRead(e)
|
||||
""
|
||||
}
|
||||
}
|
||||
|
||||
private fun logDroppedRead(throwable: Throwable) {
|
||||
Log.w(LOG_TAG, "dropping a selection read that threw: ${throwable.javaClass.simpleName}")
|
||||
}
|
||||
|
||||
private companion object {
|
||||
private const val LOG_TAG: String = "TerminalSelection"
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The long-press snap: the run of non-blank cells around the pressed cell, so one press-and-release already
|
||||
* selects something worth copying (a path, a hash, a container id) instead of a single character.
|
||||
*
|
||||
* ### Deliberately not stock's expansion
|
||||
* `TextSelectionCursorController.setInitialTextSelectionPosition` (offsets 34-189) walks outward while the
|
||||
* neighbouring cell's text `!= " "` — but a blank cell's `getSelectedText` is `""`, not `" "`, because
|
||||
* trailing blanks are trimmed. So the stock comparison never matches and the expansion runs to both row
|
||||
* edges: in Termux a long press selects the whole line. Testing for *blank* instead gives word-granular
|
||||
* selection, which is what a shell line is actually made of; the user can still drag out to the rest of
|
||||
* the line.
|
||||
*/
|
||||
internal object TerminalSelectionWords {
|
||||
|
||||
/**
|
||||
* The selection a long press at [cell] should start with, or null when there is no grid yet.
|
||||
*
|
||||
* The anchor is the START of the run and the focus its END, so a subsequent drag backwards past the
|
||||
* word start flips the span the way a text editor does.
|
||||
*/
|
||||
fun runAt(cell: TerminalCell, buffer: TerminalSelectionBuffer): TerminalSelection? {
|
||||
val bounds = buffer.bounds() ?: return null
|
||||
if (!bounds.isMeasured) return null
|
||||
val pressed = bounds.clamp(cell)
|
||||
if (isBlank(pressed, buffer)) return TerminalSelection.at(pressed)
|
||||
|
||||
var first = pressed.column
|
||||
while (first > 0 && !isBlank(pressed.copy(column = first - 1), buffer)) first--
|
||||
|
||||
var last = pressed.column
|
||||
while (last < bounds.lastColumn && !isBlank(pressed.copy(column = last + 1), buffer)) last++
|
||||
|
||||
return TerminalSelection.anchoredOn(
|
||||
from = pressed.copy(column = first),
|
||||
to = pressed.copy(column = last),
|
||||
)
|
||||
}
|
||||
|
||||
/** A cell is blank when its extracted text is empty (trailing-blank trimming) or whitespace. */
|
||||
private fun isBlank(cell: TerminalCell, buffer: TerminalSelectionBuffer): Boolean =
|
||||
buffer.textIn(TerminalSelectionSpan(cell, cell)).isBlank()
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
package wang.yaojia.webterm.terminalview
|
||||
|
||||
/** What an explicit copy did. Public so `:app` can report the outcome in its own UI. */
|
||||
public enum class TerminalCopyResult {
|
||||
/** The selected text is on the clipboard. */
|
||||
COPIED,
|
||||
|
||||
/** Nothing was selected, so there was nothing to copy. */
|
||||
NOTHING_SELECTED,
|
||||
|
||||
/** The selection resolved to blank cells; the clipboard was deliberately left alone. */
|
||||
NOTHING_TO_COPY,
|
||||
|
||||
/** The platform clipboard refused the write (see [SystemTerminalClipboard]). */
|
||||
CLIPBOARD_UNAVAILABLE,
|
||||
}
|
||||
|
||||
/**
|
||||
* The selection state machine: what a long press starts, what a drag does to it, what new output does to
|
||||
* it, and the single path from a live selection to the clipboard.
|
||||
*
|
||||
* Pure of android and of Termux — the terminal arrives through [TerminalSelectionBuffer] and leaves through
|
||||
* [TerminalClipboard], so every decision here is JVM-unit-testable. The view layer
|
||||
* ([RemoteTerminalHostView]) owns only pixels→cells and the affordance.
|
||||
*
|
||||
* Not thread-safe by design: like [TerminalResizeDriver] it is driven from touch and draw callbacks, i.e.
|
||||
* the main thread only. That is also the thread the emulator is mutated on, which is what makes the buffer
|
||||
* reads safe (see [TerminalEmulatorSelectionBuffer]).
|
||||
*
|
||||
* @param onChanged fired with the new "is a selection active" value whenever it or the span changes. The
|
||||
* view uses it to repaint the highlight and show/hide the Copy affordance.
|
||||
*/
|
||||
internal class TerminalSelectionController(
|
||||
private val buffer: TerminalSelectionBuffer,
|
||||
private val clipboard: TerminalClipboard,
|
||||
private val onChanged: (isActive: Boolean) -> Unit,
|
||||
) {
|
||||
/** The live selection, or null when there is none. Immutable — every change replaces it. */
|
||||
var selection: TerminalSelection? = null
|
||||
private set
|
||||
|
||||
val isActive: Boolean get() = selection != null
|
||||
|
||||
/**
|
||||
* Start a selection at [cell], snapped to the word under it ([TerminalSelectionWords]).
|
||||
*
|
||||
* @return true when a selection now exists. False means the grid is not measured yet (nothing is bound,
|
||||
* or the view has not been laid out), in which case the long press is simply ignored.
|
||||
*/
|
||||
fun beginAt(cell: TerminalCell): Boolean {
|
||||
val started = TerminalSelectionWords.runAt(cell, buffer) ?: return false
|
||||
publish(started)
|
||||
return true
|
||||
}
|
||||
|
||||
/** Move the focus end to [cell] (clamped into the live grid). No-op without a selection. */
|
||||
fun extendTo(cell: TerminalCell) {
|
||||
val current = selection ?: return
|
||||
val bounds = buffer.bounds() ?: return
|
||||
if (!bounds.isMeasured) return
|
||||
publish(current.withFocus(bounds.clamp(cell)))
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop the selection.
|
||||
*
|
||||
* Reports nothing when there was no selection — load-bearing, because the Copy affordance is dismissed
|
||||
* BY this method and its own dismissal callback calls it back; without the guard that is an infinite
|
||||
* loop (see [RemoteTerminalHostView]'s action-mode handling).
|
||||
*/
|
||||
fun clear() {
|
||||
if (selection == null) return
|
||||
selection = null
|
||||
onChanged(false)
|
||||
}
|
||||
|
||||
/** The text the current selection covers, or "" when there is none. Reads the buffer; mutates nothing. */
|
||||
fun selectedText(): String {
|
||||
val span = selection?.span ?: return ""
|
||||
return buffer.textIn(span)
|
||||
}
|
||||
|
||||
/**
|
||||
* THE explicit copy — the only path from terminal content to the device clipboard (plan §8).
|
||||
*
|
||||
* A blank result is refused rather than written: replacing whatever the user had on their clipboard with
|
||||
* an empty string is data loss, and it is the likely outcome of a stray long press on empty space.
|
||||
*/
|
||||
fun copy(): TerminalCopyResult {
|
||||
if (selection == null) return TerminalCopyResult.NOTHING_SELECTED
|
||||
val text = selectedText()
|
||||
if (text.isBlank()) return TerminalCopyResult.NOTHING_TO_COPY
|
||||
return if (clipboard.put(text)) TerminalCopyResult.COPIED else TerminalCopyResult.CLIPBOARD_UNAVAILABLE
|
||||
}
|
||||
|
||||
/**
|
||||
* [scrolledRows] new rows scrolled off the top of the screen, so every external row index the selection
|
||||
* holds is now that much smaller.
|
||||
*
|
||||
* Called from the screen-update path with `TerminalEmulator.getScrollCounter()` read BEFORE the stock
|
||||
* `onScreenUpdated()` clears it — the stock view only maintains this for its own selection controller
|
||||
* (`isSelectingText()`, always false here), so the shift has to be applied by us or the highlight ends
|
||||
* up pointing at different text than the user selected. Once the top of the selection falls past the
|
||||
* oldest retained transcript row the text is genuinely gone, and the selection is dropped rather than
|
||||
* silently re-pointed — mirroring stock's `stopTextSelectionMode()` in the same situation.
|
||||
*/
|
||||
fun onContentScrolled(scrolledRows: Int) {
|
||||
if (scrolledRows == 0) return
|
||||
val current = selection ?: return
|
||||
val bounds = buffer.bounds() ?: return
|
||||
val moved = current.movedBy(-scrolledRows)
|
||||
if (moved.span.start.row < bounds.firstRow) {
|
||||
clear()
|
||||
return
|
||||
}
|
||||
publish(moved)
|
||||
}
|
||||
|
||||
private fun publish(next: TerminalSelection) {
|
||||
if (next == selection) return
|
||||
selection = next
|
||||
onChanged(true)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package wang.yaojia.webterm.terminalview
|
||||
|
||||
import kotlin.math.floor
|
||||
|
||||
/** One highlight band, in the host frame's own pixel space (which is the stock child's — it sits at 0,0). */
|
||||
internal data class TerminalSelectionRect(
|
||||
val left: Float,
|
||||
val top: Float,
|
||||
val right: Float,
|
||||
val bottom: Float,
|
||||
)
|
||||
|
||||
/**
|
||||
* Pixels ↔ cells for the selection layer. Pure: the caller supplies the measured cell box and the
|
||||
* viewport's `topRow`, so both directions are JVM-unit-testable without a device — the same trick
|
||||
* [TerminalGridMath] plays for the resize formula.
|
||||
*
|
||||
* ### The mapping, and why it is not stock's
|
||||
* This is the exact inverse of the stock accessors this module already reads:
|
||||
* `TerminalView.getPointX(col)` is `round(col * fontWidth)` and `getPointY(row)` is
|
||||
* `(row - topRow) * lineSpacing`, i.e. cell `(c, r)` owns the band
|
||||
* `[c*fontWidth, (c+1)*fontWidth) x [(r-topRow)*lineSpacing, (r-topRow+1)*lineSpacing)`. `TerminalRenderer`
|
||||
* draws each row's baseline inside that same band (`y += mFontLineSpacing` per row, starting at
|
||||
* `mFontLineSpacingAndAscent`), so a highlight built this way lines up with the glyphs.
|
||||
*
|
||||
* Stock's own hit test ([com.termux.view.TerminalView.getColumnAndRow]) instead computes
|
||||
* `(y - mFontLineSpacingAndAscent) / mFontLineSpacing`, which subtracts the (negative) font ascent and so
|
||||
* lands roughly a fifth of a row above the glyph the user pressed. Reproducing that would make a long
|
||||
* press select the line above itself near a row boundary, so it is deliberately not reproduced.
|
||||
*/
|
||||
internal object TerminalSelectionGeometry {
|
||||
|
||||
/**
|
||||
* The cell under a touch, or null when the renderer has not measured a cell box yet (before the first
|
||||
* layout `fontWidth`/`lineSpacing` are 0 and there is no grid to divide by).
|
||||
*
|
||||
* The result is NOT clamped — a touch outside the grid legitimately resolves to an off-grid cell, and
|
||||
* clamping happens once, against the live bounds, in [TerminalSelectionSpan.clampedTo].
|
||||
*/
|
||||
fun cellAt(xPx: Float, yPx: Float, metrics: TerminalCellMetrics, topRow: Int): TerminalCell? {
|
||||
if (metrics.fontWidthPx <= 0f || metrics.lineSpacingPx <= 0) return null
|
||||
return TerminalCell(
|
||||
column = floor(xPx / metrics.fontWidthPx).toInt(),
|
||||
row = floor(yPx / metrics.lineSpacingPx).toInt() + topRow,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* One band per VISIBLE row of [span].
|
||||
*
|
||||
* Rows above or below the viewport are dropped rather than emitted with an off-screen `top`: a
|
||||
* selection made in scrollback and then scrolled away must paint nothing, and a 10 000-row transcript
|
||||
* selection must not build 10 000 rects for a 24-row screen.
|
||||
*/
|
||||
fun highlightRects(
|
||||
span: TerminalSelectionSpan,
|
||||
bounds: TerminalGridBounds,
|
||||
metrics: TerminalCellMetrics,
|
||||
topRow: Int,
|
||||
): List<TerminalSelectionRect> {
|
||||
if (!bounds.isMeasured) return emptyList()
|
||||
if (metrics.fontWidthPx <= 0f || metrics.lineSpacingPx <= 0) return emptyList()
|
||||
|
||||
val firstVisibleRow = maxOf(span.start.row, topRow)
|
||||
val lastVisibleRow = minOf(span.end.row, topRow + bounds.screenRows - 1)
|
||||
if (firstVisibleRow > lastVisibleRow) return emptyList()
|
||||
|
||||
return (firstVisibleRow..lastVisibleRow).mapNotNull { row ->
|
||||
val columns = span.columnsIn(row, bounds.lastColumn) ?: return@mapNotNull null
|
||||
val top = (row - topRow) * metrics.lineSpacingPx.toFloat()
|
||||
TerminalSelectionRect(
|
||||
left = columns.first * metrics.fontWidthPx,
|
||||
top = top,
|
||||
// Inclusive columns → the band covers the whole last cell, hence the +1.
|
||||
right = (columns.last + 1) * metrics.fontWidthPx,
|
||||
bottom = top + metrics.lineSpacingPx,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
package wang.yaojia.webterm.terminalview
|
||||
|
||||
/**
|
||||
* The first-party text-selection model — cell coordinates, normalisation and clamping.
|
||||
*
|
||||
* ### Why this exists at all (the stock selection UI is a crash, not a feature)
|
||||
* `TerminalView.startTextSelectionMode` arms an `ActionMode` whose own handlers dereference the
|
||||
* `TerminalSession` this app deliberately does not have:
|
||||
* `TextSelectionCursorController$1.onActionItemClicked` offsets 89-100 are
|
||||
* `mTermSession.onCopyTextToClipboard(...)` and offsets 126-136 are `mTermSession.onPasteTextFromClipboard()`
|
||||
* (re-verified with `javap -p -c` on the pinned `terminal-view-v0.118.0.aar`). So making the stock UI
|
||||
* reachable buys a selection whose **Copy button is an NPE**, which is strictly worse than no selection —
|
||||
* the user loses their work to a process kill instead of finding the affordance absent. `mTermSession`
|
||||
* cannot be supplied: `TerminalSession` is `final` and its constructor forks a local process, which is
|
||||
* exactly what this app must not do (plan §6.1).
|
||||
*
|
||||
* The one thing in that stock stack that IS session-free is the extraction —
|
||||
* `TerminalBuffer.getSelectedText(x1, y1, x2, y2)` reads rows and nothing else. So the replacement is: our
|
||||
* own coordinates (this file), our own hit-testing and highlight ([TerminalSelectionGeometry]), stock
|
||||
* extraction ([TerminalSelectionBuffer]) and a direct `ClipboardManager` write ([TerminalClipboard]) —
|
||||
* `TextSelectionCursorController` bypassed entirely, `onLongPress` still consuming so the stock mode can
|
||||
* never arm.
|
||||
*
|
||||
* Everything in this file is pure and immutable: no android, no Termux, no mutation. Every operation
|
||||
* returns a new value.
|
||||
*/
|
||||
|
||||
/**
|
||||
* One terminal cell, ordered **row-major** — which is what makes a drag up-and-right still a backwards
|
||||
* drag, and lets [TerminalSelectionSpan] normalise with plain `minOf`/`maxOf`.
|
||||
*
|
||||
* @param column 0-based, left to right.
|
||||
* @param row an **external** row in Termux's sense: `0 .. screenRows-1` is the live screen and negative
|
||||
* rows walk back into the scrollback transcript. External rows are independent of the viewport's
|
||||
* `topRow`, so scrolling the view never moves a selection — but new output does (see [movedBy]).
|
||||
*/
|
||||
internal data class TerminalCell(val column: Int, val row: Int) : Comparable<TerminalCell> {
|
||||
|
||||
override fun compareTo(other: TerminalCell): Int =
|
||||
if (row != other.row) row.compareTo(other.row) else column.compareTo(other.column)
|
||||
|
||||
fun movedBy(rows: Int): TerminalCell = copy(row = row + rows)
|
||||
}
|
||||
|
||||
/**
|
||||
* An **inclusive**, row-major-ordered cell range: [start] is never after [end].
|
||||
*
|
||||
* That invariant is established by construction — every producer ([spanning], [clampedTo], [expandedTo])
|
||||
* orders its output — and it is what lets [columnsIn] and the buffer read treat start/end as first/last
|
||||
* without re-checking.
|
||||
*/
|
||||
internal data class TerminalSelectionSpan(val start: TerminalCell, val end: TerminalCell) {
|
||||
|
||||
val isSingleCell: Boolean get() = start == end
|
||||
|
||||
/** The rows this span touches, oldest (smallest) first. */
|
||||
val rows: IntRange get() = start.row..end.row
|
||||
|
||||
/**
|
||||
* The columns selected on [row], or null when [row] is outside the span.
|
||||
*
|
||||
* The first row runs from its start column to the grid edge, the last row from column 0 to its end
|
||||
* column, and every row between is whole — matching `TerminalBuffer.getSelectedText`'s own per-row
|
||||
* range (offsets 57-98), so the highlight and the copied text can never disagree.
|
||||
*/
|
||||
fun columnsIn(row: Int, lastColumn: Int): IntRange? {
|
||||
if (row < start.row || row > end.row) return null
|
||||
val first = if (row == start.row) start.column else 0
|
||||
val last = if (row == end.row) end.column else lastColumn
|
||||
return first..last
|
||||
}
|
||||
|
||||
/** This span grown to include [cell]. A cell already inside it changes nothing. */
|
||||
fun expandedTo(cell: TerminalCell): TerminalSelectionSpan =
|
||||
TerminalSelectionSpan(minOf(start, cell), maxOf(end, cell))
|
||||
|
||||
fun movedBy(rows: Int): TerminalSelectionSpan =
|
||||
TerminalSelectionSpan(start.movedBy(rows), end.movedBy(rows))
|
||||
|
||||
/**
|
||||
* This span clamped into [bounds], or null when the grid has not been measured yet.
|
||||
*
|
||||
* **Load-bearing, not defensive dressing.** An unclamped cell reaches two unguarded stock paths:
|
||||
* `TerminalBuffer.externalToInternalRow` THROWS `IllegalArgumentException` for a row outside
|
||||
* `-activeTranscriptRows .. screenRows`, and `TerminalRow.findStartOfColumn` walks `mText` with **no
|
||||
* bound check** for any `column > mColumns` (offsets 13-173 — only the exact `column == mColumns` case
|
||||
* short-circuits), so an off-grid column runs off the end of the char array. A touch a few pixels past
|
||||
* the last cell, or a drag that leaves the view, produces exactly those inputs.
|
||||
*/
|
||||
fun clampedTo(bounds: TerminalGridBounds): TerminalSelectionSpan? {
|
||||
if (!bounds.isMeasured) return null
|
||||
return TerminalSelectionSpan(bounds.clamp(start), bounds.clamp(end))
|
||||
}
|
||||
|
||||
companion object {
|
||||
/** The normalising factory: the two cells in either order produce the same span. */
|
||||
fun spanning(one: TerminalCell, other: TerminalCell): TerminalSelectionSpan =
|
||||
TerminalSelectionSpan(minOf(one, other), maxOf(one, other))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A live selection as the user's gesture describes it.
|
||||
*
|
||||
* @param anchor what the long press claimed — a **span**, not a cell, because a press snaps to the word
|
||||
* under it ([TerminalSelectionWords]). Keeping the whole anchor means a drag backwards past the word
|
||||
* start still leaves that word selected, the way a text editor behaves, instead of collapsing it to the
|
||||
* single cell the drag passed through.
|
||||
* @param focus where the finger is now.
|
||||
*/
|
||||
internal data class TerminalSelection(val anchor: TerminalSelectionSpan, val focus: TerminalCell) {
|
||||
|
||||
/**
|
||||
* The normalised, inclusive span: the anchor grown to reach the focus. A backwards drag yields exactly
|
||||
* the same span as the equivalent forwards one — [TerminalSelectionSpan.expandedTo] orders by
|
||||
* construction, so there is no direction to get wrong.
|
||||
*/
|
||||
val span: TerminalSelectionSpan get() = anchor.expandedTo(focus)
|
||||
|
||||
fun withFocus(cell: TerminalCell): TerminalSelection = copy(focus = cell)
|
||||
|
||||
/**
|
||||
* Shift the whole selection by [rows] (negative == the screen scrolled up under a live selection).
|
||||
*
|
||||
* New output renumbers external rows: the line the user selected is one row lower after each new line
|
||||
* arrives. Without this shift the highlight would keep pointing at the same coordinates while the text
|
||||
* under it changed — and the Copy button would then put *different* text on the clipboard than the one
|
||||
* highlighted, which for terminal content (possibly secrets) is a correctness bug, not a cosmetic one.
|
||||
*/
|
||||
fun movedBy(rows: Int): TerminalSelection =
|
||||
TerminalSelection(anchor.movedBy(rows), focus.movedBy(rows))
|
||||
|
||||
companion object {
|
||||
/** A selection of one cell — the fallback when a press lands on blank space. */
|
||||
fun at(cell: TerminalCell): TerminalSelection =
|
||||
TerminalSelection(TerminalSelectionSpan(cell, cell), cell)
|
||||
|
||||
/** A selection anchored on a whole run of cells, focused at its end. */
|
||||
fun anchoredOn(from: TerminalCell, to: TerminalCell): TerminalSelection =
|
||||
TerminalSelection(TerminalSelectionSpan.spanning(from, to), maxOf(from, to))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The live grid a selection is resolved against: the emulator's `mColumns` / `mRows` and its buffer's
|
||||
* `activeTranscriptRows`. Read fresh for every operation (the grid changes on resize and the transcript
|
||||
* grows with output), never cached.
|
||||
*/
|
||||
internal data class TerminalGridBounds(
|
||||
val columns: Int,
|
||||
val screenRows: Int,
|
||||
val transcriptRows: Int,
|
||||
) {
|
||||
/** False before the first layout/bind, when there is no grid to clamp into. */
|
||||
val isMeasured: Boolean get() = columns > 0 && screenRows > 0
|
||||
|
||||
val lastColumn: Int get() = columns - 1
|
||||
|
||||
/** The oldest retained row; `getSelectedText` clamps to the same floor (offsets 15-29). */
|
||||
val firstRow: Int get() = -transcriptRows
|
||||
|
||||
/** The newest row of the live screen; `getSelectedText` clamps to the same ceiling (offsets 30-46). */
|
||||
val lastRow: Int get() = screenRows - 1
|
||||
|
||||
fun clamp(cell: TerminalCell): TerminalCell = TerminalCell(
|
||||
column = cell.column.coerceIn(0, lastColumn),
|
||||
row = cell.row.coerceIn(firstRow, lastRow),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package wang.yaojia.webterm.terminalview
|
||||
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Paint
|
||||
|
||||
/**
|
||||
* Paints the selection highlight as an OVERLAY, on top of the stock glyphs.
|
||||
*
|
||||
* ### Why an overlay rather than the renderer's own selection channel
|
||||
* `TerminalRenderer.render` already knows how to invert selected cells — `TerminalView.onDraw` passes it
|
||||
* four selector ints and the renderer flags every column between them. That channel is
|
||||
* `TerminalView.mDefaultSelectors`, which is **package-private** to `com.termux.view`, so reaching it would
|
||||
* take either reflection (silently broken by a version bump, and the module already refused reflection for
|
||||
* the cell metrics) or a source file planted in Termux's package. Neither is worth it for a highlight, and
|
||||
* plan §6.1 keeps rendering stock: nothing here patches `TerminalRenderer` or `TerminalView.onDraw`.
|
||||
*
|
||||
* A translucent fill drawn after the children therefore does the job. It is drawn in the host frame's own
|
||||
* coordinate space, which is the stock child's — the child fills the frame at (0,0) and `onDraw` applies no
|
||||
* canvas translation, so the bands line up with the glyphs cell for cell.
|
||||
*/
|
||||
internal class TerminalSelectionPainter {
|
||||
|
||||
private val fill = Paint().apply {
|
||||
color = SELECTION_ARGB
|
||||
style = Paint.Style.FILL
|
||||
// Cell-aligned rectangles: smoothing their edges only blurs the boundary between two selected rows.
|
||||
isAntiAlias = false
|
||||
}
|
||||
|
||||
fun draw(canvas: Canvas, rects: List<TerminalSelectionRect>) {
|
||||
rects.forEach { rect -> canvas.drawRect(rect.left, rect.top, rect.right, rect.bottom, fill) }
|
||||
}
|
||||
|
||||
private companion object {
|
||||
/**
|
||||
* The design system's amber-gold (#E3A64A) at 35% alpha.
|
||||
*
|
||||
* Translucent because it is drawn OVER the glyphs: an opaque fill would hide the very text the user
|
||||
* is about to copy. The literal is inline because `:terminal-view` has no dependency on `:app`'s
|
||||
* design tokens (dependencies only flow down) — keep it in step with `WebTermColors` by hand.
|
||||
*/
|
||||
const val SELECTION_ARGB: Int = 0x59E3A64A.toInt()
|
||||
}
|
||||
}
|
||||
@@ -36,7 +36,10 @@ import com.termux.view.TerminalViewClient
|
||||
* reachable and which are proven safe. Rendering and the cursor stay stock; scrolling and text selection
|
||||
* do not (plan §6.1/§6.5 deviations, both recorded there and on [onLongPress]).
|
||||
*
|
||||
|
||||
* Text selection is the one that changed shape rather than being cut: the long press is still consumed —
|
||||
* the stock `ActionMode` can never arm — but it now starts the first-party selection layer
|
||||
* ([TerminalSelectionController]) instead of being discarded.
|
||||
*
|
||||
* @param sink the single outlet — `:app`'s chord router, the emulator's live cursor modes, and the byte
|
||||
* send pump. See [TerminalInputSink].
|
||||
*/
|
||||
@@ -126,44 +129,31 @@ internal class WebTermTerminalViewClient(private val sink: TerminalInputSink) :
|
||||
override fun onScale(scale: Float): Float = NO_SCALE
|
||||
|
||||
/**
|
||||
* MUST stay true — text selection is **cut**, a recorded deviation from plan §6.5.
|
||||
* Start a FIRST-PARTY text selection at the press, and consume the event.
|
||||
*
|
||||
* `TerminalView$1.onLongPress` offsets 14-30 are `if (mClient.onLongPress(e)) return;`, so true is the
|
||||
* one supported way to stop `startTextSelectionMode` from arming. It has to be stopped, because the
|
||||
* stock selection ActionMode is a crash rather than a feature without a `TerminalSession`:
|
||||
* **The return value MUST stay true.** `TerminalView$1.onLongPress` offsets 14-30 are
|
||||
* `if (mClient.onLongPress(e)) return;`, so true is the one supported way to stop
|
||||
* `startTextSelectionMode` from arming — and it has to be stopped, because the stock selection
|
||||
* `ActionMode` is a crash rather than a feature without a `TerminalSession`:
|
||||
* `TextSelectionCursorController$1.onActionItemClicked` offsets 89-100 (**Copy**) are
|
||||
* `mTermSession.onCopyTextToClipboard(...)` and offsets 126-136 (**Paste**) are
|
||||
* `mTermSession.onPasteTextFromClipboard()` — both unguarded, both on the only two buttons the mode
|
||||
* exists for. Selecting text would work; the moment the user tapped Copy the process would die.
|
||||
* `mTermSession` cannot be supplied either: `TerminalSession` is `final`, its constructor builds a
|
||||
* process-bound `MainThreadHandler`, and having no local subprocess is the point (plan §6.1).
|
||||
*
|
||||
* The controller itself is otherwise session-free (it reads `TerminalBuffer.getSelectedText`), so a
|
||||
* clipboard path is buildable — but it needs its own ActionMode, its own copy action and its own
|
||||
* selection highlight, i.e. a feature, not a guard. Until then a long press is a no-op instead of a
|
||||
* loaded gun, and the deviation is reported to the plan owner rather than left silently dead.
|
||||
* Copy-out is therefore **no longer a feature gap**: the press is spent on the replacement layer
|
||||
* instead of being discarded. [TerminalSelection] carries the design — our own cell coordinates
|
||||
* and highlight, stock's session-free `TerminalBuffer.getSelectedText` for the extraction, and a direct
|
||||
* `ClipboardManager` write ([TerminalClipboard]), with `TextSelectionCursorController` bypassed
|
||||
* entirely. The affordance is our own floating `ActionMode` ([TerminalSelectionActionMode]), which is
|
||||
* what plan §6.5 asks for; only the stock controller is replaced. Paste is untouched — it already works
|
||||
* through the IME ([RemoteTerminalInputConnection]).
|
||||
*/
|
||||
/**
|
||||
* Consume the long press, which suppresses stock text selection.
|
||||
*
|
||||
* **RECORDED DEVIATION from plan §6.5** ("Selection/copy: Termux TextSelectionCursorController +
|
||||
* Android ActionMode → ClipboardManager"), and a KNOWN FEATURE GAP — not an oversight, and not the
|
||||
* cheaper of two equal options. An adversarial review asked for stock selection to be RESTORED after
|
||||
* an earlier revision made it unreachable. Disassembling the pinned artifact shows restoring it would
|
||||
* only relocate the crash rather than remove it: the ActionMode's own handlers dereference the absent
|
||||
* session. `TextSelectionCursorController$1.onActionItemClicked` offsets 89-100 are
|
||||
* `mTermSession.onCopyTextToClipboard(...)` and offsets 126-136 are
|
||||
* `mTermSession.onPasteTextFromClipboard()`. So `startTextSelectionMode` being reachable buys a
|
||||
* selection UI whose **Copy button is an NPE** — strictly worse than no selection, because the user
|
||||
* loses their work to a crash instead of finding the affordance absent.
|
||||
*
|
||||
* `mTermSession` cannot be supplied: `TerminalSession` is `final`, its constructor builds a
|
||||
* process-bound `MainThreadHandler`, and this app deliberately has no local subprocess (plan §6.1).
|
||||
*
|
||||
* The real fix is a first-party selection layer that reads the emulator's `TerminalBuffer` and talks
|
||||
* to `ClipboardManager` directly, bypassing `TextSelectionCursorController` entirely. That is a
|
||||
* feature, not a crash fix, so it is tracked in DEVICE_QA_CHECKLIST rather than smuggled in here.
|
||||
* Until then: copy-out is unavailable, and paste-in still works through the IME.
|
||||
*/
|
||||
override fun onLongPress(event: MotionEvent?): Boolean = true
|
||||
override fun onLongPress(event: MotionEvent?): Boolean {
|
||||
event?.let { sink.onLongPressAt(it.x, it.y) }
|
||||
return true
|
||||
}
|
||||
|
||||
override fun copyModeChanged(copyMode: Boolean) = Unit
|
||||
|
||||
|
||||
@@ -0,0 +1,319 @@
|
||||
package wang.yaojia.webterm.terminalview
|
||||
|
||||
import com.termux.terminal.TerminalEmulator
|
||||
import io.mockk.unmockkAll
|
||||
import org.junit.jupiter.api.AfterEach
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertFalse
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Test
|
||||
import wang.yaojia.webterm.terminalview.StockTerminalViewFixture.CELL_HEIGHT_PX
|
||||
import wang.yaojia.webterm.terminalview.StockTerminalViewFixture.CELL_WIDTH_PX
|
||||
import wang.yaojia.webterm.terminalview.StockTerminalViewFixture.ENTER_ALTERNATE_BUFFER
|
||||
import wang.yaojia.webterm.terminalview.StockTerminalViewFixture.down
|
||||
import wang.yaojia.webterm.terminalview.StockTerminalViewFixture.feed
|
||||
import wang.yaojia.webterm.terminalview.StockTerminalViewFixture.move
|
||||
import wang.yaojia.webterm.terminalview.StockTerminalViewFixture.up
|
||||
|
||||
/**
|
||||
* Copy-out, driven through the real host frame and the real `TerminalViewClient` — the first-party
|
||||
* replacement for the stock selection UI that cannot be made reachable (its Copy button dereferences the
|
||||
* absent `TerminalSession`; see [TerminalSelection]'s doc and [WebTermTerminalViewClient.onLongPress]).
|
||||
*
|
||||
* The gesture entry point is called directly because Termux's `GestureAndScaleRecognizer` is framework code,
|
||||
* stripped by the mockable `android.jar`. Everything after it — the client's decision, the pixel→cell
|
||||
* mapping, the touch routing, the state machine, the buffer read — is the real compiled path.
|
||||
*/
|
||||
class RemoteTerminalSelectionTest {
|
||||
|
||||
private lateinit var host: RemoteTerminalHostView
|
||||
private lateinit var client: WebTermTerminalViewClient
|
||||
private lateinit var clipboard: RecordingClipboard
|
||||
private lateinit var sink: SelectionRoutingSink
|
||||
|
||||
/** The same forwarding [RemoteTerminalView] installs: long press and tap belong to the frame. */
|
||||
private class SelectionRoutingSink(private val host: () -> RemoteTerminalHostView) : TerminalInputSink {
|
||||
val sent = mutableListOf<String>()
|
||||
var keyboardRequests = 0
|
||||
|
||||
override fun appConsumesKey(keyCode: Int, event: android.view.KeyEvent): Boolean = false
|
||||
override fun cursorModes(): TerminalCursorModes? = null
|
||||
override fun sendInput(data: String) {
|
||||
sent += data
|
||||
}
|
||||
|
||||
override fun onTapped() {
|
||||
if (host().isSelectingText) host().clearSelection() else keyboardRequests++
|
||||
}
|
||||
|
||||
override fun onLongPressAt(xPx: Float, yPx: Float) {
|
||||
host().beginSelectionAt(xPx, yPx)
|
||||
}
|
||||
|
||||
override fun onViewSize(widthPx: Int, heightPx: Int) = Unit
|
||||
}
|
||||
|
||||
private class RecordingClipboard : TerminalClipboard {
|
||||
val puts = mutableListOf<String>()
|
||||
|
||||
override fun put(text: String): Boolean {
|
||||
puts += text
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
@BeforeEach
|
||||
fun setUp() {
|
||||
StockTerminalViewFixture.mockCellMetrics()
|
||||
clipboard = RecordingClipboard()
|
||||
host = RemoteTerminalHostView(StockTerminalViewFixture.context(), clipboard)
|
||||
sink = SelectionRoutingSink { host }
|
||||
host.sink = sink
|
||||
client = WebTermTerminalViewClient(sink)
|
||||
host.installClient(client)
|
||||
}
|
||||
|
||||
@AfterEach fun tearDown() = unmockkAll()
|
||||
|
||||
private fun bind(vararg output: String): TerminalEmulator {
|
||||
val emulator = StockTerminalViewFixture.emulator()
|
||||
output.forEach { emulator.feed(it) }
|
||||
host.bindEmulator(emulator)
|
||||
return emulator
|
||||
}
|
||||
|
||||
/** Pixel centre of a cell, using the fixture's pinned cell box. */
|
||||
private fun x(column: Int) = (column + 0.5f) * CELL_WIDTH_PX
|
||||
private fun y(row: Int) = (row + 0.5f) * CELL_HEIGHT_PX
|
||||
|
||||
private fun longPress(column: Int, row: Int) {
|
||||
assertTrue(
|
||||
client.onLongPress(down(y = y(row), x = x(column))),
|
||||
"the long press must stay CONSUMED so stock text selection can never arm",
|
||||
)
|
||||
}
|
||||
|
||||
// ── The constraint: the stock selection mode stays unreachable ───────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `a long press starts a first-party selection and never arms the stock ActionMode`() {
|
||||
bind("cat /etc/hosts")
|
||||
|
||||
longPress(column = 6, row = 0)
|
||||
|
||||
assertTrue(host.isSelectingText, "the first-party layer owns the selection")
|
||||
assertFalse(
|
||||
host.stockView.isSelectingText,
|
||||
"TextSelectionCursorController's Copy is mTermSession.onCopyTextToClipboard — an NPE",
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a long press before an emulator is bound is simply ignored`() {
|
||||
longPress(column = 3, row = 0)
|
||||
|
||||
assertFalse(host.isSelectingText)
|
||||
}
|
||||
|
||||
// ── Selecting, extending, copying ───────────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `the long press snaps to the word under the finger and copies exactly that`() {
|
||||
bind("cat /etc/hosts")
|
||||
longPress(column = 6, row = 0)
|
||||
|
||||
assertEquals(TerminalCopyResult.COPIED, host.copySelection())
|
||||
|
||||
assertEquals(listOf("/etc/hosts"), clipboard.puts)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `dragging after the long press extends the selection instead of scrolling`() {
|
||||
// The same vertical drag with no selection is a transcript scroll / arrow-key emit; with a live
|
||||
// selection it must extend, or the feature is unusable on a touch screen.
|
||||
bind("alpha\r\nbravo\r\ncharlie\r\n")
|
||||
longPress(column = 0, row = 0)
|
||||
|
||||
assertTrue(host.onInterceptTouchEvent(move(y = y(2), x = x(2))), "the extend drag is claimed")
|
||||
host.onTouchEvent(up(y = y(2), x = x(2)))
|
||||
|
||||
assertEquals(TerminalCopyResult.COPIED, host.copySelection())
|
||||
assertEquals(listOf("alpha\nbravo\ncha"), clipboard.puts)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an extend drag inside an alternate-screen TUI sends no arrow keys`() {
|
||||
// Without the selection branch this drag would go to TerminalScrollGesture and type into the TUI.
|
||||
bind(ENTER_ALTERNATE_BUFFER, "vim buffer")
|
||||
longPress(column = 0, row = 0)
|
||||
|
||||
host.onInterceptTouchEvent(move(y = y(3), x = x(5)))
|
||||
host.onTouchEvent(up(y = y(3), x = x(5)))
|
||||
|
||||
assertTrue(sink.sent.isEmpty(), "extending a selection must not reach the shell")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a drag backwards past the anchor still selects forwards`() {
|
||||
bind("alpha\r\nbravo\r\ncharlie\r\n")
|
||||
longPress(column = 2, row = 2) // snaps to "charlie", columns 0-6
|
||||
|
||||
host.onInterceptTouchEvent(move(y = y(0), x = x(2)))
|
||||
host.onTouchEvent(up(y = y(0), x = x(2)))
|
||||
|
||||
assertEquals(TerminalCopyResult.COPIED, host.copySelection())
|
||||
assertEquals(listOf("pha\nbravo\ncharlie"), clipboard.puts)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a second finger during an extend is not read as an extend`() {
|
||||
bind("alpha\r\nbravo\r\ncharlie\r\n")
|
||||
longPress(column = 0, row = 0)
|
||||
val before = host.copySelection().also { clipboard.puts.clear() }
|
||||
|
||||
host.onInterceptTouchEvent(move(y = y(2), x = x(2), pointers = 2))
|
||||
|
||||
assertEquals(TerminalCopyResult.COPIED, before)
|
||||
assertEquals(TerminalCopyResult.COPIED, host.copySelection())
|
||||
assertEquals(listOf("alpha"), clipboard.puts, "the pinch left the selection alone")
|
||||
}
|
||||
|
||||
// ── Dismissal ───────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `a tap dismisses the selection instead of popping the keyboard`() {
|
||||
bind("cat /etc/hosts")
|
||||
longPress(column = 1, row = 0)
|
||||
|
||||
client.onSingleTapUp(down(y = y(0), x = x(0)))
|
||||
|
||||
assertFalse(host.isSelectingText)
|
||||
assertEquals(0, sink.keyboardRequests, "the tap is spent on the dismissal")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a tap with no selection still raises the keyboard`() {
|
||||
bind("cat /etc/hosts")
|
||||
|
||||
client.onSingleTapUp(down(y = y(0), x = x(0)))
|
||||
|
||||
assertEquals(1, sink.keyboardRequests)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `after a dismissal a drag scrolls again`() {
|
||||
val emulator = bind()
|
||||
repeat(SCROLLED_OFF_LINES) { emulator.feed("line\r\n") }
|
||||
longPress(column = 0, row = 0)
|
||||
client.onSingleTapUp(down(y = y(0), x = x(0)))
|
||||
|
||||
assertFalse(host.onInterceptTouchEvent(down(START_Y)))
|
||||
assertTrue(host.onInterceptTouchEvent(move(START_Y + CLAIM_TRAVEL_PX)))
|
||||
host.onTouchEvent(move(START_Y + CLAIM_TRAVEL_PX + 2 * CELL_HEIGHT_PX))
|
||||
|
||||
assertEquals(-2, host.stockView.topRow)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `binding a new emulator drops a stale selection`() {
|
||||
bind("cat /etc/hosts")
|
||||
longPress(column = 1, row = 0)
|
||||
|
||||
bind("a different session")
|
||||
|
||||
assertFalse(host.isSelectingText, "the old span points into a transcript that no longer exists")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a layout change drops the selection`() {
|
||||
bind("cat /etc/hosts")
|
||||
longPress(column = 1, row = 0)
|
||||
|
||||
// A new size means a regrid and a reflow: the cells the span names are about to hold other text.
|
||||
host.onSizeChanged(VIEW_WIDTH_PX, VIEW_HEIGHT_PX, 0, 0)
|
||||
|
||||
assertFalse(host.isSelectingText)
|
||||
}
|
||||
|
||||
// ── Live output under a selection ───────────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `new output shifts the selection and pins the viewport so the highlight stays on its text`() {
|
||||
val emulator = bind("target\r\n")
|
||||
// Overfills the screen on purpose: those rows scroll BEFORE the press, so the emulator's scroll
|
||||
// counter is already non-zero when the selection is made. Counting them would shift the span by
|
||||
// rows it never saw — which is why beginSelectionAt zeroes the counter.
|
||||
repeat(SCREEN_ROWS) { emulator.feed("filler\r\n") }
|
||||
longPress(column = 1, row = 0)
|
||||
val selectedBefore = host.selectedText()
|
||||
|
||||
repeat(SCROLL_ROWS) { emulator.feed("more\r\n") }
|
||||
host.requestScreenUpdate()
|
||||
|
||||
assertEquals(selectedBefore, host.selectedText(), "the same TEXT, at a new row index")
|
||||
assertEquals(
|
||||
-SCROLL_ROWS,
|
||||
host.stockView.topRow,
|
||||
"stock onScreenUpdated jumps to the newest row unless we pin it back",
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `output with no selection still jumps to the newest row`() {
|
||||
val emulator = bind()
|
||||
repeat(SCROLLED_OFF_LINES) { emulator.feed("line\r\n") }
|
||||
host.stockView.topRow = -5
|
||||
|
||||
host.requestScreenUpdate()
|
||||
|
||||
assertEquals(0, host.stockView.topRow, "the pin is for selections only")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a selection scrolled out of the transcript is dropped`() {
|
||||
val emulator = StockTerminalViewFixture.emulator(transcriptRows = SMALL_TRANSCRIPT)
|
||||
host.bindEmulator(emulator)
|
||||
emulator.feed("target\r\n")
|
||||
longPress(column = 1, row = 0)
|
||||
|
||||
repeat(SMALL_TRANSCRIPT + SCREEN_ROWS + 1) { emulator.feed("flood\r\n") }
|
||||
host.requestScreenUpdate()
|
||||
|
||||
assertFalse(host.isSelectingText)
|
||||
}
|
||||
|
||||
// ── Highlight geometry ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `the highlight covers the selected cells`() {
|
||||
bind("cat /etc/hosts")
|
||||
longPress(column = 1, row = 0)
|
||||
|
||||
val rects = host.highlightRects()
|
||||
|
||||
assertEquals(1, rects.size)
|
||||
assertEquals(0f, rects[0].left)
|
||||
assertEquals(3 * CELL_WIDTH_PX, rects[0].right, "'cat' is columns 0-2")
|
||||
assertEquals(0f, rects[0].top)
|
||||
assertEquals(CELL_HEIGHT_PX.toFloat(), rects[0].bottom)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `there is no highlight without a selection`() {
|
||||
bind("cat /etc/hosts")
|
||||
|
||||
assertTrue(host.highlightRects().isEmpty())
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val SCREEN_ROWS: Int = 24
|
||||
const val SCROLLED_OFF_LINES: Int = 40
|
||||
const val SCROLL_ROWS: Int = 3
|
||||
const val SMALL_TRANSCRIPT: Int = 100
|
||||
const val START_Y: Float = 400f
|
||||
const val CLAIM_TRAVEL_PX: Float = 100f
|
||||
const val VIEW_WIDTH_PX: Int = 720
|
||||
const val VIEW_HEIGHT_PX: Int = 1280
|
||||
}
|
||||
}
|
||||
@@ -120,6 +120,7 @@ internal object StockTerminalViewFixture {
|
||||
internal class RecordingInputSink : TerminalInputSink {
|
||||
val sent = mutableListOf<String>()
|
||||
var taps = 0
|
||||
val longPresses = mutableListOf<Pair<Float, Float>>()
|
||||
val sizes = mutableListOf<Pair<Int, Int>>()
|
||||
|
||||
override fun appConsumesKey(keyCode: Int, event: android.view.KeyEvent): Boolean = false
|
||||
@@ -132,6 +133,10 @@ internal class RecordingInputSink : TerminalInputSink {
|
||||
taps++
|
||||
}
|
||||
|
||||
override fun onLongPressAt(xPx: Float, yPx: Float) {
|
||||
longPresses += xPx to yPx
|
||||
}
|
||||
|
||||
override fun onViewSize(widthPx: Int, heightPx: Int) {
|
||||
sizes += widthPx to heightPx
|
||||
}
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
package wang.yaojia.webterm.terminalview
|
||||
|
||||
import com.termux.terminal.TerminalEmulator
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertFalse
|
||||
import org.junit.jupiter.api.Assertions.assertNull
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
import wang.yaojia.webterm.terminalview.StockTerminalViewFixture.feed
|
||||
|
||||
/**
|
||||
* Text extraction, against the **real** Termux `TerminalBuffer` driven by real bytes.
|
||||
*
|
||||
* This is the part of the stock selection stack that is session-free and therefore reusable:
|
||||
* `TerminalBuffer.getSelectedText` reads rows and nothing else — no `TerminalSession`, no view. Re-deriving
|
||||
* its wide-cell walk (`TerminalRow.findStartOfColumn` + `WcWidth`), its per-line trailing-blank trim and its
|
||||
* soft-wrap joining in Kotlin would be a second implementation of the same subtle logic, guaranteed to
|
||||
* drift from what the renderer draws. So the extraction is delegated and PINNED here instead: these tests
|
||||
* assert the behaviour this module depends on, so a Termux bump that changes it fails loudly.
|
||||
*/
|
||||
class TerminalSelectionBufferTest {
|
||||
|
||||
private fun bufferOf(emulator: TerminalEmulator?): TerminalSelectionBuffer =
|
||||
TerminalEmulatorSelectionBuffer { emulator }
|
||||
|
||||
private fun emulatorWith(vararg output: String): TerminalEmulator =
|
||||
StockTerminalViewFixture.emulator().also { emulator -> output.forEach { emulator.feed(it) } }
|
||||
|
||||
private fun TerminalSelectionBuffer.text(fromColumn: Int, fromRow: Int, toColumn: Int, toRow: Int) =
|
||||
textIn(TerminalSelectionSpan.spanning(TerminalCell(fromColumn, fromRow), TerminalCell(toColumn, toRow)))
|
||||
|
||||
// ── Bounds ───────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `bounds come from the live emulator and its transcript`() {
|
||||
val emulator = emulatorWith()
|
||||
repeat(SCROLLED_OFF_LINES) { emulator.feed("line\r\n") }
|
||||
|
||||
val bounds = bufferOf(emulator).bounds()
|
||||
|
||||
assertEquals(TerminalGridBounds(COLUMNS, SCREEN_ROWS, emulator.screen.activeTranscriptRows), bounds)
|
||||
assertTrue(bounds!!.transcriptRows > 0, "40 lines on a 24-row screen must leave a transcript")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `there are no bounds and no text before an emulator is bound`() {
|
||||
val buffer = bufferOf(null)
|
||||
|
||||
assertNull(buffer.bounds())
|
||||
assertEquals("", buffer.text(0, 0, 5, 0))
|
||||
}
|
||||
|
||||
// ── Trailing blanks, wide cells, line joining ────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `a row's trailing blanks are trimmed rather than copied as spaces`() {
|
||||
val buffer = bufferOf(emulatorWith("hi"))
|
||||
|
||||
// Selecting the whole row must not yield "hi" followed by 78 spaces.
|
||||
assertEquals("hi", buffer.text(0, 0, COLUMNS - 1, 0))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a wide CJK cell is copied whole from either of its two columns`() {
|
||||
// 你 occupies columns 0-1 and 好 columns 2-3; "world" then starts at column 4.
|
||||
val buffer = bufferOf(emulatorWith("你好world"))
|
||||
|
||||
assertEquals("你", buffer.text(0, 0, 1, 0))
|
||||
assertEquals("你", buffer.text(1, 0, 1, 0), "the right half of a wide cell is still that cell")
|
||||
assertEquals("你好", buffer.text(0, 0, 3, 0))
|
||||
assertEquals("好world", buffer.text(2, 0, 8, 0))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a soft-wrapped line is joined, so a long command copies as one line`() {
|
||||
val buffer = bufferOf(emulatorWith("a".repeat(WRAPPED_LINE_LENGTH)))
|
||||
|
||||
val copied = buffer.text(0, 0, WRAPPED_LINE_LENGTH - COLUMNS - 1, 1)
|
||||
|
||||
assertEquals("a".repeat(WRAPPED_LINE_LENGTH), copied)
|
||||
assertFalse(copied.contains('\n'), "a wrap point is not a line break")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a real line break is preserved`() {
|
||||
val buffer = bufferOf(emulatorWith("one\r\ntwo\r\n"))
|
||||
|
||||
assertEquals("one\ntwo", buffer.text(0, 0, 2, 1))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a multi-row selection takes the first row to the edge and the last to the focus`() {
|
||||
val buffer = bufferOf(emulatorWith("alpha\r\nbravo\r\ncharlie\r\n"))
|
||||
|
||||
assertEquals("pha\nbravo\ncha", buffer.text(2, 0, 2, 2))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `scrollback rows are readable at negative row indices`() {
|
||||
val emulator = emulatorWith()
|
||||
repeat(SCROLLED_OFF_LINES) { index -> emulator.feed("line-$index\r\n") }
|
||||
|
||||
// 40 lines plus the trailing empty one scroll 17 rows off a 24-row screen, so screen row 0 holds
|
||||
// line-17 and transcript row -1 holds the line just above it.
|
||||
assertEquals("line-${SCROLLED_OFF_LINES - SCREEN_ROWS}", bufferOf(emulator).text(0, -1, COLUMNS - 1, -1))
|
||||
}
|
||||
|
||||
// ── The clamp that keeps the two unguarded stock failures unreachable ────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `an off-grid span is clamped instead of throwing out of the stock row walk`() {
|
||||
val buffer = bufferOf(emulatorWith("hello"))
|
||||
|
||||
// Row -9999: TerminalBuffer.externalToInternalRow throws IllegalArgumentException outside
|
||||
// -activeTranscriptRows..screenRows. Column 9999: TerminalRow.findStartOfColumn walks mText with
|
||||
// no bound check for column > mColumns. Both are reachable from a touch outside the grid.
|
||||
// The clamp turns the span into "the whole measured grid", i.e. the one written row plus the blank
|
||||
// rows below it — the point being that it RETURNS rather than throwing.
|
||||
assertEquals("hello", buffer.text(-50, -9999, 9999, 9999).trim())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an unmeasured emulator yields no text`() {
|
||||
// A 0-column emulator cannot be constructed by Termux, so the unmeasured case is "no emulator";
|
||||
// proving the clamp refuses rather than divides is still worth pinning.
|
||||
assertNull(TerminalGridBounds(columns = 0, screenRows = 0, transcriptRows = 0).takeIf { it.isMeasured })
|
||||
}
|
||||
|
||||
// ── Long-press word snap ────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `a long press snaps to the run of non-blank cells around it`() {
|
||||
val buffer = bufferOf(emulatorWith("cat /etc/hosts"))
|
||||
|
||||
val selection = TerminalSelectionWords.runAt(TerminalCell(column = 6, row = 0), buffer)
|
||||
|
||||
assertEquals(TerminalCell(4, 0), selection?.span?.start)
|
||||
assertEquals(TerminalCell(13, 0), selection?.span?.end)
|
||||
assertEquals("/etc/hosts", buffer.textIn(selection!!.span))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the snap stops at the start of the row`() {
|
||||
val buffer = bufferOf(emulatorWith("cat /etc/hosts"))
|
||||
|
||||
val selection = TerminalSelectionWords.runAt(TerminalCell(column = 1, row = 0), buffer)
|
||||
|
||||
assertEquals("cat", buffer.textIn(selection!!.span))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the snap spans wide cells`() {
|
||||
val buffer = bufferOf(emulatorWith("你好 world"))
|
||||
|
||||
// Column 1 is the right half of 你; the run is 你好, i.e. columns 0-3.
|
||||
val selection = TerminalSelectionWords.runAt(TerminalCell(column = 1, row = 0), buffer)
|
||||
|
||||
assertEquals("你好", buffer.textIn(selection!!.span))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a long press on blank space selects just that cell`() {
|
||||
val buffer = bufferOf(emulatorWith("cat /etc/hosts"))
|
||||
|
||||
val selection = TerminalSelectionWords.runAt(TerminalCell(column = 3, row = 0), buffer)
|
||||
|
||||
assertTrue(selection!!.span.isSingleCell, "a blank cell must not swallow the words either side")
|
||||
assertEquals(TerminalCell(3, 0), selection.span.start)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `there is nothing to snap to before an emulator is bound`() {
|
||||
assertNull(TerminalSelectionWords.runAt(TerminalCell(0, 0), bufferOf(null)))
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val COLUMNS: Int = 80
|
||||
const val SCREEN_ROWS: Int = 24
|
||||
|
||||
/** Enough output to push rows off the top so there is a transcript to read. */
|
||||
const val SCROLLED_OFF_LINES: Int = 40
|
||||
|
||||
/** Longer than one row, so the emulator sets the row's wrap flag. */
|
||||
const val WRAPPED_LINE_LENGTH: Int = 100
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
package wang.yaojia.webterm.terminalview
|
||||
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertFalse
|
||||
import org.junit.jupiter.api.Assertions.assertNull
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
/**
|
||||
* The selection state machine, with the buffer and the clipboard faked so the DECISIONS are asserted
|
||||
* rather than the framework: when a selection exists, what a drag does to it, what a content scroll does
|
||||
* to it, and — the §8 rule — that terminal text reaches the clipboard on an explicit copy and at no other
|
||||
* moment.
|
||||
*/
|
||||
class TerminalSelectionControllerTest {
|
||||
|
||||
/** A one-row grid of text, enough to drive the state machine; real extraction is pinned elsewhere. */
|
||||
private class FakeBuffer(
|
||||
private val row: String = "hello world",
|
||||
private val bounds: TerminalGridBounds? = TerminalGridBounds(COLUMNS, SCREEN_ROWS, TRANSCRIPT_ROWS),
|
||||
) : TerminalSelectionBuffer {
|
||||
var reads = 0
|
||||
|
||||
override fun bounds(): TerminalGridBounds? = bounds
|
||||
|
||||
override fun textIn(span: TerminalSelectionSpan): String {
|
||||
reads++
|
||||
val bounds = bounds ?: return ""
|
||||
if (span.start.row != span.end.row) return row
|
||||
val columns = span.columnsIn(span.start.row, bounds.lastColumn) ?: return ""
|
||||
return row.substring(
|
||||
columns.first.coerceIn(0, row.length),
|
||||
(columns.last + 1).coerceIn(0, row.length),
|
||||
).trimEnd()
|
||||
}
|
||||
}
|
||||
|
||||
private class FakeClipboard(private val accepts: Boolean = true) : TerminalClipboard {
|
||||
val puts = mutableListOf<String>()
|
||||
|
||||
override fun put(text: String): Boolean {
|
||||
puts += text
|
||||
return accepts
|
||||
}
|
||||
}
|
||||
|
||||
private class Recorder {
|
||||
val changes = mutableListOf<Boolean>()
|
||||
}
|
||||
|
||||
private fun controller(
|
||||
buffer: TerminalSelectionBuffer = FakeBuffer(),
|
||||
clipboard: TerminalClipboard = FakeClipboard(),
|
||||
recorder: Recorder = Recorder(),
|
||||
) = TerminalSelectionController(buffer, clipboard) { recorder.changes += it }
|
||||
|
||||
// ── Begin / extend ───────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `a long press starts a selection snapped to the word under it`() {
|
||||
val recorder = Recorder()
|
||||
val controller = controller(recorder = recorder)
|
||||
|
||||
assertTrue(controller.beginAt(TerminalCell(column = 2, row = 0)))
|
||||
|
||||
assertTrue(controller.isActive)
|
||||
assertEquals(TerminalCell(0, 0), controller.selection?.span?.start)
|
||||
assertEquals(TerminalCell(4, 0), controller.selection?.span?.end, "'hello' is columns 0-4")
|
||||
assertEquals(listOf(true), recorder.changes)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a drag moves the focus and leaves the anchor where the press landed`() {
|
||||
val controller = controller()
|
||||
controller.beginAt(TerminalCell(2, 0))
|
||||
|
||||
controller.extendTo(TerminalCell(column = 8, row = 0))
|
||||
|
||||
assertEquals(TerminalCell(0, 0), controller.selection?.span?.start, "the anchor survives the drag")
|
||||
assertEquals(TerminalCell(8, 0), controller.selection?.span?.end)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `dragging back past the anchor flips the span and keeps the snapped word`() {
|
||||
val controller = controller()
|
||||
controller.beginAt(TerminalCell(8, 0)) // snaps to "world", columns 6-10
|
||||
controller.extendTo(TerminalCell(column = 1, row = 0))
|
||||
|
||||
val span = controller.selection?.span
|
||||
|
||||
assertEquals(TerminalCell(1, 0), span?.start)
|
||||
assertEquals(
|
||||
TerminalCell(10, 0),
|
||||
span?.end,
|
||||
"the whole anchored word stays in — a single-cell anchor would clip 'world' to its 'w'",
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a drag off the grid is clamped, not stored raw`() {
|
||||
val controller = controller()
|
||||
controller.beginAt(TerminalCell(2, 0))
|
||||
|
||||
controller.extendTo(TerminalCell(column = 9_999, row = 9_999))
|
||||
|
||||
assertEquals(TerminalCell(COLUMNS - 1, SCREEN_ROWS - 1), controller.selection?.span?.end)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `nothing can be selected before the grid is measured`() {
|
||||
val controller = controller(buffer = FakeBuffer(bounds = null))
|
||||
|
||||
assertFalse(controller.beginAt(TerminalCell(2, 0)))
|
||||
assertFalse(controller.isActive)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `extending without a selection does nothing`() {
|
||||
val controller = controller()
|
||||
|
||||
controller.extendTo(TerminalCell(4, 0))
|
||||
|
||||
assertFalse(controller.isActive)
|
||||
}
|
||||
|
||||
// ── Copy (the only path to the clipboard) ────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `selecting text puts nothing on the clipboard`() {
|
||||
// Plan §8: terminal content is untrusted and may be a secret. It reaches the clipboard ONLY on an
|
||||
// explicit user copy — never on selection, never on drag, never automatically.
|
||||
val clipboard = FakeClipboard()
|
||||
val controller = controller(clipboard = clipboard)
|
||||
|
||||
controller.beginAt(TerminalCell(2, 0))
|
||||
controller.extendTo(TerminalCell(8, 0))
|
||||
controller.clear()
|
||||
|
||||
assertTrue(clipboard.puts.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an explicit copy puts exactly the selected text on the clipboard once`() {
|
||||
val clipboard = FakeClipboard()
|
||||
val controller = controller(clipboard = clipboard)
|
||||
controller.beginAt(TerminalCell(2, 0))
|
||||
|
||||
assertEquals(TerminalCopyResult.COPIED, controller.copy())
|
||||
|
||||
assertEquals(listOf("hello"), clipboard.puts)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `copying with no selection is refused and touches nothing`() {
|
||||
val clipboard = FakeClipboard()
|
||||
val controller = controller(clipboard = clipboard)
|
||||
|
||||
assertEquals(TerminalCopyResult.NOTHING_SELECTED, controller.copy())
|
||||
assertTrue(clipboard.puts.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a blank selection is never written to the clipboard`() {
|
||||
val clipboard = FakeClipboard()
|
||||
val controller = controller(buffer = FakeBuffer(row = " "), clipboard = clipboard)
|
||||
controller.beginAt(TerminalCell(1, 0))
|
||||
|
||||
assertEquals(TerminalCopyResult.NOTHING_TO_COPY, controller.copy())
|
||||
assertTrue(clipboard.puts.isEmpty(), "clearing the user's clipboard with an empty string is data loss")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a refused clipboard write is reported instead of being reported as success`() {
|
||||
val controller = controller(clipboard = FakeClipboard(accepts = false))
|
||||
controller.beginAt(TerminalCell(2, 0))
|
||||
|
||||
assertEquals(TerminalCopyResult.CLIPBOARD_UNAVAILABLE, controller.copy())
|
||||
}
|
||||
|
||||
// ── Clear ───────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `clearing drops the selection and reports the change once`() {
|
||||
val recorder = Recorder()
|
||||
val controller = controller(recorder = recorder)
|
||||
controller.beginAt(TerminalCell(2, 0))
|
||||
|
||||
controller.clear()
|
||||
|
||||
assertFalse(controller.isActive)
|
||||
assertNull(controller.selection)
|
||||
assertEquals(listOf(true, false), recorder.changes)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `clearing an empty selection reports nothing`() {
|
||||
// Load-bearing: the copy affordance is dismissed by clear(), and its own dismissal calls clear()
|
||||
// back. Without this guard that is an infinite loop.
|
||||
val recorder = Recorder()
|
||||
val controller = controller(recorder = recorder)
|
||||
|
||||
controller.clear()
|
||||
controller.clear()
|
||||
|
||||
assertTrue(recorder.changes.isEmpty())
|
||||
}
|
||||
|
||||
// ── Content scrolling under a live selection ────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `new output shifts the selection so it keeps pointing at the same text`() {
|
||||
val controller = controller()
|
||||
controller.beginAt(TerminalCell(2, 0))
|
||||
|
||||
controller.onContentScrolled(scrolledRows = 3)
|
||||
|
||||
assertEquals(TerminalCell(0, -3), controller.selection?.span?.start)
|
||||
assertEquals(TerminalCell(4, -3), controller.selection?.span?.end)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a selection scrolled out of the retained transcript is dropped, not left pointing at nothing`() {
|
||||
val recorder = Recorder()
|
||||
val controller = controller(recorder = recorder)
|
||||
controller.beginAt(TerminalCell(2, 0))
|
||||
|
||||
controller.onContentScrolled(scrolledRows = TRANSCRIPT_ROWS + 1)
|
||||
|
||||
assertFalse(controller.isActive)
|
||||
assertEquals(listOf(true, false), recorder.changes)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a scroll of zero rows is not a change`() {
|
||||
val controller = controller()
|
||||
controller.beginAt(TerminalCell(2, 0))
|
||||
val before = controller.selection
|
||||
|
||||
controller.onContentScrolled(scrolledRows = 0)
|
||||
|
||||
assertEquals(before, controller.selection)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val COLUMNS: Int = 80
|
||||
const val SCREEN_ROWS: Int = 24
|
||||
const val TRANSCRIPT_ROWS: Int = 100
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package wang.yaojia.webterm.terminalview
|
||||
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertNull
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
/**
|
||||
* Pixels ↔ cells, and the highlight bands.
|
||||
*
|
||||
* The mapping is deliberately the INVERSE of the stock accessors this module already reads
|
||||
* (`TerminalView.getPointX(col)` = `round(col * fontWidth)`, `getPointY(row)` = `(row - topRow) *
|
||||
* lineSpacing`), so a tapped cell and its highlight band are the same cell. Stock's own
|
||||
* `getColumnAndRow` biases the row by `mFontLineSpacingAndAscent` instead, which puts its hit test
|
||||
* ~20% of a row above the glyph it is drawing — not something to reproduce.
|
||||
*/
|
||||
class TerminalSelectionGeometryTest {
|
||||
|
||||
@Test
|
||||
fun `a touch resolves to the cell under it`() {
|
||||
val cell = TerminalSelectionGeometry.cellAt(
|
||||
xPx = 2.5f * CELL_WIDTH_PX,
|
||||
yPx = 3.5f * CELL_HEIGHT_PX,
|
||||
metrics = METRICS,
|
||||
topRow = 0,
|
||||
)
|
||||
|
||||
assertEquals(TerminalCell(column = 2, row = 3), cell)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a touch while scrolled back resolves into the transcript`() {
|
||||
val cell = TerminalSelectionGeometry.cellAt(
|
||||
xPx = 0f,
|
||||
yPx = 0.5f * CELL_HEIGHT_PX,
|
||||
metrics = METRICS,
|
||||
topRow = -7,
|
||||
)
|
||||
|
||||
assertEquals(TerminalCell(column = 0, row = -7), cell, "row 0 of the viewport is topRow")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an unmeasured cell box resolves to no cell instead of dividing by zero`() {
|
||||
assertNull(
|
||||
TerminalSelectionGeometry.cellAt(10f, 10f, TerminalCellMetrics(0f, 0), topRow = 0),
|
||||
)
|
||||
assertNull(
|
||||
TerminalSelectionGeometry.cellAt(10f, 10f, TerminalCellMetrics(CELL_WIDTH_PX, 0), topRow = 0),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a one-row selection is one band, sized to the selected columns`() {
|
||||
val span = TerminalSelectionSpan.spanning(TerminalCell(2, 1), TerminalCell(4, 1))
|
||||
|
||||
val rects = TerminalSelectionGeometry.highlightRects(span, BOUNDS, METRICS, topRow = 0)
|
||||
|
||||
assertEquals(
|
||||
listOf(
|
||||
TerminalSelectionRect(
|
||||
left = 2 * CELL_WIDTH_PX,
|
||||
top = 1f * CELL_HEIGHT_PX,
|
||||
right = 5 * CELL_WIDTH_PX,
|
||||
bottom = 2f * CELL_HEIGHT_PX,
|
||||
),
|
||||
),
|
||||
rects,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a multi-row selection bands the first row to the right edge and the last from the left`() {
|
||||
val span = TerminalSelectionSpan.spanning(TerminalCell(3, 0), TerminalCell(1, 2))
|
||||
|
||||
val rects = TerminalSelectionGeometry.highlightRects(span, BOUNDS, METRICS, topRow = 0)
|
||||
|
||||
assertEquals(3, rects.size)
|
||||
assertEquals(3 * CELL_WIDTH_PX, rects[0].left)
|
||||
assertEquals(COLUMNS * CELL_WIDTH_PX, rects[0].right, "the first row runs to the grid edge")
|
||||
assertEquals(0f, rects[1].left)
|
||||
assertEquals(COLUMNS * CELL_WIDTH_PX, rects[1].right, "a middle row is fully highlighted")
|
||||
assertEquals(0f, rects[2].left)
|
||||
assertEquals(2 * CELL_WIDTH_PX, rects[2].right)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `rows scrolled out of the viewport produce no bands`() {
|
||||
// A selection made in scrollback, then scrolled away: nothing to paint, and nothing that would
|
||||
// paint at a negative y over the live screen.
|
||||
val span = TerminalSelectionSpan.spanning(TerminalCell(0, -40), TerminalCell(5, -38))
|
||||
|
||||
val rects = TerminalSelectionGeometry.highlightRects(span, BOUNDS, METRICS, topRow = 0)
|
||||
|
||||
assertTrue(rects.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a selection straddling the top of the viewport is clipped to the visible rows`() {
|
||||
val span = TerminalSelectionSpan.spanning(TerminalCell(0, -3), TerminalCell(5, 1))
|
||||
|
||||
val rects = TerminalSelectionGeometry.highlightRects(span, BOUNDS, METRICS, topRow = -1)
|
||||
|
||||
assertEquals(3, rects.size, "rows -1, 0 and 1 are visible; -3 and -2 are above the viewport")
|
||||
assertEquals(0f, rects[0].top, "the first visible band starts at the top of the view")
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val CELL_WIDTH_PX: Float = 12f
|
||||
const val CELL_HEIGHT_PX: Int = 24
|
||||
const val COLUMNS: Int = 80
|
||||
const val SCREEN_ROWS: Int = 24
|
||||
|
||||
val METRICS = TerminalCellMetrics(CELL_WIDTH_PX, CELL_HEIGHT_PX)
|
||||
val BOUNDS = TerminalGridBounds(COLUMNS, SCREEN_ROWS, transcriptRows = 100)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
package wang.yaojia.webterm.terminalview
|
||||
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertNull
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
/**
|
||||
* The selection model on its own: cell coordinates, normalisation, clamping and the per-row column
|
||||
* ranges. No view, no emulator, no android — this is the part that has to be right before any pixel or
|
||||
* any clipboard write can be.
|
||||
*/
|
||||
class TerminalSelectionModelTest {
|
||||
|
||||
// ── Normalisation ────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `a backwards drag is the same span as the forwards drag`() {
|
||||
val forwards = selection(cell(2, 1), cell(7, 4))
|
||||
val backwards = selection(cell(7, 4), cell(2, 1))
|
||||
|
||||
assertEquals(forwards.span, backwards.span)
|
||||
assertEquals(cell(2, 1), forwards.span.start)
|
||||
assertEquals(cell(7, 4), forwards.span.end)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a backwards drag inside one row swaps the columns only`() {
|
||||
val span = selection(cell(9, 3), cell(4, 3)).span
|
||||
|
||||
assertEquals(cell(4, 3), span.start)
|
||||
assertEquals(cell(9, 3), span.end)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ordering is row-major, so a lower row wins even with a bigger column`() {
|
||||
// Dragging up-and-right must not be read as a forwards selection: the row decides.
|
||||
val span = selection(cell(0, 5), cell(70, 2)).span
|
||||
|
||||
assertEquals(cell(70, 2), span.start)
|
||||
assertEquals(cell(0, 5), span.end)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a one-cell selection is a one-cell span`() {
|
||||
val span = selection(cell(5, 5), cell(5, 5)).span
|
||||
|
||||
assertEquals(span.start, span.end)
|
||||
assertTrue(span.isSingleCell)
|
||||
}
|
||||
|
||||
// ── Per-row column ranges (what both the highlight and the extraction use) ───────────────────────
|
||||
|
||||
@Test
|
||||
fun `the first row runs to the last column and the last row starts at column zero`() {
|
||||
val span = selection(cell(3, 0), cell(6, 2)).span
|
||||
|
||||
assertEquals(3..LAST_COLUMN, span.columnsIn(0, LAST_COLUMN))
|
||||
assertEquals(0..LAST_COLUMN, span.columnsIn(1, LAST_COLUMN), "a middle row is fully selected")
|
||||
assertEquals(0..6, span.columnsIn(2, LAST_COLUMN))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a single-row span uses both of its columns`() {
|
||||
val span = selection(cell(3, 4), cell(6, 4)).span
|
||||
|
||||
assertEquals(3..6, span.columnsIn(4, LAST_COLUMN))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `rows outside the span have no column range`() {
|
||||
val span = selection(cell(3, 2), cell(6, 4)).span
|
||||
|
||||
assertNull(span.columnsIn(1, LAST_COLUMN))
|
||||
assertNull(span.columnsIn(5, LAST_COLUMN))
|
||||
}
|
||||
|
||||
// ── Clamping (the reason an off-grid touch cannot crash the buffer read) ─────────────────────────
|
||||
|
||||
@Test
|
||||
fun `an off-grid touch clamps into the grid`() {
|
||||
// TerminalRow.findStartOfColumn walks mText with no bound check for column > mColumns, and
|
||||
// TerminalBuffer.externalToInternalRow THROWS for a row outside the transcript. Clamping here is
|
||||
// what keeps both unreachable.
|
||||
val span = selection(cell(-4, -900), cell(500, 99)).span.clampedTo(BOUNDS)
|
||||
|
||||
assertEquals(cell(0, -TRANSCRIPT_ROWS), span?.start)
|
||||
assertEquals(cell(LAST_COLUMN, SCREEN_ROWS - 1), span?.end)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an in-grid span is left alone by the clamp`() {
|
||||
val span = selection(cell(1, -2), cell(9, 3)).span
|
||||
|
||||
assertEquals(span, span.clampedTo(BOUNDS))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an unmeasured grid clamps to nothing rather than to cell zero`() {
|
||||
val span = selection(cell(1, 1), cell(2, 2)).span
|
||||
|
||||
assertNull(span.clampedTo(TerminalGridBounds(columns = 0, screenRows = 0, transcriptRows = 0)))
|
||||
}
|
||||
|
||||
// ── The anchor is a SPAN, because a long press snaps to a word ───────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `a word-anchored selection starts as exactly that word`() {
|
||||
val selection = TerminalSelection.anchoredOn(cell(4, 1), cell(13, 1))
|
||||
|
||||
assertEquals(cell(4, 1), selection.span.start)
|
||||
assertEquals(cell(13, 1), selection.span.end)
|
||||
assertEquals(cell(13, 1), selection.focus, "the focus starts at the end the drag continues from")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `dragging back past a word-anchored selection keeps the whole word`() {
|
||||
// The editor behaviour: the word you long-pressed stays selected however you drag. Anchoring on a
|
||||
// single cell instead would collapse "charlie" to its first character the moment the finger moved
|
||||
// above it.
|
||||
val dragged = TerminalSelection.anchoredOn(cell(0, 2), cell(6, 2)).withFocus(cell(2, 0))
|
||||
|
||||
assertEquals(cell(2, 0), dragged.span.start)
|
||||
assertEquals(cell(6, 2), dragged.span.end)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a focus inside the anchor changes nothing`() {
|
||||
val anchored = TerminalSelection.anchoredOn(cell(4, 1), cell(13, 1))
|
||||
|
||||
assertEquals(anchored.span, anchored.withFocus(cell(8, 1)).span)
|
||||
}
|
||||
|
||||
// ── Content scrolling ───────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `new output shifts the selection up by the scrolled rows`() {
|
||||
// External row indices move when the screen scrolls: the content the user selected is one row
|
||||
// higher after one new line. Without this the highlight (and the copy) would silently drift.
|
||||
val moved = selection(cell(2, 5), cell(4, 6)).movedBy(-3)
|
||||
|
||||
assertEquals(cell(2, 2), moved.span.start)
|
||||
assertEquals(cell(4, 3), moved.span.end)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val COLUMNS: Int = 80
|
||||
const val SCREEN_ROWS: Int = 24
|
||||
const val TRANSCRIPT_ROWS: Int = 100
|
||||
const val LAST_COLUMN: Int = COLUMNS - 1
|
||||
val BOUNDS = TerminalGridBounds(COLUMNS, SCREEN_ROWS, TRANSCRIPT_ROWS)
|
||||
|
||||
fun cell(column: Int, row: Int) = TerminalCell(column, row)
|
||||
|
||||
/** A press at [from] with the finger now at [to] — the one-cell-anchor case. */
|
||||
fun selection(from: TerminalCell, to: TerminalCell) = TerminalSelection.at(from).withFocus(to)
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
package wang.yaojia.webterm.terminalview
|
||||
|
||||
import android.view.KeyEvent
|
||||
import android.view.MotionEvent
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertFalse
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
@@ -26,6 +29,7 @@ class WebTermTerminalViewClientTest {
|
||||
var taps = 0
|
||||
var sizes = mutableListOf<Pair<Int, Int>>()
|
||||
var keysOfferedToApp = 0
|
||||
val longPresses = mutableListOf<Pair<Float, Float>>()
|
||||
|
||||
fun setCursorApplicationMode(on: Boolean) {
|
||||
modes = TerminalCursorModes(cursorKeysApplication = on, keypadApplication = false)
|
||||
@@ -45,6 +49,10 @@ class WebTermTerminalViewClientTest {
|
||||
taps++
|
||||
}
|
||||
|
||||
override fun onLongPressAt(xPx: Float, yPx: Float) {
|
||||
longPresses += xPx to yPx
|
||||
}
|
||||
|
||||
override fun onViewSize(widthPx: Int, heightPx: Int) {
|
||||
sizes += widthPx to heightPx
|
||||
}
|
||||
@@ -136,6 +144,20 @@ class WebTermTerminalViewClientTest {
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a long press starts the first-party selection at the pressed pixels`() {
|
||||
// Consuming the event is what keeps the stock mode unreachable; forwarding the coordinates is what
|
||||
// turns the consumed press into the replacement feature instead of a discarded gesture.
|
||||
val sink = FakeSink()
|
||||
val event = mockk<MotionEvent>(relaxed = true)
|
||||
every { event.x } returns PRESS_X_PX
|
||||
every { event.y } returns PRESS_Y_PX
|
||||
|
||||
assertTrue(WebTermTerminalViewClient(sink).onLongPress(event))
|
||||
|
||||
assertEquals(listOf(PRESS_X_PX to PRESS_Y_PX), sink.longPresses)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a tap raises the keyboard and pinch-zoom is declined`() {
|
||||
val sink = FakeSink()
|
||||
@@ -170,4 +192,9 @@ class WebTermTerminalViewClientTest {
|
||||
assertFalse(client.readShiftKey())
|
||||
assertFalse(client.readFnKey())
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val PRESS_X_PX: Float = 42f
|
||||
const val PRESS_Y_PX: Float = 96f
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user