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 DEFAULT_TERMINAL_THEME = toXtermTheme(darkTheme.colors.terminal);
export const NATIVE_TERMINAL_SELECTION_COLORS = {
background: "#ffff00",
foreground: "#000000",
} as const;
function build256Palette(theme: ITheme): string[] { function build256Palette(theme: ITheme): string[] {
const palette = Array.from({ length: 256 }, () => ""); const palette = Array.from({ length: 256 }, () => "");
@@ -11,7 +11,11 @@ import {
import { StyleSheet } from "react-native-unistyles"; import { StyleSheet } from "react-native-unistyles";
import type { ITheme } from "@xterm/xterm"; 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 { resolveNativeTerminalFontFamily } from "./font.native";
import type { TerminalViewportState } from "./headless-terminal-state"; import type { TerminalViewportState } from "./headless-terminal-state";
import { import {
@@ -21,11 +25,7 @@ import {
type TerminalGridCellMetrics, type TerminalGridCellMetrics,
} from "./terminal-grid-metrics"; } from "./terminal-grid-metrics";
import { buildRows, type TerminalRowModel, type TerminalRun } from "./terminal-row-model"; import { buildRows, type TerminalRowModel, type TerminalRun } from "./terminal-row-model";
import { import type { TerminalSelectionRange } from "./terminal-selection";
resolveTerminalSelectionRects,
type TerminalSelectionRange,
type TerminalSelectionRect,
} from "./terminal-selection";
const MEASURE_TEXT = "mmmmmmmmmm"; const MEASURE_TEXT = "mmmmmmmmmm";
const DEFAULT_FONT_SIZE = 12; const DEFAULT_FONT_SIZE = 12;
@@ -58,11 +58,6 @@ interface TerminalGridRunProps {
textStyle: StyleProp<TextStyle>; textStyle: StyleProp<TextStyle>;
} }
interface TerminalGridSelectionRectProps {
rect: TerminalSelectionRect;
color: string;
}
export interface TerminalGridViewProps { export interface TerminalGridViewProps {
state: TerminalViewportState; state: TerminalViewportState;
xtermTheme?: ITheme; 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({ function TerminalGridRow({
row, row,
cellWidth, cellWidth,
@@ -244,23 +218,21 @@ export function TerminalGridView({
[state.grid, visibleCols], [state.grid, visibleCols],
); );
const rows = useMemo( const rows = useMemo(
() => buildRows({ grid: projectedGrid, resolver }),
[projectedGrid, resolver],
);
const selectionRects = useMemo(
() => () =>
resolveTerminalSelectionRects({ buildRows({
selection, grid: projectedGrid,
viewport: { resolver,
firstRow: state.firstRow, selection: selection
rows: state.grid.length, ? {
cols: visibleCols, range: selection,
}, firstRow: state.firstRow,
metrics, 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>>( const containerStyle = useMemo<StyleProp<ViewStyle>>(
() => [styles.root, { backgroundColor: resolver.backgroundColor }, style], () => [styles.root, { backgroundColor: resolver.backgroundColor }, style],
@@ -346,9 +318,6 @@ export function TerminalGridView({
styleEpoch={resolver.themeKey} styleEpoch={resolver.themeKey}
/> />
))} ))}
{selectionRects.map((rect) => (
<MemoTerminalGridSelectionRect key={rect.key} rect={rect} color={selectionColor} />
))}
{!state.cursor.hidden && <View pointerEvents="none" style={cursorStyle} />} {!state.cursor.hidden && <View pointerEvents="none" style={cursorStyle} />}
</View> </View>
</View> </View>
@@ -384,10 +353,4 @@ const styles = StyleSheet.create({
position: "absolute", position: "absolute",
top: 0, top: 0,
}, },
selectionRect: {
left: 0,
opacity: 0.8,
position: "absolute",
top: 0,
},
}); });
@@ -40,4 +40,43 @@ describe("terminal row model", () => {
{ text: "A界", cellCount: 3 }, { 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 { TerminalCell } from "@getpaseo/protocol/messages";
import type { TerminalCellStyleResolver } from "./colors"; import type { TerminalCellStyleResolver } from "./colors";
import type { TerminalSelectionRange } from "./terminal-selection";
const WIDE_CHAR_RANGES: ReadonlyArray<readonly [number, number]> = [ const WIDE_CHAR_RANGES: ReadonlyArray<readonly [number, number]> = [
[0x1100, 0x115f], [0x1100, 0x115f],
@@ -32,6 +33,13 @@ export interface TerminalRowModel {
type TerminalRenderableCell = TerminalCell & { width?: number }; type TerminalRenderableCell = TerminalCell & { width?: number };
interface TerminalRowSelectionStyle {
range: TerminalSelectionRange;
firstRow: number;
backgroundColor: string;
foregroundColor: string;
}
function hashStringPart(hash: number, value: string): number { function hashStringPart(hash: number, value: string): number {
let nextHash = hash; let nextHash = hash;
for (let index = 0; index < value.length; index += 1) { 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: { function buildRowModel(input: {
cells: TerminalRenderableCell[]; cells: TerminalRenderableCell[];
index: number; index: number;
resolver: TerminalCellStyleResolver; resolver: TerminalCellStyleResolver;
selection?: TerminalRowSelectionStyle;
}): TerminalRowModel { }): TerminalRowModel {
const runs: TerminalRun[] = []; const runs: TerminalRun[] = [];
let hash = 2166136261; let hash = 2166136261;
@@ -106,17 +149,36 @@ function buildRowModel(input: {
const text = cell.char || " "; const text = cell.char || " ";
const resolvedStyle = input.resolver.resolve(cell); const resolvedStyle = input.resolver.resolve(cell);
const cellCount = terminalCellCount(input.cells, col, text); 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({ appendRun({
runs, runs,
text, text,
cellCount, cellCount,
styleKey: resolvedStyle.key, styleKey,
style: resolvedStyle.style, style,
col, col,
}); });
hash = hashStringPart(hash, text); hash = hashStringPart(hash, text);
hash = hashStringPart(hash, String(cellCount)); hash = hashStringPart(hash, String(cellCount));
hash = hashStringPart(hash, resolvedStyle.key); hash = hashStringPart(hash, styleKey);
if (cellCount > 1) { if (cellCount > 1) {
col += 1; col += 1;
@@ -133,8 +195,9 @@ function buildRowModel(input: {
export function buildRows(input: { export function buildRows(input: {
grid: TerminalRenderableCell[][]; grid: TerminalRenderableCell[][];
resolver: TerminalCellStyleResolver; resolver: TerminalCellStyleResolver;
selection?: TerminalRowSelectionStyle;
}): TerminalRowModel[] { }): TerminalRowModel[] {
return input.grid.map((cells, index) => return input.grid.map((cells, index) =>
buildRowModel({ cells, index, resolver: input.resolver }), buildRowModel({ cells, index, resolver: input.resolver, selection: input.selection }),
); );
} }