diff --git a/src/renderer/src/features/workspace/WorkspaceCanvas.tsx b/src/renderer/src/features/workspace/WorkspaceCanvas.tsx index 70237eb..f9ac0d2 100644 --- a/src/renderer/src/features/workspace/WorkspaceCanvas.tsx +++ b/src/renderer/src/features/workspace/WorkspaceCanvas.tsx @@ -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"; @@ -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(null); const [noteEditRequest, setNoteEditRequest] = useState<{ id: string; version: number } | null>(null); const [regionMovePreview, setRegionMovePreview] = useState(null); @@ -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; @@ -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(null); @@ -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(); @@ -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(); @@ -1162,6 +1178,7 @@ export function WorkspaceCanvas(props: WorkspaceCanvasProps): React.JSX.Element
{settings.shortcuts.renameWindow}{t(settings.locale, "renameWindow")}
{window.canvasTTY.window.isMacOS ? "Option+↑↓←→" : "Alt+↑↓←→"}{t(settings.locale, "focusWindowHint")}
Shift + drag{t(settings.locale, "marqueeSelectionHint")}
+
Ctrl + drag{t(settings.locale, "terminalMarqueeSelectionHint")}
{settings.canvasWheelCaptureMode === "key" && settings.canvasWheelOverride !== null && (
{displayCanvasNavigationBinding(settings.canvasWheelOverride, window.canvasTTY.window.isMacOS)} {t(settings.locale, "canvasWheelOverrideHint")}
diff --git a/src/renderer/src/features/workspace/canvasSelectionGesture.ts b/src/renderer/src/features/workspace/canvasSelectionGesture.ts index 0d4c271..5738f8a 100644 --- a/src/renderer/src/features/workspace/canvasSelectionGesture.ts +++ b/src/renderer/src/features/workspace/canvasSelectionGesture.ts @@ -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 } @@ -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" }; } @@ -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, mode: CanvasMarqueeMode): Set { + if (mode === "all") return new Set(layerIds); + const result = new Set(); + 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; diff --git a/src/renderer/src/features/workspace/useCanvasPointerNavigation.ts b/src/renderer/src/features/workspace/useCanvasPointerNavigation.ts index b21fe10..2fc7b01 100644 --- a/src/renderer/src/features/workspace/useCanvasPointerNavigation.ts +++ b/src/renderer/src/features/workspace/useCanvasPointerNavigation.ts @@ -14,6 +14,7 @@ import { canvasPressIntent, canvasWorldRect, pastCanvasDragThreshold, + type CanvasMarqueeMode, type CanvasGroupDragState, type CanvasMarqueeRect } from "./canvasSelectionGesture"; @@ -35,6 +36,7 @@ interface NativePanState { interface MarqueeState { pointerId: number; + mode: CanvasMarqueeMode; start: Point; current: Point; moved: boolean; @@ -49,7 +51,7 @@ interface UseCanvasPointerNavigationOptions { /** Canvas layer ids currently marquee-selected; a drag on one of them moves the group. */ selectedLayerIds: ReadonlySet; /** 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. */ @@ -160,13 +162,13 @@ export function useCanvasPointerNavigation({ return { x: clientX - bounds.left, y: clientY - bounds.top }; }, [viewport]); - const startMarquee = useCallback((event: React.PointerEvent): boolean => { + const startMarquee = useCallback((event: React.PointerEvent, 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]); @@ -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): void => { @@ -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); diff --git a/src/renderer/src/lib/i18n.ts b/src/renderer/src/lib/i18n.ts index 0495f7b..fbecd97 100644 --- a/src/renderer/src/lib/i18n.ts +++ b/src/renderer/src/lib/i18n.ts @@ -466,7 +466,8 @@ const ru = { terminalSearchClose: "Закрыть поиск", fitCanvas: "Показать всё", focusWindowHint: "Фокус на соседнее окно", - marqueeSelectionHint: "Shift + тянуть — выделить группу", + marqueeSelectionHint: "Выделить группу окон и перемещать вместе", + terminalMarqueeSelectionHint: "Выделить терминалы и перемещать вместе", attentionNotifications: "Уведомлять о внимании", attentionNotificationsDescription: "Системное уведомление, когда сессия ждёт ответа или завершилась с ошибкой", needsAttention: "Требуют внимания", @@ -1070,7 +1071,8 @@ const en: Record = { 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", diff --git a/tests/canvas-selection-gestures.test.mjs b/tests/canvas-selection-gestures.test.mjs index 985dd5f..de9b1d0 100644 --- a/tests/canvas-selection-gestures.test.mjs +++ b/tests/canvas-selection-gestures.test.mjs @@ -11,6 +11,7 @@ import { canvasMarqueeRect, canvasPressIntent, canvasWorldRect, + filterMarqueeSelectionByMode, noteLayerId, parseCanvasLayerId, pastCanvasDragThreshold, @@ -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) { @@ -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", () => { @@ -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")]); +});