From c815d1aae3f983c4f47b4c0c9575d15289753258 Mon Sep 17 00:00:00 2001 From: TroopJostle Date: Sat, 5 Sep 2026 15:24:03 +0400 Subject: [PATCH] feat: add canvas radial quick launcher --- docs/UI_CONTRACT.md | 4 +- src/main/services/SettingsStore.ts | 27 +++- src/renderer/src/App.tsx | 2 + .../src/features/launcher/QuickRadialMenu.tsx | 147 ++++++++++++++++++ .../src/features/launcher/radialLauncher.ts | 42 +++++ .../src/features/settings/SettingsPanel.tsx | 40 +++++ .../features/workspace/WorkspaceCanvas.tsx | 88 ++++++++++- src/renderer/src/lib/i18n.ts | 10 ++ src/renderer/src/styles/app.css | 65 ++++++++ src/shared/contracts.ts | 28 ++++ tests/radial-launcher.test.mjs | 62 ++++++++ tests/settings-normalizer.test.mjs | 9 +- 12 files changed, 514 insertions(+), 10 deletions(-) create mode 100644 src/renderer/src/features/launcher/QuickRadialMenu.tsx create mode 100644 src/renderer/src/features/launcher/radialLauncher.ts create mode 100644 tests/radial-launcher.test.mjs diff --git a/docs/UI_CONTRACT.md b/docs/UI_CONTRACT.md index d8e114a..e3fbf8e 100644 --- a/docs/UI_CONTRACT.md +++ b/docs/UI_CONTRACT.md @@ -42,8 +42,8 @@ This contract preserves the approved MVP concept and prevents feature ownership - The built-in Browser is one movable, resizable core canvas card, not a plugin contribution. It uses the same `54px` outer window header as other canvas cards, with its identity and hide-card action isolated from the internal tab strip below. Its trusted DOM chrome owns compact tabs/favicons, address/search, back/forward/reload, downloads, per-tab provider badges, site dialogs, and explicit Close tab/Close all. Hiding the card keeps its tabs and shared authenticated Chromium profile alive. Below `0.5×`, during Edit HOME, behind trusted dialogs/popovers, and while overlapped by a higher renderer-owned canvas window, the native page is hidden and replaced with a stable semantic surface. During card or camera motion it remains live and follows frame-coalesced viewport geometry. - An authenticated connection or heartbeat alone never shows agent presence. A branded badge appears only after that agent actually issues a browser command, and its cursor appears only after a real pointer position exists. Claude uses `#D97757`, Codex `#10A37F`, Qwen `#6D44E8`, Kimi `#7C5CFC`, OpenCode `#5A5858`, Hermes `#D6A700`, and an unknown provider `#7A8291`. Browser Settings separately controls indicator visibility without revoking browser access; the agent-access kill switch revokes the connection itself. - With window snapping enabled, drag and resize use a hidden `10px` grid and a `10px` magnetic threshold for neighboring edges, centers, and a consistent `20px` gap. -- One trusted context dispatcher owns right-clicks on the canvas. Truly empty canvas offers region creation, a sticky note, the Settings-configured launcher submenu, Browser, and Settings. A region offers rename, color, a note at the pressed point, and region-only deletion. A note frame offers edit, bring-to-front, and deletion. Terminal, Browser, plugin, textarea/input, and contenteditable surfaces keep their native context menus; middle-button panning is unchanged. `Cmd/Ctrl+K` opens a searchable palette over live sessions and the same global creation/launch actions. Regions persist independently of terminal restore, render behind HOME and windows, resize from every edge/corner, and move every terminal, plugin, Browser, or note whose complete bounds were inside the region at drag start. Attached contents follow the region continuously during the gesture and persist once at pointer release; deleting a region never deletes its contents. Sticky notes persist bounded text and geometry, snap like other windows, and resize from every edge/corner. General Settings exposes independent **Save colored regions after exit** and **Save notes after exit** controls. Both default on to preserve existing profiles. Turning either off removes only that collection from disk persistence without deleting its live canvas objects; turning it back on saves the current collection, while leaving it off starts the next launch with that collection empty. -- Context menus, region editors, and the command palette share the approved row grammar: official Lucide/provider assets, `--surface`/`--surface-soft`/semantic color tokens only, `0.7em` rows, `1.1em` surfaces, and `--shadow-lg`. Their dimensions are expressed in `em` from root `--ui-scale`. Appearance owns the `0.85–1.25` scale in `0.05` steps and the sage/lilac/night system palette; the scale changes UI chrome rather than canvas zoom or terminal font size. Agents owns the configurable canvas-launcher subset and its safe reset defaults. +- One trusted context dispatcher owns right-clicks on the canvas. Truly empty canvas offers region creation, a sticky note, the Settings-configured launcher submenu, Browser, and Settings. Holding RMB on empty canvas also opens the configurable radial quick launcher: moving toward an action and releasing activates it, while a stationary right-click continues to open the standard empty-canvas menu. Region and note right-clicks remain context-specific. A region offers rename, color, a note at the pressed point, and region-only deletion. A note frame offers edit, bring-to-front, and deletion. Terminal, Browser, plugin, textarea/input, and contenteditable surfaces keep their native context menus; middle-button panning is unchanged. `Cmd/Ctrl+K` opens a searchable palette over live sessions and the same global creation/launch actions. Regions persist independently of terminal restore, render behind HOME and windows, resize from every edge/corner, and move every terminal, plugin, Browser, or note whose complete bounds were inside the region at drag start. Attached contents follow the region continuously during the gesture and persist once at pointer release; deleting a region never deletes its contents. Sticky notes persist bounded text and geometry, snap like other windows, and resize from every edge/corner. General Settings exposes independent **Save colored regions after exit** and **Save notes after exit** controls. Both default on to preserve existing profiles. Turning either off removes only that collection from disk persistence without deleting its live canvas objects; turning it back on saves the current collection, while leaving it off starts the next launch with that collection empty. +- Context menus, region editors, the radial launcher, and the command palette use official Lucide/provider assets and shared application color tokens. Their dimensions follow root `--ui-scale`. Appearance owns the `0.85–1.25` scale in `0.05` steps and the sage/lilac/night system palette; the scale changes UI chrome rather than canvas zoom or terminal font size. Agents owns the independent context-menu and radial-launcher subsets and their safe reset defaults. - Settings uses the approved two-pane modal grammar: a persistent icon-labelled sidebar for all seven sections, a separate current-section header, and a scrollable content pane. Settings must not fall back to the legacy light slide-over with one horizontal segmented tab strip. Simple settings use compact copy/control rows; all Settings chrome reads the active system tokens, official Lucide assets, and root `--ui-scale`. - Empty-canvas drag and middle-button drag pan the canvas. On fresh profiles ordinary scroll over empty canvas pans on both axes; pinch and `Cmd/Ctrl + scroll` zoom around the pointer. **Use scroll wheel to zoom** restores historical ordinary-wheel zoom and is enabled by migration for existing profiles. Only a focused input widget interrupts canvas wheel/pinch, except the HOME session list keeps ordinary local scrolling without a focus precondition; unfocused or non-focusable widgets otherwise leave those events to the canvas. **Wheel/pinch capture over widgets** has Off, On, and Key modes: Off preserves local ownership, On always transfers only wheel/pinch to the canvas, and Key transfers only wheel/pinch while its binding is held. Key and full-navigation bindings accept modifier chords or Mouse3/Mouse4/Mouse5. A focused live Browser page therefore scrolls normally in Off or released-Key mode; unfocused Browser content, On, an active Key binding, the full override, pinch, and `Cmd/Ctrl + scroll` are canvas-owned. The owner selected for a Browser wheel sequence is held until 250 ms of wheel inactivity. Browser summaries and placeholders are always canvas-owned. Fresh profiles use Key with `Command` on macOS or `Ctrl` elsewhere. A separate configurable **Canvas navigation override** (default `Option` on macOS and `Alt` elsewhere) transfers wheel/pinch and drag while held. While full override is held all canvas surfaces show `grab`; an owned drag shows `grabbing` until pointer up/cancel even after the binding is released. - The minimap is an RTS-style camera-centred radar over the infinite Canvas. Its projection uses one uniform scale and never auto-fits around HOME or visible objects, so pan moves HOME, regions, and fixed-size window markers through the frame without stretching them. The white camera rectangle stays centred at `46 × 30px`; HOME and windows may cross and leave the radar. Controls chooses between two mutually exclusive modes: **Click** (the default, which recentres without changing zoom) and **Drag**. Drag mirrors empty-canvas grab semantics: pointer-down never jumps the camera, moving right/down pulls the scene right/down from the gesture's starting camera, and pointer capture continues outside the radar. Pressing and releasing without movement in Drag mode never recentres the camera. Arrow keys always pan it from keyboard focus. Minimap, passive shortcut hints, and Home/zoom controls share four corner slots and stack instead of overlapping when assigned to the same corner. diff --git a/src/main/services/SettingsStore.ts b/src/main/services/SettingsStore.ts index 6c6d505..b4547d6 100644 --- a/src/main/services/SettingsStore.ts +++ b/src/main/services/SettingsStore.ts @@ -23,6 +23,7 @@ import type { MinimapInteractionMode, PaletteId, PluginCanvasInstance, + RadialLauncherItemId, SessionRowColorMode, ShortcutBindings, StickyNote, @@ -34,11 +35,13 @@ import { DEFAULT_HOME_ACCENT_COLORS, DEFAULT_HOME_GRID_SIZE, DEFAULT_HOME_LAYOUT, + DEFAULT_RADIAL_LAUNCHER_ITEMS, DEFAULT_UI_SCALE, HOME_GRID_MAX_COLUMNS, HOME_GRID_MAX_ROWS, HOME_GRID_MIN_COLUMNS, HOME_GRID_MIN_ROWS, + RADIAL_LAUNCHER_ITEMS, STICKY_NOTE_MAX_SIZE, STICKY_NOTE_MIN_SIZE, UI_SCALE_MAX, @@ -59,7 +62,7 @@ const SESSION_ROW_COLOR_MODES = new Set(["monochrome", "sta const CANVAS_COLORS = new Set(["sage", "lilac", "night", "sand", "mist", "rose", "slate"]); const PATTERNS = new Set(["dots", "grid", "waves", "diagonal", "rings", "none"]); const MEDIA_FITS = new Set(["cover", "contain"]); -const SETTINGS_VERSION = 13; +const SETTINGS_VERSION = 14; const GROK_LAUNCHER_SETTINGS_VERSION = 3; const EXPANDED_LIMIT_SETTINGS_VERSION = 5; const QWEN_SETTINGS_VERSION = 6; @@ -71,6 +74,7 @@ const PRE_QWEN_LIMIT_PROVIDERS: LimitProviderId[] = [...LEGACY_LIMIT_PROVIDERS, const LIMIT_PROVIDERS: LimitProviderId[] = ["codex", "claude", "qwen", "kimi", "opencode", "grok"]; const LIMIT_PROVIDER_SET = new Set(LIMIT_PROVIDERS); const CANVAS_LAUNCHER_ITEM_SET = new Set(CANVAS_LAUNCHER_ITEMS); +const RADIAL_LAUNCHER_ITEM_SET = new Set(RADIAL_LAUNCHER_ITEMS); const EDGE_PAN_SPEEDS = new Set(["slow", "normal", "fast"]); const ZOOM_SENSITIVITIES = new Set(["slow", "normal", "fast"]); const FOCUS_ACTIVATIONS = new Set(["off", "single", "double"]); @@ -133,6 +137,7 @@ export class SettingsStore { || !("homeLauncherProviders" in source) || !("homeLimitProviders" in source) || !("canvasLauncherItems" in source) + || !("radialLauncherItems" in source) || !("agentLifecycleHooksEnabled" in source) || !("uiScale" in source) || !("canvasColor" in source) @@ -246,6 +251,7 @@ function createDefaults(systemLocale: string, platform: CanvasNavigationPlatform homeLauncherProviders: [...AGENT_PROVIDERS], homeLimitProviders: [...LIMIT_PROVIDERS], canvasLauncherItems: [...DEFAULT_CANVAS_LAUNCHER_ITEMS], + radialLauncherItems: [...DEFAULT_RADIAL_LAUNCHER_ITEMS], agentLifecycleHooksEnabled: true, uiScale: DEFAULT_UI_SCALE, canvasColor: "sage", @@ -343,6 +349,10 @@ export function normalizeSettings( source.canvasLauncherItems, fallback.canvasLauncherItems ?? DEFAULT_CANVAS_LAUNCHER_ITEMS ); + const radialLauncherItems = normalizeRadialLauncherItems( + source.radialLauncherItems, + fallback.radialLauncherItems ?? DEFAULT_RADIAL_LAUNCHER_ITEMS + ); const palette = PALETTES.has(source.palette as PaletteId) ? source.palette as PaletteId : fallback.palette; const canvasColorCandidate = (source as Record).canvasColor; const canvasColor = canvasColorCandidate === undefined || canvasColorCandidate === "palette" @@ -373,6 +383,7 @@ export function normalizeSettings( homeLauncherProviders, homeLimitProviders, canvasLauncherItems, + radialLauncherItems, agentLifecycleHooksEnabled: typeof source.agentLifecycleHooksEnabled === "boolean" ? source.agentLifecycleHooksEnabled : fallback.agentLifecycleHooksEnabled, @@ -463,6 +474,20 @@ export function normalizeCanvasLauncherItems( return result.length > 0 ? result : [...fallback]; } +export function normalizeRadialLauncherItems( + candidate: unknown, + fallback: readonly RadialLauncherItemId[] = DEFAULT_RADIAL_LAUNCHER_ITEMS +): RadialLauncherItemId[] { + if (!Array.isArray(candidate)) return [...fallback]; + const result: RadialLauncherItemId[] = []; + for (const item of candidate) { + if (typeof item !== "string" || !RADIAL_LAUNCHER_ITEM_SET.has(item as RadialLauncherItemId)) continue; + if (!result.includes(item as RadialLauncherItemId)) result.push(item as RadialLauncherItemId); + if (result.length === 8) break; + } + return result.length > 0 ? result : [...fallback]; +} + export function normalizeUiScale(candidate: unknown, fallback = DEFAULT_UI_SCALE): number { if (typeof candidate !== "number" || !Number.isFinite(candidate)) return fallback; const stepped = Math.round(candidate / UI_SCALE_STEP) * UI_SCALE_STEP; diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index b9397ab..a57569a 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -30,6 +30,7 @@ import { DEFAULT_HOME_GRID_SIZE, DEFAULT_HOME_LAYOUT, DEFAULT_CANVAS_LAUNCHER_ITEMS, + DEFAULT_RADIAL_LAUNCHER_ITEMS, DEFAULT_UI_SCALE, DEFAULT_SHORTCUTS } from "../../shared/contracts"; @@ -76,6 +77,7 @@ const FALLBACK_SETTINGS: AppSettings = { homeLauncherProviders: ["codex", "claude", "qwen", "kimi", "opencode", "hermes", "grok"], homeLimitProviders: ["codex", "claude", "qwen", "kimi", "opencode", "grok"], canvasLauncherItems: [...DEFAULT_CANVAS_LAUNCHER_ITEMS], + radialLauncherItems: [...DEFAULT_RADIAL_LAUNCHER_ITEMS], agentLifecycleHooksEnabled: true, uiScale: DEFAULT_UI_SCALE, canvasColor: "sage", diff --git a/src/renderer/src/features/launcher/QuickRadialMenu.tsx b/src/renderer/src/features/launcher/QuickRadialMenu.tsx new file mode 100644 index 0000000..fcecb0a --- /dev/null +++ b/src/renderer/src/features/launcher/QuickRadialMenu.tsx @@ -0,0 +1,147 @@ +import { useEffect, useRef, useState } from "react"; +import type { + LocaleId, + Point, + ProviderId, + RadialLauncherItemId +} from "../../../../shared/contracts"; +import { ProviderIcon } from "../../components/ProviderIcon"; +import { UiIcon } from "../../components/UiIcon"; +import { t } from "../../lib/i18n"; +import { PROVIDERS } from "../../lib/providers"; +import { radialItemAtPointer, radialItemOffset } from "./radialLauncher"; + +interface RadialLauncherProps { + anchor: Point; + pointerAnchor: Point; + items: RadialLauncherItemId[]; + locale: LocaleId; + pointerId: number; + onActivate(item: RadialLauncherItemId, fromPointerRelease?: boolean): void; + onClose(reason?: "release" | "cancel"): void; +} + +const PROVIDER_IDS = new Set([ + "terminal", "codex", "claude", "qwen", "kimi", "opencode", "hermes", "grok" +]); + +export function RadialLauncher({ + anchor, + pointerAnchor, + items, + locale, + pointerId, + onActivate, + onClose +}: RadialLauncherProps): React.JSX.Element { + const [highlighted, setHighlighted] = useState(null); + const highlightedRef = useRef(highlighted); + highlightedRef.current = highlighted; + const menuRef = useRef(null); + + useEffect(() => { + menuRef.current?.focus({ preventScroll: true }); + }, []); + + useEffect(() => { + const handleMove = (event: PointerEvent): void => { + if (event.pointerId !== pointerId) return; + const next = radialItemAtPointer(pointerAnchor, { x: event.clientX, y: event.clientY }, items.length); + highlightedRef.current = next; + setHighlighted(next); + }; + const handleUp = (event: PointerEvent): void => { + if (event.pointerId !== pointerId || event.button !== 2) return; + const item = highlightedRef.current === null ? null : items[highlightedRef.current]; + if (item) onActivate(item, true); + else onClose("release"); + }; + const handleDown = (event: PointerEvent): void => { + if (event.button === 0 && !(event.target instanceof Element && event.target.closest(".radial-launcher"))) { + onClose("cancel"); + } + }; + const handleKeyDown = (event: KeyboardEvent): void => { + if (event.key === "Escape") { + event.preventDefault(); + onClose("cancel"); + return; + } + if (event.key === "ArrowRight" || event.key === "ArrowDown") { + event.preventDefault(); + setHighlighted((current) => current === null ? 0 : (current + 1) % items.length); + return; + } + if (event.key === "ArrowLeft" || event.key === "ArrowUp") { + event.preventDefault(); + setHighlighted((current) => current === null ? items.length - 1 : (current - 1 + items.length) % items.length); + return; + } + if ((event.key === "Enter" || event.key === " ") && highlightedRef.current !== null) { + event.preventDefault(); + onActivate(items[highlightedRef.current]); + } + }; + window.addEventListener("pointermove", handleMove, true); + window.addEventListener("pointerup", handleUp, true); + window.addEventListener("pointerdown", handleDown, true); + window.addEventListener("keydown", handleKeyDown, true); + return () => { + window.removeEventListener("pointermove", handleMove, true); + window.removeEventListener("pointerup", handleUp, true); + window.removeEventListener("pointerdown", handleDown, true); + window.removeEventListener("keydown", handleKeyDown, true); + }; + }, [items, onActivate, onClose, pointerAnchor, pointerId]); + + return ( +
event.preventDefault()} + > + + {items.map((item, index) => { + const offset = radialItemOffset(index, items.length); + const active = highlighted === index; + return ( + + ); + })} +
+ ); +} + +function renderIcon(item: RadialLauncherItemId): React.JSX.Element { + if (PROVIDER_IDS.has(item as ProviderId)) return ; + if (item === "browser") return ; + if (item === "settings") return ; + return ; +} + +export function itemLabel(locale: LocaleId, item: RadialLauncherItemId): string { + if (item === "note") return t(locale, "stickyNote"); + if (item === "browser") return t(locale, "browser"); + if (item === "settings") return t(locale, "settings"); + return PROVIDERS[item].label; +} diff --git a/src/renderer/src/features/launcher/radialLauncher.ts b/src/renderer/src/features/launcher/radialLauncher.ts new file mode 100644 index 0000000..a69a855 --- /dev/null +++ b/src/renderer/src/features/launcher/radialLauncher.ts @@ -0,0 +1,42 @@ +import type { Point, RadialLauncherItemId } from "../../../../shared/contracts"; + +export const RADIAL_LAUNCHER_RADIUS = 126; +export const RADIAL_LAUNCHER_DEAD_ZONE = 28; + +export function radialItemOffset(index: number, count: number, radius = RADIAL_LAUNCHER_RADIUS): Point { + if (count <= 0) return { x: 0, y: 0 }; + const angle = -Math.PI / 2 + index * (Math.PI * 2 / count); + return { x: Math.cos(angle) * radius, y: Math.sin(angle) * radius }; +} + +export function radialItemAtPointer( + anchor: Point, + pointer: Point, + count: number, + deadZone = RADIAL_LAUNCHER_DEAD_ZONE +): number | null { + if (count <= 0) return null; + const dx = pointer.x - anchor.x; + const dy = pointer.y - anchor.y; + if (Math.hypot(dx, dy) < deadZone) return null; + const pointerAngle = normalizeAngle(Math.atan2(dy, dx) + Math.PI / 2); + return Math.round(pointerAngle / (Math.PI * 2 / count)) % count; +} + +export function setRadialLauncherItemEnabled( + current: readonly RadialLauncherItemId[], + item: RadialLauncherItemId, + enabled: boolean +): RadialLauncherItemId[] { + if (!enabled) { + const next = current.filter((candidate) => candidate !== item); + return next.length > 0 ? next : [...current]; + } + if (current.includes(item) || current.length >= 8) return [...current]; + return [...current, item]; +} + +function normalizeAngle(value: number): number { + const fullCircle = Math.PI * 2; + return ((value % fullCircle) + fullCircle) % fullCircle; +} diff --git a/src/renderer/src/features/settings/SettingsPanel.tsx b/src/renderer/src/features/settings/SettingsPanel.tsx index 694f10a..68741ff 100644 --- a/src/renderer/src/features/settings/SettingsPanel.tsx +++ b/src/renderer/src/features/settings/SettingsPanel.tsx @@ -25,6 +25,7 @@ import type { PluginManifest, PluginInstallPreview, PluginUpdateStatus, + RadialLauncherItemId, SessionRowColorMode, ShortcutAction, ZoomSensitivity @@ -33,6 +34,8 @@ import { BROWSER_PROVIDER_COLORS, CANVAS_LAUNCHER_ITEMS, DEFAULT_CANVAS_LAUNCHER_ITEMS, + DEFAULT_RADIAL_LAUNCHER_ITEMS, + RADIAL_LAUNCHER_ITEMS, UI_SCALE_MAX, UI_SCALE_MIN, UI_SCALE_STEP @@ -70,6 +73,8 @@ import { CanvasNavigationShortcutEditor } from "./CanvasNavigationShortcutEditor import { AgentHooksSettings } from "./AgentHooksSettings"; import { AboutSettings } from "./AboutSettings"; import { setCanvasLauncherItemEnabled } from "../launcher/canvasLauncher"; +import { itemLabel } from "../launcher/QuickRadialMenu"; +import { setRadialLauncherItemEnabled } from "../launcher/radialLauncher"; type SettingsSection = "general" | "appearance" | "agents" | "controls" | "browser" | "plugins" | "about"; @@ -550,6 +555,41 @@ export function SettingsPanel({ >{t(locale, "resetCanvasLauncher")} + +
+ {t(locale, "quickLauncherCount").replace("{count}", String(settings.radialLauncherItems.length))} + {RADIAL_LAUNCHER_ITEMS.map((item: RadialLauncherItemId) => { + const enabled = settings.radialLauncherItems.includes(item); + return ( + void onChange({ + radialLauncherItems: setRadialLauncherItemEnabled( + settings.radialLauncherItems, + item, + !enabled + ) + })} + >{itemLabel(locale, item)} + ); + })} + + void onChange({ radialLauncherItems: [...DEFAULT_RADIAL_LAUNCHER_ITEMS] })} + >{t(locale, "useDefaults")} +
+
(null); const [regionEditor, setRegionEditor] = useState(null); const [commandPaletteOpen, setCommandPaletteOpen] = useState(false); + const [radialLauncher, setRadialLauncher] = useState<{ + anchor: Point; + pointerAnchor: Point; + canvasPosition: Point; + pointerId: number; + } | null>(null); + const suppressNextContextMenu = useRef(false); + const pendingRadialContextMenu = useRef(null); const [noteEditRequest, setNoteEditRequest] = useState<{ id: string; version: number } | null>(null); const [regionMovePreview, setRegionMovePreview] = useState(null); const cameraRef = useRef(camera); @@ -353,11 +363,54 @@ export function WorkspaceCanvas(props: WorkspaceCanvasProps): React.JSX.Element setCommandPaletteOpen(false); }, [onOpenAgent, onOpenTerminal]); + const activateRadialItem = useCallback((item: RadialLauncherItemId, fromPointerRelease = false): void => { + const launcher = radialLauncher; + if (!launcher) return; + suppressNextContextMenu.current = fromPointerRelease; + if (fromPointerRelease) { + window.setTimeout(() => { + suppressNextContextMenu.current = false; + }, 0); + } + pendingRadialContextMenu.current = null; + setRadialLauncher(null); + if (item === "note") createNote(launcher.canvasPosition); + else if (item === "browser") onOpenBrowser(launcher.canvasPosition); + else if (item === "settings") onOpenSettings(); + else launchAt(item, launcher.canvasPosition); + }, [createNote, launchAt, onOpenBrowser, onOpenSettings, radialLauncher]); + + const closeRadialLauncher = useCallback((reason: "release" | "cancel" = "cancel"): void => { + setRadialLauncher(null); + if (reason === "release" && pendingRadialContextMenu.current) { + setContextMenu(pendingRadialContextMenu.current); + } + pendingRadialContextMenu.current = null; + }, []); + + const openRadialLauncher = useCallback((event: React.PointerEvent): boolean => { + if (event.button !== 2 || shouldKeepCanvasContextMenu(event.target)) return false; + const anchor = viewportPoint(event.clientX, event.clientY); + pendingRadialContextMenu.current = null; + setContextMenu(null); + setRegionEditor(null); + setCommandPaletteOpen(false); + setRadialLauncher({ + anchor, + pointerAnchor: { x: event.clientX, y: event.clientY }, + canvasPosition: worldPoint(event.clientX, event.clientY), + pointerId: event.pointerId + }); + event.stopPropagation(); + return true; + }, [viewportPoint, worldPoint]); + useEffect(() => { if (homeEditing || !browserViewVisible) { setContextMenu(null); setRegionEditor(null); setCommandPaletteOpen(false); + setRadialLauncher(null); return; } const handleShortcut = (event: KeyboardEvent): void => { @@ -391,6 +444,7 @@ export function WorkspaceCanvas(props: WorkspaceCanvasProps): React.JSX.Element ref={viewport} className={`workspace pattern-${settings.pattern} ${pointerNavigation.panning ? "workspace--panning" : ""} ${canvasOverrideActive ? "workspace--canvas-override" : ""}`} onPointerDownCapture={(event) => { + if (openRadialLauncher(event)) return; const element = event.target as HTMLElement; if (event.button === 0) { const layerId = element.closest("[data-canvas-layer-id]")?.dataset.canvasLayerId; @@ -418,6 +472,11 @@ export function WorkspaceCanvas(props: WorkspaceCanvasProps): React.JSX.Element onPointerCancel={pointerNavigation.handlePointerEnd} onPointerLeave={pointerNavigation.handlePointerLeave} onContextMenu={(event) => { + if (suppressNextContextMenu.current) { + suppressNextContextMenu.current = false; + event.preventDefault(); + return; + } const element = event.target as HTMLElement; const regionId = element.closest("[data-canvas-region-id]")?.dataset.canvasRegionId; const noteId = element.closest("[data-sticky-note-id]")?.dataset.stickyNoteId; @@ -435,12 +494,17 @@ export function WorkspaceCanvas(props: WorkspaceCanvasProps): React.JSX.Element event.preventDefault(); setRegionEditor(null); setCommandPaletteOpen(false); - setContextMenu({ + const nextContextMenu: CanvasMenuState = { kind, position: menuPosition(event.clientX, event.clientY), worldPoint: worldPoint(event.clientX, event.clientY), targetId: kind === "region" ? regionId : kind === "note" ? noteId : undefined - }); + }; + if (radialLauncher) { + pendingRadialContextMenu.current = nextContextMenu; + return; + } + setContextMenu(nextContextMenu); }} >
@@ -591,7 +655,7 @@ export function WorkspaceCanvas(props: WorkspaceCanvasProps): React.JSX.Element zoom={camera.zoom} camera={camera} visible={browserViewVisible && !homeEditing && contextMenu === null - && regionEditor === null && !commandPaletteOpen && !browserOccluded} + && regionEditor === null && !commandPaletteOpen && radialLauncher === null && !browserOccluded} stackIndex={canvasLayerZIndex(layerOrder, browserLayerId)} uiScale={settings.uiScale} snapEnabled={settings.snapToGrid} @@ -708,6 +772,18 @@ export function WorkspaceCanvas(props: WorkspaceCanvasProps): React.JSX.Element /> )} + {radialLauncher && ( + + )} + {regionEditor && (regionEditor.mode === "create" || editedRegion) && ( = { homeLauncherAgents: "Agents in the launcher", homeLauncherAgentsDescription: "Disabled agents are hidden only from the HOME launcher. Existing sessions keep running.", canvasLauncherItems: "Canvas launcher items", + quickLauncher: "Quick launcher", + quickLauncherDescription: "Hold the right mouse button on the Canvas, point toward an action, and release. Choose up to eight actions.", + quickLauncherCount: "Selected: {count}/8", + useDefaults: "Use defaults", + releaseToLaunch: "Release to launch", canvasLauncherItemsDescription: "Choose the safe agent and terminal set shown in the context-menu launcher and command palette.", canvasLauncherSettingsLabel: "launcher setup", resetCanvasLauncher: "Reset launcher set", diff --git a/src/renderer/src/styles/app.css b/src/renderer/src/styles/app.css index 1bac6e3..f611453 100644 --- a/src/renderer/src/styles/app.css +++ b/src/renderer/src/styles/app.css @@ -56,6 +56,71 @@ button { border: 0; } .workspace__windows { position: absolute; left: 0; top: 0; } .workspace__windows--hidden { visibility: hidden; pointer-events: none; } +.radial-launcher { + position: absolute; + z-index: 8500; + width: 0; + height: 0; + outline: 0; + cursor: default; + font-size: calc(13px * var(--ui-scale, 1)); + filter: drop-shadow(0 1.1em 2.2em color-mix(in srgb, var(--surface) 48%, transparent)); +} +.radial-launcher__center { + position: absolute; + left: 0; + top: 0; + width: 6.6em; + height: 6.6em; + padding: 1.05em; + display: grid; + place-items: center; + border: .08em solid color-mix(in srgb, var(--text) 18%, transparent); + border-radius: 50%; + color: var(--text-muted-on-dark); + background: color-mix(in srgb, var(--surface) 93%, transparent); + box-shadow: inset 0 .08em color-mix(in srgb, var(--text) 8%, transparent); + font: 800 .7em/1.25 var(--font-mono); + text-align: center; + transform: translate(-50%, -50%); + backdrop-filter: blur(.9em); +} +.radial-launcher__item { + position: absolute; + left: 0; + top: 0; + width: 6.6em; + min-height: 5.4em; + padding: .6em .45em; + display: grid; + grid-template-rows: 2.6em auto; + place-items: center; + gap: .25em; + border: .08em solid color-mix(in srgb, var(--text) 13%, transparent); + border-radius: 1.4em; + color: var(--text); + background: color-mix(in srgb, var(--surface-soft) 94%, transparent); + box-shadow: inset 0 .08em color-mix(in srgb, var(--text) 6%, transparent); + cursor: pointer; + font-size: .7em; + font-weight: 900; + text-align: center; + transition: width .1s ease, min-height .1s ease, color .1s ease, background .1s ease, box-shadow .1s ease; + backdrop-filter: blur(.9em); +} +.radial-launcher__item--active, +.radial-launcher__item:hover, +.radial-launcher__item:focus-visible { + width: 7.25em; + min-height: 5.85em; + color: var(--text-dark); + background: var(--primary); + box-shadow: 0 0 0 .3em color-mix(in srgb, var(--text) 18%, transparent), inset 0 .08em color-mix(in srgb, var(--text) 50%, transparent); + outline: 0; +} +.radial-launcher__icon { height: 2.6em; display: grid; place-items: center; } +.radial-launcher__item .provider-icon { --provider-icon-art-size: 1.85em; } + .tile { position: relative; overflow: hidden; border: 1px solid var(--border); border-radius: var(--radius-lg); box-shadow: var(--shadow-md); } .home-zone { position: absolute; left: 0; top: 0; display: grid; gap: 18px; border-radius: calc(var(--radius-lg) + 4px); } diff --git a/src/shared/contracts.ts b/src/shared/contracts.ts index 5ff02c9..71358fb 100644 --- a/src/shared/contracts.ts +++ b/src/shared/contracts.ts @@ -20,6 +20,8 @@ export type BrowserViewportSurface = "native" | "placeholder" | "hidden"; export type FocusActivation = "off" | "single" | "double"; export type ShortcutAction = "home" | "renameWindow"; export type CanvasLauncherItemId = ProviderId; +export type RadialLauncherActionId = "note" | "browser" | "settings"; +export type RadialLauncherItemId = ProviderId | RadialLauncherActionId; export const CANVAS_LAUNCHER_ITEMS: readonly CanvasLauncherItemId[] = [ "codex", @@ -42,6 +44,31 @@ export const DEFAULT_CANVAS_LAUNCHER_ITEMS: readonly CanvasLauncherItemId[] = [ "terminal" ]; +export const RADIAL_LAUNCHER_ITEMS: readonly RadialLauncherItemId[] = [ + "codex", + "claude", + "qwen", + "kimi", + "opencode", + "hermes", + "grok", + "terminal", + "note", + "browser", + "settings" +]; + +export const DEFAULT_RADIAL_LAUNCHER_ITEMS: readonly RadialLauncherItemId[] = [ + "codex", + "claude", + "qwen", + "opencode", + "note", + "terminal", + "browser", + "settings" +]; + export const UI_SCALE_MIN = 0.85; export const UI_SCALE_MAX = 1.25; export const UI_SCALE_STEP = 0.05; @@ -159,6 +186,7 @@ export interface AppSettings { homeLauncherProviders: AgentProviderId[]; homeLimitProviders: LimitProviderId[]; canvasLauncherItems: CanvasLauncherItemId[]; + radialLauncherItems: RadialLauncherItemId[]; agentLifecycleHooksEnabled: boolean; uiScale: number; canvasColor: CanvasColorId; diff --git a/tests/radial-launcher.test.mjs b/tests/radial-launcher.test.mjs new file mode 100644 index 0000000..4e8950d --- /dev/null +++ b/tests/radial-launcher.test.mjs @@ -0,0 +1,62 @@ +import assert from "node:assert/strict"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { + radialItemAtPointer, + radialItemOffset, + setRadialLauncherItemEnabled +} from "../src/renderer/src/features/launcher/radialLauncher.ts"; +import { normalizeRadialLauncherItems, SettingsStore } from "../src/main/services/SettingsStore.ts"; + +test("direction selection follows the visual radial positions", () => { + const anchor = { x: 400, y: 300 }; + for (let index = 0; index < 8; index += 1) { + const offset = radialItemOffset(index, 8); + assert.equal( + radialItemAtPointer(anchor, { x: anchor.x + offset.x, y: anchor.y + offset.y }, 8), + index + ); + } + assert.equal(radialItemAtPointer(anchor, anchor, 8), null); +}); + +test("custom launcher contents preserve user order and enforce the eight-item limit", () => { + assert.deepEqual( + normalizeRadialLauncherItems([ + "note", "codex", "unknown", "note", "settings", "browser", "terminal", + "claude", "qwen", "kimi", "grok" + ]), + ["note", "codex", "settings", "browser", "terminal", "claude", "qwen", "kimi"] + ); +}); + +test("launcher toggles retain at least one action", () => { + let items = ["codex", "note"]; + items = setRadialLauncherItemEnabled(items, "browser", true); + assert.deepEqual(items, ["codex", "note", "browser"]); + items = setRadialLauncherItemEnabled(items, "browser", false); + assert.deepEqual(items, ["codex", "note"]); + assert.deepEqual(setRadialLauncherItemEnabled(["note"], "note", false), ["note"]); +}); + +test("invalid or empty persisted configurations recover to defaults", () => { + assert.deepEqual(normalizeRadialLauncherItems([]), [ + "codex", "claude", "qwen", "opencode", "note", "terminal", "browser", "settings" + ]); + assert.deepEqual(normalizeRadialLauncherItems("codex", ["note"]), ["note"]); +}); + +test("a custom launcher selection persists through SettingsStore", async () => { + const directory = await mkdtemp(join(tmpdir(), "canvastty-radial-settings-")); + try { + const store = new SettingsStore(directory, "en", "darwin"); + await store.load(); + await store.update({ radialLauncherItems: ["codex", "note"] }); + const reloaded = await new SettingsStore(directory, "en", "darwin").load(); + assert.deepEqual(reloaded.radialLauncherItems, ["codex", "note"]); + } finally { + await rm(directory, { recursive: true, force: true }); + } +}); diff --git a/tests/settings-normalizer.test.mjs b/tests/settings-normalizer.test.mjs index 9f71ce4..a1b34a9 100644 --- a/tests/settings-normalizer.test.mjs +++ b/tests/settings-normalizer.test.mjs @@ -28,6 +28,7 @@ const fallback = { homeLauncherProviders: ["codex", "claude", "qwen", "kimi", "opencode", "hermes", "grok"], homeLimitProviders: ["codex", "claude", "qwen", "kimi", "opencode", "grok"], canvasLauncherItems: ["codex", "claude", "qwen", "opencode", "terminal"], + radialLauncherItems: ["codex", "claude", "qwen", "opencode", "note", "terminal", "browser", "settings"], agentLifecycleHooksEnabled: true, uiScale: 1, canvasColor: "sage", @@ -367,7 +368,7 @@ test("the Qwen migration does not rerun the older expanded-limit migration", asy assert.deepEqual(loaded.homeLimitProviders, ["codex", "claude", "kimi"]); const persisted = JSON.parse(await readFile(join(dir, "settings.json"), "utf8")); - assert.equal(persisted.settingsVersion, 13); + assert.equal(persisted.settingsVersion, 14); assert.equal(persisted.agentLifecycleHooksEnabled, true); assert.deepEqual(persisted.homeLimitProviders, ["codex", "claude", "kimi"]); } finally { @@ -390,7 +391,7 @@ test("the limit-display migration preserves a version-three launcher subset", as assert.deepEqual(loaded.homeLimitProviders, fallback.homeLimitProviders); const persisted = JSON.parse(await readFile(join(dir, "settings.json"), "utf8")); - assert.equal(persisted.settingsVersion, 13); + assert.equal(persisted.settingsVersion, 14); assert.equal(persisted.agentLifecycleHooksEnabled, true); assert.deepEqual(persisted.homeLimitProviders, fallback.homeLimitProviders); } finally { @@ -410,7 +411,7 @@ test("the expanded limit migration preserves a curated version-four subset", asy assert.deepEqual(loaded.homeLimitProviders, ["kimi"]); const persisted = JSON.parse(await readFile(join(dir, "settings.json"), "utf8")); - assert.equal(persisted.settingsVersion, 13); + assert.equal(persisted.settingsVersion, 14); assert.equal(persisted.agentLifecycleHooksEnabled, true); assert.deepEqual(persisted.homeLimitProviders, ["kimi"]); } finally { @@ -517,7 +518,7 @@ test("existing profiles migrate minimap interaction to click and persist later c const store = new SettingsStore(dir, "en"); assert.equal((await store.load()).minimapInteractionMode, "click"); let persisted = JSON.parse(await readFile(join(dir, "settings.json"), "utf8")); - assert.equal(persisted.settingsVersion, 13); + assert.equal(persisted.settingsVersion, 14); assert.equal(persisted.minimapInteractionMode, "click"); await store.update({ minimapInteractionMode: "drag" });