feat(app): make mobile terminal selection unmistakable

Use an opaque high-contrast selection treatment so touch selection has an immediate, unambiguous visual state.
This commit is contained in:
Mohamed Boudra
2026-08-04 10:15:56 +02:00
parent 98c9878c57
commit e238fea158
4 changed files with 129 additions and 59 deletions
@@ -56,6 +56,11 @@ export interface TerminalCellStyleResolver {
export const DEFAULT_TERMINAL_THEME = toXtermTheme(darkTheme.colors.terminal);
export const NATIVE_TERMINAL_SELECTION_COLORS = {
background: "#ffff00",
foreground: "#000000",
} as const;
function build256Palette(theme: ITheme): string[] {
const palette = Array.from({ length: 256 }, () => "");
@@ -11,7 +11,11 @@ import {
import { StyleSheet } from "react-native-unistyles";
import type { ITheme } from "@xterm/xterm";
import { createTerminalCellStyleResolver, DEFAULT_TERMINAL_THEME } from "./colors";
import {
createTerminalCellStyleResolver,
DEFAULT_TERMINAL_THEME,
NATIVE_TERMINAL_SELECTION_COLORS,
} from "./colors";
import { resolveNativeTerminalFontFamily } from "./font.native";
import type { TerminalViewportState } from "./headless-terminal-state";
import {
@@ -21,11 +25,7 @@ import {
type TerminalGridCellMetrics,
} from "./terminal-grid-metrics";
import { buildRows, type TerminalRowModel, type TerminalRun } from "./terminal-row-model";
import {
resolveTerminalSelectionRects,
type TerminalSelectionRange,
type TerminalSelectionRect,
} from "./terminal-selection";
import type { TerminalSelectionRange } from "./terminal-selection";
const MEASURE_TEXT = "mmmmmmmmmm";
const DEFAULT_FONT_SIZE = 12;
@@ -58,11 +58,6 @@ interface TerminalGridRunProps {
textStyle: StyleProp<TextStyle>;
}
interface TerminalGridSelectionRectProps {
rect: TerminalSelectionRect;
color: string;
}
export interface TerminalGridViewProps {
state: TerminalViewportState;
xtermTheme?: ITheme;
@@ -130,27 +125,6 @@ const MemoTerminalGridRun = memo(TerminalGridRun, (previous, next) => {
);
});
function TerminalGridSelectionRect({ rect, color }: TerminalGridSelectionRectProps) {
const rectStyle = useMemo<StyleProp<ViewStyle>>(
() => [
styles.selectionRect,
{
backgroundColor: color,
height: rect.height,
transform: [{ translateX: rect.x }, { translateY: rect.y }],
width: rect.width,
},
],
[color, rect.height, rect.width, rect.x, rect.y],
);
return <View pointerEvents="none" style={rectStyle} />;
}
const MemoTerminalGridSelectionRect = memo(TerminalGridSelectionRect, (previous, next) => {
return previous.rect === next.rect && previous.color === next.color;
});
function TerminalGridRow({
row,
cellWidth,
@@ -244,23 +218,21 @@ export function TerminalGridView({
[state.grid, visibleCols],
);
const rows = useMemo(
() => buildRows({ grid: projectedGrid, resolver }),
[projectedGrid, resolver],
);
const selectionRects = useMemo(
() =>
resolveTerminalSelectionRects({
selection,
viewport: {
firstRow: state.firstRow,
rows: state.grid.length,
cols: visibleCols,
},
metrics,
buildRows({
grid: projectedGrid,
resolver,
selection: selection
? {
range: selection,
firstRow: state.firstRow,
backgroundColor: NATIVE_TERMINAL_SELECTION_COLORS.background,
foregroundColor: NATIVE_TERMINAL_SELECTION_COLORS.foreground,
}
: undefined,
}),
[metrics, selection, state.firstRow, state.grid.length, visibleCols],
[projectedGrid, resolver, selection, state.firstRow],
);
const selectionColor = xtermTheme.selectionBackground ?? "rgba(90, 160, 255, 0.35)";
const containerStyle = useMemo<StyleProp<ViewStyle>>(
() => [styles.root, { backgroundColor: resolver.backgroundColor }, style],
@@ -346,9 +318,6 @@ export function TerminalGridView({
styleEpoch={resolver.themeKey}
/>
))}
{selectionRects.map((rect) => (
<MemoTerminalGridSelectionRect key={rect.key} rect={rect} color={selectionColor} />
))}
{!state.cursor.hidden && <View pointerEvents="none" style={cursorStyle} />}
</View>
</View>
@@ -384,10 +353,4 @@ const styles = StyleSheet.create({
position: "absolute",
top: 0,
},
selectionRect: {
left: 0,
opacity: 0.8,
position: "absolute",
top: 0,
},
});
@@ -40,4 +40,43 @@ describe("terminal row model", () => {
{ text: "A界", cellCount: 3 },
]);
});
test("redraws selected cells with the terminal selection colors", () => {
const resolver = createTerminalCellStyleResolver(DEFAULT_TERMINAL_THEME);
const grid = [[cell("a", { fg: 1, fgMode: 1 }), cell("b", { dim: true }), cell("c")]];
const rows = buildRows({
grid,
resolver,
selection: {
range: {
start: { row: 7, col: 1 },
end: { row: 7, col: 2 },
coordinateEpoch: 1,
},
firstRow: 7,
backgroundColor: "#ffff00",
foregroundColor: "#000000",
},
});
expect(
rows[0].runs.map((run) => ({
text: run.text,
color: run.style.color,
backgroundColor: run.style.backgroundColor,
opacity: run.style.opacity,
})),
).toEqual([
{
text: "a",
color: DEFAULT_TERMINAL_THEME.red,
backgroundColor: undefined,
opacity: undefined,
},
{ text: "b", color: "#000000", backgroundColor: "#ffff00", opacity: 1 },
{ text: "c", color: "#000000", backgroundColor: "#ffff00", opacity: 1 },
]);
expect(rows[0].hash).not.toBe(buildRows({ grid, resolver })[0].hash);
});
});
@@ -2,6 +2,7 @@ import type { TextStyle } from "react-native";
import type { TerminalCell } from "@getpaseo/protocol/messages";
import type { TerminalCellStyleResolver } from "./colors";
import type { TerminalSelectionRange } from "./terminal-selection";
const WIDE_CHAR_RANGES: ReadonlyArray<readonly [number, number]> = [
[0x1100, 0x115f],
@@ -32,6 +33,13 @@ export interface TerminalRowModel {
type TerminalRenderableCell = TerminalCell & { width?: number };
interface TerminalRowSelectionStyle {
range: TerminalSelectionRange;
firstRow: number;
backgroundColor: string;
foregroundColor: string;
}
function hashStringPart(hash: number, value: string): number {
let nextHash = hash;
for (let index = 0; index < value.length; index += 1) {
@@ -93,10 +101,45 @@ function appendRun(input: {
});
}
function cellIntersectsSelection(input: {
selection: TerminalRowSelectionStyle;
row: number;
col: number;
cellCount: number;
}): boolean {
const absoluteRow = input.selection.firstRow + input.row;
const { start, end } = input.selection.range;
if (absoluteRow < start.row || absoluteRow > end.row) {
return false;
}
const selectedStartCol = absoluteRow === start.row ? start.col : 0;
const selectedEndCol = absoluteRow === end.row ? end.col : Number.POSITIVE_INFINITY;
const cellEndCol = input.col + input.cellCount - 1;
return cellEndCol >= selectedStartCol && input.col <= selectedEndCol;
}
function selectedCellStyle(input: {
style: TextStyle;
foregroundColor: string;
backgroundColor: string;
}): TextStyle {
return {
...input.style,
backgroundColor: input.backgroundColor,
color: input.foregroundColor,
opacity: 1,
...(input.style.textDecorationLine
? { textDecorationColor: input.foregroundColor }
: undefined),
};
}
function buildRowModel(input: {
cells: TerminalRenderableCell[];
index: number;
resolver: TerminalCellStyleResolver;
selection?: TerminalRowSelectionStyle;
}): TerminalRowModel {
const runs: TerminalRun[] = [];
let hash = 2166136261;
@@ -106,17 +149,36 @@ function buildRowModel(input: {
const text = cell.char || " ";
const resolvedStyle = input.resolver.resolve(cell);
const cellCount = terminalCellCount(input.cells, col, text);
const selection = input.selection;
let styleKey = resolvedStyle.key;
let style = resolvedStyle.style;
if (
selection &&
cellIntersectsSelection({
selection,
row: input.index,
col,
cellCount,
})
) {
styleKey = `${resolvedStyle.key}|selection:${selection.foregroundColor}:${selection.backgroundColor}`;
style = selectedCellStyle({
style: resolvedStyle.style,
foregroundColor: selection.foregroundColor,
backgroundColor: selection.backgroundColor,
});
}
appendRun({
runs,
text,
cellCount,
styleKey: resolvedStyle.key,
style: resolvedStyle.style,
styleKey,
style,
col,
});
hash = hashStringPart(hash, text);
hash = hashStringPart(hash, String(cellCount));
hash = hashStringPart(hash, resolvedStyle.key);
hash = hashStringPart(hash, styleKey);
if (cellCount > 1) {
col += 1;
@@ -133,8 +195,9 @@ function buildRowModel(input: {
export function buildRows(input: {
grid: TerminalRenderableCell[][];
resolver: TerminalCellStyleResolver;
selection?: TerminalRowSelectionStyle;
}): TerminalRowModel[] {
return input.grid.map((cells, index) =>
buildRowModel({ cells, index, resolver: input.resolver }),
buildRowModel({ cells, index, resolver: input.resolver, selection: input.selection }),
);
}