Skip to content
Merged
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
2 changes: 2 additions & 0 deletions crates/vcad-desktop/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
155 changes: 155 additions & 0 deletions crates/vcad-desktop/src/commands/context_menu.rs
Original file line number Diff line number Diff line change
@@ -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<String>,
#[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<ItemSpec> },
}

#[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<R: Runtime> {
last: Mutex<Option<tauri::menu::Menu<R>>>,
}

impl<R: Runtime> ContextMenuState<R> {
pub fn new() -> Self {
Self {
last: Mutex::new(None),
}
}
}

fn build_item<R: Runtime>(app: &AppHandle<R>, spec: &ItemSpec) -> tauri::Result<BuiltItem<R>> {
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<R: Runtime> {
Leaf(MenuItem<R>),
Separator(PredefinedMenuItem<R>),
Submenu(tauri::menu::Submenu<R>),
}

/// 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<R: Runtime>(
app: AppHandle<R>,
items: Vec<ItemSpec>,
) -> 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<Webview> 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<R> = webview_window.as_ref();
menu.popup(webview.window()).map_err(|e| e.to_string())?;

if let Some(state) = app.try_state::<ContextMenuState<R>>() {
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<R: Runtime>(app: &AppHandle<R>, id: &str) {
if let Some(window) = app.get_webview_window("main") {
let _ = window.emit("context-menu-select", SelectEvent { id });
}
}
1 change: 1 addition & 0 deletions crates/vcad-desktop/src/commands/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
18 changes: 16 additions & 2 deletions crates/vcad-desktop/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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());
Expand All @@ -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::<tauri::Wry>::new());
if let Some(window) = app.get_webview_window("main") {
platform::apply_window_effects(&window);
let _ = window.show();
Expand All @@ -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,
Expand All @@ -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");
Expand Down
84 changes: 73 additions & 11 deletions crates/vcad-desktop/src/platform.rs
Original file line number Diff line number Diff line change
@@ -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<R: Runtime>(app: AppHandle<R>, 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<R: Runtime>(app: AppHandle<R>, 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);
}
}
}
}
2 changes: 2 additions & 0 deletions packages/app/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -207,6 +208,7 @@ function FeatureTreeSlot({ sketchActive }: { sketchActive: boolean }) {
export function App() {
useEngine();
useThemeSync();
useNativeShellClass();
useAutoSave();
useCollabSync();
useChatHandler();
Expand Down
Loading