Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 21 additions & 4 deletions src/renderer/src/features/workspace/WorkspaceCanvas.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -74,10 +74,12 @@ import {
import { boundsIntersect } from "./minimapGeometry";
import {
browserLayerId,
filterMarqueeSelectionByMode,
noteLayerId,
parseCanvasLayerId,
pluginLayerId,
terminalLayerId
terminalLayerId,
type CanvasMarqueeMode
} from "./canvasSelectionGesture";
import { snapMove } from "./snap";
import { useCanvasPointerNavigation } from "./useCanvasPointerNavigation";
Expand Down Expand Up @@ -217,6 +219,7 @@ export function WorkspaceCanvas(props: WorkspaceCanvasProps): React.JSX.Element
pointerId: number;
} | null>(null);
const suppressNextContextMenu = useRef(false);
const suppressCtrlContextMenuUntil = useRef(0);
const pendingRadialContextMenu = useRef<CanvasMenuState | null>(null);
const [noteEditRequest, setNoteEditRequest] = useState<{ id: string; version: number } | null>(null);
const [regionMovePreview, setRegionMovePreview] = useState<RegionMovePreview | null>(null);
Expand Down Expand Up @@ -342,7 +345,7 @@ export function WorkspaceCanvas(props: WorkspaceCanvasProps): React.JSX.Element
size: homeGridPixelSize(settings.homeGridSize)
};

