From e41492dedd45120c033bec0a9b40ffa6a785ef85 Mon Sep 17 00:00:00 2001 From: Keavon Chambers Date: Mon, 28 Jul 2025 01:44:45 -0700 Subject: [PATCH] Desktop: Add the transparent viewport hole punch and hook up window button plumbing --- editor/src/dispatcher.rs | 4 ++ .../messages/app_window/app_window_message.rs | 9 ++++ .../app_window/app_window_message_handler.rs | 52 +++++++++++++++++++ editor/src/messages/app_window/mod.rs | 7 +++ .../src/messages/frontend/frontend_message.rs | 10 ++++ editor/src/messages/message.rs | 2 + editor/src/messages/mod.rs | 1 + .../portfolio/portfolio_message_handler.rs | 6 +-- .../src/messages/portfolio/utility_types.rs | 2 +- editor/src/messages/prelude.rs | 1 + frontend/index.html | 8 ++- frontend/src/components/Editor.svelte | 14 ++++- .../src/components/panels/Document.svelte | 52 +++++++++++-------- .../src/components/window/MainWindow.svelte | 13 +++-- .../window/title-bar/TitleBar.svelte | 10 ++-- .../window/title-bar/WindowButtonsMac.svelte | 12 +++-- .../window/title-bar/WindowButtonsWeb.svelte | 13 ++++- .../title-bar/WindowButtonsWindows.svelte | 26 +++++----- .../components/window/workspace/Panel.svelte | 16 +++++- .../window/workspace/Workspace.svelte | 14 ++++- frontend/src/io-managers/input.ts | 10 ++-- frontend/src/messages.ts | 30 ++++++++--- frontend/src/state-providers/app-window.ts | 40 ++++++++++++++ frontend/src/state-providers/portfolio.ts | 2 - frontend/wasm/src/editor_api.rs | 21 ++++++++ node-graph/wgpu-executor/src/context.rs | 2 +- 26 files changed, 300 insertions(+), 77 deletions(-) create mode 100644 editor/src/messages/app_window/app_window_message.rs create mode 100644 editor/src/messages/app_window/app_window_message_handler.rs create mode 100644 editor/src/messages/app_window/mod.rs create mode 100644 frontend/src/state-providers/app-window.ts diff --git a/editor/src/dispatcher.rs b/editor/src/dispatcher.rs index ee098193c5..573beb7915 100644 --- a/editor/src/dispatcher.rs +++ b/editor/src/dispatcher.rs @@ -14,6 +14,7 @@ pub struct Dispatcher { #[derive(Debug, Default)] pub struct DispatcherMessageHandlers { animation_message_handler: AnimationMessageHandler, + app_window_message_handler: AppWindowMessageHandler, broadcast_message_handler: BroadcastMessageHandler, debug_message_handler: DebugMessageHandler, dialog_message_handler: DialogMessageHandler, @@ -129,6 +130,9 @@ impl Dispatcher { Message::Animation(message) => { self.message_handlers.animation_message_handler.process_message(message, &mut queue, ()); } + Message::AppWindow(message) => { + self.message_handlers.app_window_message_handler.process_message(message, &mut queue, ()); + } Message::Broadcast(message) => self.message_handlers.broadcast_message_handler.process_message(message, &mut queue, ()), Message::Debug(message) => { self.message_handlers.debug_message_handler.process_message(message, &mut queue, ()); diff --git a/editor/src/messages/app_window/app_window_message.rs b/editor/src/messages/app_window/app_window_message.rs new file mode 100644 index 0000000000..f21c831bef --- /dev/null +++ b/editor/src/messages/app_window/app_window_message.rs @@ -0,0 +1,9 @@ +use crate::messages::prelude::*; + +#[impl_message(Message, AppWindow)] +#[derive(PartialEq, Clone, Debug, serde::Serialize, serde::Deserialize)] +pub enum AppWindowMessage { + AppWindowMinimize, + AppWindowMaximize, + AppWindowClose, +} diff --git a/editor/src/messages/app_window/app_window_message_handler.rs b/editor/src/messages/app_window/app_window_message_handler.rs new file mode 100644 index 0000000000..1b57cec630 --- /dev/null +++ b/editor/src/messages/app_window/app_window_message_handler.rs @@ -0,0 +1,52 @@ +use crate::messages::app_window::AppWindowMessage; +use crate::messages::prelude::*; +use graphite_proc_macros::{ExtractField, message_handler_data}; + +#[derive(Debug, Clone, Default, ExtractField)] +pub struct AppWindowMessageHandler { + platform: AppWindowPlatform, + maximized: bool, + viewport_hole_punch_active: bool, +} + +#[message_handler_data] +impl MessageHandler for AppWindowMessageHandler { + fn process_message(&mut self, message: AppWindowMessage, responses: &mut std::collections::VecDeque, _: ()) { + match message { + AppWindowMessage::AppWindowMinimize => { + self.platform = if self.platform == AppWindowPlatform::Mac { + AppWindowPlatform::Windows + } else { + AppWindowPlatform::Mac + }; + responses.add(FrontendMessage::UpdatePlatform { platform: self.platform }); + } + AppWindowMessage::AppWindowMaximize => { + self.maximized = !self.maximized; + responses.add(FrontendMessage::UpdateMaximized { maximized: self.maximized }); + + self.viewport_hole_punch_active = !self.viewport_hole_punch_active; + responses.add(FrontendMessage::UpdateViewportHolePunch { + active: self.viewport_hole_punch_active, + }); + } + AppWindowMessage::AppWindowClose => { + self.platform = AppWindowPlatform::Web; + responses.add(FrontendMessage::UpdatePlatform { platform: self.platform }); + } + } + } + + fn actions(&self) -> ActionList { + actions!(AppWindowMessageDiscriminant;) + } +} + +#[derive(PartialEq, Eq, Clone, Copy, Default, Debug, serde::Serialize, serde::Deserialize, specta::Type)] +pub enum AppWindowPlatform { + #[default] + Web, + Windows, + Mac, + Linux, +} diff --git a/editor/src/messages/app_window/mod.rs b/editor/src/messages/app_window/mod.rs new file mode 100644 index 0000000000..414a6d903e --- /dev/null +++ b/editor/src/messages/app_window/mod.rs @@ -0,0 +1,7 @@ +mod app_window_message; +pub mod app_window_message_handler; + +#[doc(inline)] +pub use app_window_message::{AppWindowMessage, AppWindowMessageDiscriminant}; +#[doc(inline)] +pub use app_window_message_handler::AppWindowMessageHandler; diff --git a/editor/src/messages/frontend/frontend_message.rs b/editor/src/messages/frontend/frontend_message.rs index e84507dd68..2b0a8b7eec 100644 --- a/editor/src/messages/frontend/frontend_message.rs +++ b/editor/src/messages/frontend/frontend_message.rs @@ -1,4 +1,5 @@ use super::utility_types::{FrontendDocumentDetails, MouseCursorIcon}; +use crate::messages::app_window::app_window_message_handler::AppWindowPlatform; use crate::messages::layout::utility_types::widget_prelude::*; use crate::messages::portfolio::document::node_graph::utility_types::{ BoxSelection, ContextMenuInformation, FrontendClickTargets, FrontendGraphInput, FrontendGraphOutput, FrontendNode, FrontendNodeType, Transform, @@ -309,4 +310,13 @@ pub enum FrontendMessage { layout_target: LayoutTarget, diff: Vec, }, + UpdatePlatform { + platform: AppWindowPlatform, + }, + UpdateMaximized { + maximized: bool, + }, + UpdateViewportHolePunch { + active: bool, + }, } diff --git a/editor/src/messages/message.rs b/editor/src/messages/message.rs index 18023c1b6c..975ffac05a 100644 --- a/editor/src/messages/message.rs +++ b/editor/src/messages/message.rs @@ -9,6 +9,8 @@ pub enum Message { #[child] Animation(AnimationMessage), #[child] + AppWindow(AppWindowMessage), + #[child] Broadcast(BroadcastMessage), #[child] Debug(DebugMessage), diff --git a/editor/src/messages/mod.rs b/editor/src/messages/mod.rs index 7b43a40108..69a3bc23b0 100644 --- a/editor/src/messages/mod.rs +++ b/editor/src/messages/mod.rs @@ -1,6 +1,7 @@ //! The root-level messages forming the first layer of the message system architecture. pub mod animation; +pub mod app_window; pub mod broadcast; pub mod debug; pub mod dialog; diff --git a/editor/src/messages/portfolio/portfolio_message_handler.rs b/editor/src/messages/portfolio/portfolio_message_handler.rs index 71de86b90f..ff66ceb7c4 100644 --- a/editor/src/messages/portfolio/portfolio_message_handler.rs +++ b/editor/src/messages/portfolio/portfolio_message_handler.rs @@ -107,15 +107,15 @@ impl MessageHandler> for Portfolio let compatible_type = first_layer.and_then(|layer| { let graph_layer = graph_modification_utils::NodeGraphLayer::new(layer, &document.network_interface); - graph_layer.horizontal_layer_flow().nth(1).and_then(|node_id| { + graph_layer.horizontal_layer_flow().nth(1).map(|node_id| { let (output_type, _) = document.network_interface.output_type(&node_id, 0, &[]); - Some(format!("type:{}", output_type.nested_type())) + format!("type:{}", output_type.nested_type()) }) }); let is_compatible = compatible_type.as_deref() == Some("type:Instances"); - let is_modifiable = first_layer.map_or(false, |layer| { + let is_modifiable = first_layer.is_some_and(|layer| { let graph_layer = graph_modification_utils::NodeGraphLayer::new(layer, &document.network_interface); matches!(graph_layer.find_input("Path", 1), Some(TaggedValue::VectorModification(_))) }); diff --git a/editor/src/messages/portfolio/utility_types.rs b/editor/src/messages/portfolio/utility_types.rs index fb0b2e4b4d..c8cce28021 100644 --- a/editor/src/messages/portfolio/utility_types.rs +++ b/editor/src/messages/portfolio/utility_types.rs @@ -53,7 +53,7 @@ impl From for PanelType { "Layers" => PanelType::Layers, "Properties" => PanelType::Properties, "Spreadsheet" => PanelType::Spreadsheet, - _ => panic!("Unknown panel type: {}", value), + _ => panic!("Unknown panel type: {value}"), } } } diff --git a/editor/src/messages/prelude.rs b/editor/src/messages/prelude.rs index 72a6cb9ba7..45bcbb4ffb 100644 --- a/editor/src/messages/prelude.rs +++ b/editor/src/messages/prelude.rs @@ -3,6 +3,7 @@ pub use crate::utility_traits::{ActionList, AsMessage, HierarchicalTree, Message pub use crate::utility_types::{DebugMessageTree, MessageData}; // Message, MessageData, MessageDiscriminant, MessageHandler pub use crate::messages::animation::{AnimationMessage, AnimationMessageDiscriminant, AnimationMessageHandler}; +pub use crate::messages::app_window::{AppWindowMessage, AppWindowMessageDiscriminant, AppWindowMessageHandler}; pub use crate::messages::broadcast::{BroadcastMessage, BroadcastMessageDiscriminant, BroadcastMessageHandler}; pub use crate::messages::debug::{DebugMessage, DebugMessageDiscriminant, DebugMessageHandler}; pub use crate::messages::dialog::export_dialog::{ExportDialogMessage, ExportDialogMessageContext, ExportDialogMessageDiscriminant, ExportDialogMessageHandler}; diff --git a/frontend/index.html b/frontend/index.html index 1e834df4d4..ccaf55e420 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -21,11 +21,17 @@ diff --git a/frontend/src/components/window/workspace/Workspace.svelte b/frontend/src/components/window/workspace/Workspace.svelte index 4d22dd5634..1f27bd75f2 100644 --- a/frontend/src/components/window/workspace/Workspace.svelte +++ b/frontend/src/components/window/workspace/Workspace.svelte @@ -137,6 +137,7 @@ 0 ? "Document" : undefined} tabCloseButtons={true} tabMinWidths={true} @@ -176,8 +177,9 @@ flex: 1 1 100%; .workspace-grid-subdivision { - min-height: 28px; + position: relative; flex: 1 1 0; + min-height: 28px; &.folded { flex-grow: 0; @@ -196,5 +198,15 @@ cursor: ew-resize; } } + + // Needed for the viewport hole punch on desktop + .viewport-hole-punch & .workspace-grid-subdivision:has(.panel.document-panel)::after { + content: ""; + position: absolute; + inset: 6px; + border-radius: 6px; + box-shadow: 0 0 0 calc(100vw + 100vh) var(--color-2-mildblack); + z-index: -1; + } } diff --git a/frontend/src/io-managers/input.ts b/frontend/src/io-managers/input.ts index aee4091c8b..5d4bc84760 100644 --- a/frontend/src/io-managers/input.ts +++ b/frontend/src/io-managers/input.ts @@ -169,7 +169,7 @@ export function createInputManager(editor: Editor, dialog: DialogState, portfoli potentiallyRestoreCanvasFocus(e); const { target } = e; - const isTargetingCanvas = target instanceof Element && target.closest("[data-viewport], [data-node-graph]"); + const isTargetingCanvas = target instanceof Element && target.closest("[data-viewport], [data-viewport-container], [data-node-graph]"); const inDialog = target instanceof Element && target.closest("[data-dialog] [data-floating-menu-content]"); const inContextMenu = target instanceof Element && target.closest("[data-context-menu]"); const inTextInput = target === textToolInteractiveInputElement; @@ -219,7 +219,7 @@ export function createInputManager(editor: Editor, dialog: DialogState, portfoli // Allow only events within the viewport or node graph boundaries const { target } = e; - const isTargetingCanvas = target instanceof Element && target.closest("[data-viewport], [data-node-graph]"); + const isTargetingCanvas = target instanceof Element && target.closest("[data-viewport], [data-viewport-container], [data-node-graph]"); if (!(isTargetingCanvas instanceof Element)) return; // Allow only repeated increments of double-clicks (not 1, 3, 5, etc.) @@ -256,7 +256,7 @@ export function createInputManager(editor: Editor, dialog: DialogState, portfoli function onWheelScroll(e: WheelEvent) { const { target } = e; - const isTargetingCanvas = target instanceof Element && target.closest("[data-viewport], [data-node-graph]"); + const isTargetingCanvas = target instanceof Element && target.closest("[data-viewport], [data-viewport-container], [data-node-graph]"); // Redirect vertical scroll wheel movement into a horizontal scroll on a horizontally scrollable element // There seems to be no possible way to properly employ the browser's smooth scrolling interpolation @@ -502,7 +502,9 @@ export function createInputManager(editor: Editor, dialog: DialogState, portfoli function potentiallyRestoreCanvasFocus(e: Event) { const { target } = e; - const newInCanvasArea = (target instanceof Element && target.closest("[data-viewport], [data-graph]")) instanceof Element && !targetIsTextField(window.document.activeElement || undefined); + const newInCanvasArea = + (target instanceof Element && target.closest("[data-viewport], [data-viewport-container], [data-graph]")) instanceof Element && + !targetIsTextField(window.document.activeElement || undefined); if (!canvasFocused && newInCanvasArea) { canvasFocused = true; app?.focus(); diff --git a/frontend/src/messages.ts b/frontend/src/messages.ts index 48abf5af72..20a9df9992 100644 --- a/frontend/src/messages.ts +++ b/frontend/src/messages.ts @@ -349,6 +349,21 @@ export class TriggerIndexedDbRemoveDocument extends JsMessage { documentId!: string; } +export type AppWindowPlatform = "Web" | "Windows" | "Mac" | "Linux"; + +export class UpdatePlatform extends JsMessage { + @Transform(({ value }: { value: AppWindowPlatform }) => value) + readonly platform!: AppWindowPlatform; +} + +export class UpdateMaximized extends JsMessage { + readonly maximized!: boolean; +} + +export class UpdateViewportHolePunch extends JsMessage { + readonly active!: boolean; +} + export class UpdateInputHints extends JsMessage { @Type(() => HintInfo) readonly hintData!: HintData; @@ -1670,29 +1685,32 @@ export const messageMakers: Record = { UpdateEyedropperSamplingState, UpdateGraphFadeArtwork, UpdateGraphViewOverlay, - UpdateSpreadsheetState, UpdateImportReorderIndex, UpdateImportsExports, UpdateInputHints, UpdateInSelectedNetwork, + UpdateLayersPanelBottomBarLayout, UpdateLayersPanelControlBarLeftLayout, UpdateLayersPanelControlBarRightLayout, - UpdateLayersPanelBottomBarLayout, UpdateLayerWidths, + UpdateMaximized, UpdateMenuBarLayout, UpdateMouseCursor, - UpdateNodeGraphNodes, - UpdateVisibleNodes, - UpdateNodeGraphWires, - UpdateNodeGraphTransform, UpdateNodeGraphControlBarLayout, + UpdateNodeGraphNodes, UpdateNodeGraphSelection, + UpdateNodeGraphTransform, + UpdateNodeGraphWires, UpdateNodeThumbnail, UpdateOpenDocumentsList, + UpdatePlatform, UpdatePropertyPanelSectionsLayout, UpdateSpreadsheetLayout, + UpdateSpreadsheetState, UpdateToolOptionsLayout, UpdateToolShelfLayout, + UpdateViewportHolePunch, + UpdateVisibleNodes, UpdateWirePathInProgress, UpdateWorkingColorsLayout, } as const; diff --git a/frontend/src/state-providers/app-window.ts b/frontend/src/state-providers/app-window.ts new file mode 100644 index 0000000000..4a022ca174 --- /dev/null +++ b/frontend/src/state-providers/app-window.ts @@ -0,0 +1,40 @@ +/* eslint-disable max-classes-per-file */ + +import { writable } from "svelte/store"; + +import { type Editor } from "@graphite/editor"; +import { type AppWindowPlatform, UpdatePlatform, UpdateMaximized, UpdateViewportHolePunch } from "@graphite/messages"; + +// eslint-disable-next-line @typescript-eslint/explicit-function-return-type +export function createAppWindowState(editor: Editor) { + const { subscribe, update } = writable({ + platform: "Web" as AppWindowPlatform, + maximized: false, + viewportHolePunch: false, + }); + + // Set up message subscriptions on creation + editor.subscriptions.subscribeJsMessage(UpdatePlatform, (updatePlatform) => { + update((state) => { + state.platform = updatePlatform.platform; + return state; + }); + }); + editor.subscriptions.subscribeJsMessage(UpdateMaximized, (maximized) => { + update((state) => { + state.maximized = maximized.maximized; + return state; + }); + }); + editor.subscriptions.subscribeJsMessage(UpdateViewportHolePunch, (viewportHolePunch) => { + update((state) => { + state.viewportHolePunch = viewportHolePunch.active; + return state; + }); + }); + + return { + subscribe, + }; +} +export type AppWindowState = ReturnType; diff --git a/frontend/src/state-providers/portfolio.ts b/frontend/src/state-providers/portfolio.ts index f425ba17d4..aaf5711401 100644 --- a/frontend/src/state-providers/portfolio.ts +++ b/frontend/src/state-providers/portfolio.ts @@ -103,7 +103,6 @@ export function createPortfolioState(editor: Editor) { // Fail silently if there's an error rasterizing the SVG, such as a zero-sized image } }); - editor.subscriptions.subscribeJsMessage(UpdateSpreadsheetState, async (updateSpreadsheetState) => { update((state) => { state.spreadsheetOpen = updateSpreadsheetState.open; @@ -111,7 +110,6 @@ export function createPortfolioState(editor: Editor) { return state; }); }); - editor.subscriptions.subscribeJsMessage(UpdateSpreadsheetLayout, (updateSpreadsheetLayout) => { update((state) => { patchWidgetLayout(state.spreadsheetWidgets, updateSpreadsheetLayout); diff --git a/frontend/wasm/src/editor_api.rs b/frontend/wasm/src/editor_api.rs index c649ee17ac..176b4bba18 100644 --- a/frontend/wasm/src/editor_api.rs +++ b/frontend/wasm/src/editor_api.rs @@ -270,6 +270,27 @@ impl EditorHandle { } } + /// Minimizes the application window to the taskbar or dock + #[wasm_bindgen(js_name = appWindowMinimize)] + pub fn app_window_minimize(&self) { + let message = AppWindowMessage::AppWindowMinimize; + self.dispatch(message); + } + + /// Toggles minimizing or restoring down the application window + #[wasm_bindgen(js_name = appWindowMaximize)] + pub fn app_window_maximize(&self) { + let message = AppWindowMessage::AppWindowMaximize; + self.dispatch(message); + } + + /// Closes the application window + #[wasm_bindgen(js_name = appWindowClose)] + pub fn app_window_close(&self) { + let message = AppWindowMessage::AppWindowClose; + self.dispatch(message); + } + /// Displays a dialog with an error message #[wasm_bindgen(js_name = errorDialog)] pub fn error_dialog(&self, title: String, description: String) { diff --git a/node-graph/wgpu-executor/src/context.rs b/node-graph/wgpu-executor/src/context.rs index a8ffd7b17f..07e30daf2e 100644 --- a/node-graph/wgpu-executor/src/context.rs +++ b/node-graph/wgpu-executor/src/context.rs @@ -43,7 +43,7 @@ impl Context { trace: wgpu::Trace::Off, }) .await - .unwrap(); + .ok()?; let info = adapter.get_info(); // skip this on LavaPipe temporarily