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:
@@ -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 }),
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user