const selectMarquee = useCallback((bounds: SessionBounds | null): void => {
const selectMarquee = useCallback((bounds: SessionBounds | null, mode: CanvasMarqueeMode = "all"): void => {
if (bounds === null) {
setMarqueeSelection(EMPTY_MARQUEE_SELECTION);
return;
Expand All @@ -353,7 +356,8 @@ export function WorkspaceCanvas(props: WorkspaceCanvasProps): React.JSX.Element
.map(([layerId]) => layerId);
// Presentation-only: the marquee never moves logical input focus or the active
// session, and a group drag is read from the selection alone.
setMarqueeSelection(ids.length === 0 ? EMPTY_MARQUEE_SELECTION : new Set(ids));
const selected = filterMarqueeSelectionByMode(ids, mode);
setMarqueeSelection(selected.size === 0 ? EMPTY_MARQUEE_SELECTION : selected);
}, [boundsByLayer]);

const groupDragBasis = useRef<GroupDragBasis | null>(null);
Expand Down Expand Up @@ -714,7 +718,14 @@ export function WorkspaceCanvas(props: WorkspaceCanvasProps): React.JSX.Element
}
if (contextMenu && !element.closest(".canvas-menu")) setContextMenu(null);
if (regionEditor && !element.closest(".canvas-region-editor")) setRegionEditor(null);
if (pointerNavigation.handlePointerDownCapture(event)) return;
if (pointerNavigation.handlePointerDownCapture(event)) {
// macOS interprets Control+left-click as a context-menu request. Keep
// that menu from replacing the terminal marquee we just started.
if (event.button === 0 && event.ctrlKey && !event.altKey && !event.metaKey && !event.shiftKey) {
suppressCtrlContextMenuUntil.current = window.performance.now() + 1000;
}
return;
}
const target = canvasWidgetTarget(event.target);
if (target.focusableWidgetId !== null) {
focusController.cancelHover();
Expand All @@ -737,6 +748,11 @@ export function WorkspaceCanvas(props: WorkspaceCanvasProps): React.JSX.Element
onPointerCancelCapture={pointerNavigation.handlePointerCancel}
onPointerLeave={pointerNavigation.handlePointerLeave}
onContextMenu={(event) => {
if (window.performance.now() < suppressCtrlContextMenuUntil.current) {
suppressCtrlContextMenuUntil.current = 0;
event.preventDefault();
return;
}
if (suppressNextContextMenu.current) {
suppressNextContextMenu.current = false;
event.preventDefault();
Expand Down Expand Up @@ -1162,6 +1178,7 @@ export function WorkspaceCanvas(props: WorkspaceCanvasProps): React.JSX.Element
<div><kbd>{settings.shortcuts.renameWindow}</kbd><span>{t(settings.locale, "renameWindow")}</span></div>
<div><kbd>{window.canvasTTY.window.isMacOS ? "Option+↑↓←→" : "Alt+↑↓←→"}</kbd><span>{t(settings.locale, "focusWindowHint")}</span></div>
<div><kbd>Shift + drag</kbd><span>{t(settings.locale, "marqueeSelectionHint")}</span></div>
<div><kbd>Ctrl + drag</kbd><span>{t(settings.locale, "terminalMarqueeSelectionHint")}</span></div>
{settings.canvasWheelCaptureMode === "key" && settings.canvasWheelOverride !== null && (
<div><kbd>{displayCanvasNavigationBinding(settings.canvasWheelOverride, window.canvasTTY.window.isMacOS)}</kbd>
<span>{t(settings.locale, "canvasWheelOverrideHint")}</span></div>
Expand Down
61 changes: 51 additions & 10 deletions src/renderer/src/features/workspace/canvasSelectionGesture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,10 +58,12 @@ export interface CanvasMarqueeRect {
height: number;
}

export type CanvasMarqueeMode = "all" | "terminal-only";

export type CanvasPressIntent =
/** Shift on empty canvas: select every window the rectangle covers. */
| { kind: "marquee" }
/** Plain press on empty canvas: drop the marquee group and fall through to the pan. */
/** Shift (all windows) or Ctrl (terminal-only) on empty canvas: marquee select. */
| { kind: "marquee"; mode: CanvasMarqueeMode }
/** Plain press or click outside selection: drop the marquee group. */
| { kind: "clear-selection" }
/** Press on a card that is one of several selected cards. */
| { kind: "group-drag"; layerId: string }
Expand Down Expand Up @@ -89,17 +91,43 @@ export interface CanvasPress {
*/
export function canvasPressIntent(press: CanvasPress): CanvasPressIntent {
if (press.button !== 0) return { kind: "none" };

if (press.cardLayerId !== null) {
// A group drag is a drag, not a press: until it travels it leaves the card's
// own controls, focus, and click path untouched.
const grouped = press.selection.size > 1
&& press.selection.has(press.cardLayerId)
&& !press.onCardControl
&& !press.altKey;
return grouped ? { kind: "group-drag", layerId: press.cardLayerId } : { kind: "none" };
const isSelected = press.selection.has(press.cardLayerId);
if (isSelected) {
const grouped = press.selection.size > 1
&& !press.onCardControl
&& !press.altKey;
return grouped ? { kind: "group-drag", layerId: press.cardLayerId } : { kind: "none" };
}
// Clearing the visual group does not consume the press: card controls still
// receive their own click, even when the card is outside the selected group.
if (press.selection.size > 0) {
return { kind: "clear-selection" };
}
return { kind: "none" };
}
if (press.onCanvasWidget) return { kind: "none" };
if (press.shiftKey && !press.altKey && !press.ctrlKey && !press.metaKey) return { kind: "marquee" };

// A press on any other widget also clears the group without consuming its click.
if (press.onCanvasWidget) {
if (press.selection.size > 0) {
return { kind: "clear-selection" };
}
return { kind: "none" };
}

// Empty canvas marquee:
// Shift+left drag -> marquee all
// Ctrl+left drag -> terminal-only marquee
if (press.shiftKey && !press.altKey && !press.ctrlKey && !press.metaKey) {
return { kind: "marquee", mode: "all" };
}
if (press.ctrlKey && !press.altKey && !press.shiftKey && !press.metaKey) {
return { kind: "marquee", mode: "terminal-only" };
}

return { kind: "clear-selection" };
}

Expand Down Expand Up @@ -134,6 +162,19 @@ export function canvasWorldRect(start: Point, current: Point, camera: CameraStat
};
}

/** Filters layer ids according to marquee mode (e.g. terminal-only vs all). */
export function filterMarqueeSelectionByMode(layerIds: Iterable<string>, mode: CanvasMarqueeMode): Set<string> {
if (mode === "all") return new Set(layerIds);
const result = new Set<string>();
for (const id of layerIds) {
const ref = parseCanvasLayerId(id);
if (ref?.kind === "terminal") {
result.add(id);
}
}
return result;
}

export interface CanvasGroupDragState {
pointerId: number;
layerId: string;
Expand Down
20 changes: 13 additions & 7 deletions src/renderer/src/features/workspace/useCanvasPointerNavigation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
canvasPressIntent,
canvasWorldRect,
pastCanvasDragThreshold,
type CanvasMarqueeMode,
type CanvasGroupDragState,
type CanvasMarqueeRect
} from "./canvasSelectionGesture";
Expand All @@ -35,6 +36,7 @@ interface NativePanState {

interface MarqueeState {
pointerId: number;
mode: CanvasMarqueeMode;
start: Point;
current: Point;
moved: boolean;
Expand All @@ -49,7 +51,7 @@ interface UseCanvasPointerNavigationOptions {
/** Canvas layer ids currently marquee-selected; a drag on one of them moves the group. */
selectedLayerIds: ReadonlySet<string>;
/** Replaces the marquee group with the layers intersecting the world rectangle; null clears it. */
onMarqueeSelection(bounds: SessionBounds | null): void;
onMarqueeSelection(bounds: SessionBounds | null, mode?: CanvasMarqueeMode): void;
/** Freezes the commit basis of a travelled group move; called once, when the press activates. */
onGroupDragStart(layerId: string): void;
/** Commits a group move: the pointer offset in world units, applied from the pressed layer. */
Expand Down Expand Up @@ -160,13 +162,13 @@ export function useCanvasPointerNavigation({
return { x: clientX - bounds.left, y: clientY - bounds.top };
}, [viewport]);

const startMarquee = useCallback((event: React.PointerEvent<HTMLDivElement>): boolean => {
const startMarquee = useCallback((event: React.PointerEvent<HTMLDivElement>, mode: CanvasMarqueeMode): boolean => {
const local = localPoint(event.clientX, event.clientY);
if (!local) return false;
event.preventDefault();
event.stopPropagation();
event.currentTarget.setPointerCapture(event.pointerId);
marqueeState.current = { pointerId: event.pointerId, start: local, current: local, moved: false };
marqueeState.current = { pointerId: event.pointerId, mode, start: local, current: local, moved: false };
setMarquee(canvasMarqueeRect(local, local));
return true;
}, [localPoint]);
Expand All @@ -188,11 +190,15 @@ export function useCanvasPointerNavigation({
const element = viewport.current;
if (element?.hasPointerCapture(state.pointerId)) element.releasePointerCapture(state.pointerId);
setMarquee(null);
// A press without travel stays a plain click, so focus handling is untouched.
if (!state.moved) return;
// A click on empty canvas outside the group clears it even if the modifier
// was held without enough travel to draw a marquee.
if (!state.moved) {
onMarqueeSelectionRef.current(null);
return;
}
suppressClick.current = true;
window.setTimeout(() => { suppressClick.current = false; }, 0);
onMarqueeSelectionRef.current(canvasWorldRect(state.start, state.current, cameraRef.current));
onMarqueeSelectionRef.current(canvasWorldRect(state.start, state.current, cameraRef.current), state.mode);
}, [cameraRef, viewport]);

const updateGroupDrag = useCallback((event: React.PointerEvent<HTMLDivElement>): void => {
Expand Down Expand Up @@ -393,7 +399,7 @@ export function useCanvasPointerNavigation({
selection: selectedLayerIdsRef.current
});
if (intent.kind === "group-drag") return startGroupDrag(event, intent.layerId);
if (intent.kind === "marquee") return startMarquee(event);
if (intent.kind === "marquee") return startMarquee(event, intent.mode);
if (intent.kind === "clear-selection") onMarqueeSelectionRef.current(null);
if (!canvasOverrideActiveRef.current || !widgetTarget) return false;
return startPan(event, true);
Expand Down
6 changes: 4 additions & 2 deletions src/renderer/src/lib/i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -466,7 +466,8 @@ const ru = {
terminalSearchClose: "Закрыть поиск",
fitCanvas: "Показать всё",
focusWindowHint: "Фокус на соседнее окно",
marqueeSelectionHint: "Shift + тянуть — выделить группу",
marqueeSelectionHint: "Выделить группу окон и перемещать вместе",
terminalMarqueeSelectionHint: "Выделить терминалы и перемещать вместе",
attentionNotifications: "Уведомлять о внимании",
attentionNotificationsDescription: "Системное уведомление, когда сессия ждёт ответа или завершилась с ошибкой",
needsAttention: "Требуют внимания",
Expand Down Expand Up @@ -1070,7 +1071,8 @@ const en: Record<keyof typeof ru, string> = {
terminalSearchClose: "Close search",
fitCanvas: "Fit to content",
focusWindowHint: "Focus the neighbouring window",
marqueeSelectionHint: "Shift + drag selects a group",
marqueeSelectionHint: "Select windows and move them together",
terminalMarqueeSelectionHint: "Select terminals and move them together",
attentionNotifications: "Notify when attention is needed",
attentionNotificationsDescription: "System notification when a session needs input or fails",
needsAttention: "Needs attention",
Expand Down
53 changes: 49 additions & 4 deletions tests/canvas-selection-gestures.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
canvasMarqueeRect,
canvasPressIntent,
canvasWorldRect,
filterMarqueeSelectionByMode,
noteLayerId,
parseCanvasLayerId,
pastCanvasDragThreshold,
Expand Down Expand Up @@ -61,16 +62,51 @@ function hookBody(source, name) {
return rest.slice(0, end);
}

test("shift on empty canvas starts a marquee and every other modifier combination does not", () => {
assert.deepEqual(canvasPressIntent(press({ shiftKey: true })), { kind: "marquee" });
test("shift on empty canvas starts an 'all' marquee and ctrl starts a 'terminal-only' marquee", () => {
assert.deepEqual(canvasPressIntent(press({ shiftKey: true })), { kind: "marquee", mode: "all" });
assert.deepEqual(canvasPressIntent(press({ ctrlKey: true })), { kind: "marquee", mode: "terminal-only" });
assert.deepEqual(canvasPressIntent(press()), { kind: "clear-selection" });
assert.deepEqual(canvasPressIntent(press({ shiftKey: true, altKey: true })), { kind: "clear-selection" });
assert.deepEqual(canvasPressIntent(press({ shiftKey: true, ctrlKey: true })), { kind: "clear-selection" });
assert.deepEqual(canvasPressIntent(press({ shiftKey: true, metaKey: true })), { kind: "clear-selection" });
assert.deepEqual(canvasPressIntent(press({ ctrlKey: true, altKey: true })), { kind: "clear-selection" });
assert.deepEqual(canvasPressIntent(press({ ctrlKey: true, metaKey: true })), { kind: "clear-selection" });
assert.deepEqual(canvasPressIntent(press({ shiftKey: true, onCanvasWidget: true })), { kind: "none" });
assert.deepEqual(canvasPressIntent(press({ ctrlKey: true, onCanvasWidget: true })), { kind: "none" });
assert.deepEqual(canvasPressIntent(press({ button: 2 })), { kind: "none" });
});

test("click outside selection clears selection for empty canvas, nonselected card, and canvas widget", () => {
const selection = new Set([terminalLayerId("a"), terminalLayerId("b")]);

// Click on empty canvas clears selection
assert.deepEqual(canvasPressIntent(press({ selection })), { kind: "clear-selection" });

// Click on a nonselected card clears selection
assert.deepEqual(
canvasPressIntent(press({ cardLayerId: terminalLayerId("c"), selection })),
{ kind: "clear-selection" }
);

// A nonselected card control clears the visual group while keeping its own click.
assert.deepEqual(
canvasPressIntent(press({ cardLayerId: terminalLayerId("c"), selection, onCardControl: true })),
{ kind: "clear-selection" }
);

// Click on canvas widget surface outside cards clears selection
assert.deepEqual(
canvasPressIntent(press({ onCanvasWidget: true, selection })),
{ kind: "clear-selection" }
);

// But without selection, clicking canvas widget is just "none"
assert.deepEqual(
canvasPressIntent(press({ onCanvasWidget: true, selection: new Set() })),
{ kind: "none" }
);
});

test("a press on a card is a group drag only when the card is one of several selected", () => {
const selection = new Set(everyLayerId);
for (const layerId of everyLayerId) {
Expand All @@ -82,9 +118,9 @@ test("a press on a card is a group drag only when the card is one of several sel
}
const [terminal, plugin] = everyLayerId;
assert.deepEqual(canvasPressIntent(press({ cardLayerId: terminal, selection: new Set([terminal]) })), { kind: "none" });
assert.deepEqual(canvasPressIntent(press({ cardLayerId: "terminal:elsewhere", selection })), { kind: "none" });
assert.deepEqual(canvasPressIntent(press({ cardLayerId: "terminal:elsewhere", selection })), { kind: "clear-selection" });
assert.deepEqual(canvasPressIntent(press({ cardLayerId: terminal })), { kind: "none" });
assert.deepEqual(canvasPressIntent(press({ cardLayerId: plugin, selection: new Set([terminal, "terminal:x"]) })), { kind: "none" });
assert.deepEqual(canvasPressIntent(press({ cardLayerId: plugin, selection: new Set([terminal, "terminal:x"]) })), { kind: "clear-selection" });
});

test("a press on a card control, the search input, or a resize handle reaches that surface", () => {
Expand Down Expand Up @@ -217,3 +253,12 @@ test("a travelled group drag commits one delta once and suppresses exactly one f
assert.notEqual(bail, -1, "only a travelled drag may commit");
assert.ok(bail < finish.indexOf("suppressClick.current = true"), "a jitter press must not suppress the click");
});

test("filterMarqueeSelectionByMode filters terminal-only vs all", () => {
const ids = [terminalLayerId("1"), pluginLayerId("2"), browserLayerId, noteLayerId("3")];
const allFiltered = filterMarqueeSelectionByMode(ids, "all");
assert.deepEqual([...allFiltered], ids);

const terminalFiltered = filterMarqueeSelectionByMode(ids, "terminal-only");
assert.deepEqual([...terminalFiltered], [terminalLayerId("1")]);
});
Loading