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