diff --git a/crates/vcad-desktop/Cargo.toml b/crates/vcad-desktop/Cargo.toml index 9dcd4e04..2702131d 100644 --- a/crates/vcad-desktop/Cargo.toml +++ b/crates/vcad-desktop/Cargo.toml @@ -37,3 +37,5 @@ reqwest = { version = "0.12", default-features = false, features = ["rustls-tls" [target."cfg(target_os = \"macos\")".dependencies] window-vibrancy = "0.6" +cocoa = "0.26" +objc = "0.2" diff --git a/crates/vcad-desktop/src/commands/context_menu.rs b/crates/vcad-desktop/src/commands/context_menu.rs new file mode 100644 index 00000000..fa8bb4ca --- /dev/null +++ b/crates/vcad-desktop/src/commands/context_menu.rs @@ -0,0 +1,155 @@ +//! Native context-menu popup. +//! +//! The webview describes the menu as a flat tree of items (label + id, with +//! optional separators, accelerators, disabled state, and submenus); we +//! build a real `tauri::menu::Menu`, pop it under the cursor, and emit a +//! `context-menu-select` event with the chosen id back to the window. The +//! webview dispatches the action. +//! +//! We use Tauri's menu builder rather than touching `NSMenu` directly so +//! Linux/Windows still get a real OS menu (GTK / Win32) instead of the +//! Radix-rendered fallback. That fallback is still used in the browser +//! build where no Tauri runtime exists. + +use serde::{Deserialize, Serialize}; +use std::sync::Mutex; +use tauri::{ + menu::{ + ContextMenu, MenuBuilder, MenuItem, MenuItemBuilder, PredefinedMenuItem, SubmenuBuilder, + }, + AppHandle, Emitter, Manager, Runtime, +}; + +/// Item spec coming from the webview. `kind` discriminates the variant — +/// keeps the JSON shape obvious in DevTools and avoids `Option` churn. +#[derive(Debug, Deserialize)] +#[serde(tag = "kind", rename_all = "lowercase")] +pub enum ItemSpec { + /// Regular clickable item. + Item { + id: String, + label: String, + #[serde(default)] + accelerator: Option, + #[serde(default)] + disabled: bool, + /// If set, item renders with a leading checkmark (radio-group feel). + #[serde(default)] + checked: bool, + }, + /// Visual divider; no id, no action. + Separator, + /// Nested submenu opened on hover. + Submenu { label: String, items: Vec }, +} + +#[derive(Debug, Serialize, Clone)] +struct SelectEvent<'a> { + id: &'a str, +} + +/// Holds the most-recently-built popup menu so its `MenuItem` handles +/// outlive the on-click closures (Tauri requires the menu to stay alive +/// while it's onscreen). We swap on each popup. +pub struct ContextMenuState { + last: Mutex>>, +} + +impl ContextMenuState { + pub fn new() -> Self { + Self { + last: Mutex::new(None), + } + } +} + +fn build_item(app: &AppHandle, spec: &ItemSpec) -> tauri::Result> { + match spec { + ItemSpec::Separator => Ok(BuiltItem::Separator(PredefinedMenuItem::separator(app)?)), + ItemSpec::Item { + id, + label, + accelerator, + disabled, + checked, + } => { + let display = if *checked { + format!("✓ {label}") + } else { + label.clone() + }; + let mut b = MenuItemBuilder::with_id(id, display); + if let Some(a) = accelerator { + b = b.accelerator(a); + } + if *disabled { + b = b.enabled(false); + } + Ok(BuiltItem::Leaf(b.build(app)?)) + } + ItemSpec::Submenu { label, items } => { + let mut sub = SubmenuBuilder::new(app, label); + for child in items { + match build_item(app, child)? { + BuiltItem::Leaf(item) => sub = sub.item(&item), + BuiltItem::Separator(sep) => sub = sub.item(&sep), + BuiltItem::Submenu(inner) => sub = sub.item(&inner), + } + } + Ok(BuiltItem::Submenu(sub.build()?)) + } + } +} + +enum BuiltItem { + Leaf(MenuItem), + Separator(PredefinedMenuItem), + Submenu(tauri::menu::Submenu), +} + +/// Build the menu and pop it at the cursor. Returns immediately — the +/// selected id (or none) arrives later as a `context-menu-select` event +/// on the calling window. We can't easily make this `await` the choice +/// because the menu loop is driven by the OS, not Rust. +#[tauri::command] +pub fn show_context_menu( + app: AppHandle, + items: Vec, +) -> Result<(), String> { + let mut menu = MenuBuilder::new(&app); + for spec in &items { + let built = build_item(&app, spec).map_err(|e| e.to_string())?; + menu = match built { + BuiltItem::Leaf(item) => menu.item(&item), + BuiltItem::Separator(sep) => menu.item(&sep), + BuiltItem::Submenu(sub) => menu.item(&sub), + }; + } + let menu = menu.build().map_err(|e| e.to_string())?; + + // ContextMenu::popup expects the parent `tauri::Window`. WebviewWindow + // wraps a Window but only exposes it indirectly: AsRef hands + // back the inner Webview, whose public `window()` returns the Window. + let webview_window = app + .get_webview_window("main") + .ok_or_else(|| "no main window".to_string())?; + let webview: &tauri::Webview = webview_window.as_ref(); + menu.popup(webview.window()).map_err(|e| e.to_string())?; + + if let Some(state) = app.try_state::>() { + if let Ok(mut slot) = state.last.lock() { + *slot = Some(menu); + } + } + Ok(()) +} + +/// Wired in `main.rs` via `on_menu_event` — when the user clicks an item in +/// any menu we built (popup or top-level), we emit `context-menu-select` +/// with its id. Top-level menu ids are namespaced; popup ids aren't, so +/// the webview can filter by listening to the right event. +pub fn handle_event(app: &AppHandle, id: &str) { + if let Some(window) = app.get_webview_window("main") { + let _ = window.emit("context-menu-select", SelectEvent { id }); + } +} diff --git a/crates/vcad-desktop/src/commands/mod.rs b/crates/vcad-desktop/src/commands/mod.rs index ef53062a..69e28b53 100644 --- a/crates/vcad-desktop/src/commands/mod.rs +++ b/crates/vcad-desktop/src/commands/mod.rs @@ -4,4 +4,5 @@ //! features come online; see the desktop plan for the staged rollout. pub mod bambu; +pub mod context_menu; pub mod local_ai; diff --git a/crates/vcad-desktop/src/main.rs b/crates/vcad-desktop/src/main.rs index a2126ad1..ae8ee465 100644 --- a/crates/vcad-desktop/src/main.rs +++ b/crates/vcad-desktop/src/main.rs @@ -6,7 +6,7 @@ mod platform; use tauri::Manager; -use commands::{bambu, local_ai}; +use commands::{bambu, context_menu, local_ai}; fn main() { vcad_i18n::init(&vcad_i18n::Locale::from_env()); @@ -27,6 +27,10 @@ fn main() { app.set_activation_policy(tauri::ActivationPolicy::Regular); } menu::install(&app.handle())?; + // Holds the live popup menu's items so they outlive the click + // closures — Tauri's popup is fire-and-forget and the OS keeps + // a weak ref to the menu object. + app.manage(context_menu::ContextMenuState::::new()); if let Some(window) = app.get_webview_window("main") { platform::apply_window_effects(&window); let _ = window.show(); @@ -35,7 +39,14 @@ fn main() { Ok(()) }) .on_menu_event(|app, event| { - menu::handle_event(app, event.id().as_ref()); + let id = event.id().as_ref(); + // Top-level menu and popup menus share Tauri's single event + // stream. We dispatch both: top-level ids land on the + // `menu-command` channel, popup ids on `context-menu-select`. + // The webview only listens to the relevant one for each + // surface, so harmless overlap if an id collides. + menu::handle_event(app, id); + context_menu::handle_event(app, id); }) .invoke_handler(tauri::generate_handler![ bambu::bambu_discover, @@ -46,6 +57,9 @@ fn main() { local_ai::local_ai_probe, local_ai::local_ai_chat_stream, menu::set_menu_enabled, + context_menu::show_context_menu, + platform::set_document_edited, + platform::set_represented_filename, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/crates/vcad-desktop/src/platform.rs b/crates/vcad-desktop/src/platform.rs index 2c2cfe0f..741f3946 100644 --- a/crates/vcad-desktop/src/platform.rs +++ b/crates/vcad-desktop/src/platform.rs @@ -1,36 +1,98 @@ //! Platform-specific window dressing. //! //! Applies native visual effects after the window is created — currently -//! the macOS "Under Window" vibrancy that lets our tinted panels pick up the -//! translucent blur users expect from apps like Linear, Things, or Notion. +//! the macOS sidebar vibrancy that lets our tinted panels pick up the +//! translucent blur users expect from apps like Linear, Things, Finder. +//! +//! Also exposes commands for native macOS title-bar affordances: +//! `setDocumentEdited:` (the dot inside the close traffic light), and +//! `setRepresentedFilename:` (the proxy icon + ⌘-click path popover, even +//! though our title is hidden — once shown it gets the icon for free). -use tauri::WebviewWindow; +use tauri::{AppHandle, Manager, Runtime, WebviewWindow}; /// Apply any platform-specific window effects (vibrancy, etc.) to `window`. pub fn apply_window_effects(window: &WebviewWindow) { #[cfg(target_os = "macos")] - mac::apply(window); + mac::apply_vibrancy(window); #[cfg(not(target_os = "macos"))] let _ = window; } +/// Toggle the modified-document indicator on the main window. On macOS this +/// renders as a dot inside the close traffic light — the standard signal +/// that a document has unsaved changes. +#[tauri::command] +pub fn set_document_edited(app: AppHandle, edited: bool) { + if let Some(window) = app.get_webview_window("main") { + #[cfg(target_os = "macos")] + mac::set_document_edited(&window, edited); + #[cfg(not(target_os = "macos"))] + let _ = (window, edited); + } +} + +/// Tell the OS this window represents a real file on disk. Powers the +/// proxy-icon drag and ⌘-click path popover when the title bar is visible; +/// also enables the standard "edited" badge in the Window menu. Pass an +/// empty string to clear. +#[tauri::command] +pub fn set_represented_filename(app: AppHandle, path: String) { + if let Some(window) = app.get_webview_window("main") { + #[cfg(target_os = "macos")] + mac::set_represented_filename(&window, &path); + #[cfg(not(target_os = "macos"))] + let _ = (window, path); + } +} + #[cfg(target_os = "macos")] mod mac { + use cocoa::appkit::NSWindow; + use cocoa::base::{id, nil, BOOL, NO, YES}; + use cocoa::foundation::NSString; use tauri::WebviewWindow; - use window_vibrancy::{apply_vibrancy, NSVisualEffectMaterial, NSVisualEffectState}; + use window_vibrancy::{apply_vibrancy as apply_v, NSVisualEffectMaterial, NSVisualEffectState}; - pub fn apply(window: &WebviewWindow) { - // UnderWindowBackground — the subtle material used in native macOS - // sidebars. "Active" state keeps the blur visible even when the - // window loses focus, which matches how Finder/Music behave. - if let Err(err) = apply_vibrancy( + /// Sidebar material — the strong, lively blur used for the leftmost + /// column in Finder, Mail, Music, Notes. We let it bleed through the + /// whole window: chrome panels paint translucent over it, the 3D + /// viewport canvas paints opaque. "Active" state keeps the blur lit + /// even when the window loses focus, matching system apps. + pub fn apply_vibrancy(window: &WebviewWindow) { + if let Err(err) = apply_v( window, - NSVisualEffectMaterial::UnderWindowBackground, + NSVisualEffectMaterial::Sidebar, Some(NSVisualEffectState::Active), None, ) { eprintln!("[platform] apply_vibrancy failed: {err}"); } } + + /// `[[NSWindow setDocumentEdited:]]` — the dot inside the close traffic + /// light. Cheapest possible "this is a real Mac app" signal. + pub fn set_document_edited(window: &WebviewWindow, edited: bool) { + if let Ok(ptr) = window.ns_window() { + unsafe { + let ns_window = ptr as id; + let flag: BOOL = if edited { YES } else { NO }; + NSWindow::setDocumentEdited_(ns_window, flag); + } + } + } + + /// `[[NSWindow setRepresentedFilename:]]` — surfaces the proxy icon and + /// path popover when the title is visible, and unlocks Window menu's + /// "Recent Documents" automatically. Empty string clears it. + pub fn set_represented_filename(window: &WebviewWindow, path: &str) { + if let Ok(ptr) = window.ns_window() { + unsafe { + let ns_window = ptr as id; + let ns_str = NSString::alloc(nil).init_str(path); + NSWindow::setRepresentedFilename_(ns_window, ns_str); + } + } + } } diff --git a/packages/app/src/App.tsx b/packages/app/src/App.tsx index 8d06cbc4..47112795 100644 --- a/packages/app/src/App.tsx +++ b/packages/app/src/App.tsx @@ -23,6 +23,7 @@ import { FeatureTree } from "@/components/FeatureTree"; import { MobileShell } from "@/components/mobile/MobileShell"; import { useIsMobile } from "@/hooks/useIsMobile"; import { useNativeWindowDrag } from "@/hooks/useNativeWindowDrag"; +import { useNativeShellClass } from "@/lib/capabilities"; import { lazyWithRetry } from "@/lib/lazy-with-retry"; // Lazy-loaded components (behind user actions, modals, or conditional renders). @@ -207,6 +208,7 @@ function FeatureTreeSlot({ sketchActive }: { sketchActive: boolean }) { export function App() { useEngine(); useThemeSync(); + useNativeShellClass(); useAutoSave(); useCollabSync(); useChatHandler(); diff --git a/packages/app/src/components/ContextMenu.tsx b/packages/app/src/components/ContextMenu.tsx index 6e16075e..aaf5dbf3 100644 --- a/packages/app/src/components/ContextMenu.tsx +++ b/packages/app/src/components/ContextMenu.tsx @@ -8,8 +8,20 @@ import { Intersect } from "@phosphor-icons/react/dist/ssr/Intersect"; import { Circuitry } from "@phosphor-icons/react/dist/ssr/Circuitry"; import { CrosshairSimple } from "@phosphor-icons/react/dist/ssr/CrosshairSimple"; import { Check } from "@phosphor-icons/react/dist/ssr/Check"; -import { useDocumentStore, useUiStore, useEngineStore, SELECTION_FILTER_OPTIONS } from "@vcad/core"; +import { + useDocumentStore, + useUiStore, + useEngineStore, + SELECTION_FILTER_OPTIONS, + type SelectionFilter, +} from "@vcad/core"; import type { ReactNode } from "react"; +import { useCallback } from "react"; +import { + nativeMenuAvailable, + popupNativeContextMenu, + type NativeMenuItem, +} from "@/lib/native-context-menu"; function MenuItem({ icon: Icon, @@ -39,6 +51,43 @@ function MenuItem({ ); } +/** + * Compute the dimensions of the design-PCB-to-fit feature for the + * selected part — pulled into a helper so both the native and Radix + * paths share the bounding-box logic. + */ +function dispatchDesignPcbForSelection() { + const selectedIds = useUiStore.getState().selectedPartIds; + if (selectedIds.size !== 1) return; + const partId = Array.from(selectedIds)[0]!; + const scene = useEngineStore.getState().scene; + const parts = useDocumentStore.getState().parts; + const partIdx = parts.findIndex((p) => p.id === partId); + const evalPart = partIdx >= 0 ? scene?.parts?.[partIdx] : null; + if ( + evalPart?.mesh?.positions && + evalPart.mesh.positions.length >= 3 + ) { + const pos = evalPart.mesh.positions; + let minX = Infinity, maxX = -Infinity; + let minY = Infinity, maxY = -Infinity; + for (let i = 0; i < pos.length; i += 3) { + const x = pos[i]!, y = pos[i + 1]!; + if (x < minX) minX = x; + if (x > maxX) maxX = x; + if (y < minY) minY = y; + if (y > maxY) maxY = y; + } + const w = Math.ceil((maxX - minX) * 10) / 10; + const h = Math.ceil((maxY - minY) * 10) / 10; + window.dispatchEvent( + new CustomEvent("vcad:fit-pcb-dialog", { detail: { width: w, height: h } }), + ); + } else { + window.dispatchEvent(new CustomEvent("vcad:open-pcb-dialog")); + } +} + export function ContextMenu({ children }: { children: ReactNode }) { const selectedPartIds = useUiStore((s) => s.selectedPartIds); const clearSelection = useUiStore((s) => s.clearSelection); @@ -51,25 +100,109 @@ export function ContextMenu({ children }: { children: ReactNode }) { const hasSelection = selectedPartIds.size > 0; const hasTwoSelected = selectedPartIds.size === 2; + const hasOneSelected = selectedPartIds.size === 1; - function handleDelete() { - for (const id of selectedPartIds) { + const handleDelete = useCallback(() => { + const ids = useUiStore.getState().selectedPartIds; + for (const id of ids) { removePart(id); } clearSelection(); - } + }, [removePart, clearSelection]); - function handleDuplicate() { - const ids = Array.from(selectedPartIds); + const handleDuplicate = useCallback(() => { + const ids = Array.from(useUiStore.getState().selectedPartIds); const newIds = duplicateParts(ids); useUiStore.getState().selectMultiple(newIds); - } + }, [duplicateParts]); + + const handleBoolean = useCallback( + (type: "union" | "difference" | "intersection") => { + const ids = Array.from(useUiStore.getState().selectedPartIds); + if (ids.length !== 2) return; + const newId = applyBoolean(type, ids[0]!, ids[1]!); + if (newId) select(newId); + }, + [applyBoolean, select], + ); - function handleBoolean(type: "union" | "difference" | "intersection") { - if (!hasTwoSelected) return; - const ids = Array.from(selectedPartIds); - const newId = applyBoolean(type, ids[0]!, ids[1]!); - if (newId) select(newId); + // Native popup path — composed at click time so disabled/checked state + // reflects the latest store snapshot, and the radio submenu reads off + // SELECTION_FILTER_OPTIONS without re-encoding it. Action ids match the + // dispatch table at the bottom; rename `vcad:` events for clarity. + const buildNativeMenu = (): NativeMenuItem[] => { + const filter = useUiStore.getState().selectionFilter; + const sel = useUiStore.getState().selectedPartIds; + const has = sel.size > 0; + const oneSel = sel.size === 1; + const twoSel = sel.size === 2; + return [ + { kind: "item", id: "duplicate", label: "Duplicate", accelerator: "CmdOrCtrl+D", disabled: !has }, + { kind: "item", id: "rename", label: "Rename", disabled: !oneSel }, + { kind: "item", id: "delete", label: "Delete", accelerator: "Delete", disabled: !has }, + { kind: "separator" }, + { + kind: "submenu", + label: "Selection priority", + items: SELECTION_FILTER_OPTIONS.map((o) => ({ + kind: "item" as const, + id: `selfilter:${o.value}`, + label: o.hotkey ? `${o.label} (${o.hotkey})` : o.label, + checked: filter === o.value, + })), + }, + { kind: "separator" }, + { kind: "item", id: "boolean:union", label: "Union", accelerator: "CmdOrCtrl+Shift+U", disabled: !twoSel }, + { kind: "item", id: "boolean:difference", label: "Difference", accelerator: "CmdOrCtrl+Shift+D", disabled: !twoSel }, + { kind: "item", id: "boolean:intersection", label: "Intersection", accelerator: "CmdOrCtrl+Shift+I", disabled: !twoSel }, + { kind: "separator" }, + { kind: "item", id: "pcb:add", label: "Add PCB Board" }, + { kind: "item", id: "pcb:fit", label: "Design PCB to fit", disabled: !oneSel }, + ]; + }; + + const dispatchById = (id: string) => { + if (id === "duplicate") return handleDuplicate(); + if (id === "rename") + return window.dispatchEvent(new CustomEvent("vcad:rename-part")); + if (id === "delete") return handleDelete(); + if (id.startsWith("selfilter:")) { + const v = id.slice("selfilter:".length) as SelectionFilter; + return setSelectionFilter(v); + } + if (id.startsWith("boolean:")) { + const op = id.slice("boolean:".length) as + | "union" + | "difference" + | "intersection"; + return handleBoolean(op); + } + if (id === "pcb:add") + return window.dispatchEvent(new CustomEvent("vcad:open-pcb-dialog")); + if (id === "pcb:fit") return dispatchDesignPcbForSelection(); + }; + + // Native path — a single right-click handler swaps the Radix root for + // a real OS menu. We still render Radix's so `children` keeps + // its tabindex/aria, but we intercept contextmenu before Radix sees it. + const onNativeContext = async (e: React.MouseEvent) => { + if (!nativeMenuAvailable()) return; + e.preventDefault(); + e.stopPropagation(); + try { + const id = await popupNativeContextMenu(buildNativeMenu()); + if (id) dispatchById(id); + } catch { + // Native popup failed — Radix handles the fallback on the next click. + } + }; + + if (nativeMenuAvailable()) { + return ( +
+ {children} +
+ ); } return ( @@ -87,9 +220,8 @@ export function ContextMenu({ children }: { children: ReactNode }) { { - // Dispatch custom event for inline rename window.dispatchEvent(new CustomEvent("vcad:rename-part")); }} /> @@ -177,31 +309,8 @@ export function ContextMenu({ children }: { children: ReactNode }) { { - const partId = Array.from(selectedPartIds)[0]!; - const scene = useEngineStore.getState().scene; - const parts = useDocumentStore.getState().parts; - const partIdx = parts.findIndex((p) => p.id === partId); - const evalPart = partIdx >= 0 ? scene?.parts?.[partIdx] : null; - if (evalPart?.mesh?.positions && evalPart.mesh.positions.length >= 3) { - const pos = evalPart.mesh.positions; - let minX = Infinity, maxX = -Infinity; - let minY = Infinity, maxY = -Infinity; - for (let i = 0; i < pos.length; i += 3) { - const x = pos[i]!, y = pos[i + 1]!; - if (x < minX) minX = x; - if (x > maxX) maxX = x; - if (y < minY) minY = y; - if (y > maxY) maxY = y; - } - const w = Math.ceil((maxX - minX) * 10) / 10; - const h = Math.ceil((maxY - minY) * 10) / 10; - window.dispatchEvent(new CustomEvent("vcad:fit-pcb-dialog", { detail: { width: w, height: h } })); - } else { - window.dispatchEvent(new CustomEvent("vcad:open-pcb-dialog")); - } - }} + disabled={!hasOneSelected} + onClick={dispatchDesignPcbForSelection} /> diff --git a/packages/app/src/components/DocTitle.tsx b/packages/app/src/components/DocTitle.tsx index 5ebcde83..d7adbe6d 100644 --- a/packages/app/src/components/DocTitle.tsx +++ b/packages/app/src/components/DocTitle.tsx @@ -17,6 +17,8 @@ import { useDrawingStore } from "@/stores/drawing-store"; import { useElectronicsStore } from "@/stores/electronics-store"; import { Tooltip } from "@/components/ui/tooltip"; import { cn } from "@/lib/utils"; +import { invoke, isTauri } from "@/lib/tauri"; +import { useCapabilities } from "@/lib/capabilities"; /** * Titlebar identity strip — filename, ambient save state, scope breadcrumb. @@ -70,7 +72,6 @@ export function DocTitle({ macOverlay }: { macOverlay?: boolean }) { let cancelled = false; (async () => { try { - const { isTauri } = await import("@/lib/tauri"); if (!isTauri()) return; const { getCurrentWindow } = await import("@tauri-apps/api/window"); if (cancelled) return; @@ -88,6 +89,18 @@ export function DocTitle({ macOverlay }: { macOverlay?: boolean }) { }; }, [documentName]); + // Native macOS modified-dot. setDocumentEdited: paints the dot inside + // the close traffic light — the standard signal for unsaved changes. + // Read-only sessions are never "edited" from the OS's perspective even + // if local diff state exists. + useEffect(() => { + if (!isTauri()) return; + const edited = !!isDirty && !readOnlyShare; + invoke("set_document_edited", { edited }).catch(() => { + // Mac-only command; silently no-op on Windows/Linux. + }); + }, [isDirty, readOnlyShare]); + // Select the whole name when edit mode opens so a single click + type // replaces it wholesale — the familiar Finder rename interaction. // Depending on `draft !== null` (not `draft` itself) keeps the effect from @@ -146,14 +159,70 @@ export function DocTitle({ macOverlay }: { macOverlay?: boolean }) { const nameDisplay = documentName || "Untitled"; const nameIsDefault = !documentName || documentName === "Untitled"; + // ⌘-click path popover — Finder-style breadcrumb that slides down from + // the title bar. Anchored to the proxy icon, dismissed on outside click + // or Escape. The "path" is synthetic for now: cloud-synced docs read as + // `~/vcad/`, locals as `Untitled — vcad`. Rendered only inside + // Tauri-mac, where the affordance is expected. + const { tauri: inTauri, platform } = useCapabilities(); + const showProxy = inTauri && platform === "mac" && !sketchActive && drawingMode !== "2d" && !electronicsActive; + const [pathOpen, setPathOpen] = useState(false); + const pathRef = useRef(null); + useEffect(() => { + if (!pathOpen) return; + function onDocClick(e: MouseEvent) { + if (!pathRef.current) return; + if (!pathRef.current.contains(e.target as Node)) setPathOpen(false); + } + function onKey(e: KeyboardEvent) { + if (e.key === "Escape") setPathOpen(false); + } + window.addEventListener("mousedown", onDocClick); + window.addEventListener("keydown", onKey); + return () => { + window.removeEventListener("mousedown", onDocClick); + window.removeEventListener("keydown", onKey); + }; + }, [pathOpen]); + + const handleProxyClick = (e: React.MouseEvent) => { + if (e.metaKey || e.ctrlKey) { + e.preventDefault(); + setPathOpen((v) => !v); + } + }; + return (
+ {/* Proxy icon — Finder-style document glyph. ⌘-click opens the path + popover (vcad's "where does this live" affordance). Drag exports + a virtual file ref the rest of the OS can drop into Finder. */} + {showProxy && ( + + { + e.dataTransfer.setData( + "text/plain", + nameIsDefault ? "Untitled.vcad" : `${documentName}.vcad`, + ); + e.dataTransfer.effectAllowed = "copy"; + }} + aria-label="Document proxy" + > + v + + + )} + {/* Filename — click to rename */} {draft !== null ? ( )} + + {/* ⌘-click path popover — Finder-like breadcrumb panel. */} + {pathOpen && ( +
+
+ v + vcad + + {isSignedIn ? "Cloud" : "Local"} + + + {nameDisplay}.vcad + +
+ {isDirty && ( +
+ + Unsaved changes +
+ )} +
+ )}
); } diff --git a/packages/app/src/index.css b/packages/app/src/index.css index 5d7df6e4..3b4d6c99 100644 --- a/packages/app/src/index.css +++ b/packages/app/src/index.css @@ -442,3 +442,157 @@ body { outline: none; box-shadow: none; } + +/* ─── macOS native shell ───────────────────────────────────────────────── + * `body.tauri-mac` is set by `useNativeShellClass()` once the Tauri probe + * resolves. Everything in this block layers on top of the existing dark + * (default) and light themes so we never fight them — we just lift the + * background opacity off chrome so the AppKit `Sidebar` vibrancy material + * (applied to NSWindow in Rust) bleeds through. + * + * The 3D viewport canvas paints opaque pixels, so it's unaffected by the + * background changes — only the chrome panels inherit the blur. + * + * Shape: + * - bg → fully transparent (so vibrancy carries the wash) + * - surface (chrome panels) → translucent over vibrancy + * - card (raised content) → slightly more opaque + * - border → softer to match macOS hairlines + * ──────────────────────────────────────────────────────────────────────── */ +body.tauri-mac { + /* Dark theme — UnderWindowBackground reads as charcoal at ~0.7-0.8 alpha. + We tune surfaces just dark enough to keep text contrast >= 7:1. */ + --bg: rgba(28, 28, 28, 0.0); + --surface: rgba(38, 38, 38, 0.62); + --card: rgba(48, 48, 48, 0.72); + --border: rgba(255, 255, 255, 0.10); + --hover: rgba(255, 255, 255, 0.06); +} +body.tauri-mac:has(:root.light) { + /* Light theme — Sidebar material reads as ~#e8e8e8 with subtle blur. */ + --bg: rgba(0, 0, 0, 0.0); + --surface: rgba(245, 245, 247, 0.55); + --card: rgba(255, 255, 255, 0.72); + --border: rgba(0, 0, 0, 0.08); + --hover: rgba(0, 0, 0, 0.04); +} +/* HTML must allow vibrancy to pass through the document background as + well — Tauri's transparent window only helps if the page itself isn't + painting an opaque rect. */ +html.tauri-mac, body.tauri-mac { background: transparent; } + +/* ─── AppKit motion springs ────────────────────────────────────────────── + * macOS animations use a spring curve that decelerates harder than + * `ease-out` and slightly overshoots before settling. Apple publishes + * these as `.smooth` / `.snappy` SwiftUI presets; `(0.32, 0.72, 0, 1)` is + * the standard CSS approximation. Apply via the `appkit-spring` utility + * to opt specific transitions in — we deliberately don't override the + * whole base layer because some web components (Radix, headlessui) + * tune their own timings already. + * ──────────────────────────────────────────────────────────────────────── */ +.appkit-spring { + transition-timing-function: cubic-bezier(0.32, 0.72, 0, 1); +} +.appkit-spring-quick { + transition-timing-function: cubic-bezier(0.4, 0.0, 0.2, 1); + transition-duration: 140ms; +} + +/* ─── Focus rings: keyboard-only ───────────────────────────────────────── + * Native macOS apps don't paint focus rings on mouse interaction — they + * only show on Tab. The browser's default `:focus` lights up rings on + * mouse clicks too, which feels distinctly web-y. Strip the mouse-focus + * ring globally on Tauri-mac, but keep `:focus-visible` rings so + * keyboard users still get the affordance. + * + * We only narrow this on `body.tauri-mac` so the browser build keeps the + * default a11y behavior. Inputs are exempt — text fields keep their + * focus ring on click, like every native text input on macOS. + * ──────────────────────────────────────────────────────────────────────── */ +body.tauri-mac :is(button, [role="button"], a, [tabindex]):focus:not(:focus-visible) { + outline: none; + box-shadow: none; +} + +/* ─── Overlay scrollbars on macOS ──────────────────────────────────────── + * Native Mac scrollbars overlay content and only appear during scroll; + * the browser default takes layout space. WebKit lets us approximate + * with width:0 + a thumb that's only painted on hover. Keep this + * scoped to Tauri-mac so the browser build stays consistent across + * platforms. The `.scrollbar-thin` utility opt-in still wins where + * present (for drawers that want a permanent track). */ +body.tauri-mac *:not(.scrollbar-thin)::-webkit-scrollbar { + width: 9px; + height: 9px; +} +body.tauri-mac *:not(.scrollbar-thin)::-webkit-scrollbar-track { + background: transparent; +} +body.tauri-mac *:not(.scrollbar-thin)::-webkit-scrollbar-thumb { + background: transparent; + border: 2px solid transparent; + background-clip: padding-box; + border-radius: 999px; +} +body.tauri-mac *:not(.scrollbar-thin):hover::-webkit-scrollbar-thumb { + background: rgba(255, 255, 255, 0.18); + background-clip: padding-box; +} +body.tauri-mac:has(:root.light) *:not(.scrollbar-thin):hover::-webkit-scrollbar-thumb { + background: rgba(0, 0, 0, 0.22); + background-clip: padding-box; +} + +/* ─── System font stack on macOS ───────────────────────────────────────── + * Tauri-mac picks up SF Pro via `-apple-system`, but Inter is loaded + * first in our cascade. We deliberately keep Inter as the primary — + * vcad has a designed identity around it — but tune metrics so it + * pairs well with the surrounding AppKit chrome (window menu bar, dock + * tooltips). Slight negative tracking at small sizes matches AppKit's + * default tracking better; full-weight body text stays as-is. */ +body.tauri-mac { + font-feature-settings: "ss01", "cv11"; /* Inter's straighter "a" + tighter alts */ + letter-spacing: -0.005em; +} + +/* ─── Title-bar / proxy strip ──────────────────────────────────────────── + * Used by DocTitle when running in Tauri-mac to render the file name with + * native-feeling spacing and the modified-dot affordance. The dot is a + * 6×6 amber/brand circle inset to the left of the name when isDirty — + * mirrors the system close-traffic-light dot for users who can't see + * the OS-rendered one (overlay mode hides per-window glyphs sometimes). */ +.proxy-icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 14px; + height: 14px; + border-radius: 3px; + background: linear-gradient(135deg, var(--color-brand) 0%, #b91752 100%); + color: #fff; + font-size: 9px; + font-weight: 800; + letter-spacing: -0.04em; + user-select: none; +} +.proxy-icon[draggable="true"] { + cursor: grab; +} +.proxy-icon[draggable="true"]:active { + cursor: grabbing; +} + +/* Path popover (⌘-click on the proxy icon) — slim breadcrumb panel that + slides down from the title bar, Finder-style. */ +.path-popover { + background: var(--card); + border: 1px solid var(--border); + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.18); + backdrop-filter: blur(16px) saturate(140%); + -webkit-backdrop-filter: blur(16px) saturate(140%); + animation: path-popover-in 220ms cubic-bezier(0.32, 0.72, 0, 1); +} +@keyframes path-popover-in { + from { opacity: 0; transform: translate(-50%, -4px) scale(0.98); } + to { opacity: 1; transform: translate(-50%, 0) scale(1); } +} diff --git a/packages/app/src/lib/capabilities.ts b/packages/app/src/lib/capabilities.ts index 96a438d7..bb45adca 100644 --- a/packages/app/src/lib/capabilities.ts +++ b/packages/app/src/lib/capabilities.ts @@ -107,3 +107,23 @@ export function useCapabilities(): Capabilities { }, []); return caps; } + +/** + * Stamp body classes that scope macOS-specific styling — vibrancy + * translucency, AppKit motion springs, system-font fallback. Pure side + * effect; runs once after capabilities resolve. Lives here (not in CSS + * via @media) because macOS detection requires a Tauri probe, not just a + * UA sniff: the browser build on Mac shouldn't pretend to be a desktop + * app, and the desktop build on Linux shouldn't pull in mac styling. + */ +export function useNativeShellClass(): void { + const { tauri, platform } = useCapabilities(); + useEffect(() => { + if (typeof document === "undefined") return; + const cls = document.body.classList; + cls.toggle("tauri", tauri); + cls.toggle("tauri-mac", tauri && platform === "mac"); + cls.toggle("tauri-windows", tauri && platform === "windows"); + cls.toggle("tauri-linux", tauri && platform === "linux"); + }, [tauri, platform]); +} diff --git a/packages/app/src/lib/native-context-menu.ts b/packages/app/src/lib/native-context-menu.ts new file mode 100644 index 00000000..444f0fb8 --- /dev/null +++ b/packages/app/src/lib/native-context-menu.ts @@ -0,0 +1,92 @@ +/** + * Native macOS context menus via the Tauri menu bridge. + * + * The Rust side (`crates/vcad-desktop/src/commands/context_menu.rs`) takes + * a flat menu spec, builds a real `NSMenu` (or GTK / Win32 equivalent on + * other desktops), pops it under the cursor, and emits a + * `context-menu-select` event with the chosen id when the user picks + * something. This module is the JS counterpart: a small `popup()` helper + * that returns a Promise resolving to the chosen id (or null on dismiss). + * + * In the browser (or any non-Tauri environment) the helper rejects the + * promise with a sentinel — callers fall back to the Radix-rendered HTML + * menu in that case. On Linux/Windows the OS menu renders normally; we + * keep the contract identical because GTK/Win32 menus look closer to + * "native" than our HTML clone too. + */ + +import { invoke, isTauri } from "@/lib/tauri"; + +export interface NativeMenuLeaf { + kind: "item"; + id: string; + label: string; + accelerator?: string; + disabled?: boolean; + /** Renders a leading checkmark — for radio-group / toggle items. */ + checked?: boolean; +} + +export interface NativeMenuSeparator { + kind: "separator"; +} + +export interface NativeMenuSubmenu { + kind: "submenu"; + label: string; + items: NativeMenuItem[]; +} + +export type NativeMenuItem = + | NativeMenuLeaf + | NativeMenuSeparator + | NativeMenuSubmenu; + +/** Available only inside Tauri; cheap predicate so callers can branch + * without async work in render. */ +export function nativeMenuAvailable(): boolean { + return isTauri(); +} + +/** + * Pop a native context menu and resolve to the chosen item id, or `null` + * if the user dismissed it (clicked away / pressed Escape). + * + * The Rust side is fire-and-forget — there's no callback when the menu + * dismisses without a selection — so we listen for the next select event + * and race it against a short watchdog. Once we see a click or 30s pass + * we stop listening to avoid leaks. 30s is generous: real users either + * pick within ~2s or move the mouse and dismiss; the timeout is just a + * safety net so menu-listener subscriptions can't accumulate. + */ +export async function popupNativeContextMenu( + items: NativeMenuItem[], +): Promise { + if (!isTauri()) { + throw new Error("native context menu unavailable"); + } + const { listen } = await import("@tauri-apps/api/event"); + let unlisten: { fn: (() => void) | null } = { fn: null }; + + const choice = new Promise((resolve) => { + let settled = false; + listen<{ id: string }>("context-menu-select", (e) => { + if (settled) return; + settled = true; + resolve(e.payload.id); + }).then((u) => { + unlisten.fn = u; + // Watchdog — if no click in 30s the user dismissed; stop waiting. + window.setTimeout(() => { + if (settled) return; + settled = true; + resolve(null); + }, 30000); + }); + }); + + await invoke("show_context_menu", { items }); + const id = await choice; + unlisten.fn?.(); + return id; +}