diff --git a/.github/workflows/library-rawkit.yml b/.github/workflows/library-rawkit.yml index b7b4bb315..569d59650 100644 --- a/.github/workflows/library-rawkit.yml +++ b/.github/workflows/library-rawkit.yml @@ -38,6 +38,13 @@ jobs: - name: 📦 Run sccache-cache uses: mozilla-actions/sccache-action@v0.0.6 + continue-on-error: true + + - name: 🔧 Fallback if sccache fails + if: failure() + run: | + echo "sccache failed, disabling it" + echo "RUSTC_WRAPPER=" >> $GITHUB_ENV - name: 🔬 Check Rust formatting run: | @@ -56,4 +63,4 @@ jobs: - name: 📈 Run sccache stat for check shell: bash - run: sccache --show-stats + run: sccache --show-stats || echo "sccache stats unavailable" diff --git a/deny.toml b/deny.toml index 932f8f6bf..ca81b234a 100644 --- a/deny.toml +++ b/deny.toml @@ -39,8 +39,6 @@ db-urls = ["https://github.com/rustsec/advisory-db"] # A list of advisory IDs to ignore. Note that ignored advisories will still # output a note when they are encountered. ignore = [ - "RUSTSEC-2024-0370", # Unmaintained but still fully functional crate `proc-macro-error` - "RUSTSEC-2024-0388", # Unmaintained but still fully functional crate `derivative` "RUSTSEC-2024-0436", # Unmaintained but still fully functional crate `paste` ] # Threshold for security vulnerabilities, any vulnerability with a CVSS score diff --git a/desktop/Cargo.toml b/desktop/Cargo.toml index ac45e8881..377cb2b23 100644 --- a/desktop/Cargo.toml +++ b/desktop/Cargo.toml @@ -54,9 +54,11 @@ windows = { version = "0.58.0", features = [ "Win32_Graphics_Dwm", "Win32_Graphics_Gdi", "Win32_System_LibraryLoader", + "Win32_System_Com", "Win32_UI_Controls", "Win32_UI_WindowsAndMessaging", "Win32_UI_HiDpi", + "Win32_UI_Shell", ] } # macOS-specific dependencies diff --git a/desktop/platform/win/build.rs b/desktop/platform/win/build.rs index 73d8a2fd5..7adda46f1 100644 --- a/desktop/platform/win/build.rs +++ b/desktop/platform/win/build.rs @@ -10,13 +10,13 @@ fn main() { // TODO: Replace with actual version res.set_version_info(winres::VersionInfo::FILEVERSION, { const MAJOR: u64 = 0; - const MINOR: u64 = 999; + const MINOR: u64 = 0; const PATCH: u64 = 0; const RELEASE: u64 = 0; (MAJOR << 48) | (MINOR << 32) | (PATCH << 16) | RELEASE }); - res.set("FileVersion", "0.999.0.0"); - res.set("ProductVersion", "0.999.0.0"); + res.set("FileVersion", "0.0.0.0"); + res.set("ProductVersion", "0.0.0.0"); res.set("OriginalFilename", "Graphite.exe"); diff --git a/desktop/src/app.rs b/desktop/src/app.rs index 8fcb4077f..9cab62567 100644 --- a/desktop/src/app.rs +++ b/desktop/src/app.rs @@ -1,17 +1,13 @@ use rfd::AsyncFileDialog; use std::fs; use std::path::PathBuf; -use std::sync::mpsc::Receiver; -use std::sync::mpsc::Sender; -use std::sync::mpsc::SyncSender; +use std::sync::mpsc::{Receiver, Sender, SyncSender}; use std::thread; -use std::time::Duration; -use std::time::Instant; +use std::time::{Duration, Instant}; use winit::application::ApplicationHandler; use winit::dpi::PhysicalSize; -use winit::event::WindowEvent; -use winit::event_loop::ActiveEventLoop; -use winit::event_loop::ControlFlow; +use winit::event::{ButtonSource, ElementState, MouseButton, WindowEvent}; +use winit::event_loop::{ActiveEventLoop, ControlFlow}; use winit::window::WindowId; use crate::cef; @@ -20,7 +16,7 @@ use crate::event::{AppEvent, AppEventScheduler}; use crate::persist::PersistentData; use crate::render::{RenderError, RenderState}; use crate::window::Window; -use crate::wrapper::messages::{DesktopFrontendMessage, DesktopWrapperMessage, Platform}; +use crate::wrapper::messages::{DesktopFrontendMessage, DesktopWrapperMessage, InputMessage, MouseKeys, MouseState, Platform}; use crate::wrapper::{DesktopWrapper, NodeGraphExecutionResult, WgpuContext, serialize_frontend_messages}; pub(crate) struct App { @@ -31,6 +27,7 @@ pub(crate) struct App { window_size: PhysicalSize, window_maximized: bool, window_fullscreen: bool, + ui_scale: f64, app_event_receiver: Receiver, app_event_scheduler: AppEventScheduler, desktop_wrapper: DesktopWrapper, @@ -87,6 +84,7 @@ impl App { window_size: PhysicalSize { width: 0, height: 0 }, window_maximized: false, window_fullscreen: false, + ui_scale: 1., app_event_receiver, app_event_scheduler, desktop_wrapper: DesktopWrapper::new(), @@ -123,7 +121,7 @@ impl App { } let size = window.surface_size(); - let scale = window.scale_factor(); + let scale = window.scale_factor() * self.ui_scale; let is_new_size = size != self.window_size; let is_new_scale = scale != self.window_scale; @@ -232,6 +230,10 @@ impl App { render_state.set_viewport_scale([viewport_scale_x as f32, viewport_scale_y as f32]); } } + DesktopFrontendMessage::UpdateUIScale { scale } => { + self.ui_scale = scale; + self.resize(); + } DesktopFrontendMessage::UpdateOverlays(scene) => { if let Some(render_state) = &mut self.render_state { render_state.set_overlays_scene(scene); @@ -454,6 +456,10 @@ impl ApplicationHandler for App { let render_state = RenderState::new(self.window.as_ref().unwrap(), self.wgpu_context.clone()); self.render_state = Some(render_state); + if let Some(window) = &self.window.as_ref() { + window.show(); + } + self.resize(); self.desktop_wrapper.init(self.wgpu_context.clone()); @@ -489,6 +495,10 @@ impl ApplicationHandler for App { let Some(render_state) = &mut self.render_state else { return }; if let Some(window) = &self.window { + if !window.can_render() { + return; + } + match render_state.render(window) { Ok(_) => {} Err(RenderError::OutdatedUITextureError) => { @@ -520,6 +530,32 @@ impl ApplicationHandler for App { }; } } + + // Forward and Back buttons are not supported by CEF and thus need to be directly forwarded the editor + WindowEvent::PointerButton { + button: ButtonSource::Mouse(button), + state: ElementState::Pressed, + .. + } => { + let mouse_keys = match button { + MouseButton::Back => Some(MouseKeys::BACK), + MouseButton::Forward => Some(MouseKeys::FORWARD), + _ => None, + }; + if let Some(mouse_keys) = mouse_keys { + let message = DesktopWrapperMessage::Input(InputMessage::PointerDown { + editor_mouse_state: MouseState { mouse_keys, ..Default::default() }, + modifier_keys: Default::default(), + }); + self.app_event_scheduler.schedule(AppEvent::DesktopWrapperMessage(message)); + + let message = DesktopWrapperMessage::Input(InputMessage::PointerUp { + editor_mouse_state: Default::default(), + modifier_keys: Default::default(), + }); + self.app_event_scheduler.schedule(AppEvent::DesktopWrapperMessage(message)); + } + } _ => {} } diff --git a/desktop/src/cef/input.rs b/desktop/src/cef/input.rs index d70550d0a..a764db916 100644 --- a/desktop/src/cef/input.rs +++ b/desktop/src/cef/input.rs @@ -45,7 +45,7 @@ pub(crate) fn handle_window_event(browser: &Browser, input_state: &mut InputStat MouseButton::Left => cef::MouseButtonType::from(cef_mouse_button_type_t::MBT_LEFT), MouseButton::Right => cef::MouseButtonType::from(cef_mouse_button_type_t::MBT_RIGHT), MouseButton::Middle => cef::MouseButtonType::from(cef_mouse_button_type_t::MBT_MIDDLE), - _ => return, //TODO: Handle Forward and Back button + _ => return, }; let Some(host) = browser.host() else { return }; diff --git a/desktop/src/consts.rs b/desktop/src/consts.rs index 0ce2e21e7..f56b2a2d7 100644 --- a/desktop/src/consts.rs +++ b/desktop/src/consts.rs @@ -1,5 +1,5 @@ pub(crate) const APP_NAME: &str = "Graphite"; -#[cfg(target_os = "linux")] +#[cfg(any(target_os = "linux", target_os = "windows"))] pub(crate) const APP_ID: &str = "art.graphite.Graphite"; pub(crate) const APP_DIRECTORY_NAME: &str = "graphite"; diff --git a/desktop/src/window.rs b/desktop/src/window.rs index 76b8ca1de..1b049787b 100644 --- a/desktop/src/window.rs +++ b/desktop/src/window.rs @@ -12,6 +12,9 @@ pub(crate) trait NativeWindow { fn init() {} fn configure(attributes: WindowAttributes, event_loop: &dyn ActiveEventLoop) -> WindowAttributes; fn new(window: &dyn WinitWindow, app_event_scheduler: AppEventScheduler) -> Self; + fn can_render(&self) -> bool { + true + } fn update_menu(&self, _entries: Vec) {} fn hide(&self) {} fn hide_others(&self) {} @@ -52,6 +55,7 @@ impl Window { .with_min_surface_size(winit::dpi::LogicalSize::new(400, 300)) .with_surface_size(winit::dpi::LogicalSize::new(1200, 800)) .with_resizable(true) + .with_visible(false) .with_theme(Some(winit::window::Theme::Dark)); attributes = native::NativeWindowImpl::configure(attributes, event_loop); @@ -67,6 +71,11 @@ impl Window { } } + pub(crate) fn show(&self) { + self.winit_window.set_visible(true); + self.winit_window.focus_window(); + } + pub(crate) fn request_redraw(&self) { self.winit_window.request_redraw(); } @@ -79,6 +88,10 @@ impl Window { self.winit_window.pre_present_notify(); } + pub(crate) fn can_render(&self) -> bool { + self.native_handle.can_render() + } + pub(crate) fn surface_size(&self) -> winit::dpi::PhysicalSize { self.winit_window.surface_size() } diff --git a/desktop/src/window/win.rs b/desktop/src/window/win.rs index afd53db18..788f0c5b9 100644 --- a/desktop/src/window/win.rs +++ b/desktop/src/window/win.rs @@ -1,9 +1,10 @@ -use winit::dpi::PhysicalSize; +use windows::Win32::System::Com::{COINIT_APARTMENTTHREADED, CoInitializeEx}; +use windows::Win32::UI::Shell::SetCurrentProcessExplicitAppUserModelID; +use windows::core::HSTRING; use winit::event_loop::ActiveEventLoop; -use winit::icon::Icon; -use winit::platform::windows::{WinIcon, WindowAttributesWindows}; use winit::window::{Window, WindowAttributes}; +use crate::consts::APP_ID; use crate::event::AppEventScheduler; pub(super) struct NativeWindowImpl { @@ -11,17 +12,26 @@ pub(super) struct NativeWindowImpl { } impl super::NativeWindow for NativeWindowImpl { + fn init() { + let app_id = HSTRING::from(APP_ID); + unsafe { + let _ = CoInitializeEx(None, COINIT_APARTMENTTHREADED).ok(); + SetCurrentProcessExplicitAppUserModelID(&app_id).ok(); + } + } + fn configure(attributes: WindowAttributes, _event_loop: &dyn ActiveEventLoop) -> WindowAttributes { - let icon = WinIcon::from_resource(1, Some(PhysicalSize::new(256, 256))).ok().map(|icon| Icon(std::sync::Arc::new(icon))); - let win_window = WindowAttributesWindows::default().with_taskbar_icon(icon); - let icon = WinIcon::from_resource(1, None).ok().map(|icon| Icon(std::sync::Arc::new(icon))); - attributes.with_window_icon(icon).with_platform_attributes(Box::new(win_window)) + attributes } fn new(window: &dyn Window, _app_event_scheduler: AppEventScheduler) -> Self { let native_handle = native_handle::NativeWindowHandle::new(window); NativeWindowImpl { native_handle } } + + fn can_render(&self) -> bool { + self.native_handle.can_render() + } } impl Drop for NativeWindowImpl { diff --git a/desktop/src/window/win/native_handle.rs b/desktop/src/window/win/native_handle.rs index 17ff1f19f..84fd9a3bc 100644 --- a/desktop/src/window/win/native_handle.rs +++ b/desktop/src/window/win/native_handle.rs @@ -9,7 +9,8 @@ //! - The helper window is a invisible window that never activates, so it doesn't steal focus from the main window. //! - The main window needs to update the helper window's position and size whenever it moves or resizes. -use std::sync::OnceLock; +use std::sync::{Arc, Mutex, OnceLock}; +use std::time::Instant; use wgpu::rwh::{HasWindowHandle, RawWindowHandle}; use windows::Win32::Foundation::*; use windows::Win32::Graphics::Dwm::*; @@ -21,16 +22,23 @@ use windows::Win32::UI::WindowsAndMessaging::*; use windows::core::PCWSTR; use winit::window::Window; +#[derive(Default)] +struct NativeWindowState { + can_render: bool, + can_render_since: Option, +} + #[derive(Clone)] pub(super) struct NativeWindowHandle { main: HWND, helper: HWND, prev_window_message_handler: isize, + state: Arc>, } impl NativeWindowHandle { pub(super) fn new(window: &dyn Window) -> NativeWindowHandle { // Extract Win32 HWND from winit. - let hwnd = match window.window_handle().expect("No window handle").as_raw() { + let main = match window.window_handle().expect("No window handle").as_raw() { RawWindowHandle::Win32(h) => HWND(h.hwnd.get() as *mut std::ffi::c_void), _ => panic!("Not a Win32 window"), }; @@ -57,47 +65,62 @@ impl NativeWindowHandle { None, HINSTANCE(std::ptr::null_mut()), // Pass the main window's HWND to WM_NCCREATE so the helper can store it. - Some(&hwnd as *const _ as _), + Some(&main as *const _ as _), ) } .expect("CreateWindowExW failed"); // Subclass the main window. // https://learn.microsoft.com/windows/win32/api/winuser/nf-winuser-setwindowlongptra - let prev_window_message_handler = unsafe { SetWindowLongPtrW(hwnd, GWLP_WNDPROC, main_window_handle_message as isize) }; + let prev_window_message_handler = unsafe { SetWindowLongPtrW(main, GWLP_WNDPROC, main_window_handle_message as isize) }; if prev_window_message_handler == 0 { let _ = unsafe { DestroyWindow(helper) }; panic!("SetWindowLongPtrW failed"); } - let inner = NativeWindowHandle { - main: hwnd, + let native_handle = NativeWindowHandle { + main, helper, prev_window_message_handler, + state: Arc::new(Mutex::new(NativeWindowState::default())), }; - registry::insert(&inner); + registry::insert(&native_handle); // Place the helper over the main window and show it without activation. - unsafe { position_helper(hwnd, helper) }; + unsafe { position_helper(main, helper) }; let _ = unsafe { ShowWindow(helper, SW_SHOWNOACTIVATE) }; // DwmExtendFrameIntoClientArea is needed to keep native window frame (but no titlebar). // https://learn.microsoft.com/windows/win32/api/dwmapi/nf-dwmapi-dwmextendframeintoclientarea // https://learn.microsoft.com/windows/win32/api/dwmapi/ne-dwmapi-dwmwindowattribute let mut boarder_size: u32 = 1; - let _ = unsafe { DwmGetWindowAttribute(hwnd, DWMWA_VISIBLE_FRAME_BORDER_THICKNESS, &mut boarder_size as *mut _ as *mut _, size_of::() as u32) }; + let _ = unsafe { DwmGetWindowAttribute(main, DWMWA_VISIBLE_FRAME_BORDER_THICKNESS, &mut boarder_size as *mut _ as *mut _, size_of::() as u32) }; let margins = MARGINS { cxLeftWidth: 0, cxRightWidth: 0, cyBottomHeight: 0, cyTopHeight: boarder_size as i32, }; - let _ = unsafe { DwmExtendFrameIntoClientArea(hwnd, &margins) }; + let _ = unsafe { DwmExtendFrameIntoClientArea(main, &margins) }; + + let hinst = unsafe { GetModuleHandleW(None) }.unwrap(); + + // Set taskbar icon + if let Ok(big) = unsafe { LoadImageW(hinst, PCWSTR(1usize as *const u16), IMAGE_ICON, GetSystemMetrics(SM_CXICON), GetSystemMetrics(SM_CYICON), LR_SHARED) } { + unsafe { SetClassLongPtrW(main, GCLP_HICON, big.0 as isize) }; + unsafe { SendMessageW(main, WM_SETICON, WPARAM(ICON_BIG as usize), LPARAM(big.0 as isize)) }; + } + + // Set window icon + if let Ok(small) = unsafe { LoadImageW(hinst, PCWSTR(1usize as *const u16), IMAGE_ICON, GetSystemMetrics(SM_CXSMICON), GetSystemMetrics(SM_CYSMICON), LR_SHARED) } { + unsafe { SetClassLongPtrW(main, GCLP_HICONSM, small.0 as isize) }; + unsafe { SendMessageW(main, WM_SETICON, WPARAM(ICON_SMALL as usize), LPARAM(small.0 as isize)) }; + } // Force window update - let _ = unsafe { SetWindowPos(hwnd, None, 0, 0, 0, 0, SWP_FRAMECHANGED | SWP_NOMOVE | SWP_NOSIZE | SWP_NOZORDER) }; + let _ = unsafe { SetWindowPos(main, None, 0, 0, 0, 0, SWP_FRAMECHANGED | SWP_NOMOVE | SWP_NOSIZE | SWP_NOZORDER) }; - inner + native_handle } pub(super) fn destroy(&self) { @@ -105,10 +128,36 @@ impl NativeWindowHandle { // Undo subclassing and destroy the helper window. let _ = unsafe { SetWindowLongPtrW(self.main, GWLP_WNDPROC, self.prev_window_message_handler) }; - if self.helper.0 != std::ptr::null_mut() { + if !self.helper.is_invalid() { let _ = unsafe { DestroyWindow(self.helper) }; } } + + // Rendering should be disabled when window is minimized + // Rendering also needs to be disabled during minimize and restore animations + // Reenabling rendering is done after a small delay to account for restore animation + // TODO: Find a cleaner solution that doesn't depend on a timeout + pub(super) fn can_render(&self) -> bool { + let can_render = !unsafe { IsIconic(self.main).into() } && unsafe { IsWindowVisible(self.main).into() }; + let Ok(mut state) = self.state.lock() else { + tracing::error!("Failed to lock NativeWindowState"); + return true; + }; + match (can_render, state.can_render, state.can_render_since) { + (true, false, None) => { + state.can_render_since = Some(Instant::now()); + } + (true, false, Some(can_render_since)) if can_render_since.elapsed().as_millis() > 50 => { + state.can_render = true; + state.can_render_since = None; + } + (false, true, _) => { + state.can_render = false; + } + _ => {} + } + state.can_render + } } mod registry { @@ -212,7 +261,7 @@ unsafe extern "system" fn main_window_handle_message(hwnd: HWND, msg: u32, wpara // Call the previous window message handler, this is a standard subclassing pattern. let prev_window_message_handler_fn_ptr: *const () = std::ptr::without_provenance(handle.prev_window_message_handler as usize); let prev_window_message_handler_fn = unsafe { std::mem::transmute::<_, _>(prev_window_message_handler_fn_ptr) }; - return unsafe { CallWindowProcW(Some(prev_window_message_handler_fn), hwnd, msg, wparam, lparam) }; + unsafe { CallWindowProcW(Some(prev_window_message_handler_fn), hwnd, msg, wparam, lparam) } } // Helper window message handler, called on the UI thread for every message the helper window receives. @@ -264,18 +313,19 @@ unsafe extern "system" fn helper_window_handle_message(hwnd: HWND, msg: u32, wpa unsafe { DefWindowProcW(hwnd, msg, wparam, lparam) } } +const RESIZE_BAND_THICKNESS: i32 = 8; + // Position the helper window to match the main window's location and size (plus the resize band size). unsafe fn position_helper(main: HWND, helper: HWND) { let mut r = RECT::default(); let _ = unsafe { GetWindowRect(main, &mut r) }; - const RESIZE_BAND_SIZE: i32 = 8; - let x = r.left - RESIZE_BAND_SIZE; - let y = r.top - RESIZE_BAND_SIZE; - let w = (r.right - r.left) + RESIZE_BAND_SIZE * 2; - let h = (r.bottom - r.top) + RESIZE_BAND_SIZE * 2; + let x = r.left - RESIZE_BAND_THICKNESS; + let y = r.top - RESIZE_BAND_THICKNESS; + let w = (r.right - r.left) + RESIZE_BAND_THICKNESS * 2; + let h = (r.bottom - r.top) + RESIZE_BAND_THICKNESS * 2; - let _ = unsafe { SetWindowPos(helper, main, x, y, w, h, SWP_NOACTIVATE) }; + let _ = unsafe { SetWindowPos(helper, main, x, y, w, h, SWP_NOACTIVATE | SWP_NOZORDER | SWP_NOOWNERZORDER | SWP_NOSENDCHANGING) }; } unsafe fn calculate_hit(helper: HWND, lparam: LPARAM) -> u32 { @@ -285,7 +335,6 @@ unsafe fn calculate_hit(helper: HWND, lparam: LPARAM) -> u32 { let mut r = RECT::default(); let _ = unsafe { GetWindowRect(helper, &mut r) }; - const RESIZE_BAND_THICKNESS: i32 = 8; let on_top = y < (r.top + RESIZE_BAND_THICKNESS) as u32; let on_right = x >= (r.right - RESIZE_BAND_THICKNESS) as u32; let on_bottom = y >= (r.bottom - RESIZE_BAND_THICKNESS) as u32; diff --git a/desktop/wrapper/src/handle_desktop_wrapper_message.rs b/desktop/wrapper/src/handle_desktop_wrapper_message.rs index 01daee633..b1857e9f8 100644 --- a/desktop/wrapper/src/handle_desktop_wrapper_message.rs +++ b/desktop/wrapper/src/handle_desktop_wrapper_message.rs @@ -12,6 +12,9 @@ pub(super) fn handle_desktop_wrapper_message(dispatcher: &mut DesktopWrapperMess DesktopWrapperMessage::FromWeb(message) => { dispatcher.queue_editor_message(*message); } + DesktopWrapperMessage::Input(message) => { + dispatcher.queue_editor_message(EditorMessage::InputPreprocessor(message)); + } DesktopWrapperMessage::OpenFileDialogResult { path, content, context } => match context { OpenFileDialogContext::Document => { dispatcher.queue_desktop_wrapper_message(DesktopWrapperMessage::OpenDocument { path, content }); diff --git a/desktop/wrapper/src/intercept_frontend_message.rs b/desktop/wrapper/src/intercept_frontend_message.rs index bc6398b36..764f11fdd 100644 --- a/desktop/wrapper/src/intercept_frontend_message.rs +++ b/desktop/wrapper/src/intercept_frontend_message.rs @@ -67,6 +67,10 @@ pub(super) fn intercept_frontend_message(dispatcher: &mut DesktopWrapperMessageD FrontendMessage::UpdateViewportPhysicalBounds { x, y, width, height } => { dispatcher.respond(DesktopFrontendMessage::UpdateViewportPhysicalBounds { x, y, width, height }); } + FrontendMessage::UpdateUIScale { scale } => { + dispatcher.respond(DesktopFrontendMessage::UpdateUIScale { scale }); + return Some(FrontendMessage::UpdateUIScale { scale }); + } FrontendMessage::TriggerPersistenceWriteDocument { document_id, document, details } => { dispatcher.respond(DesktopFrontendMessage::PersistenceWriteDocument { id: document_id, diff --git a/desktop/wrapper/src/messages.rs b/desktop/wrapper/src/messages.rs index 350bf5e70..a5769ecfe 100644 --- a/desktop/wrapper/src/messages.rs +++ b/desktop/wrapper/src/messages.rs @@ -3,6 +3,10 @@ use std::path::PathBuf; pub(crate) use graphite_editor::messages::prelude::Message as EditorMessage; +pub use graphite_editor::messages::input_mapper::utility_types::input_keyboard::{Key, ModifierKeys}; +pub use graphite_editor::messages::input_mapper::utility_types::input_mouse::{EditorMouseState as MouseState, EditorPosition as Position, MouseKeys}; +pub use graphite_editor::messages::prelude::InputPreprocessorMessage as InputMessage; + pub use graphite_editor::messages::prelude::DocumentId; pub use graphite_editor::messages::prelude::PreferencesMessageHandler as Preferences; pub enum DesktopFrontendMessage { @@ -31,6 +35,9 @@ pub enum DesktopFrontendMessage { width: f64, height: f64, }, + UpdateUIScale { + scale: f64, + }, UpdateOverlays(vello::Scene), PersistenceWriteDocument { id: DocumentId, @@ -69,6 +76,7 @@ pub enum DesktopFrontendMessage { pub enum DesktopWrapperMessage { FromWeb(Box), + Input(InputMessage), OpenFileDialogResult { path: PathBuf, content: Vec, diff --git a/editor/src/consts.rs b/editor/src/consts.rs index b17a8621c..f515ee07b 100644 --- a/editor/src/consts.rs +++ b/editor/src/consts.rs @@ -158,3 +158,8 @@ pub const AUTO_SAVE_TIMEOUT_SECONDS: u64 = 1; // INPUT pub const DOUBLE_CLICK_MILLISECONDS: u64 = 500; + +// UI +pub const UI_SCALE_DEFAULT: f64 = 1.; +pub const UI_SCALE_MIN: f64 = 0.5; +pub const UI_SCALE_MAX: f64 = 3.; diff --git a/editor/src/dispatcher.rs b/editor/src/dispatcher.rs index ae437d11b..66e82e322 100644 --- a/editor/src/dispatcher.rs +++ b/editor/src/dispatcher.rs @@ -2,6 +2,7 @@ use crate::messages::debug::utility_types::MessageLoggingVerbosity; use crate::messages::defer::DeferMessageContext; use crate::messages::dialog::DialogMessageContext; use crate::messages::layout::layout_message_handler::LayoutMessageContext; +use crate::messages::preferences::preferences_message_handler::PreferencesMessageContext; use crate::messages::prelude::*; use crate::messages::tool::common_functionality::utility_functions::make_path_editable_is_allowed; @@ -51,7 +52,8 @@ const SIDE_EFFECT_FREE_MESSAGES: &[MessageDiscriminant] = &[ NodeGraphMessageDiscriminant::RunDocumentGraph, ))), MessageDiscriminant::Portfolio(PortfolioMessageDiscriminant::SubmitActiveGraphRender), - MessageDiscriminant::Frontend(FrontendMessageDiscriminant::TriggerFontLoad), + MessageDiscriminant::Frontend(FrontendMessageDiscriminant::TriggerFontDataLoad), + MessageDiscriminant::Frontend(FrontendMessageDiscriminant::UpdateUIScale), ]; /// Since we don't need to update the frontend multiple times per frame, /// we have a set of messages which we will buffer until the next frame is requested. @@ -68,6 +70,7 @@ const FRONTEND_UPDATE_MESSAGES: &[MessageDiscriminant] = &[ const DEBUG_MESSAGE_BLOCK_LIST: &[MessageDiscriminant] = &[ MessageDiscriminant::Broadcast(BroadcastMessageDiscriminant::TriggerEvent(EventMessageDiscriminant::AnimationFrame)), MessageDiscriminant::Animation(AnimationMessageDiscriminant::IncrementFrameCounter), + MessageDiscriminant::Portfolio(PortfolioMessageDiscriminant::AutoSaveAllDocuments), ]; // TODO: Find a way to combine these with the list above. We use strings for now since these are the standard variant names used by multiple messages. But having these also type-checked would be best. const DEBUG_MESSAGE_ENDING_BLOCK_LIST: &[&str] = &["PointerMove", "PointerOutsideViewport", "Overlays", "Draw", "CurrentTime", "Time"]; @@ -178,7 +181,7 @@ impl Dispatcher { } Message::Frontend(message) => { // Handle these messages immediately by returning early - if let FrontendMessage::TriggerFontLoad { .. } = message { + if let FrontendMessage::TriggerFontDataLoad { .. } | FrontendMessage::TriggerFontCatalogLoad = message { self.responses.push(message); self.cleanup_queues(false); @@ -275,7 +278,11 @@ impl Dispatcher { menu_bar_message_handler.process_message(message, &mut queue, ()); } Message::Preferences(message) => { - self.message_handlers.preferences_message_handler.process_message(message, &mut queue, ()); + let context = PreferencesMessageContext { + tool_message_handler: &self.message_handlers.tool_message_handler, + }; + + self.message_handlers.preferences_message_handler.process_message(message, &mut queue, context); } Message::Tool(message) => { let Some(document_id) = self.message_handlers.portfolio_message_handler.active_document_id() else { @@ -330,7 +337,7 @@ impl Dispatcher { if let Some(document) = self.message_handlers.portfolio_message_handler.active_document() && !document.graph_view_overlay_open { - list.extend(self.message_handlers.tool_message_handler.actions()); + list.extend(self.message_handlers.tool_message_handler.actions_with_preferences(&self.message_handlers.preferences_message_handler)); } list.extend(self.message_handlers.portfolio_message_handler.actions()); list @@ -358,8 +365,9 @@ impl Dispatcher { fn log_message(&self, message: &Message, queues: &[VecDeque], message_logging_verbosity: MessageLoggingVerbosity) { let discriminant = MessageDiscriminant::from(message); let is_blocked = DEBUG_MESSAGE_BLOCK_LIST.contains(&discriminant) || DEBUG_MESSAGE_ENDING_BLOCK_LIST.iter().any(|blocked_name| discriminant.local_name().ends_with(blocked_name)); + let is_empty_batched = if let Message::Batched { messages } = message { messages.is_empty() } else { false }; - if !is_blocked { + if !is_blocked && !is_empty_batched { match message_logging_verbosity { MessageLoggingVerbosity::Off => {} MessageLoggingVerbosity::Names => { diff --git a/editor/src/messages/dialog/preferences_dialog/preferences_dialog_message_handler.rs b/editor/src/messages/dialog/preferences_dialog/preferences_dialog_message_handler.rs index 74034200a..5078d4beb 100644 --- a/editor/src/messages/dialog/preferences_dialog/preferences_dialog_message_handler.rs +++ b/editor/src/messages/dialog/preferences_dialog/preferences_dialog_message_handler.rs @@ -36,250 +36,278 @@ impl PreferencesDialogMessageHandler { const TITLE: &'static str = "Editor Preferences"; fn layout(&self, preferences: &PreferencesMessageHandler) -> Layout { + let mut rows = Vec::new(); + // ========== // NAVIGATION // ========== + { + let header = vec![TextLabel::new("Navigation").italic(true).widget_instance()]; - let navigation_header = vec![TextLabel::new("Navigation").italic(true).widget_instance()]; + let zoom_rate_description = "Adjust how fast zooming occurs when using the scroll wheel or pinch gesture (relative to a default of 50)."; + let zoom_rate_label = vec![ + Separator::new(SeparatorType::Unrelated).widget_instance(), + Separator::new(SeparatorType::Unrelated).widget_instance(), + TextLabel::new("Zoom Rate").tooltip_label("Zoom Rate").tooltip_description(zoom_rate_description).widget_instance(), + ]; + let zoom_rate = vec![ + Separator::new(SeparatorType::Unrelated).widget_instance(), + Separator::new(SeparatorType::Unrelated).widget_instance(), + NumberInput::new(Some(map_zoom_rate_to_display(preferences.viewport_zoom_wheel_rate))) + .tooltip_label("Zoom Rate") + .tooltip_description(zoom_rate_description) + .mode_range() + .int() + .min(1.) + .max(100.) + .on_update(|number_input: &NumberInput| { + if let Some(display_value) = number_input.value { + let actual_rate = map_display_to_zoom_rate(display_value); + PreferencesMessage::ViewportZoomWheelRate { rate: actual_rate }.into() + } else { + PreferencesMessage::ViewportZoomWheelRate { rate: VIEWPORT_ZOOM_WHEEL_RATE }.into() + } + }) + .widget_instance(), + ]; - let zoom_rate_description = "Adjust how fast zooming occurs when using the scroll wheel or pinch gesture (relative to a default of 50)."; - let zoom_rate_label = vec![ - Separator::new(SeparatorType::Unrelated).widget_instance(), - Separator::new(SeparatorType::Unrelated).widget_instance(), - TextLabel::new("Zoom Rate").tooltip_label("Zoom Rate").tooltip_description(zoom_rate_description).widget_instance(), - ]; - let zoom_rate = vec![ - Separator::new(SeparatorType::Unrelated).widget_instance(), - Separator::new(SeparatorType::Unrelated).widget_instance(), - NumberInput::new(Some(map_zoom_rate_to_display(preferences.viewport_zoom_wheel_rate))) - .tooltip_label("Zoom Rate") - .tooltip_description(zoom_rate_description) - .mode_range() - .int() - .min(1.) - .max(100.) - .on_update(|number_input: &NumberInput| { - if let Some(display_value) = number_input.value { - let actual_rate = map_display_to_zoom_rate(display_value); - PreferencesMessage::ViewportZoomWheelRate { rate: actual_rate }.into() - } else { - PreferencesMessage::ViewportZoomWheelRate { rate: VIEWPORT_ZOOM_WHEEL_RATE }.into() - } - }) - .widget_instance(), - ]; + let checkbox_id = CheckboxId::new(); + let zoom_with_scroll_description = "Use the scroll wheel for zooming instead of vertically panning (not recommended for trackpads)."; + let zoom_with_scroll = vec![ + Separator::new(SeparatorType::Unrelated).widget_instance(), + Separator::new(SeparatorType::Unrelated).widget_instance(), + CheckboxInput::new(preferences.zoom_with_scroll) + .tooltip_label("Zoom with Scroll") + .tooltip_description(zoom_with_scroll_description) + .on_update(|checkbox_input: &CheckboxInput| { + PreferencesMessage::ModifyLayout { + zoom_with_scroll: checkbox_input.checked, + } + .into() + }) + .for_label(checkbox_id) + .widget_instance(), + TextLabel::new("Zoom with Scroll") + .tooltip_label("Zoom with Scroll") + .tooltip_description(zoom_with_scroll_description) + .for_checkbox(checkbox_id) + .widget_instance(), + ]; - let checkbox_id = CheckboxId::new(); - let zoom_with_scroll_description = "Use the scroll wheel for zooming instead of vertically panning (not recommended for trackpads)."; - let zoom_with_scroll = vec![ - Separator::new(SeparatorType::Unrelated).widget_instance(), - Separator::new(SeparatorType::Unrelated).widget_instance(), - CheckboxInput::new(preferences.zoom_with_scroll) - .tooltip_label("Zoom with Scroll") - .tooltip_description(zoom_with_scroll_description) - .on_update(|checkbox_input: &CheckboxInput| { - PreferencesMessage::ModifyLayout { - zoom_with_scroll: checkbox_input.checked, - } - .into() - }) - .for_label(checkbox_id) - .widget_instance(), - TextLabel::new("Zoom with Scroll") - .tooltip_label("Zoom with Scroll") - .tooltip_description(zoom_with_scroll_description) - .for_checkbox(checkbox_id) - .widget_instance(), - ]; + rows.extend_from_slice(&[header, zoom_rate_label, zoom_rate, zoom_with_scroll]); + } // ======= // EDITING // ======= + { + let header = vec![TextLabel::new("Editing").italic(true).widget_instance()]; - let editing_header = vec![TextLabel::new("Editing").italic(true).widget_instance()]; + let selection_label = vec![ + Separator::new(SeparatorType::Unrelated).widget_instance(), + Separator::new(SeparatorType::Unrelated).widget_instance(), + TextLabel::new("Selection") + .tooltip_label("Selection") + .tooltip_description("Choose how targets are selected within dragged rectangular and lasso areas.") + .widget_instance(), + ]; - let selection_label = vec![ - Separator::new(SeparatorType::Unrelated).widget_instance(), - Separator::new(SeparatorType::Unrelated).widget_instance(), - TextLabel::new("Selection") - .tooltip_label("Selection") - .tooltip_description("Choose how targets are selected within dragged rectangular and lasso areas.") - .widget_instance(), - ]; + let selection_mode = RadioInput::new(vec![ + RadioEntryData::new(SelectionMode::Touched.to_string()) + .label(SelectionMode::Touched.to_string()) + .tooltip_label(SelectionMode::Touched.to_string()) + .tooltip_description(SelectionMode::Touched.tooltip_description()) + .on_update(move |_| { + PreferencesMessage::SelectionMode { + selection_mode: SelectionMode::Touched, + } + .into() + }), + RadioEntryData::new(SelectionMode::Enclosed.to_string()) + .label(SelectionMode::Enclosed.to_string()) + .tooltip_label(SelectionMode::Enclosed.to_string()) + .tooltip_description(SelectionMode::Enclosed.tooltip_description()) + .on_update(move |_| { + PreferencesMessage::SelectionMode { + selection_mode: SelectionMode::Enclosed, + } + .into() + }), + RadioEntryData::new(SelectionMode::Directional.to_string()) + .label(SelectionMode::Directional.to_string()) + .tooltip_label(SelectionMode::Directional.to_string()) + .tooltip_description(SelectionMode::Directional.tooltip_description()) + .on_update(move |_| { + PreferencesMessage::SelectionMode { + selection_mode: SelectionMode::Directional, + } + .into() + }), + ]) + .selected_index(Some(preferences.selection_mode as u32)) + .widget_instance(); + let selection_mode = vec![ + Separator::new(SeparatorType::Unrelated).widget_instance(), + Separator::new(SeparatorType::Unrelated).widget_instance(), + selection_mode, + ]; - let selection_mode = RadioInput::new(vec![ - RadioEntryData::new(SelectionMode::Touched.to_string()) - .label(SelectionMode::Touched.to_string()) - .tooltip_label(SelectionMode::Touched.to_string()) - .tooltip_description(SelectionMode::Touched.tooltip_description()) - .on_update(move |_| { - PreferencesMessage::SelectionMode { - selection_mode: SelectionMode::Touched, - } - .into() - }), - RadioEntryData::new(SelectionMode::Enclosed.to_string()) - .label(SelectionMode::Enclosed.to_string()) - .tooltip_label(SelectionMode::Enclosed.to_string()) - .tooltip_description(SelectionMode::Enclosed.tooltip_description()) - .on_update(move |_| { - PreferencesMessage::SelectionMode { - selection_mode: SelectionMode::Enclosed, - } - .into() - }), - RadioEntryData::new(SelectionMode::Directional.to_string()) - .label(SelectionMode::Directional.to_string()) - .tooltip_label(SelectionMode::Directional.to_string()) - .tooltip_description(SelectionMode::Directional.tooltip_description()) - .on_update(move |_| { - PreferencesMessage::SelectionMode { - selection_mode: SelectionMode::Directional, - } - .into() - }), - ]) - .selected_index(Some(preferences.selection_mode as u32)) - .widget_instance(); - let selection_mode = vec![ - Separator::new(SeparatorType::Unrelated).widget_instance(), - Separator::new(SeparatorType::Unrelated).widget_instance(), - selection_mode, - ]; + rows.extend_from_slice(&[header, selection_label, selection_mode]); + } + + // ========= + // INTERFACE + // ========= + #[cfg(not(target_family = "wasm"))] + { + let header = vec![TextLabel::new("Interface").italic(true).widget_instance()]; + + let scale_description = "Adjust the scale of the entire user interface (100% is default)."; + let scale_label = vec![ + Separator::new(SeparatorType::Unrelated).widget_instance(), + Separator::new(SeparatorType::Unrelated).widget_instance(), + TextLabel::new("Scale").tooltip_label("Scale").tooltip_description(scale_description).widget_instance(), + ]; + let scale = vec![ + Separator::new(SeparatorType::Unrelated).widget_instance(), + Separator::new(SeparatorType::Unrelated).widget_instance(), + NumberInput::new(Some(ui_scale_to_display(preferences.ui_scale))) + .tooltip_label("Scale") + .tooltip_description(scale_description) + .mode_range() + .int() + .min(ui_scale_to_display(crate::consts::UI_SCALE_MIN)) + .max(ui_scale_to_display(crate::consts::UI_SCALE_MAX)) + .unit("%") + .on_update(|number_input: &NumberInput| { + if let Some(display_value) = number_input.value { + let scale = map_display_to_ui_scale(display_value); + PreferencesMessage::UIScale { scale }.into() + } else { + PreferencesMessage::UIScale { + scale: crate::consts::UI_SCALE_DEFAULT, + } + .into() + } + }) + .widget_instance(), + ]; + + rows.extend_from_slice(&[header, scale_label, scale]); + } // ============ // EXPERIMENTAL // ============ + { + let header = vec![TextLabel::new("Experimental").italic(true).widget_instance()]; - let experimental_header = vec![TextLabel::new("Experimental").italic(true).widget_instance()]; + let node_graph_section_description = "Configure the appearance of the wires running between node connections in the graph."; + let node_graph_wires_label = vec![ + Separator::new(SeparatorType::Unrelated).widget_instance(), + Separator::new(SeparatorType::Unrelated).widget_instance(), + TextLabel::new("Node Graph Wires") + .tooltip_label("Node Graph Wires") + .tooltip_description(node_graph_section_description) + .widget_instance(), + ]; + let graph_wire_style = RadioInput::new(vec![ + RadioEntryData::new(GraphWireStyle::Direct.to_string()) + .label(GraphWireStyle::Direct.to_string()) + .tooltip_label(GraphWireStyle::Direct.to_string()) + .tooltip_description(GraphWireStyle::Direct.tooltip_description()) + .on_update(move |_| PreferencesMessage::GraphWireStyle { style: GraphWireStyle::Direct }.into()), + RadioEntryData::new(GraphWireStyle::GridAligned.to_string()) + .label(GraphWireStyle::GridAligned.to_string()) + .tooltip_label(GraphWireStyle::GridAligned.to_string()) + .tooltip_description(GraphWireStyle::GridAligned.tooltip_description()) + .on_update(move |_| PreferencesMessage::GraphWireStyle { style: GraphWireStyle::GridAligned }.into()), + ]) + .selected_index(Some(preferences.graph_wire_style as u32)) + .widget_instance(); + let graph_wire_style = vec![ + Separator::new(SeparatorType::Unrelated).widget_instance(), + Separator::new(SeparatorType::Unrelated).widget_instance(), + graph_wire_style, + ]; - let node_graph_section_description = "Configure the appearance of the wires running between node connections in the graph."; - let node_graph_wires_label = vec![ - Separator::new(SeparatorType::Unrelated).widget_instance(), - Separator::new(SeparatorType::Unrelated).widget_instance(), - TextLabel::new("Node Graph Wires") - .tooltip_label("Node Graph Wires") - .tooltip_description(node_graph_section_description) - .widget_instance(), - ]; - let graph_wire_style = RadioInput::new(vec![ - RadioEntryData::new(GraphWireStyle::Direct.to_string()) - .label(GraphWireStyle::Direct.to_string()) - .tooltip_label(GraphWireStyle::Direct.to_string()) - .tooltip_description(GraphWireStyle::Direct.tooltip_description()) - .on_update(move |_| PreferencesMessage::GraphWireStyle { style: GraphWireStyle::Direct }.into()), - RadioEntryData::new(GraphWireStyle::GridAligned.to_string()) - .label(GraphWireStyle::GridAligned.to_string()) - .tooltip_label(GraphWireStyle::GridAligned.to_string()) - .tooltip_description(GraphWireStyle::GridAligned.tooltip_description()) - .on_update(move |_| PreferencesMessage::GraphWireStyle { style: GraphWireStyle::GridAligned }.into()), - ]) - .selected_index(Some(preferences.graph_wire_style as u32)) - .widget_instance(); - let graph_wire_style = vec![ - Separator::new(SeparatorType::Unrelated).widget_instance(), - Separator::new(SeparatorType::Unrelated).widget_instance(), - graph_wire_style, - ]; + let checkbox_id = CheckboxId::new(); + let vello_description = "Use the experimental Vello renderer instead of SVG-based rendering.".to_string(); + #[cfg(target_family = "wasm")] + let mut vello_description = vello_description; + #[cfg(target_family = "wasm")] + vello_description.push_str("\n\n(Your browser must support WebGPU.)"); - let checkbox_id = CheckboxId::new(); - let vello_description = "Use the experimental Vello renderer instead of SVG-based rendering.".to_string(); - #[cfg(target_family = "wasm")] - let mut vello_description = vello_description; - #[cfg(target_family = "wasm")] - vello_description.push_str("\n\n(Your browser must support WebGPU.)"); + let use_vello = vec![ + Separator::new(SeparatorType::Unrelated).widget_instance(), + Separator::new(SeparatorType::Unrelated).widget_instance(), + CheckboxInput::new(preferences.use_vello && preferences.supports_wgpu()) + .tooltip_label("Vello Renderer") + .tooltip_description(vello_description.clone()) + .disabled(!preferences.supports_wgpu()) + .on_update(|checkbox_input: &CheckboxInput| PreferencesMessage::UseVello { use_vello: checkbox_input.checked }.into()) + .for_label(checkbox_id) + .widget_instance(), + TextLabel::new("Vello Renderer") + .tooltip_label("Vello Renderer") + .tooltip_description(vello_description) + .disabled(!preferences.supports_wgpu()) + .for_checkbox(checkbox_id) + .widget_instance(), + ]; - let use_vello = vec![ - Separator::new(SeparatorType::Unrelated).widget_instance(), - Separator::new(SeparatorType::Unrelated).widget_instance(), - CheckboxInput::new(preferences.use_vello && preferences.supports_wgpu()) - .tooltip_label("Vello Renderer") - .tooltip_description(vello_description.clone()) - .disabled(!preferences.supports_wgpu()) - .on_update(|checkbox_input: &CheckboxInput| PreferencesMessage::UseVello { use_vello: checkbox_input.checked }.into()) - .for_label(checkbox_id) - .widget_instance(), - TextLabel::new("Vello Renderer") - .tooltip_label("Vello Renderer") - .tooltip_description(vello_description) - .disabled(!preferences.supports_wgpu()) - .for_checkbox(checkbox_id) - .widget_instance(), - ]; - - let checkbox_id = CheckboxId::new(); - let vector_mesh_description = " + let checkbox_id = CheckboxId::new(); + let vector_mesh_description = " Allow the Pen tool to produce branching geometry, where more than two segments may be connected to one anchor point.\n\ \n\ Currently, vector meshes do not properly render strokes (branching joins) and fills (multiple regions). " - .trim(); - let vector_meshes = vec![ - Separator::new(SeparatorType::Unrelated).widget_instance(), - Separator::new(SeparatorType::Unrelated).widget_instance(), - CheckboxInput::new(preferences.vector_meshes) - .tooltip_label("Vector Meshes") - .tooltip_description(vector_mesh_description) - .on_update(|checkbox_input: &CheckboxInput| PreferencesMessage::VectorMeshes { enabled: checkbox_input.checked }.into()) - .for_label(checkbox_id) - .widget_instance(), - TextLabel::new("Vector Meshes") - .tooltip_label("Vector Meshes") - .tooltip_description(vector_mesh_description) - .for_checkbox(checkbox_id) - .widget_instance(), - ]; + .trim(); + let vector_meshes = vec![ + Separator::new(SeparatorType::Unrelated).widget_instance(), + Separator::new(SeparatorType::Unrelated).widget_instance(), + CheckboxInput::new(preferences.vector_meshes) + .tooltip_label("Vector Meshes") + .tooltip_description(vector_mesh_description) + .on_update(|checkbox_input: &CheckboxInput| PreferencesMessage::VectorMeshes { enabled: checkbox_input.checked }.into()) + .for_label(checkbox_id) + .widget_instance(), + TextLabel::new("Vector Meshes") + .tooltip_label("Vector Meshes") + .tooltip_description(vector_mesh_description) + .for_checkbox(checkbox_id) + .widget_instance(), + ]; - let checkbox_id = CheckboxId::new(); - let brush_tool_description = " + let checkbox_id = CheckboxId::new(); + let brush_tool_description = " Enable the Brush tool to support basic raster-based layer painting.\n\ \n\ - This legacy tool has performance and quality limitations and is slated for replacement in future versions of Graphite that will focus on raster graphics editing. + This legacy experimental tool has performance and quality limitations and is slated for replacement in future versions of Graphite that will focus on raster graphics editing.\n\ + \n\ + Content created with the Brush tool may not be compatible with future versions of Graphite. " - .trim(); - let brush_tool = vec![ - Separator::new(SeparatorType::Unrelated).widget_instance(), - Separator::new(SeparatorType::Unrelated).widget_instance(), - CheckboxInput::new(preferences.brush_tool) - .tooltip_label("Brush Tool") - .tooltip_description(brush_tool_description) - .on_update(|checkbox_input: &CheckboxInput| PreferencesMessage::BrushTool { enabled: checkbox_input.checked }.into()) - .for_label(checkbox_id) - .widget_instance(), - TextLabel::new("Brush Tool") - .tooltip_label("Brush Tool") - .tooltip_description(brush_tool_description) - .for_checkbox(checkbox_id) - .widget_instance(), - ]; + .trim(); + let brush_tool = vec![ + Separator::new(SeparatorType::Unrelated).widget_instance(), + Separator::new(SeparatorType::Unrelated).widget_instance(), + CheckboxInput::new(preferences.brush_tool) + .tooltip_label("Brush Tool") + .tooltip_description(brush_tool_description) + .on_update(|checkbox_input: &CheckboxInput| PreferencesMessage::BrushTool { enabled: checkbox_input.checked }.into()) + .for_label(checkbox_id) + .widget_instance(), + TextLabel::new("Brush Tool") + .tooltip_label("Brush Tool") + .tooltip_description(brush_tool_description) + .for_checkbox(checkbox_id) + .widget_instance(), + ]; - Layout(vec![ - // NAVIGATION - LayoutGroup::Row { widgets: navigation_header }, - // Navigation: Zoom Rate - LayoutGroup::Row { widgets: zoom_rate_label }, - LayoutGroup::Row { widgets: zoom_rate }, - // Navigation: Zoom with Scroll - LayoutGroup::Row { widgets: zoom_with_scroll }, - // - // EDITING - LayoutGroup::Row { widgets: editing_header }, - // Editing: Selection - LayoutGroup::Row { widgets: selection_label }, - LayoutGroup::Row { widgets: selection_mode }, - // - // EXPERIMENTAL - LayoutGroup::Row { widgets: experimental_header }, - // Experimental: Node Graph Wires - LayoutGroup::Row { widgets: node_graph_wires_label }, - LayoutGroup::Row { widgets: graph_wire_style }, - // Experimental: Vello Renderer - LayoutGroup::Row { widgets: use_vello }, - // Experimental: Vector Meshes - LayoutGroup::Row { widgets: vector_meshes }, - // Experimental: Brush Tool - LayoutGroup::Row { widgets: brush_tool }, - ]) + rows.extend_from_slice(&[header, node_graph_wires_label, graph_wire_style, use_vello, vector_meshes, brush_tool]); + } + + Layout(rows.into_iter().map(|r| LayoutGroup::Row { widgets: r }).collect()) } pub fn send_layout(&self, responses: &mut VecDeque, layout_target: LayoutTarget, preferences: &PreferencesMessageHandler) { @@ -351,3 +379,15 @@ fn map_zoom_rate_to_display(rate: f64) -> f64 { let display = 50. + distance_from_reference; display.clamp(1., 100.).round() } + +/// Maps display values in percent to actual ui scale. +#[cfg(not(target_family = "wasm"))] +fn map_display_to_ui_scale(display: f64) -> f64 { + display / 100. +} + +/// Maps actual ui scale back to display values in percent. +#[cfg(not(target_family = "wasm"))] +fn ui_scale_to_display(scale: f64) -> f64 { + scale * 100. +} diff --git a/editor/src/messages/frontend/frontend_message.rs b/editor/src/messages/frontend/frontend_message.rs index 573dc8ffd..a63c6a226 100644 --- a/editor/src/messages/frontend/frontend_message.rs +++ b/editor/src/messages/frontend/frontend_message.rs @@ -39,7 +39,8 @@ pub enum FrontendMessage { #[serde(rename = "fontSize")] font_size: f64, color: Color, - url: String, + #[serde(rename = "fontData")] + font_data: Vec, transform: [f64; 6], #[serde(rename = "maxWidth")] max_width: Option, @@ -47,6 +48,10 @@ pub enum FrontendMessage { max_height: Option, align: TextAlign, }, + DisplayEditableTextboxUpdateFontData { + #[serde(rename = "fontData")] + font_data: Vec, + }, DisplayEditableTextboxTransform { transform: [f64; 6], }, @@ -65,6 +70,9 @@ pub enum FrontendMessage { SendShortcutAltClick { shortcut: Option, }, + SendShortcutShiftClick { + shortcut: Option, + }, // Trigger prefix: cause a frontend specific API to do something TriggerAboutGraphiteLocalizedCommitDate { @@ -92,8 +100,10 @@ pub enum FrontendMessage { name: String, filename: String, }, - TriggerFontLoad { + TriggerFontCatalogLoad, + TriggerFontDataLoad { font: Font, + url: String, }, TriggerImport, TriggerPersistenceRemoveDocument { @@ -338,6 +348,9 @@ pub enum FrontendMessage { width: f64, height: f64, }, + UpdateUIScale { + scale: f64, + }, #[cfg(not(target_family = "wasm"))] RenderOverlays { diff --git a/editor/src/messages/layout/layout_message_handler.rs b/editor/src/messages/layout/layout_message_handler.rs index d3d32abd9..2f34cbd9b 100644 --- a/editor/src/messages/layout/layout_message_handler.rs +++ b/editor/src/messages/layout/layout_message_handler.rs @@ -2,7 +2,6 @@ use crate::messages::input_mapper::utility_types::input_keyboard::KeysGroup; use crate::messages::layout::utility_types::widget_prelude::*; use crate::messages::prelude::*; use graphene_std::raster::color::Color; -use graphene_std::text::Font; use graphene_std::vector::style::{FillChoice, GradientStops}; use serde_json::Value; use std::collections::HashMap; @@ -48,7 +47,6 @@ impl MessageHandler> for LayoutMessageHa } LayoutMessage::WidgetValueUpdate { layout_target, widget_id, value } => { self.handle_widget_callback(layout_target, widget_id, value, WidgetValueAction::Update, responses); - responses.add(LayoutMessage::ResendActiveWidget { layout_target, widget_id }); } } } @@ -264,44 +262,6 @@ impl LayoutMessageHandler { responses.add(callback_message); } - Widget::FontInput(font_input) => { - let callback_message = match action { - WidgetValueAction::Commit => (font_input.on_commit.callback)(&()), - WidgetValueAction::Update => { - let Some(update_value) = value.as_object() else { - error!("FontInput update was not of type: object"); - return; - }; - let Some(font_family_value) = update_value.get("fontFamily") else { - error!("FontInput update does not have a fontFamily"); - return; - }; - let Some(font_style_value) = update_value.get("fontStyle") else { - error!("FontInput update does not have a fontStyle"); - return; - }; - - let Some(font_family) = font_family_value.as_str() else { - error!("FontInput update fontFamily was not of type: string"); - return; - }; - let Some(font_style) = font_style_value.as_str() else { - error!("FontInput update fontStyle was not of type: string"); - return; - }; - - font_input.font_family = font_family.into(); - font_input.font_style = font_style.into(); - - responses.add(PortfolioMessage::LoadFont { - font: Font::new(font_family.into(), font_style.into()), - }); - (font_input.on_update.callback)(font_input) - } - }; - - responses.add(callback_message); - } Widget::IconButton(icon_button) => { let callback_message = match action { WidgetValueAction::Commit => (icon_button.on_commit.callback)(&()), diff --git a/editor/src/messages/layout/utility_types/layout_widget.rs b/editor/src/messages/layout/utility_types/layout_widget.rs index 17c3a7acf..6224ea4f0 100644 --- a/editor/src/messages/layout/utility_types/layout_widget.rs +++ b/editor/src/messages/layout/utility_types/layout_widget.rs @@ -353,43 +353,7 @@ impl From> for LayoutGroup { } impl LayoutGroup { - /// Applies a tooltip label to all widgets in this row or column without a tooltip. - pub fn with_tooltip_label(self, label: impl Into) -> Self { - let (is_col, mut widgets) = match self { - LayoutGroup::Column { widgets } => (true, widgets), - LayoutGroup::Row { widgets } => (false, widgets), - _ => unimplemented!(), - }; - let label = label.into(); - for widget in &mut widgets { - let val = match &mut widget.widget { - Widget::CheckboxInput(x) => &mut x.tooltip_label, - Widget::ColorInput(x) => &mut x.tooltip_label, - Widget::CurveInput(x) => &mut x.tooltip_label, - Widget::DropdownInput(x) => &mut x.tooltip_label, - Widget::FontInput(x) => &mut x.tooltip_label, - Widget::IconButton(x) => &mut x.tooltip_label, - Widget::IconLabel(x) => &mut x.tooltip_label, - Widget::ImageButton(x) => &mut x.tooltip_label, - Widget::ImageLabel(x) => &mut x.tooltip_label, - Widget::NumberInput(x) => &mut x.tooltip_label, - Widget::ParameterExposeButton(x) => &mut x.tooltip_label, - Widget::PopoverButton(x) => &mut x.tooltip_label, - Widget::TextAreaInput(x) => &mut x.tooltip_label, - Widget::TextButton(x) => &mut x.tooltip_label, - Widget::TextInput(x) => &mut x.tooltip_label, - Widget::TextLabel(x) => &mut x.tooltip_label, - Widget::BreadcrumbTrailButtons(x) => &mut x.tooltip_label, - Widget::ReferencePointInput(_) | Widget::RadioInput(_) | Widget::Separator(_) | Widget::ShortcutLabel(_) | Widget::WorkingColorsInput(_) | Widget::NodeCatalog(_) => continue, - }; - if val.is_empty() { - val.clone_from(&label); - } - } - if is_col { Self::Column { widgets } } else { Self::Row { widgets } } - } - - /// Applies a tooltip description to all widgets in this row or column without a tooltip. + /// Applies a tooltip description to all widgets without a tooltip in this row or column. pub fn with_tooltip_description(self, description: impl Into) -> Self { let (is_col, mut widgets) = match self { LayoutGroup::Column { widgets } => (true, widgets), @@ -403,20 +367,24 @@ impl LayoutGroup { Widget::ColorInput(x) => &mut x.tooltip_description, Widget::CurveInput(x) => &mut x.tooltip_description, Widget::DropdownInput(x) => &mut x.tooltip_description, - Widget::FontInput(x) => &mut x.tooltip_description, Widget::IconButton(x) => &mut x.tooltip_description, Widget::IconLabel(x) => &mut x.tooltip_description, Widget::ImageButton(x) => &mut x.tooltip_description, Widget::ImageLabel(x) => &mut x.tooltip_description, Widget::NumberInput(x) => &mut x.tooltip_description, - Widget::ParameterExposeButton(x) => &mut x.tooltip_description, Widget::PopoverButton(x) => &mut x.tooltip_description, Widget::TextAreaInput(x) => &mut x.tooltip_description, Widget::TextButton(x) => &mut x.tooltip_description, Widget::TextInput(x) => &mut x.tooltip_description, Widget::TextLabel(x) => &mut x.tooltip_description, Widget::BreadcrumbTrailButtons(x) => &mut x.tooltip_description, - Widget::ReferencePointInput(_) | Widget::RadioInput(_) | Widget::Separator(_) | Widget::ShortcutLabel(_) | Widget::WorkingColorsInput(_) | Widget::NodeCatalog(_) => continue, + Widget::ReferencePointInput(_) + | Widget::RadioInput(_) + | Widget::Separator(_) + | Widget::ShortcutLabel(_) + | Widget::WorkingColorsInput(_) + | Widget::NodeCatalog(_) + | Widget::ParameterExposeButton(_) => continue, }; if val.is_empty() { val.clone_from(&description); @@ -727,7 +695,6 @@ pub enum Widget { ColorInput(ColorInput), CurveInput(CurveInput), DropdownInput(DropdownInput), - FontInput(FontInput), IconButton(IconButton), IconLabel(IconLabel), ImageButton(ImageButton), @@ -782,7 +749,6 @@ impl DiffUpdate { Widget::CheckboxInput(widget) => widget.tooltip_shortcut.as_mut(), Widget::ColorInput(widget) => widget.tooltip_shortcut.as_mut(), Widget::DropdownInput(widget) => widget.tooltip_shortcut.as_mut(), - Widget::FontInput(widget) => widget.tooltip_shortcut.as_mut(), Widget::IconButton(widget) => widget.tooltip_shortcut.as_mut(), Widget::NumberInput(widget) => widget.tooltip_shortcut.as_mut(), Widget::ParameterExposeButton(widget) => widget.tooltip_shortcut.as_mut(), @@ -838,10 +804,38 @@ impl DiffUpdate { (recursive_wrapper.0)(entry_sections, &recursive_wrapper) }; + // Hash the menu list entry sections for caching purposes + let hash_menu_list_entry_sections = |entry_sections: &MenuListEntrySections| { + struct RecursiveHasher<'a> { + hasher: DefaultHasher, + hash_fn: &'a dyn Fn(&mut RecursiveHasher, &MenuListEntrySections), + } + let mut recursive_hasher = RecursiveHasher { + hasher: DefaultHasher::new(), + hash_fn: &|recursive_hasher, entry_sections| { + for (index, entries) in entry_sections.iter().enumerate() { + index.hash(&mut recursive_hasher.hasher); + for entry in entries { + entry.hash(&mut recursive_hasher.hasher); + (recursive_hasher.hash_fn)(recursive_hasher, &entry.children); + } + } + }, + }; + (recursive_hasher.hash_fn)(&mut recursive_hasher, entry_sections); + recursive_hasher.hasher.finish() + }; + // Apply shortcut conversions to all widgets that have menu lists let convert_menu_lists = |widget_instance: &mut WidgetInstance| match &mut widget_instance.widget { - Widget::DropdownInput(dropdown_input) => apply_action_shortcut_to_menu_lists(&mut dropdown_input.entries), - Widget::TextButton(text_button) => apply_action_shortcut_to_menu_lists(&mut text_button.menu_list_children), + Widget::DropdownInput(dropdown_input) => { + apply_action_shortcut_to_menu_lists(&mut dropdown_input.entries); + dropdown_input.entries_hash = hash_menu_list_entry_sections(&dropdown_input.entries); + } + Widget::TextButton(text_button) => { + apply_action_shortcut_to_menu_lists(&mut text_button.menu_list_children); + text_button.menu_list_children_hash = hash_menu_list_entry_sections(&text_button.menu_list_children); + } _ => {} }; diff --git a/editor/src/messages/layout/utility_types/widgets/button_widgets.rs b/editor/src/messages/layout/utility_types/widgets/button_widgets.rs index 3f8f59b8a..e1b48940f 100644 --- a/editor/src/messages/layout/utility_types/widgets/button_widgets.rs +++ b/editor/src/messages/layout/utility_types/widgets/button_widgets.rs @@ -144,6 +144,10 @@ pub struct TextButton { #[serde(rename = "menuListChildren")] pub menu_list_children: MenuListEntrySections, + #[serde(rename = "menuListChildrenHash")] + #[widget_builder(skip)] + pub menu_list_children_hash: u64, + // Callbacks #[serde(skip)] #[derivative(Debug = "ignore", PartialEq = "ignore")] diff --git a/editor/src/messages/layout/utility_types/widgets/input_widgets.rs b/editor/src/messages/layout/utility_types/widgets/input_widgets.rs index c1dfe3d50..75ae5fafc 100644 --- a/editor/src/messages/layout/utility_types/widgets/input_widgets.rs +++ b/editor/src/messages/layout/utility_types/widgets/input_widgets.rs @@ -80,6 +80,10 @@ pub struct DropdownInput { #[widget_builder(constructor)] pub entries: MenuListEntrySections, + #[serde(rename = "entriesHash")] + #[widget_builder(skip)] + pub entries_hash: u64, + // This uses `u32` instead of `usize` since it will be serialized as a normal JS number (replace this with `usize` after switching to a Rust-based GUI) #[serde(rename = "selectedIndex")] pub selected_index: Option, @@ -94,6 +98,9 @@ pub struct DropdownInput { pub narrow: bool, + #[serde(rename = "virtualScrolling")] + pub virtual_scrolling: bool, + #[serde(rename = "tooltipLabel")] pub tooltip_label: String, @@ -142,6 +149,10 @@ pub struct MenuListEntry { pub children: MenuListEntrySections, + #[serde(rename = "childrenHash")] + #[widget_builder(skip)] + pub children_hash: u64, + // Callbacks #[serde(skip)] #[derivative(Debug = "ignore", PartialEq = "ignore")] @@ -152,39 +163,13 @@ pub struct MenuListEntry { pub on_commit: WidgetCallback<()>, } -#[derive(Clone, serde::Serialize, serde::Deserialize, Derivative, WidgetBuilder, specta::Type)] -#[derivative(Debug, PartialEq, Default)] -pub struct FontInput { - #[serde(rename = "fontFamily")] - #[widget_builder(constructor)] - pub font_family: String, - - #[serde(rename = "fontStyle")] - #[widget_builder(constructor)] - pub font_style: String, - - #[serde(rename = "isStyle")] - pub is_style_picker: bool, - - pub disabled: bool, - - #[serde(rename = "tooltipLabel")] - pub tooltip_label: String, - - #[serde(rename = "tooltipDescription")] - pub tooltip_description: String, - - #[serde(rename = "tooltipShortcut")] - pub tooltip_shortcut: Option, - - // Callbacks - #[serde(skip)] - #[derivative(Debug = "ignore", PartialEq = "ignore")] - pub on_update: WidgetCallback, - - #[serde(skip)] - #[derivative(Debug = "ignore", PartialEq = "ignore")] - pub on_commit: WidgetCallback<()>, +impl std::hash::Hash for MenuListEntry { + fn hash(&self, state: &mut H) { + self.value.hash(state); + self.label.hash(state); + self.icon.hash(state); + self.disabled.hash(state); + } } #[derive(Clone, serde::Serialize, serde::Deserialize, Derivative, WidgetBuilder, specta::Type)] diff --git a/editor/src/messages/portfolio/document/document_message_handler.rs b/editor/src/messages/portfolio/document/document_message_handler.rs index 9898a95b9..a0b7332c2 100644 --- a/editor/src/messages/portfolio/document/document_message_handler.rs +++ b/editor/src/messages/portfolio/document/document_message_handler.rs @@ -19,8 +19,7 @@ use crate::messages::portfolio::document::utility_types::document_metadata::{Doc use crate::messages::portfolio::document::utility_types::misc::{AlignAggregate, AlignAxis, FlipAxis, PTZ}; use crate::messages::portfolio::document::utility_types::network_interface::{FlowType, InputConnector, NodeTemplate}; use crate::messages::portfolio::document::utility_types::nodes::RawBuffer; -use crate::messages::portfolio::utility_types::PanelType; -use crate::messages::portfolio::utility_types::PersistentData; +use crate::messages::portfolio::utility_types::{FontCatalog, PanelType, PersistentData}; use crate::messages::prelude::*; use crate::messages::tool::common_functionality::graph_modification_utils::{self, get_blend_mode, get_fill, get_opacity}; use crate::messages::tool::tool_messages::select_tool::SelectToolPointerKeys; @@ -36,6 +35,7 @@ use graphene_std::raster::BlendMode; use graphene_std::raster_types::Raster; use graphene_std::subpath::Subpath; use graphene_std::table::Table; +use graphene_std::text::Font; use graphene_std::vector::PointId; use graphene_std::vector::click_target::{ClickTarget, ClickTargetType}; use graphene_std::vector::misc::{dvec2_to_point, point_to_dvec2}; @@ -2171,54 +2171,28 @@ impl DocumentMessageHandler { } /// Loads all of the fonts in the document. - pub fn load_layer_resources(&self, responses: &mut VecDeque) { - let mut fonts = HashSet::new(); - for (_node_id, node, _) in self.document_network().recursive_nodes() { + pub fn load_layer_resources(&self, responses: &mut VecDeque, font_catalog: &FontCatalog) { + let mut fonts_to_load = HashSet::new(); + + for (_, node, _) in self.document_network().recursive_nodes() { for input in &node.inputs { if let Some(TaggedValue::Font(font)) = input.as_value() { - fonts.insert(font.clone()); + fonts_to_load.insert(font.clone()); } } } - for font in fonts { - responses.add_front(FrontendMessage::TriggerFontLoad { font }); + + for font in fonts_to_load { + if let Some(style) = font_catalog.find_font_style_in_catalog(&font) { + responses.add_front(FrontendMessage::TriggerFontDataLoad { + font: Font::new(font.font_family, style.to_named_style()), + url: style.url, + }); + } } } pub fn update_document_widgets(&self, responses: &mut VecDeque, animation_is_playing: bool, time: Duration) { - // // Document mode (dropdown menu at the left of the bar above the viewport, before the tool options) - // let layout = Layout(vec![LayoutGroup::Row { - // widgets: vec![ - // DropdownInput::new( - // vec![vec![ - // MenuListEntry::new(format!("{:?}", DocumentMode::DesignMode)) - // .label(DocumentMode::DesignMode.to_string()) - // .icon(DocumentMode::DesignMode.icon_name()), - // // TODO: See issue #330 - // MenuListEntry::new(format!("{:?}", DocumentMode::SelectMode)) - // .label(DocumentMode::SelectMode.to_string()) - // .icon(DocumentMode::SelectMode.icon_name()) - // .on_commit(|_| todo!()), - // // TODO: See issue #331 - // MenuListEntry::new(format!("{:?}", DocumentMode::GuideMode)) - // .label(DocumentMode::GuideMode.to_string()) - // .icon(DocumentMode::GuideMode.icon_name()) - // .on_commit(|_| todo!()), - // ]]) - // .selected_index(Some(self.document_mode as u32)) - // .draw_icon(true) - // .interactive(false) - // .widget_instance(), - // Separator::new(SeparatorType::Section).widget_instance(), - // ], - // }]); - // responses.add(LayoutMessage::SendLayout { - // layout, - // layout_target: LayoutTarget::DocumentMode, - // }); - - // Document bar (right portion of the bar above the viewport) - let mut snapping_state = self.snapping_state.clone(); let mut snapping_state2 = self.snapping_state.clone(); diff --git a/editor/src/messages/portfolio/document/node_graph/document_node_definitions.rs b/editor/src/messages/portfolio/document/node_graph/document_node_definitions.rs index 61f00fc18..16cf2983a 100644 --- a/editor/src/messages/portfolio/document/node_graph/document_node_definitions.rs +++ b/editor/src/messages/portfolio/document/node_graph/document_node_definitions.rs @@ -274,7 +274,7 @@ fn static_nodes() -> Vec { ..Default::default() }, }, - description: Cow::Borrowed("Merges new content as an entry into the graphic table that represents a layer compositing stack."), + description: Cow::Borrowed("Merges the provided content as a new element in the graphic table that represents a layer compositing stack."), properties: None, }, DocumentNodeDefinition { diff --git a/editor/src/messages/portfolio/document/node_graph/node_properties.rs b/editor/src/messages/portfolio/document/node_graph/node_properties.rs index 7786cfb32..2369dc784 100644 --- a/editor/src/messages/portfolio/document/node_graph/node_properties.rs +++ b/editor/src/messages/portfolio/document/node_graph/node_properties.rs @@ -4,6 +4,7 @@ use super::document_node_definitions::{NODE_OVERRIDES, NodePropertiesContext}; use super::utility_types::FrontendGraphDataType; use crate::messages::layout::utility_types::widget_prelude::*; use crate::messages::portfolio::document::utility_types::network_interface::InputConnector; +use crate::messages::portfolio::utility_types::{FontCatalogStyle, PersistentData}; use crate::messages::prelude::*; use choice::enum_choice; use dyn_any::DynAny; @@ -34,7 +35,7 @@ pub(crate) fn string_properties(text: &str) -> Vec { fn optionally_update_value(value: impl Fn(&T) -> Option + 'static + Send + Sync, node_id: NodeId, input_index: usize) -> impl Fn(&T) -> Message + 'static + Send + Sync { move |input_value: &T| match value(input_value) { Some(value) => NodeGraphMessage::SetInputValue { node_id, input_index, value }.into(), - _ => Message::NoOp, + None => Message::NoOp, } } @@ -86,6 +87,7 @@ pub fn start_widgets(parameter_widgets_info: ParameterWidgetsInfo) -> Vec (Vec, Option>) { - let ParameterWidgetsInfo { document_node, node_id, index, .. } = parameter_widgets_info; + let ParameterWidgetsInfo { + persistent_data, + document_node, + node_id, + index, + .. + } = parameter_widgets_info; let mut first_widgets = start_widgets(parameter_widgets_info); let mut second_widgets = None; - let from_font_input = |font: &FontInput| TaggedValue::Font(Font::new(font.font_family.clone(), font.font_style.clone())); - let Some(document_node) = document_node else { return (Vec::new(), None) }; let Some(input) = document_node.inputs.get(index) else { log::warn!("A widget failed to be built because its node's input index is invalid."); return (vec![], None); }; + if let Some(TaggedValue::Font(font)) = &input.as_non_exposed_value() { first_widgets.extend_from_slice(&[ Separator::new(SeparatorType::Unrelated).widget_instance(), - FontInput::new(font.font_family.clone(), font.font_style.clone()) - .on_update(update_value(from_font_input, node_id, index)) - .on_commit(commit_value) - .widget_instance(), + DropdownInput::new(vec![ + persistent_data + .font_catalog + .0 + .iter() + .map(|family| { + MenuListEntry::new(family.name.clone()) + .label(family.name.clone()) + .font(family.closest_style(400, false).preview_url(&family.name)) + .on_update({ + // Construct the new font using the new family and the initial or previous style, although this style might not exist in the catalog + let mut new_font = Font::new(family.name.clone(), font.font_style_to_restore.clone().unwrap_or_else(|| font.font_style.clone())); + new_font.font_style_to_restore = font.font_style_to_restore.clone(); + + // If not already, store the initial style so it can be restored if the user switches to another family + if new_font.font_style_to_restore.is_none() { + new_font.font_style_to_restore = Some(new_font.font_style.clone()); + } + + // Use the closest style available in the family for the new font to ensure the style exists + let FontCatalogStyle { weight, italic, .. } = FontCatalogStyle::from_named_style(&new_font.font_style, ""); + new_font.font_style = family.closest_style(weight, italic).to_named_style(); + + move |_| { + let new_font = new_font.clone(); + + Message::Batched { + messages: Box::new([ + PortfolioMessage::LoadFontData { font: new_font.clone() }.into(), + update_value(move |_| TaggedValue::Font(new_font.clone()), node_id, index)(&()), + ]), + } + } + }) + .on_commit({ + // Use the new value from the user selection + let font_family = family.name.clone(); + + // Use the previous style selection and extract its weight and italic properties, then find the closest style in the new family + let FontCatalogStyle { weight, italic, .. } = FontCatalogStyle::from_named_style(&font.font_style, ""); + let font_style = family.closest_style(weight, italic).to_named_style(); + + move |_| { + let new_font = Font::new(font_family.clone(), font_style.clone()); + + DeferMessage::AfterGraphRun { + messages: vec![update_value(move |_| TaggedValue::Font(new_font.clone()), node_id, index)(&()), commit_value(&())], + } + .into() + } + }) + }) + .collect::>(), + ]) + .selected_index(persistent_data.font_catalog.0.iter().position(|family| family.name == font.font_family).map(|i| i as u32)) + .virtual_scrolling(true) + .widget_instance(), ]); let mut second_row = vec![TextLabel::new("").widget_instance()]; add_blank_assist(&mut second_row); second_row.extend_from_slice(&[ Separator::new(SeparatorType::Unrelated).widget_instance(), - FontInput::new(font.font_family.clone(), font.font_style.clone()) - .is_style_picker(true) - .on_update(update_value(from_font_input, node_id, index)) - .on_commit(commit_value) - .widget_instance(), + DropdownInput::new({ + persistent_data + .font_catalog + .0 + .iter() + .find(|family| family.name == font.font_family) + .map(|family| { + let build_entry = |style: &FontCatalogStyle| { + let font_style = style.to_named_style(); + MenuListEntry::new(font_style.clone()) + .label(font_style.clone()) + .on_update({ + let font_family = font.font_family.clone(); + let font_style = font_style.clone(); + + move |_| { + // Keep the existing family + let new_font = Font::new(font_family.clone(), font_style.clone()); + + Message::Batched { + messages: Box::new([ + PortfolioMessage::LoadFontData { font: new_font.clone() }.into(), + update_value(move |_| TaggedValue::Font(new_font.clone()), node_id, index)(&()), + ]), + } + } + }) + .on_commit(commit_value) + }; + + vec![ + family.styles.iter().filter(|style| !style.italic).map(build_entry).collect::>(), + family.styles.iter().filter(|style| style.italic).map(build_entry).collect::>(), + ] + }) + .filter(|styles| !styles.is_empty()) + .unwrap_or_default() + }) + .selected_index( + persistent_data + .font_catalog + .0 + .iter() + .find(|family| family.name == font.font_family) + .and_then(|family| { + let not_italic = family.styles.iter().filter(|style| !style.italic); + let italic = family.styles.iter().filter(|style| style.italic); + not_italic.chain(italic).position(|style| style.to_named_style() == font.font_style) + }) + .map(|i| i as u32), + ) + .widget_instance(), ]); second_widgets = Some(second_row); } @@ -2033,6 +2140,7 @@ pub fn math_properties(node_id: NodeId, context: &mut NodePropertiesContext) -> } pub struct ParameterWidgetsInfo<'a> { + persistent_data: &'a PersistentData, document_node: Option<&'a DocumentNode>, node_id: NodeId, index: usize, @@ -2048,11 +2156,12 @@ impl<'a> ParameterWidgetsInfo<'a> { let (name, description) = context.network_interface.displayed_input_name_and_description(&node_id, index, context.selection_network_path); let input_type = context .network_interface - .input_type(&InputConnector::node(node_id, index), context.selection_network_path) + .input_type_not_invalid(&InputConnector::node(node_id, index), context.selection_network_path) .displayed_type(); let document_node = context.network_interface.document_node(&node_id, context.selection_network_path); ParameterWidgetsInfo { + persistent_data: context.persistent_data, document_node, node_id, index, @@ -2227,7 +2336,7 @@ pub mod choice { let mut row = LayoutGroup::Row { widgets }; if let Some(desc) = self.widget_factory.description() { - row = row.with_tooltip_label(desc); + row = row.with_tooltip_description(desc); } row } diff --git a/editor/src/messages/portfolio/document/overlays/utility_functions.rs b/editor/src/messages/portfolio/document/overlays/utility_functions.rs index a666cfe2c..eb2877b5d 100644 --- a/editor/src/messages/portfolio/document/overlays/utility_functions.rs +++ b/editor/src/messages/portfolio/document/overlays/utility_functions.rs @@ -227,7 +227,7 @@ pub static GLOBAL_FONT_CACHE: LazyLock = LazyLock::new(|| { // Initialize with the hardcoded font used by overlay text const FONT_DATA: &[u8] = include_bytes!("source-sans-pro-regular.ttf"); let font = Font::new("Source Sans Pro".to_string(), "Regular".to_string()); - font_cache.insert(font, String::new(), FONT_DATA.to_vec()); + font_cache.insert(font, FONT_DATA.to_vec()); font_cache }); diff --git a/editor/src/messages/portfolio/document/overlays/utility_types_native.rs b/editor/src/messages/portfolio/document/overlays/utility_types_native.rs index 3a0b40ce9..1e86778fb 100644 --- a/editor/src/messages/portfolio/document/overlays/utility_types_native.rs +++ b/editor/src/messages/portfolio/document/overlays/utility_types_native.rs @@ -804,34 +804,32 @@ impl OverlayContextInternal { let transform = self.get_transform(); - // Draw the background circle with a white fill and colored outline let circle = kurbo::Circle::new((x, y), DOWEL_PIN_RADIUS); self.scene.fill(peniko::Fill::NonZero, transform, Self::parse_color(COLOR_OVERLAY_WHITE), None, &circle); self.scene.stroke(&kurbo::Stroke::new(1.), transform, Self::parse_color(color), None, &circle); - // Draw the two filled sectors using paths let mut path = BezPath::new(); - // Top-left sector + let start1 = FRAC_PI_2 + angle; + let start1_x = x + DOWEL_PIN_RADIUS * start1.cos(); + let start1_y = y + DOWEL_PIN_RADIUS * start1.sin(); path.move_to(kurbo::Point::new(x, y)); - let end_x = x + DOWEL_PIN_RADIUS * (FRAC_PI_2 + angle.cos()); - let end_y = y + DOWEL_PIN_RADIUS * (FRAC_PI_2 + angle.sin()); - path.line_to(kurbo::Point::new(end_x, end_y)); - // Draw arc manually - let arc = kurbo::Arc::new((x, y), (DOWEL_PIN_RADIUS, DOWEL_PIN_RADIUS), FRAC_PI_2 + angle, FRAC_PI_2, 0.0); - arc.to_cubic_beziers(0.1, |p1, p2, p| { + path.line_to(kurbo::Point::new(start1_x, start1_y)); + + let arc1 = kurbo::Arc::new((x, y), (DOWEL_PIN_RADIUS, DOWEL_PIN_RADIUS), start1, FRAC_PI_2, 0.0); + arc1.to_cubic_beziers(0.1, |p1, p2, p| { path.curve_to(p1, p2, p); }); path.close_path(); - // Bottom-right sector + let start2 = PI + FRAC_PI_2 + angle; + let start2_x = x + DOWEL_PIN_RADIUS * start2.cos(); + let start2_y = y + DOWEL_PIN_RADIUS * start2.sin(); path.move_to(kurbo::Point::new(x, y)); - let end_x = x + DOWEL_PIN_RADIUS * (PI + FRAC_PI_2 + angle.cos()); - let end_y = y + DOWEL_PIN_RADIUS * (PI + FRAC_PI_2 + angle.sin()); - path.line_to(kurbo::Point::new(end_x, end_y)); - // Draw arc manually - let arc = kurbo::Arc::new((x, y), (DOWEL_PIN_RADIUS, DOWEL_PIN_RADIUS), PI + FRAC_PI_2 + angle, FRAC_PI_2, 0.0); - arc.to_cubic_beziers(0.1, |p1, p2, p| { + path.line_to(kurbo::Point::new(start2_x, start2_y)); + + let arc2 = kurbo::Arc::new((x, y), (DOWEL_PIN_RADIUS, DOWEL_PIN_RADIUS), start2, FRAC_PI_2, 0.0); + arc2.to_cubic_beziers(0.1, |p1, p2, p| { path.curve_to(p1, p2, p); }); path.close_path(); diff --git a/editor/src/messages/portfolio/document/utility_types/network_interface/resolved_types.rs b/editor/src/messages/portfolio/document/utility_types/network_interface/resolved_types.rs index f380d3fd4..97eb99ac1 100644 --- a/editor/src/messages/portfolio/document/utility_types/network_interface/resolved_types.rs +++ b/editor/src/messages/portfolio/document/utility_types/network_interface/resolved_types.rs @@ -2,7 +2,7 @@ use std::collections::{HashMap, HashSet}; use graph_craft::document::value::TaggedValue; use graph_craft::document::{DocumentNodeImplementation, InlineRust, NodeInput}; -use graph_craft::proto::GraphErrors; +use graph_craft::proto::{GraphErrorType, GraphErrors}; use graph_craft::{Type, concrete}; use graphene_std::uuid::NodeId; use interpreted_executor::dynamic_executor::{NodeTypes, ResolvedDocumentNodeTypesDelta}; @@ -129,7 +129,14 @@ impl NodeNetworkInterface { InputConnector::Export(_) => false, }) } - DocumentNodeImplementation::ProtoNode(_) => self.resolved_types.node_graph_errors.iter().any(|error| error.node_path == node_path), + DocumentNodeImplementation::ProtoNode(_) => self.resolved_types.node_graph_errors.iter().any(|error| { + error.node_path == node_path + && match &error.error { + GraphErrorType::InvalidImplementations { error_inputs, .. } => error_inputs.iter().any(|solution| solution.iter().any(|(index, _)| index == input_index)), + _ => true, + } + }), + DocumentNodeImplementation::Extract => false, } } @@ -137,7 +144,7 @@ impl NodeNetworkInterface { } } - fn input_type_not_invalid(&mut self, input_connector: &InputConnector, network_path: &[NodeId]) -> TypeSource { + pub fn input_type_not_invalid(&mut self, input_connector: &InputConnector, network_path: &[NodeId]) -> TypeSource { let Some(input) = self.input_from_connector(input_connector, network_path) else { return TypeSource::Error("Could not get input from connector"); }; diff --git a/editor/src/messages/portfolio/portfolio_message.rs b/editor/src/messages/portfolio/portfolio_message.rs index 42d9db99d..b601e3f55 100644 --- a/editor/src/messages/portfolio/portfolio_message.rs +++ b/editor/src/messages/portfolio/portfolio_message.rs @@ -2,6 +2,7 @@ use super::document::utility_types::document_metadata::LayerNodeIdentifier; use super::utility_types::PanelType; use crate::messages::frontend::utility_types::{ExportBounds, FileType}; use crate::messages::portfolio::document::utility_types::clipboards::Clipboard; +use crate::messages::portfolio::utility_types::FontCatalog; use crate::messages::prelude::*; use graphene_std::Color; use graphene_std::raster::Image; @@ -46,19 +47,21 @@ pub enum PortfolioMessage { }, DestroyAllDocuments, EditorPreferences, + FontCatalogLoaded { + catalog: FontCatalog, + }, + LoadFontData { + font: Font, + }, FontLoaded { font_family: String, font_style: String, - preview_url: String, data: Vec, }, Import, LoadDocumentResources { document_id: DocumentId, }, - LoadFont { - font: Font, - }, NewDocumentWithName { name: String, }, diff --git a/editor/src/messages/portfolio/portfolio_message_handler.rs b/editor/src/messages/portfolio/portfolio_message_handler.rs index ee4f9acff..70f344894 100644 --- a/editor/src/messages/portfolio/portfolio_message_handler.rs +++ b/editor/src/messages/portfolio/portfolio_message_handler.rs @@ -124,6 +124,9 @@ impl MessageHandler> for Portfolio responses.add(FrontendMessage::SendShortcutAltClick { shortcut: action_shortcut_manual!(Key::Alt, Key::MouseLeft), }); + responses.add(FrontendMessage::SendShortcutShiftClick { + shortcut: action_shortcut_manual!(Key::Shift, Key::MouseLeft), + }); // Before loading any documents, initially prepare the welcome screen buttons layout responses.add(PortfolioMessage::RequestWelcomeScreenButtonsLayout); @@ -316,7 +319,7 @@ impl MessageHandler> for Portfolio if let Some(active_document) = self.active_document() && active_document.graph_view_overlay_open() { - responses.add(NodeGraphMessage::Copy); + responses.add(NodeGraphMessage::Cut); return; } @@ -349,16 +352,31 @@ impl MessageHandler> for Portfolio self.active_document_id = None; responses.add(MenuBarMessage::SendLayout); } - PortfolioMessage::FontLoaded { - font_family, - font_style, - preview_url, - data, - } => { - let font = Font::new(font_family, font_style); + PortfolioMessage::FontCatalogLoaded { catalog } => { + self.persistent_data.font_catalog = catalog; - self.persistent_data.font_cache.insert(font, preview_url, data); + if let Some(document_id) = self.active_document_id { + responses.add(PortfolioMessage::LoadDocumentResources { document_id }); + } + + // Load the default font + let font = Font::new(graphene_std::consts::DEFAULT_FONT_FAMILY.into(), graphene_std::consts::DEFAULT_FONT_STYLE.into()); + responses.add(PortfolioMessage::LoadFontData { font }); + } + PortfolioMessage::LoadFontData { font } => { + if let Some(style) = self.persistent_data.font_catalog.find_font_style_in_catalog(&font) { + let font = Font::new(font.font_family, style.to_named_style()); + + if !self.persistent_data.font_cache.loaded_font(&font) { + responses.add(FrontendMessage::TriggerFontDataLoad { font, url: style.url }); + } + } + } + PortfolioMessage::FontLoaded { font_family, font_style, data } => { + let font = Font::new(font_family, font_style); + self.persistent_data.font_cache.insert(font, data); self.executor.update_font_cache(self.persistent_data.font_cache.clone()); + for document_id in self.document_ids.iter() { let node_to_inspect = self.node_to_inspect(); @@ -382,6 +400,10 @@ impl MessageHandler> for Portfolio if self.active_document_mut().is_some() { responses.add(NodeGraphMessage::RunDocumentGraph); } + + if current_tool == &ToolType::Text { + responses.add(TextToolMessage::RefreshEditingFontData); + } } PortfolioMessage::EditorPreferences => self.executor.update_editor_preferences(preferences.editor_preferences()), PortfolioMessage::Import => { @@ -389,13 +411,14 @@ impl MessageHandler> for Portfolio responses.add(FrontendMessage::TriggerImport); } PortfolioMessage::LoadDocumentResources { document_id } => { - if let Some(document) = self.document_mut(document_id) { - document.load_layer_resources(responses); + let catalog = &self.persistent_data.font_catalog; + + if catalog.0.is_empty() { + log::error!("Tried to load document resources before font catalog was loaded"); } - } - PortfolioMessage::LoadFont { font } => { - if !self.persistent_data.font_cache.loaded_font(&font) { - responses.add_front(FrontendMessage::TriggerFontLoad { font }); + + if let Some(document) = self.documents.get_mut(&document_id) { + document.load_layer_resources(responses, catalog); } } PortfolioMessage::NewDocumentWithName { name } => { @@ -592,7 +615,7 @@ impl MessageHandler> for Portfolio added_nodes = true; } - document.load_layer_resources(responses); + document.load_layer_resources(responses, &self.persistent_data.font_catalog); let new_ids: HashMap<_, _> = entry.nodes.iter().map(|(id, _)| (*id, NodeId::new())).collect(); let layer = LayerNodeIdentifier::new_unchecked(new_ids[&NodeId(0)]); all_new_ids.extend(new_ids.values().cloned()); @@ -973,7 +996,9 @@ impl MessageHandler> for Portfolio }; if !document.is_loaded { document.is_loaded = true; - responses.add(PortfolioMessage::LoadDocumentResources { document_id }); + if self.persistent_data.font_catalog.0.is_empty() { + responses.add_front(FrontendMessage::TriggerFontCatalogLoad); + } responses.add(PortfolioMessage::UpdateDocumentWidgets); responses.add(PropertiesPanelMessage::Clear); } @@ -1229,10 +1254,6 @@ impl PortfolioMessageHandler { if self.active_document().is_some() { responses.add(EventMessage::ToolAbort); responses.add(ToolMessage::DeactivateTools); - } else { - // Load the default font upon creating the first document - let font = Font::new(graphene_std::consts::DEFAULT_FONT_FAMILY.into(), graphene_std::consts::DEFAULT_FONT_STYLE.into()); - responses.add(FrontendMessage::TriggerFontLoad { font }); } // TODO: Remove this and find a way to fix the issue where creating a new document when the node graph is open causes the transform in the new document to be incorrect diff --git a/editor/src/messages/portfolio/utility_types.rs b/editor/src/messages/portfolio/utility_types.rs index 4bcdd56de..54a526ecd 100644 --- a/editor/src/messages/portfolio/utility_types.rs +++ b/editor/src/messages/portfolio/utility_types.rs @@ -1,11 +1,87 @@ -use graphene_std::text::FontCache; +use graphene_std::text::{Font, FontCache}; #[derive(Debug, Default)] pub struct PersistentData { pub font_cache: FontCache, + pub font_catalog: FontCatalog, pub use_vello: bool, } +// TODO: Should this be a BTreeMap instead? +#[derive(Clone, Debug, Default, Eq, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct FontCatalog(pub Vec); + +impl FontCatalog { + pub fn find_font_style_in_catalog(&self, font: &Font) -> Option { + let family = self.0.iter().find(|family| family.name == font.font_family); + + let found_style = family.map(|family| { + let FontCatalogStyle { weight, italic, .. } = FontCatalogStyle::from_named_style(&font.font_style, ""); + family.closest_style(weight, italic).clone() + }); + + if found_style.is_none() { + log::warn!("Font not found in catalog: {:?}", font); + } + + found_style + } +} + +#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct FontCatalogFamily { + /// The font family name. + pub name: String, + /// The font styles (variants) available for the font family. + pub styles: Vec, +} + +impl FontCatalogFamily { + /// Finds the closest style to the given weight and italic setting. + /// Aims to find the nearest weight while maintaining the italic setting if possible, but italic may change if no other option is available. + pub fn closest_style(&self, weight: u32, italic: bool) -> &FontCatalogStyle { + self.styles + .iter() + .map(|style| ((style.weight as i32 - weight as i32).unsigned_abs() + 10000 * (style.italic != italic) as u32, style)) + .min_by_key(|(distance, _)| *distance) + .map(|(_, style)| style) + .unwrap_or(&self.styles[0]) + } +} + +#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct FontCatalogStyle { + pub weight: u32, + pub italic: bool, + pub url: String, +} + +impl FontCatalogStyle { + pub fn to_named_style(&self) -> String { + let weight = self.weight; + let italic = self.italic; + + let named_weight = Font::named_weight(weight); + let maybe_italic = if italic { " Italic" } else { "" }; + + format!("{named_weight}{maybe_italic} ({weight})") + } + + pub fn from_named_style(named_style: &str, url: impl Into) -> FontCatalogStyle { + let weight = named_style.split_terminator(['(', ')']).next_back().and_then(|x| x.parse::().ok()).unwrap_or(400); + let italic = named_style.contains("Italic ("); + FontCatalogStyle { weight, italic, url: url.into() } + } + + /// Get the URL for the stylesheet for loading a font preview for this style of the given family name, subsetted to only the letters in the family name. + pub fn preview_url(&self, family: impl Into) -> String { + let name = family.into().replace(' ', "+"); + let italic = if self.italic { "ital," } else { "" }; + let weight = self.weight; + format!("https://fonts.googleapis.com/css2?display=swap&family={name}:{italic}wght@{weight}&text={name}") + } +} + #[derive(PartialEq, Eq, Clone, Copy, Default, Debug, serde::Serialize, serde::Deserialize)] pub enum Platform { #[default] diff --git a/editor/src/messages/preferences/mod.rs b/editor/src/messages/preferences/mod.rs index 916c384f8..3f485b23c 100644 --- a/editor/src/messages/preferences/mod.rs +++ b/editor/src/messages/preferences/mod.rs @@ -1,5 +1,5 @@ mod preferences_message; -mod preferences_message_handler; +pub mod preferences_message_handler; pub mod utility_types; #[doc(inline)] diff --git a/editor/src/messages/preferences/preferences_message.rs b/editor/src/messages/preferences/preferences_message.rs index 0b58e8c1f..027bdd406 100644 --- a/editor/src/messages/preferences/preferences_message.rs +++ b/editor/src/messages/preferences/preferences_message.rs @@ -17,4 +17,5 @@ pub enum PreferencesMessage { ModifyLayout { zoom_with_scroll: bool }, GraphWireStyle { style: GraphWireStyle }, ViewportZoomWheelRate { rate: f64 }, + UIScale { scale: f64 }, } diff --git a/editor/src/messages/preferences/preferences_message_handler.rs b/editor/src/messages/preferences/preferences_message_handler.rs index 06080b23c..a85e384b7 100644 --- a/editor/src/messages/preferences/preferences_message_handler.rs +++ b/editor/src/messages/preferences/preferences_message_handler.rs @@ -1,11 +1,18 @@ -use crate::consts::VIEWPORT_ZOOM_WHEEL_RATE; +use crate::consts::{UI_SCALE_DEFAULT, VIEWPORT_ZOOM_WHEEL_RATE}; use crate::messages::input_mapper::key_mapping::MappingVariant; use crate::messages::portfolio::document::utility_types::wires::GraphWireStyle; use crate::messages::preferences::SelectionMode; use crate::messages::prelude::*; +use crate::messages::tool::utility_types::ToolType; use graph_craft::wasm_application_io::EditorPreferences; +#[derive(ExtractField)] +pub struct PreferencesMessageContext<'a> { + pub tool_message_handler: &'a ToolMessageHandler, +} + #[derive(Debug, PartialEq, Clone, serde::Serialize, serde::Deserialize, specta::Type, ExtractField)] +#[serde(default)] pub struct PreferencesMessageHandler { pub selection_mode: SelectionMode, pub zoom_with_scroll: bool, @@ -14,6 +21,7 @@ pub struct PreferencesMessageHandler { pub brush_tool: bool, pub graph_wire_style: GraphWireStyle, pub viewport_zoom_wheel_rate: f64, + pub ui_scale: f64, } impl PreferencesMessageHandler { @@ -42,13 +50,16 @@ impl Default for PreferencesMessageHandler { brush_tool: false, graph_wire_style: GraphWireStyle::default(), viewport_zoom_wheel_rate: VIEWPORT_ZOOM_WHEEL_RATE, + ui_scale: UI_SCALE_DEFAULT, } } } #[message_handler_data] -impl MessageHandler for PreferencesMessageHandler { - fn process_message(&mut self, message: PreferencesMessage, responses: &mut VecDeque, _: ()) { +impl MessageHandler> for PreferencesMessageHandler { + fn process_message(&mut self, message: PreferencesMessage, responses: &mut VecDeque, context: PreferencesMessageContext) { + let PreferencesMessageContext { tool_message_handler } = context; + match message { // Management messages PreferencesMessage::Load { preferences } => { @@ -61,6 +72,7 @@ impl MessageHandler for PreferencesMessageHandler { responses.add(PreferencesMessage::ModifyLayout { zoom_with_scroll: self.zoom_with_scroll, }); + responses.add(FrontendMessage::UpdateUIScale { scale: self.ui_scale }); } PreferencesMessage::ResetToDefaults => { refresh_dialog(responses); @@ -80,6 +92,11 @@ impl MessageHandler for PreferencesMessageHandler { } PreferencesMessage::BrushTool { enabled } => { self.brush_tool = enabled; + + if !enabled && tool_message_handler.tool_state.tool_data.active_tool_type == ToolType::Brush { + responses.add(ToolMessage::ActivateToolSelect); + } + responses.add(ToolMessage::RefreshToolShelf); } PreferencesMessage::ModifyLayout { zoom_with_scroll } => { @@ -99,6 +116,10 @@ impl MessageHandler for PreferencesMessageHandler { PreferencesMessage::ViewportZoomWheelRate { rate } => { self.viewport_zoom_wheel_rate = rate; } + PreferencesMessage::UIScale { scale } => { + self.ui_scale = scale; + responses.add(FrontendMessage::UpdateUIScale { scale: self.ui_scale }); + } } responses.add(FrontendMessage::TriggerSavePreferences { preferences: self.clone() }); diff --git a/editor/src/messages/tool/common_functionality/pivot.rs b/editor/src/messages/tool/common_functionality/pivot.rs index 2b99a347c..0e61fd9a8 100644 --- a/editor/src/messages/tool/common_functionality/pivot.rs +++ b/editor/src/messages/tool/common_functionality/pivot.rs @@ -169,6 +169,7 @@ pub struct PivotGizmoState { impl PivotGizmoState { pub fn is_pivot_type(&self) -> bool { + // A disabled pivot is considered a pivot-type gizmo that is always centered self.gizmo_type == PivotGizmoType::Pivot || self.disabled } diff --git a/editor/src/messages/tool/tool_message_handler.rs b/editor/src/messages/tool/tool_message_handler.rs index 65edd2045..0a263e199 100644 --- a/editor/src/messages/tool/tool_message_handler.rs +++ b/editor/src/messages/tool/tool_message_handler.rs @@ -44,7 +44,6 @@ impl MessageHandler> for ToolMessageHandler preferences, viewport, } = context; - let font_cache = &persistent_data.font_cache; match message { // Messages @@ -122,11 +121,11 @@ impl MessageHandler> for ToolMessageHandler document_id, global_tool_data: &self.tool_state.document_tool_data, input, - font_cache, shape_editor: &mut self.shape_editor, node_graph, preferences, viewport, + persistent_data, }; if let Some(tool_abort_message) = tool.event_to_message_map().tool_abort { @@ -217,7 +216,7 @@ impl MessageHandler> for ToolMessageHandler tool_data.tools.get(active_tool).unwrap().activate(responses); // Register initial properties - tool_data.tools.get(active_tool).unwrap().send_layout(responses, LayoutTarget::ToolOptions); + tool_data.tools.get(active_tool).unwrap().refresh_options(responses, persistent_data); // Notify the frontend about the initial active tool tool_data.send_layout(responses, LayoutTarget::ToolShelf, preferences.brush_tool); @@ -230,11 +229,11 @@ impl MessageHandler> for ToolMessageHandler document_id, global_tool_data: &self.tool_state.document_tool_data, input, - font_cache, shape_editor: &mut self.shape_editor, node_graph, preferences, viewport, + persistent_data, }; // Set initial hints and cursor @@ -257,7 +256,8 @@ impl MessageHandler> for ToolMessageHandler } ToolMessage::RefreshToolOptions => { let tool_data = &mut self.tool_state.tool_data; - tool_data.tools.get(&tool_data.active_tool_type).unwrap().send_layout(responses, LayoutTarget::ToolOptions); + + tool_data.tools.get(&tool_data.active_tool_type).unwrap().refresh_options(responses, persistent_data); } ToolMessage::RefreshToolShelf => { let tool_data = &mut self.tool_state.tool_data; @@ -341,11 +341,11 @@ impl MessageHandler> for ToolMessageHandler document_id, global_tool_data: &self.tool_state.document_tool_data, input, - font_cache, shape_editor: &mut self.shape_editor, node_graph, preferences, viewport, + persistent_data, }; if matches!(tool_message, ToolMessage::UpdateHints) { if graph_view_overlay_open { @@ -384,8 +384,6 @@ impl MessageHandler> for ToolMessageHandler ActivateToolShape, ActivateToolText, - ActivateToolBrush, - ToggleSelectVsPath, SelectRandomWorkingColor, @@ -400,3 +398,17 @@ impl MessageHandler> for ToolMessageHandler list } } + +impl ToolMessageHandler { + pub fn actions_with_preferences(&self, preferences: &PreferencesMessageHandler) -> ActionList { + let mut list = self.actions(); + + if preferences.brush_tool { + list.extend(actions!(ToolMessageDiscriminant; + ActivateToolBrush, + )); + } + + list + } +} diff --git a/editor/src/messages/tool/tool_messages/path_tool.rs b/editor/src/messages/tool/tool_messages/path_tool.rs index 5bf09bd7a..dd0d2865d 100644 --- a/editor/src/messages/tool/tool_messages/path_tool.rs +++ b/editor/src/messages/tool/tool_messages/path_tool.rs @@ -1575,6 +1575,10 @@ impl Fsm for PathToolFsmState { shape_editor.set_selected_layers(target_layers); + let new_state = make_path_editable_is_allowed(&mut document.network_interface).is_some(); + if tool_data.make_path_editable_is_allowed != new_state { + responses.add(MenuBarMessage::SendLayout); + } responses.add(OverlaysMessage::Draw); self } @@ -3125,8 +3129,14 @@ impl Fsm for PathToolFsmState { colinear, }; + let old = tool_data.make_path_editable_is_allowed; tool_data.make_path_editable_is_allowed = make_path_editable_is_allowed(&mut document.network_interface).is_some(); tool_data.update_selection_status(shape_editor, document); + + if old != tool_data.make_path_editable_is_allowed { + responses.add(MenuBarMessage::SendLayout); + } + self } (_, PathToolMessage::ManipulatorMakeHandlesColinear) => { diff --git a/editor/src/messages/tool/tool_messages/select_tool.rs b/editor/src/messages/tool/tool_messages/select_tool.rs index 3a19f4a39..28ecbdfcb 100644 --- a/editor/src/messages/tool/tool_messages/select_tool.rs +++ b/editor/src/messages/tool/tool_messages/select_tool.rs @@ -400,6 +400,7 @@ struct SelectToolData { selected_layers_changed: bool, snap_candidates: Vec, auto_panning: AutoPanning, + drag_start_center: ViewportPosition, } impl SelectToolData { @@ -599,7 +600,7 @@ impl Fsm for SelectToolFsmState { document, input, viewport, - font_cache, + persistent_data, .. } = tool_action_data; @@ -624,7 +625,7 @@ impl Fsm for SelectToolFsmState { overlay_context.outline(document.metadata().layer_with_free_points_outline(layer), layer_to_viewport, None); if is_layer_fed_by_node_of_name(layer, &document.network_interface, "Text") { - let transformed_quad = layer_to_viewport * text_bounding_box(layer, document, font_cache); + let transformed_quad = layer_to_viewport * text_bounding_box(layer, document, &persistent_data.font_cache); overlay_context.dashed_quad(transformed_quad, None, None, Some(7.), Some(5.), None); } } @@ -911,11 +912,10 @@ impl Fsm for SelectToolFsmState { let angle = -mouse_position.angle_to(DVec2::X); let snapped_angle = (angle / snap_resolution).round() * snap_resolution; - let extension = tool_data.drag_current - tool_data.drag_start; - let origin = compass_center - extension; + let origin = tool_data.drag_start_center; let viewport_diagonal = viewport.size().into_dvec2().length(); - let edge = DVec2::from_angle(snapped_angle).normalize_or(DVec2::X) * viewport_diagonal; + let edge = DVec2::from_angle(snapped_angle).normalize_or(DVec2::X); let perp = edge.perp(); let (edge_color, perp_color) = if edge.x.abs() > edge.y.abs() { @@ -1032,6 +1032,8 @@ impl Fsm for SelectToolFsmState { let position = tool_data.pivot_gizmo().position(document); let (resize, rotate, skew) = transforming_transform_cage(document, &mut tool_data.bounding_box_manager, input, responses, &mut tool_data.layers_dragging, Some(position)); + tool_data.drag_start_center = position; + // If the user is dragging the bounding box bounds, go into ResizingBounds mode. // If the user is dragging the rotate trigger, go into RotatingBounds mode. // If the user clicks on a layer that is in their current selection, go into the dragging mode. diff --git a/editor/src/messages/tool/tool_messages/text_tool.rs b/editor/src/messages/tool/tool_messages/text_tool.rs index 3bdbc479d..4bc39f208 100644 --- a/editor/src/messages/tool/tool_messages/text_tool.rs +++ b/editor/src/messages/tool/tool_messages/text_tool.rs @@ -6,6 +6,7 @@ use crate::messages::portfolio::document::graph_operation::utility_types::Transf use crate::messages::portfolio::document::overlays::utility_types::OverlayContext; use crate::messages::portfolio::document::utility_types::document_metadata::LayerNodeIdentifier; use crate::messages::portfolio::document::utility_types::network_interface::InputConnector; +use crate::messages::portfolio::utility_types::{FontCatalog, FontCatalogStyle, PersistentData}; use crate::messages::tool::common_functionality::auto_panning::AutoPanning; use crate::messages::tool::common_functionality::color_selector::{ToolColorOptions, ToolColorType}; use crate::messages::tool::common_functionality::graph_modification_utils::{self, is_layer_fed_by_node_of_name}; @@ -13,6 +14,7 @@ use crate::messages::tool::common_functionality::resize::Resize; use crate::messages::tool::common_functionality::snapping::{self, SnapCandidatePoint, SnapData}; use crate::messages::tool::common_functionality::transformation_cage::*; use crate::messages::tool::common_functionality::utility_functions::text_bounding_box; +use crate::messages::tool::utility_types::ToolRefreshOptions; use graph_craft::document::value::TaggedValue; use graph_craft::document::{NodeId, NodeInput}; use graphene_std::Color; @@ -31,8 +33,7 @@ pub struct TextOptions { font_size: f64, line_height_ratio: f64, character_spacing: f64, - font_name: String, - font_style: String, + font: Font, fill: ToolColorOptions, tilt: f64, align: TextAlign, @@ -44,8 +45,7 @@ impl Default for TextOptions { font_size: 24., line_height_ratio: 1.2, character_spacing: 0., - font_name: graphene_std::consts::DEFAULT_FONT_FAMILY.into(), - font_style: graphene_std::consts::DEFAULT_FONT_STYLE.into(), + font: Font::new(graphene_std::consts::DEFAULT_FONT_FAMILY.into(), graphene_std::consts::DEFAULT_FONT_STYLE.into()), fill: ToolColorOptions::new_primary(), tilt: 0., align: TextAlign::default(), @@ -71,13 +71,14 @@ pub enum TextToolMessage { TextChange { new_text: String, is_left_or_right_click: bool }, UpdateBounds { new_text: String }, UpdateOptions { options: TextOptionsUpdate }, + RefreshEditingFontData, } #[derive(PartialEq, Clone, Debug, serde::Serialize, serde::Deserialize, specta::Type)] pub enum TextOptionsUpdate { FillColor(Option), FillColorType(ToolColorType), - Font { family: String, style: String }, + Font { font: Font }, FontSize(f64), LineHeightRatio(f64), Align(TextAlign), @@ -96,31 +97,84 @@ impl ToolMetadata for TextTool { } } -fn create_text_widgets(tool: &TextTool) -> Vec { - let font = FontInput::new(&tool.options.font_name, &tool.options.font_style) - .is_style_picker(false) - .on_update(|font_input: &FontInput| { +fn create_text_widgets(tool: &TextTool, font_catalog: &FontCatalog) -> Vec { + fn update_options(font: Font, commit_style: Option) -> impl Fn(&()) -> Message + Clone { + let mut font = font; + if let Some(style) = commit_style { + font.font_style = style; + } + + move |_| { TextToolMessage::UpdateOptions { - options: TextOptionsUpdate::Font { - family: font_input.font_family.clone(), - style: font_input.font_style.clone(), - }, + options: TextOptionsUpdate::Font { font: font.clone() }, } .into() - }) - .widget_instance(); - let style = FontInput::new(&tool.options.font_name, &tool.options.font_style) - .is_style_picker(true) - .on_update(|font_input: &FontInput| { - TextToolMessage::UpdateOptions { - options: TextOptionsUpdate::Font { - family: font_input.font_family.clone(), - style: font_input.font_style.clone(), - }, - } - .into() - }) - .widget_instance(); + } + } + + let font = DropdownInput::new(vec![ + font_catalog + .0 + .iter() + .map(|family| { + let font = Font::new(family.name.clone(), tool.options.font.font_style.clone()); + let commit_style = font_catalog.find_font_style_in_catalog(&tool.options.font).map(|style| style.to_named_style()); + let update = update_options(font.clone(), None); + let commit = update_options(font, commit_style); + + MenuListEntry::new(family.name.clone()) + .label(family.name.clone()) + .font(family.closest_style(400, false).preview_url(&family.name)) + .on_update(update) + .on_commit(commit) + }) + .collect::>(), + ]) + .selected_index(font_catalog.0.iter().position(|family| family.name == tool.options.font.font_family).map(|i| i as u32)) + .virtual_scrolling(true) + .widget_instance(); + + let style = DropdownInput::new({ + font_catalog + .0 + .iter() + .find(|family| family.name == tool.options.font.font_family) + .map(|family| { + let build_entry = |style: &FontCatalogStyle| { + let font_style = style.to_named_style(); + + let font = Font::new(tool.options.font.font_family.clone(), font_style.clone()); + let commit_style = font_catalog.find_font_style_in_catalog(&tool.options.font).map(|style| style.to_named_style()); + let update = update_options(font.clone(), None); + let commit = update_options(font, commit_style); + + MenuListEntry::new(font_style.clone()).on_update(update).on_commit(commit).label(font_style) + }; + + vec![ + family.styles.iter().filter(|style| !style.italic).map(build_entry).collect::>(), + family.styles.iter().filter(|style| style.italic).map(build_entry).collect::>(), + ] + }) + .filter(|styles| !styles.is_empty()) + .unwrap_or_default() + }) + .selected_index( + font_catalog + .0 + .iter() + .find(|family| family.name == tool.options.font.font_family) + .and_then(|family| { + let not_italic = family.styles.iter().filter(|style| !style.italic); + let italic = family.styles.iter().filter(|style| style.italic); + not_italic + .chain(italic) + .position(|style| Some(style) == font_catalog.find_font_style_in_catalog(&tool.options.font).as_ref()) + }) + .map(|i| i as u32), + ) + .widget_instance(); + let size = NumberInput::new(Some(tool.options.font_size)) .unit(" px") .label("Size") @@ -172,9 +226,22 @@ fn create_text_widgets(tool: &TextTool) -> Vec { ] } -impl LayoutHolder for TextTool { - fn layout(&self) -> Layout { - let mut widgets = create_text_widgets(self); +impl ToolRefreshOptions for TextTool { + fn refresh_options(&self, responses: &mut VecDeque, persistent_data: &PersistentData) { + self.send_layout(responses, LayoutTarget::ToolOptions, &persistent_data.font_catalog); + } +} + +impl TextTool { + fn send_layout(&self, responses: &mut VecDeque, layout_target: LayoutTarget, font_catalog: &FontCatalog) { + responses.add(LayoutMessage::SendLayout { + layout: self.layout(font_catalog), + layout_target, + }); + } + + fn layout(&self, font_catalog: &FontCatalog) -> Layout { + let mut widgets = create_text_widgets(self, font_catalog); widgets.push(Separator::new(SeparatorType::Unrelated).widget_instance()); @@ -215,11 +282,8 @@ impl<'a> MessageHandler> for Text return; }; match options { - TextOptionsUpdate::Font { family, style } => { - self.options.font_name = family; - self.options.font_style = style; - - self.send_layout(responses, LayoutTarget::ToolOptions); + TextOptionsUpdate::Font { font } => { + self.options.font = font; } TextOptionsUpdate::FontSize(font_size) => self.options.font_size = font_size, TextOptionsUpdate::LineHeightRatio(line_height_ratio) => self.options.line_height_ratio = line_height_ratio, @@ -235,7 +299,7 @@ impl<'a> MessageHandler> for Text } } - self.send_layout(responses, LayoutTarget::ToolOptions); + self.send_layout(responses, LayoutTarget::ToolOptions, &context.persistent_data.font_catalog); } fn actions(&self) -> ActionList { @@ -339,6 +403,7 @@ impl TextToolData { TextToolFsmState::Ready } + /// Set the editing state of the currently modifying layer fn set_editing(&self, editable: bool, font_cache: &FontCache, responses: &mut VecDeque) { if let Some(editing_text) = self.editing_text.as_ref().filter(|_| editable) { @@ -347,7 +412,7 @@ impl TextToolData { line_height_ratio: editing_text.typesetting.line_height_ratio, font_size: editing_text.typesetting.font_size, color: editing_text.color.unwrap_or(Color::BLACK), - url: font_cache.get_preview_url(&editing_text.font).cloned().unwrap_or_default(), + font_data: font_cache.get(&editing_text.font).map(|(data, _)| data.clone()).unwrap_or_default(), transform: editing_text.transform.to_cols_array(), max_width: editing_text.typesetting.max_width, max_height: editing_text.typesetting.max_height, @@ -411,6 +476,7 @@ impl TextToolData { self.layer = LayerNodeIdentifier::new_unchecked(NodeId::new()); + responses.add(PortfolioMessage::LoadFontData { font: editing_text.font.clone() }); responses.add(GraphOperationMessage::NewTextLayer { id: self.layer.to_node(), text: String::new(), @@ -498,10 +564,11 @@ impl Fsm for TextToolFsmState { document, global_tool_data, input, - font_cache, + persistent_data, viewport, .. } = transition_data; + let font_cache = &persistent_data.font_cache; let fill_color = graphene_std::Color::from_rgb_str(COLOR_OVERLAY_BLUE.strip_prefix('#').unwrap()) .unwrap() .with_alpha(0.05) @@ -827,7 +894,7 @@ impl Fsm for TextToolFsmState { tilt: tool_options.tilt, align: tool_options.align, }, - font: Font::new(tool_options.font_name.clone(), tool_options.font_style.clone()), + font: Font::new(tool_options.font.font_family.clone(), tool_options.font.font_style.clone()), color: tool_options.fill.active_color(), }; tool_data.new_text(document, editing_text, font_cache, responses); @@ -852,6 +919,14 @@ impl Fsm for TextToolFsmState { TextToolFsmState::Ready } + (TextToolFsmState::Editing, TextToolMessage::RefreshEditingFontData) => { + let font = Font::new(tool_options.font.font_family.clone(), tool_options.font.font_style.clone()); + responses.add(FrontendMessage::DisplayEditableTextboxUpdateFontData { + font_data: font_cache.get(&font).map(|(data, _)| data.clone()).unwrap_or_default(), + }); + + TextToolFsmState::Editing + } (TextToolFsmState::Editing, TextToolMessage::TextChange { new_text, is_left_or_right_click }) => { tool_data.new_text = new_text; @@ -871,6 +946,7 @@ impl Fsm for TextToolFsmState { } responses.add(FrontendMessage::TriggerTextCommit); + TextToolFsmState::Editing } } diff --git a/editor/src/messages/tool/utility_types.rs b/editor/src/messages/tool/utility_types.rs index e9e7a1700..052926cfe 100644 --- a/editor/src/messages/tool/utility_types.rs +++ b/editor/src/messages/tool/utility_types.rs @@ -9,12 +9,12 @@ use crate::messages::input_mapper::utility_types::macros::action_shortcut; use crate::messages::input_mapper::utility_types::misc::ActionShortcut; use crate::messages::layout::utility_types::widget_prelude::*; use crate::messages::portfolio::document::overlays::utility_types::OverlayProvider; +use crate::messages::portfolio::utility_types::PersistentData; use crate::messages::preferences::PreferencesMessageHandler; use crate::messages::prelude::*; use crate::messages::tool::common_functionality::shapes::shape_utility::ShapeType; use crate::node_graph_executor::NodeGraphExecutor; use graphene_std::raster::color::Color; -use graphene_std::text::FontCache; use std::borrow::Cow; use std::fmt::{self, Debug}; @@ -24,18 +24,28 @@ pub struct ToolActionMessageContext<'a> { pub document_id: DocumentId, pub global_tool_data: &'a DocumentToolData, pub input: &'a InputPreprocessorMessageHandler, - pub font_cache: &'a FontCache, + pub persistent_data: &'a PersistentData, pub shape_editor: &'a mut ShapeState, pub node_graph: &'a NodeGraphExecutor, pub preferences: &'a PreferencesMessageHandler, pub viewport: &'a ViewportMessageHandler, } -pub trait ToolCommon: for<'a, 'b> MessageHandler> + LayoutHolder + ToolTransition + ToolMetadata {} -impl ToolCommon for T where T: for<'a, 'b> MessageHandler> + LayoutHolder + ToolTransition + ToolMetadata {} +pub trait ToolCommon: for<'a, 'b> MessageHandler> + ToolRefreshOptions + ToolTransition + ToolMetadata {} +impl ToolCommon for T where T: for<'a, 'b> MessageHandler> + ToolRefreshOptions + ToolTransition + ToolMetadata {} type Tool = dyn ToolCommon + Send + Sync; +pub trait ToolRefreshOptions { + fn refresh_options(&self, responses: &mut VecDeque, _persistent_data: &PersistentData); +} + +impl ToolRefreshOptions for T { + fn refresh_options(&self, responses: &mut VecDeque, _persistent_data: &PersistentData) { + self.send_layout(responses, LayoutTarget::ToolOptions); + } +} + /// The FSM (finite state machine) is a flowchart between different operating states that a specific tool might be in. /// It is the central "core" logic area of each tool which is in charge of maintaining the state of the tool and responding to events coming from outside (like user input). /// For example, a tool might be `Ready` or `Drawing` depending on if the user is idle or actively drawing with the mouse held down. diff --git a/frontend/src/components/Editor.svelte b/frontend/src/components/Editor.svelte index 9831e018e..ecbd24b8f 100644 --- a/frontend/src/components/Editor.svelte +++ b/frontend/src/components/Editor.svelte @@ -11,7 +11,7 @@ import { createAppWindowState } from "@graphite/state-providers/app-window"; import { createDialogState } from "@graphite/state-providers/dialog"; import { createDocumentState } from "@graphite/state-providers/document"; - import { createFontsState } from "@graphite/state-providers/fonts"; + import { createFontsManager } from "/src/io-managers/fonts"; import { createFullscreenState } from "@graphite/state-providers/fullscreen"; import { createNodeGraphState } from "@graphite/state-providers/node-graph"; import { createPortfolioState } from "@graphite/state-providers/portfolio"; @@ -27,12 +27,10 @@ // State provider systems let dialog = createDialogState(editor); setContext("dialog", dialog); - let tooltip = createTooltipState(); + let tooltip = createTooltipState(editor); setContext("tooltip", tooltip); let document = createDocumentState(editor); setContext("document", document); - let fonts = createFontsState(editor); - setContext("fonts", fonts); let fullscreen = createFullscreenState(editor); setContext("fullscreen", fullscreen); let nodeGraph = createNodeGraphState(editor); @@ -48,6 +46,7 @@ createLocalizationManager(editor); createPanicManager(editor, dialog); createPersistenceManager(editor, portfolio); + createFontsManager(editor); let inputManagerDestructor = createInputManager(editor, dialog, portfolio, document, fullscreen); onMount(() => { diff --git a/frontend/src/components/floating-menus/ColorPicker.svelte b/frontend/src/components/floating-menus/ColorPicker.svelte index e3388f849..07abea96f 100644 --- a/frontend/src/components/floating-menus/ColorPicker.svelte +++ b/frontend/src/components/floating-menus/ColorPicker.svelte @@ -1,9 +1,9 @@ {/if} - {#each entries as section, sectionIndex (sectionIndex)} - {#if includeSeparator(entries, section, sectionIndex, search)} + {#each reactiveEntries as section, sectionIndex (sectionIndex)} + {#if includeSeparator(reactiveEntries, section, sectionIndex, search)} {/if} {#each currentEntries(section, virtualScrollingEntryHeight, virtualScrollingStartIndex, virtualScrollingEndIndex, search) as entry, entryIndex (entryIndex + startIndex)} @@ -442,10 +458,10 @@ {/if} {#if entry.font} - + {/if} - + {#if entry.tooltipShortcut?.shortcut.length} @@ -470,6 +486,7 @@ open={getChildReference(entry)?.open || false} direction="TopRight" entries={entry.children} + entriesHash={entry.childrenHash || 0n} {minWidth} {drawIcon} {scrollableY} diff --git a/frontend/src/components/floating-menus/Tooltip.svelte b/frontend/src/components/floating-menus/Tooltip.svelte index 5a9681c01..33beaaff3 100644 --- a/frontend/src/components/floating-menus/Tooltip.svelte +++ b/frontend/src/components/floating-menus/Tooltip.svelte @@ -15,8 +15,8 @@ let self: FloatingMenu | undefined; - $: label = filterTodo($tooltip.element?.getAttribute("data-tooltip-label")?.trim()); - $: description = filterTodo($tooltip.element?.getAttribute("data-tooltip-description")?.trim()); + $: label = parseMarkdown(filterTodo($tooltip.element?.getAttribute("data-tooltip-label")?.trim())); + $: description = parseMarkdown(filterTodo($tooltip.element?.getAttribute("data-tooltip-description")?.trim())); $: shortcutJSON = $tooltip.element?.getAttribute("data-tooltip-shortcut")?.trim(); $: shortcut = ((shortcutJSON) => { if (!shortcutJSON) return undefined; @@ -32,6 +32,26 @@ if (text?.trim().toUpperCase() === "TODO" && !editor.handle.inDevelopmentMode()) return ""; return text; } + + function parseMarkdown(markdown: string | undefined): string | undefined { + if (!markdown) return undefined; + + return ( + markdown + // .split("\n") + // .map((line) => line.trim()) + // .join("\n") + // .split("\n\n") + // .map((paragraph) => paragraph.replaceAll("\n", " ")) + // .join("\n\n") + // Bold + .replace(/\*\*((?:(?!\*\*).)+)\*\*/g, "$1") + // Italic + .replace(/\*([^*]+)\*/g, "$1") + // Backticks + .replace(/`([^`]+)`/g, "$1") + ); + } {#if label || description} @@ -40,7 +60,7 @@ {#if label || shortcut} {#if label} - {label} + {@html label} {/if} {#if shortcut} @@ -48,7 +68,7 @@ {/if} {#if description} - {description} + {@html description} {/if} diff --git a/frontend/src/components/layout/FloatingMenu.svelte b/frontend/src/components/layout/FloatingMenu.svelte index bc6112dc6..b26d6977b 100644 --- a/frontend/src/components/layout/FloatingMenu.svelte +++ b/frontend/src/components/layout/FloatingMenu.svelte @@ -372,6 +372,9 @@ // Start with the parent of the spawner for this floating menu and keep widening the search for any other valid spawners that are hover-transferrable let currentAncestor = (targetSpawner && ownSpawner?.parentElement) || undefined; while (currentAncestor) { + // If the current ancestor blocks hover transfer, stop searching + if (currentAncestor.hasAttribute("data-block-hover-transfer")) break; + const ownSpawnerDepthFromCurrentAncestor = ownSpawner && getDepthFromAncestor(ownSpawner, currentAncestor); const currentAncestor2 = currentAncestor; // This duplicate variable avoids an ESLint warning @@ -382,8 +385,8 @@ const notOurself = !ownDescendantMenuSpawners.includes(item); // And filter away unequal depths from the current ancestor const notUnequalDepths = notOurself && getDepthFromAncestor(item, currentAncestor2) === ownSpawnerDepthFromCurrentAncestor; - // And filter away elements that explicitly disable hover transfer - return notUnequalDepths && !(item as HTMLElement).getAttribute?.("data-floating-menu-spawner")?.includes("no-hover-transfer"); + // And filter away descendants that explicitly disable hover transfer + return notUnequalDepths && !(item instanceof HTMLElement && item.hasAttribute("data-block-hover-transfer")); }); // If none were found, widen the search by a level and keep trying (or stop looping if the root was reached) diff --git a/frontend/src/components/panels/Document.svelte b/frontend/src/components/panels/Document.svelte index f149cb3fd..8c03e135b 100644 --- a/frontend/src/components/panels/Document.svelte +++ b/frontend/src/components/panels/Document.svelte @@ -6,6 +6,7 @@ type MouseCursorIcon, type XY, DisplayEditableTextbox, + DisplayEditableTextboxUpdateFontData, DisplayEditableTextboxTransform, DisplayRemoveEditableTextbox, TriggerTextCommit, @@ -360,10 +361,14 @@ if (!textInput) return; editor.handle.updateBounds(textInputCleanup(textInput.innerText)); }; + textInputMatrix = displayEditableTextbox.transform; - const newFont = new FontFace("text-font", `url(${displayEditableTextbox.url})`); - window.document.fonts.add(newFont); - textInput.style.fontFamily = "text-font"; + + const data = new Uint8Array(displayEditableTextbox.fontData); + if (data.length > 0) { + window.document.fonts.add(new FontFace("text-font", data)); + textInput.style.fontFamily = "text-font"; + } // Necessary to select contenteditable: https://stackoverflow.com/questions/6139107/programmatically-select-text-in-a-contenteditable-html-element/6150060#6150060 @@ -471,6 +476,15 @@ displayEditableTextbox(data); }); + editor.subscriptions.subscribeJsMessage(DisplayEditableTextboxUpdateFontData, async (data) => { + await tick(); + + const fontData = new Uint8Array(data.fontData); + if (fontData.length > 0 && textInput) { + window.document.fonts.add(new FontFace("text-font", fontData)); + textInput.style.fontFamily = "text-font"; + } + }); editor.subscriptions.subscribeJsMessage(DisplayEditableTextboxTransform, async (data) => { textInputMatrix = data.transform; }); diff --git a/frontend/src/components/panels/Layers.svelte b/frontend/src/components/panels/Layers.svelte index 732df7cb4..2b343b03b 100644 --- a/frontend/src/components/panels/Layers.svelte +++ b/frontend/src/components/panels/Layers.svelte @@ -9,10 +9,10 @@ UpdateLayersPanelControlBarLeftLayout, UpdateLayersPanelControlBarRightLayout, UpdateLayersPanelBottomBarLayout, - SendShortcutAltClick, } from "@graphite/messages"; - import type { ActionShortcut, DataBuffer, LayerPanelEntry, Layout } from "@graphite/messages"; + import type { DataBuffer, LayerPanelEntry, Layout } from "@graphite/messages"; import type { NodeGraphState } from "@graphite/state-providers/node-graph"; + import type { TooltipState } from "@graphite/state-providers/tooltip"; import { operatingSystem } from "@graphite/utility-functions/platform"; import { extractPixelData } from "@graphite/utility-functions/rasterization"; @@ -49,6 +49,7 @@ const editor = getContext("editor"); const nodeGraph = getContext("nodeGraph"); + const tooltip = getContext("tooltip"); let list: LayoutCol | undefined; @@ -73,13 +74,7 @@ let layersPanelControlBarRightLayout: Layout = []; let layersPanelBottomBarLayout: Layout = []; - let altClickShortcut: ActionShortcut | undefined; - onMount(() => { - editor.subscriptions.subscribeJsMessage(SendShortcutAltClick, async (data) => { - altClickShortcut = data.shortcut; - }); - editor.subscriptions.subscribeJsMessage(UpdateLayersPanelControlBarLeftLayout, (updateLayersPanelControlBarLeftLayout) => { patchLayout(layersPanelControlBarLeftLayout, updateLayersPanelControlBarLeftLayout); layersPanelControlBarLeftLayout = layersPanelControlBarLeftLayout; @@ -627,8 +622,8 @@ data-tooltip-description={(listing.entry.expanded ? "Hide the layers nested within. (To affect all open descendants, perform the shortcut shown.)" : "Show the layers nested within. (To affect all closed descendants, perform the shortcut shown.)") + - (listing.entry.ancestorOfSelected && !listing.entry.expanded ? "\n\nNote: a selected layer is currently contained within.\n" : "")} - data-tooltip-shortcut={altClickShortcut?.shortcut ? JSON.stringify(altClickShortcut.shortcut) : undefined} + (listing.entry.ancestorOfSelected && !listing.entry.expanded ? "\n\nA selected layer is currently contained within.\n" : "")} + data-tooltip-shortcut={$tooltip.altClickShortcut?.shortcut ? JSON.stringify($tooltip.altClickShortcut.shortcut) : undefined} on:click={(e) => handleExpandArrowClickWithModifiers(e, listing.entry.id)} tabindex="0" > @@ -639,8 +634,9 @@ {/if}
diff --git a/frontend/src/components/views/Graph.svelte b/frontend/src/components/views/Graph.svelte index 58e09d796..90de83406 100644 --- a/frontend/src/components/views/Graph.svelte +++ b/frontend/src/components/views/Graph.svelte @@ -501,7 +501,7 @@ style:--data-color-dim={`var(--color-data-${(node.primaryOutput?.dataType || "General").toLowerCase()}-dim)`} style:--layer-area-width={layerAreaWidth} style:--node-chain-area-left-extension={layerChainWidth !== 0 ? layerChainWidth + 0.5 : 0} - data-tooltip-label={node.displayName === node.reference ? node.displayName : `${node.displayName} (${node.reference})`} + data-tooltip-label={node.displayName === node.reference || !node.reference ? node.displayName : `${node.displayName} (${node.reference})`} data-tooltip-description={` ${(description || "").trim()}${editor.handle.inDevelopmentMode() ? `\n\nID: ${node.id}. Position: (${node.position.x}, ${node.position.y}).` : ""} `.trim()} @@ -651,7 +651,7 @@ style:--clip-path-id={`url(#${clipPathId})`} style:--data-color={`var(--color-data-${(node.primaryOutput?.dataType || "General").toLowerCase()})`} style:--data-color-dim={`var(--color-data-${(node.primaryOutput?.dataType || "General").toLowerCase()}-dim)`} - data-tooltip-label={node.displayName === node.reference ? node.displayName : `${node.displayName} (${node.reference})`} + data-tooltip-label={node.displayName === node.reference || !node.reference ? node.displayName : `${node.displayName} (${node.reference})`} data-tooltip-description={` ${(description || "").trim()}${editor.handle.inDevelopmentMode() ? `\n\nID: ${node.id}. Position: (${node.position.x}, ${node.position.y}).` : ""} `.trim()} diff --git a/frontend/src/components/widgets/WidgetSection.svelte b/frontend/src/components/widgets/WidgetSection.svelte index c9964c74a..c51e72428 100644 --- a/frontend/src/components/widgets/WidgetSection.svelte +++ b/frontend/src/components/widgets/WidgetSection.svelte @@ -59,7 +59,7 @@ /> {#if expanded} - + {#each widgetData.layout as layoutGroup} {#if isWidgetSpanRow(layoutGroup)} diff --git a/frontend/src/components/widgets/WidgetSpan.svelte b/frontend/src/components/widgets/WidgetSpan.svelte index a46a63fe4..c4e474bc0 100644 --- a/frontend/src/components/widgets/WidgetSpan.svelte +++ b/frontend/src/components/widgets/WidgetSpan.svelte @@ -17,7 +17,6 @@ import ColorInput from "@graphite/components/widgets/inputs/ColorInput.svelte"; import CurveInput from "@graphite/components/widgets/inputs/CurveInput.svelte"; import DropdownInput from "@graphite/components/widgets/inputs/DropdownInput.svelte"; - import FontInput from "@graphite/components/widgets/inputs/FontInput.svelte"; import NumberInput from "@graphite/components/widgets/inputs/NumberInput.svelte"; import RadioInput from "@graphite/components/widgets/inputs/RadioInput.svelte"; import ReferencePointInput from "@graphite/components/widgets/inputs/ReferencePointInput.svelte"; @@ -61,16 +60,16 @@ return widgets; } - function widgetValueCommit(index: number, value: unknown) { - editor.handle.widgetValueCommit(layoutTarget, widgets[index].widgetId, value); + function widgetValueCommit(widgetIndex: number, value: unknown) { + editor.handle.widgetValueCommit(layoutTarget, widgets[widgetIndex].widgetId, value); } - function widgetValueUpdate(index: number, value: unknown) { - editor.handle.widgetValueUpdate(layoutTarget, widgets[index].widgetId, value); + function widgetValueUpdate(widgetIndex: number, value: unknown, resendWidget: boolean) { + editor.handle.widgetValueUpdate(layoutTarget, widgets[widgetIndex].widgetId, value, resendWidget); } - function widgetValueCommitAndUpdate(index: number, value: unknown) { - editor.handle.widgetValueCommitAndUpdate(layoutTarget, widgets[index].widgetId, value); + function widgetValueCommitAndUpdate(widgetIndex: number, value: unknown, resendWidget: boolean) { + editor.handle.widgetValueCommitAndUpdate(layoutTarget, widgets[widgetIndex].widgetId, value, resendWidget); } // TODO: This seems to work, but verify the correctness and terseness of this, it's adapted from https://stackoverflow.com/a/67434028/775283 @@ -85,43 +84,47 @@
- {#each widgets as component, index} + {#each widgets as component, widgetIndex} {@const checkboxInput = narrowWidgetProps(component.props, "CheckboxInput")} {#if checkboxInput} - widgetValueCommitAndUpdate(index, detail)} /> + widgetValueCommitAndUpdate(widgetIndex, detail, true)} /> {/if} {@const colorInput = narrowWidgetProps(component.props, "ColorInput")} {#if colorInput} - widgetValueUpdate(index, detail)} on:startHistoryTransaction={() => widgetValueCommit(index, colorInput.value)} /> + widgetValueUpdate(widgetIndex, detail, false)} + on:startHistoryTransaction={() => widgetValueCommit(widgetIndex, colorInput.value)} + /> {/if} + {@const curvesInput = narrowWidgetProps(component.props, "CurveInput")} {#if curvesInput} - debouncer((value) => widgetValueCommitAndUpdate(index, value), { debounceTime: 120 }).debounceUpdateValue(detail)} /> + debouncer((value) => widgetValueCommitAndUpdate(widgetIndex, value, false), { debounceTime: 120 }).debounceUpdateValue(detail)} + /> {/if} {@const dropdownInput = narrowWidgetProps(component.props, "DropdownInput")} {#if dropdownInput} { - return widgetValueUpdate(index, detail); + return widgetValueUpdate(widgetIndex, detail, false); }} on:hoverOutEntry={({ detail }) => { - return widgetValueUpdate(index, detail); + return widgetValueUpdate(widgetIndex, detail, false); }} - on:selectedIndex={({ detail }) => widgetValueCommitAndUpdate(index, detail)} + on:selectedIndex={({ detail }) => widgetValueCommitAndUpdate(widgetIndex, detail, true)} /> {/if} - {@const fontInput = narrowWidgetProps(component.props, "FontInput")} - {#if fontInput} - widgetValueCommitAndUpdate(index, detail)} /> - {/if} {@const parameterExposeButton = narrowWidgetProps(component.props, "ParameterExposeButton")} {#if parameterExposeButton} - widgetValueCommitAndUpdate(index, undefined)} /> + widgetValueCommitAndUpdate(widgetIndex, undefined, true)} /> {/if} {@const iconButton = narrowWidgetProps(component.props, "IconButton")} {#if iconButton} - widgetValueCommitAndUpdate(index, undefined)} /> + widgetValueCommitAndUpdate(widgetIndex, undefined, true)} /> {/if} {@const iconLabel = narrowWidgetProps(component.props, "IconLabel")} {#if iconLabel} @@ -138,25 +141,25 @@ {/if} {@const imageButton = narrowWidgetProps(component.props, "ImageButton")} {#if imageButton} - widgetValueCommitAndUpdate(index, undefined)} /> + widgetValueCommitAndUpdate(widgetIndex, undefined, true)} /> {/if} {@const nodeCatalog = narrowWidgetProps(component.props, "NodeCatalog")} {#if nodeCatalog} - widgetValueCommitAndUpdate(index, e.detail)} /> + widgetValueCommitAndUpdate(widgetIndex, e.detail, false)} /> {/if} {@const numberInput = narrowWidgetProps(component.props, "NumberInput")} {#if numberInput} debouncer((value) => widgetValueUpdate(index, value)).debounceUpdateValue(detail)} - on:startHistoryTransaction={() => widgetValueCommit(index, numberInput.value)} - incrementCallbackIncrease={() => widgetValueCommitAndUpdate(index, "Increment")} - incrementCallbackDecrease={() => widgetValueCommitAndUpdate(index, "Decrement")} + on:value={({ detail }) => debouncer((value) => widgetValueUpdate(widgetIndex, value, true)).debounceUpdateValue(detail)} + on:startHistoryTransaction={() => widgetValueCommit(widgetIndex, numberInput.value)} + incrementCallbackIncrease={() => widgetValueCommitAndUpdate(widgetIndex, "Increment", false)} + incrementCallbackDecrease={() => widgetValueCommitAndUpdate(widgetIndex, "Decrement", false)} /> {/if} {@const referencePointInput = narrowWidgetProps(component.props, "ReferencePointInput")} {#if referencePointInput} - widgetValueCommitAndUpdate(index, detail)} /> + widgetValueCommitAndUpdate(widgetIndex, detail, true)} /> {/if} {@const popoverButton = narrowWidgetProps(component.props, "PopoverButton")} {#if popoverButton} @@ -166,7 +169,7 @@ {/if} {@const radioInput = narrowWidgetProps(component.props, "RadioInput")} {#if radioInput} - widgetValueCommitAndUpdate(index, detail)} /> + widgetValueCommitAndUpdate(widgetIndex, detail, true)} /> {/if} {@const separator = narrowWidgetProps(component.props, "Separator")} {#if separator} @@ -178,19 +181,23 @@ {/if} {@const textAreaInput = narrowWidgetProps(component.props, "TextAreaInput")} {#if textAreaInput} - widgetValueCommitAndUpdate(index, detail)} /> + widgetValueCommitAndUpdate(widgetIndex, detail, false)} /> {/if} {@const textButton = narrowWidgetProps(component.props, "TextButton")} {#if textButton} - widgetValueCommitAndUpdate(index, [])} on:selectedEntryValuePath={({ detail }) => widgetValueCommitAndUpdate(index, detail)} /> + widgetValueCommitAndUpdate(widgetIndex, [], true)} + on:selectedEntryValuePath={({ detail }) => widgetValueCommitAndUpdate(widgetIndex, detail, false)} + /> {/if} {@const breadcrumbTrailButtons = narrowWidgetProps(component.props, "BreadcrumbTrailButtons")} {#if breadcrumbTrailButtons} - widgetValueCommitAndUpdate(index, breadcrumbIndex)} /> + widgetValueCommitAndUpdate(widgetIndex, breadcrumbIndex, true)} /> {/if} {@const textInput = narrowWidgetProps(component.props, "TextInput")} {#if textInput} - widgetValueCommitAndUpdate(index, detail)} /> + widgetValueCommitAndUpdate(widgetIndex, detail, true)} /> {/if} {@const textLabel = narrowWidgetProps(component.props, "TextLabel")} {#if textLabel} diff --git a/frontend/src/components/widgets/buttons/TextButton.svelte b/frontend/src/components/widgets/buttons/TextButton.svelte index 49d78eb15..86608e3d9 100644 --- a/frontend/src/components/widgets/buttons/TextButton.svelte +++ b/frontend/src/components/widgets/buttons/TextButton.svelte @@ -27,6 +27,7 @@ export let tooltipDescription: string | undefined = undefined; export let tooltipShortcut: ActionShortcut | undefined = undefined; export let menuListChildren: MenuListEntry[][] | undefined = undefined; + export let menuListChildrenHash: bigint | undefined = undefined; // Callbacks // TODO: Replace this with an event binding (and on other components that do this) @@ -71,7 +72,8 @@ data-disabled={disabled || undefined} data-text-button tabindex={disabled ? -1 : 0} - data-floating-menu-spawner={menuListChildrenExists ? "" : "no-hover-transfer"} + data-floating-menu-spawner + data-block-hover-transfer={menuListChildrenExists ? undefined : ""} on:click={onClick} > {#if icon} @@ -90,6 +92,7 @@ on:selectedEntryValuePath={({ detail }) => dispatch("selectedEntryValuePath", detail)} open={self?.open || false} entries={menuListChildren || []} + entriesHash={menuListChildrenHash || 0n} direction="Bottom" minWidth={240} drawIcon={true} diff --git a/frontend/src/components/widgets/inputs/DropdownInput.svelte b/frontend/src/components/widgets/inputs/DropdownInput.svelte index 4cfb031ea..a7c10d63c 100644 --- a/frontend/src/components/widgets/inputs/DropdownInput.svelte +++ b/frontend/src/components/widgets/inputs/DropdownInput.svelte @@ -12,15 +12,16 @@ const dispatch = createEventDispatcher<{ selectedIndex: number; hoverInEntry: number; hoverOutEntry: number }>(); - let menuList: MenuList | undefined; let self: LayoutRow | undefined; export let entries: MenuListEntry[][]; + export let entriesHash: bigint | undefined = undefined; export let selectedIndex: number | undefined = undefined; // When not provided, a dash is displayed export let drawIcon = false; export let interactive = true; export let disabled = false; export let narrow = false; + export let virtualScrolling = false; export let tooltipLabel: string | undefined = undefined; export let tooltipDescription: string | undefined = undefined; export let tooltipShortcut: ActionShortcut | undefined = undefined; @@ -53,19 +54,32 @@ activeEntry = makeActiveEntry(); } - // Called when the `activeEntry` two-way binding on this component's MenuList component is changed, or by the `selectedIndex()` watcher above (but we want to skip that case) + // Called when the `activeEntry` two-way binding on this component's MenuList component is changed, or by the `watchSelectedIndex()` watcher above (but we want to skip that case) function watchActiveEntry(activeEntry: MenuListEntry) { if (activeEntrySkipWatcher) { activeEntrySkipWatcher = false; } else if (activeEntry !== DASH_ENTRY) { // We need to set to the initial value first to track a right history step, as if we hover in initial selection. if (initialSelectedIndex !== undefined) dispatch("hoverInEntry", initialSelectedIndex); - dispatch("selectedIndex", entries.flat().indexOf(activeEntry)); + const index = entries.flat().findIndex((entry) => entry.value === activeEntry.value); + if (index !== -1) { + dispatch("selectedIndex", index); + } else { + // eslint-disable-next-line no-console + console.error("Selected index not found in entries:", activeEntry); + } } } function dispatchHoverInEntry(hoveredEntry: MenuListEntry) { - dispatch("hoverInEntry", entries.flat().indexOf(hoveredEntry)); + const index = entries.flat().findIndex((entry) => entry.value === hoveredEntry.value); + + if (index !== -1) { + dispatch("hoverInEntry", index); + } else { + // eslint-disable-next-line no-console + console.error("Hovered entry not found in entries:", hoveredEntry); + } } function dispatchHoverOutEntry() { @@ -123,11 +137,12 @@ {open} {activeEntry} {entries} + entriesHash={entriesHash || 0n} {drawIcon} {interactive} + {virtualScrolling} direction="Bottom" scrollableY={true} - bind:this={menuList} /> diff --git a/frontend/src/components/widgets/inputs/FontInput.svelte b/frontend/src/components/widgets/inputs/FontInput.svelte deleted file mode 100644 index e8b290461..000000000 --- a/frontend/src/components/widgets/inputs/FontInput.svelte +++ /dev/null @@ -1,186 +0,0 @@ - - - - - 0 ? { "min-width": `${minWidth}px` } : {}) }} - {tooltipLabel} - {tooltipDescription} - {tooltipShortcut} - tabindex={disabled ? -1 : 0} - on:click={toggleOpen} - data-floating-menu-spawner - > - {activeEntry?.value || ""} - - - isStyle && (minWidth = detail)} - {activeEntry} - on:activeEntry={({ detail }) => (activeEntry = detail)} - {open} - on:open={({ detail }) => (open = detail)} - entries={[entries]} - minWidth={isStyle ? 0 : minWidth} - virtualScrollingEntryHeight={isStyle ? 0 : 20} - scrollableY={true} - bind:this={menuList} - /> - - - diff --git a/frontend/src/components/widgets/inputs/WorkingColorsInput.svelte b/frontend/src/components/widgets/inputs/WorkingColorsInput.svelte index cff3beacb..051f9367a 100644 --- a/frontend/src/components/widgets/inputs/WorkingColorsInput.svelte +++ b/frontend/src/components/widgets/inputs/WorkingColorsInput.svelte @@ -37,7 +37,7 @@ - + (primaryOpen = detail)} @@ -47,7 +47,7 @@ /> - + (secondaryOpen = detail)} diff --git a/frontend/src/components/widgets/labels/TextLabel.svelte b/frontend/src/components/widgets/labels/TextLabel.svelte index 4174ebf43..8465ff871 100644 --- a/frontend/src/components/widgets/labels/TextLabel.svelte +++ b/frontend/src/components/widgets/labels/TextLabel.svelte @@ -72,7 +72,8 @@ font-style: italic; } - &.monospace { + &.monospace, + code { font-family: "Source Code Pro", monospace; font-size: 12px; } @@ -94,5 +95,10 @@ a { color: inherit; } + + code { + background: var(--color-3-darkgray); + padding: 0 2px; + } } diff --git a/frontend/src/components/window/title-bar/TitleBar.svelte b/frontend/src/components/window/title-bar/TitleBar.svelte index 71b046825..802066a93 100644 --- a/frontend/src/components/window/title-bar/TitleBar.svelte +++ b/frontend/src/components/window/title-bar/TitleBar.svelte @@ -17,6 +17,9 @@ let menuBarLayout: Layout = []; + // On mac menu bar needs to be scaled with inverse of UI scale to match native menu buttons. + $: height = $appWindow.platform === "Mac" ? 28 * (1 / $appWindow.uiScale) : 28; + onMount(() => { editor.subscriptions.subscribeJsMessage(UpdateMenuBarLayout, (updateMenuBarLayout) => { patchLayout(menuBarLayout, updateMenuBarLayout); @@ -25,7 +28,7 @@ }); - + {#if $appWindow.platform !== "Mac"} @@ -48,7 +51,6 @@