Desktop: Add fullscreen window mode (#3625)

This commit is contained in:
Timon 2026-01-19 17:32:03 +01:00 committed by GitHub
parent fd0addf61c
commit 07fbcd489c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
20 changed files with 195 additions and 153 deletions

View File

@ -352,6 +352,11 @@ impl App {
window.toggle_maximize(); window.toggle_maximize();
} }
} }
DesktopFrontendMessage::WindowFullscreen => {
if let Some(window) = &mut self.window {
window.toggle_fullscreen();
}
}
DesktopFrontendMessage::WindowDrag => { DesktopFrontendMessage::WindowDrag => {
if let Some(window) = &self.window { if let Some(window) = &self.window {
window.start_drag(); window.start_drag();

View File

@ -1,13 +1,13 @@
use crate::consts::APP_NAME;
use crate::event::AppEventScheduler;
use crate::wrapper::messages::MenuItem;
use std::collections::HashMap; use std::collections::HashMap;
use std::sync::Arc; use std::sync::Arc;
use winit::cursor::{CursorIcon, CustomCursor, CustomCursorSource}; use winit::cursor::{CursorIcon, CustomCursor, CustomCursorSource};
use winit::event_loop::ActiveEventLoop; use winit::event_loop::ActiveEventLoop;
use winit::monitor::Fullscreen;
use winit::window::{Window as WinitWindow, WindowAttributes}; use winit::window::{Window as WinitWindow, WindowAttributes};
use crate::consts::APP_NAME;
use crate::event::AppEventScheduler;
use crate::wrapper::messages::MenuItem;
pub(crate) trait NativeWindow { pub(crate) trait NativeWindow {
fn init() {} fn init() {}
fn configure(attributes: WindowAttributes, event_loop: &dyn ActiveEventLoop) -> WindowAttributes; fn configure(attributes: WindowAttributes, event_loop: &dyn ActiveEventLoop) -> WindowAttributes;
@ -111,6 +111,9 @@ impl Window {
} }
pub(crate) fn toggle_maximize(&self) { pub(crate) fn toggle_maximize(&self) {
if self.is_fullscreen() {
return;
}
self.winit_window.set_maximized(!self.winit_window.is_maximized()); self.winit_window.set_maximized(!self.winit_window.is_maximized());
} }
@ -118,11 +121,22 @@ impl Window {
self.winit_window.is_maximized() self.winit_window.is_maximized()
} }
pub(crate) fn toggle_fullscreen(&mut self) {
if self.is_fullscreen() {
self.winit_window.set_fullscreen(None);
} else {
self.winit_window.set_fullscreen(Some(Fullscreen::Borderless(None)));
}
}
pub(crate) fn is_fullscreen(&self) -> bool { pub(crate) fn is_fullscreen(&self) -> bool {
self.winit_window.fullscreen().is_some() self.winit_window.fullscreen().is_some()
} }
pub(crate) fn start_drag(&self) { pub(crate) fn start_drag(&self) {
if self.is_fullscreen() {
return;
}
let _ = self.winit_window.drag_window(); let _ = self.winit_window.drag_window();
} }

View File

@ -210,10 +210,10 @@ unsafe fn ensure_helper_class() {
// Main window message handler, called on the UI thread for every message the main window receives. // Main window message handler, called on the UI thread for every message the main window receives.
unsafe extern "system" fn main_window_handle_message(hwnd: HWND, msg: u32, wparam: WPARAM, lparam: LPARAM) -> LRESULT { unsafe extern "system" fn main_window_handle_message(hwnd: HWND, msg: u32, wparam: WPARAM, lparam: LPARAM) -> LRESULT {
if msg == WM_NCCALCSIZE && wparam.0 != 0 { if msg == WM_NCCALCSIZE && wparam.0 != 0 {
// When maximized, shrink to visible frame so content doesn't extend beyond it.
if unsafe { IsZoomed(hwnd).as_bool() } {
let params = unsafe { &mut *(lparam.0 as *mut NCCALCSIZE_PARAMS) }; let params = unsafe { &mut *(lparam.0 as *mut NCCALCSIZE_PARAMS) };
// When maximized, shrink to visible frame so content doesn't extend beyond it.
if unsafe { IsZoomed(hwnd).as_bool() } && !is_effectively_fullscreen(params.rgrc[0]) {
let dpi = unsafe { GetDpiForWindow(hwnd) }; let dpi = unsafe { GetDpiForWindow(hwnd) };
let size = unsafe { GetSystemMetricsForDpi(SM_CXSIZEFRAME, dpi) }; let size = unsafe { GetSystemMetricsForDpi(SM_CXSIZEFRAME, dpi) };
let pad = unsafe { GetSystemMetricsForDpi(SM_CXPADDEDBORDER, dpi) }; let pad = unsafe { GetSystemMetricsForDpi(SM_CXPADDEDBORDER, dpi) };
@ -366,3 +366,27 @@ unsafe fn calculate_resize_direction(helper: HWND, lparam: LPARAM) -> Option<u32
_ => None, _ => None,
} }
} }
// Check if the rect is effectively fullscreen, meaning it would cover the entire monitor.
// We need to use this heuristic because Windows doesn't provide a way to check for fullscreen state.
fn is_effectively_fullscreen(rect: RECT) -> bool {
let hmon = unsafe { MonitorFromRect(&rect, MONITOR_DEFAULTTONEAREST) };
if hmon.is_invalid() {
return false;
}
let mut monitor_info = MONITORINFO {
cbSize: std::mem::size_of::<MONITORINFO>() as u32,
..Default::default()
};
if !unsafe { GetMonitorInfoW(hmon, &mut monitor_info) }.as_bool() {
return false;
}
// Allow a tiny tolerance for DPI / rounding issues
const EPS: i32 = 1;
(rect.left - monitor_info.rcMonitor.left).abs() <= EPS
&& (rect.top - monitor_info.rcMonitor.top).abs() <= EPS
&& (rect.right - monitor_info.rcMonitor.right).abs() <= EPS
&& (rect.bottom - monitor_info.rcMonitor.bottom).abs() <= EPS
}

View File

@ -148,6 +148,9 @@ pub(super) fn intercept_frontend_message(dispatcher: &mut DesktopWrapperMessageD
FrontendMessage::WindowMaximize => { FrontendMessage::WindowMaximize => {
dispatcher.respond(DesktopFrontendMessage::WindowMaximize); dispatcher.respond(DesktopFrontendMessage::WindowMaximize);
} }
FrontendMessage::WindowFullscreen => {
dispatcher.respond(DesktopFrontendMessage::WindowFullscreen);
}
FrontendMessage::WindowDrag => { FrontendMessage::WindowDrag => {
dispatcher.respond(DesktopFrontendMessage::WindowDrag); dispatcher.respond(DesktopFrontendMessage::WindowDrag);
} }

View File

@ -69,6 +69,7 @@ pub enum DesktopFrontendMessage {
WindowClose, WindowClose,
WindowMinimize, WindowMinimize,
WindowMaximize, WindowMaximize,
WindowFullscreen,
WindowDrag, WindowDrag,
WindowHide, WindowHide,
WindowHideOthers, WindowHideOthers,

View File

@ -11,6 +11,7 @@ pub enum AppWindowMessage {
Close, Close,
Minimize, Minimize,
Maximize, Maximize,
Fullscreen,
Drag, Drag,
Hide, Hide,
HideOthers, HideOthers,

View File

@ -30,6 +30,9 @@ impl MessageHandler<AppWindowMessage, ()> for AppWindowMessageHandler {
AppWindowMessage::Maximize => { AppWindowMessage::Maximize => {
responses.add(FrontendMessage::WindowMaximize); responses.add(FrontendMessage::WindowMaximize);
} }
AppWindowMessage::Fullscreen => {
responses.add(FrontendMessage::WindowFullscreen);
}
AppWindowMessage::Drag => { AppWindowMessage::Drag => {
responses.add(FrontendMessage::WindowDrag); responses.add(FrontendMessage::WindowDrag);
} }
@ -48,6 +51,7 @@ impl MessageHandler<AppWindowMessage, ()> for AppWindowMessageHandler {
Close, Close,
Minimize, Minimize,
Maximize, Maximize,
Fullscreen,
Drag, Drag,
Hide, Hide,
HideOthers, HideOthers,

View File

@ -64,8 +64,10 @@ pub enum FrontendMessage {
#[serde(rename = "nodeTypes")] #[serde(rename = "nodeTypes")]
node_types: Vec<FrontendNodeType>, node_types: Vec<FrontendNodeType>,
}, },
SendShortcutF11 { SendShortcutFullscreen {
shortcut: Option<ActionShortcut>, shortcut: Option<ActionShortcut>,
#[serde(rename = "shortcutMac")]
shortcut_mac: Option<ActionShortcut>,
}, },
SendShortcutAltClick { SendShortcutAltClick {
shortcut: Option<ActionShortcut>, shortcut: Option<ActionShortcut>,
@ -371,6 +373,7 @@ pub enum FrontendMessage {
WindowClose, WindowClose,
WindowMinimize, WindowMinimize,
WindowMaximize, WindowMaximize,
WindowFullscreen,
WindowDrag, WindowDrag,
WindowHide, WindowHide,
WindowHideOthers, WindowHideOthers,

View File

@ -4,7 +4,6 @@ use crate::messages::input_mapper::utility_types::input_keyboard::{self, Key};
use crate::messages::input_mapper::utility_types::misc::MappingEntry; use crate::messages::input_mapper::utility_types::misc::MappingEntry;
use crate::messages::portfolio::utility_types::KeyboardPlatformLayout; use crate::messages::portfolio::utility_types::KeyboardPlatformLayout;
use crate::messages::prelude::*; use crate::messages::prelude::*;
use std::fmt::Write;
#[derive(ExtractField)] #[derive(ExtractField)]
pub struct InputMapperMessageContext<'a> { pub struct InputMapperMessageContext<'a> {
@ -34,27 +33,6 @@ impl InputMapperMessageHandler {
self.mapping = mapping; self.mapping = mapping;
} }
pub fn hints(&self, actions: ActionList) -> String {
let mut output = String::new();
let mut actions = actions
.into_iter()
.flatten()
.filter(|a| !matches!(*a, MessageDiscriminant::Tool(ToolMessageDiscriminant::ActivateTool) | MessageDiscriminant::Debug(_)));
self.mapping
.key_down
.iter()
.enumerate()
.filter_map(|(i, m)| {
let ma = m.0.iter().find_map(|m| actions.find_map(|a| (a == m.action.to_discriminant()).then(|| m.action.to_discriminant())));
ma.map(|a| ((i as u8).try_into().unwrap(), a))
})
.for_each(|(k, a): (Key, _)| {
let _ = write!(output, "{}: {}, ", k.to_discriminant().local_name(), a.local_name().split('.').next_back().unwrap());
});
output.replace("Key", "")
}
pub fn action_input_mapping(&self, action_to_find: &MessageDiscriminant) -> Option<KeysGroup> { pub fn action_input_mapping(&self, action_to_find: &MessageDiscriminant) -> Option<KeysGroup> {
let all_key_mapping_entries = std::iter::empty() let all_key_mapping_entries = std::iter::empty()
.chain(self.mapping.key_up.iter()) .chain(self.mapping.key_up.iter())

View File

@ -17,16 +17,19 @@ use glam::DVec2;
impl From<MappingVariant> for Mapping { impl From<MappingVariant> for Mapping {
fn from(value: MappingVariant) -> Self { fn from(value: MappingVariant) -> Self {
match value { match value {
MappingVariant::Default => input_mappings(), MappingVariant::Default => input_mappings(false),
MappingVariant::ZoomWithScroll => zoom_with_scroll(), MappingVariant::ZoomWithScroll => input_mappings(true),
} }
} }
} }
pub fn input_mappings() -> Mapping { pub fn input_mappings(zoom_with_scroll: bool) -> Mapping {
use InputMapperMessage::*; use InputMapperMessage::*;
use Key::*; use Key::*;
// TODO: Fix this failing to load the correct data (and throwing a console warning) because it's occurring before the value has been supplied during initialization from the JS `initAfterFrontendReady`
let keyboard_platform = GLOBAL_PLATFORM.get().copied().unwrap_or_default().as_keyboard_platform_layout();
// NOTICE: // NOTICE:
// If a new mapping you added here isn't working (and perhaps another lower-precedence one is instead), make sure to advertise // If a new mapping you added here isn't working (and perhaps another lower-precedence one is instead), make sure to advertise
// it as an available action in the respective message handler file (such as the bottom of `document_message_handler.rs`). // it as an available action in the respective message handler file (such as the bottom of `document_message_handler.rs`).
@ -54,6 +57,11 @@ pub fn input_mappings() -> Mapping {
// Hack to prevent Left Click + Accel + Z combo (this effectively blocks you from making a double undo with AbortTransaction) // Hack to prevent Left Click + Accel + Z combo (this effectively blocks you from making a double undo with AbortTransaction)
entry!(KeyDown(KeyZ); modifiers=[Accel, MouseLeft], action_dispatch=DocumentMessage::Noop), entry!(KeyDown(KeyZ); modifiers=[Accel, MouseLeft], action_dispatch=DocumentMessage::Noop),
// //
// AppWindowMessage
entry!(KeyDown(F11); disabled=(keyboard_platform == KeyboardPlatformLayout::Mac), action_dispatch=AppWindowMessage::Fullscreen),
entry!(KeyDown(KeyF); modifiers=[Command, Control], disabled=(keyboard_platform != KeyboardPlatformLayout::Mac), action_dispatch=AppWindowMessage::Fullscreen),
entry!(KeyDown(KeyQ); modifiers=[Command], disabled=cfg!(not(target_os = "macos")), action_dispatch=AppWindowMessage::Close),
//
// ClipboardMessage // ClipboardMessage
entry!(KeyDown(KeyX); modifiers=[Accel], action_dispatch=ClipboardMessage::Cut), entry!(KeyDown(KeyX); modifiers=[Accel], action_dispatch=ClipboardMessage::Cut),
entry!(KeyDown(KeyC); modifiers=[Accel], action_dispatch=ClipboardMessage::Copy), entry!(KeyDown(KeyC); modifiers=[Accel], action_dispatch=ClipboardMessage::Copy),
@ -416,10 +424,14 @@ pub fn input_mappings() -> Mapping {
entry!(KeyDown(FakeKeyPlus); modifiers=[Accel], canonical, action_dispatch=NavigationMessage::CanvasZoomIncrease { center_on_mouse: false }), entry!(KeyDown(FakeKeyPlus); modifiers=[Accel], canonical, action_dispatch=NavigationMessage::CanvasZoomIncrease { center_on_mouse: false }),
entry!(KeyDown(Equal); modifiers=[Accel], action_dispatch=NavigationMessage::CanvasZoomIncrease { center_on_mouse: false }), entry!(KeyDown(Equal); modifiers=[Accel], action_dispatch=NavigationMessage::CanvasZoomIncrease { center_on_mouse: false }),
entry!(KeyDown(Minus); modifiers=[Accel], action_dispatch=NavigationMessage::CanvasZoomDecrease { center_on_mouse: false }), entry!(KeyDown(Minus); modifiers=[Accel], action_dispatch=NavigationMessage::CanvasZoomDecrease { center_on_mouse: false }),
entry!(WheelScroll; modifiers=[Control], action_dispatch=NavigationMessage::CanvasZoomMouseWheel), entry!(WheelScroll; modifiers=[Control], disabled=zoom_with_scroll, action_dispatch=NavigationMessage::CanvasZoomMouseWheel),
entry!(WheelScroll; modifiers=[Command], action_dispatch=NavigationMessage::CanvasZoomMouseWheel), entry!(WheelScroll; modifiers=[Command], disabled=zoom_with_scroll, action_dispatch=NavigationMessage::CanvasZoomMouseWheel),
entry!(WheelScroll; modifiers=[Shift], action_dispatch=NavigationMessage::CanvasPanMouseWheel { use_y_as_x: true }), entry!(WheelScroll; modifiers=[Shift], disabled=zoom_with_scroll, action_dispatch=NavigationMessage::CanvasPanMouseWheel { use_y_as_x: true }),
entry!(WheelScroll; action_dispatch=NavigationMessage::CanvasPanMouseWheel { use_y_as_x: false }), entry!(WheelScroll; disabled=zoom_with_scroll, action_dispatch=NavigationMessage::CanvasPanMouseWheel { use_y_as_x: false }),
// On Mac, the OS already converts Shift+scroll into horizontal scrolling so we have to reverse the behavior from normal to produce the same outcome
entry!(WheelScroll; modifiers=[Control], disabled=!zoom_with_scroll, action_dispatch=NavigationMessage::CanvasPanMouseWheel { use_y_as_x: keyboard_platform == KeyboardPlatformLayout::Mac }),
entry!(WheelScroll; modifiers=[Shift], disabled=!zoom_with_scroll, action_dispatch=NavigationMessage::CanvasPanMouseWheel { use_y_as_x: keyboard_platform != KeyboardPlatformLayout::Mac }),
entry!(WheelScroll; disabled=!zoom_with_scroll, action_dispatch=NavigationMessage::CanvasZoomMouseWheel),
entry!(KeyDown(PageUp); modifiers=[Shift], action_dispatch=NavigationMessage::CanvasPanByViewportFraction { delta: DVec2::new(1., 0.) }), entry!(KeyDown(PageUp); modifiers=[Shift], action_dispatch=NavigationMessage::CanvasPanByViewportFraction { delta: DVec2::new(1., 0.) }),
entry!(KeyDown(PageDown); modifiers=[Shift], action_dispatch=NavigationMessage::CanvasPanByViewportFraction { delta: DVec2::new(-1., 0.) }), entry!(KeyDown(PageDown); modifiers=[Shift], action_dispatch=NavigationMessage::CanvasPanByViewportFraction { delta: DVec2::new(-1., 0.) }),
entry!(KeyDown(PageUp); action_dispatch=NavigationMessage::CanvasPanByViewportFraction { delta: DVec2::new(0., 1.) }), entry!(KeyDown(PageUp); action_dispatch=NavigationMessage::CanvasPanByViewportFraction { delta: DVec2::new(0., 1.) }),
@ -471,7 +483,7 @@ pub fn input_mappings() -> Mapping {
// Sort `pointer_shake` // Sort `pointer_shake`
sort(&mut pointer_shake); sort(&mut pointer_shake);
let mut mapping = Mapping { Mapping {
key_up, key_up,
key_down, key_down,
key_up_no_repeat, key_up_no_repeat,
@ -480,54 +492,5 @@ pub fn input_mappings() -> Mapping {
wheel_scroll, wheel_scroll,
pointer_move, pointer_move,
pointer_shake, pointer_shake,
};
if cfg!(target_os = "macos") {
let remove: [&[&[MappingEntry; 0]; 0]; 0] = [];
let add = [entry!(KeyDown(KeyQ); modifiers=[Accel], action_dispatch=AppWindowMessage::Close)];
apply_mapping_patch(&mut mapping, remove, add);
}
mapping
}
/// Default mappings except that scrolling without modifier keys held down is bound to zooming instead of vertical panning
pub fn zoom_with_scroll() -> Mapping {
use InputMapperMessage::*;
// On Mac, the OS already converts Shift+scroll into horizontal scrolling so we have to reverse the behavior from normal to produce the same outcome
let keyboard_platform = GLOBAL_PLATFORM.get().copied().unwrap_or_default().as_keyboard_platform_layout();
let mut mapping = input_mappings();
let remove = [
entry!(WheelScroll; modifiers=[Control], action_dispatch=NavigationMessage::CanvasZoomMouseWheel),
entry!(WheelScroll; modifiers=[Command], action_dispatch=NavigationMessage::CanvasZoomMouseWheel),
entry!(WheelScroll; modifiers=[Shift], action_dispatch=NavigationMessage::CanvasPanMouseWheel { use_y_as_x: true }),
entry!(WheelScroll; action_dispatch=NavigationMessage::CanvasPanMouseWheel { use_y_as_x: false }),
];
let add = [
entry!(WheelScroll; modifiers=[Control], action_dispatch=NavigationMessage::CanvasPanMouseWheel { use_y_as_x: keyboard_platform == KeyboardPlatformLayout::Mac }),
entry!(WheelScroll; modifiers=[Shift], action_dispatch=NavigationMessage::CanvasPanMouseWheel { use_y_as_x: keyboard_platform != KeyboardPlatformLayout::Mac }),
entry!(WheelScroll; action_dispatch=NavigationMessage::CanvasZoomMouseWheel),
];
apply_mapping_patch(&mut mapping, remove, add);
mapping
}
fn apply_mapping_patch<'a, const N: usize, const M: usize, const X: usize, const Y: usize>(
mapping: &mut Mapping,
remove: impl IntoIterator<Item = &'a [&'a [MappingEntry; N]; M]>,
add: impl IntoIterator<Item = &'a [&'a [MappingEntry; X]; Y]>,
) {
for entry in remove.into_iter().flat_map(|inner| inner.iter()).flat_map(|inner| inner.iter()) {
mapping.remove(entry);
}
for entry in add.into_iter().flat_map(|inner| inner.iter()).flat_map(|inner| inner.iter()) {
mapping.add(entry.clone());
} }
} }

View File

@ -25,17 +25,44 @@ macro_rules! modifiers {
/// When an action is currently available, and the user enters that input, the action's message is dispatched on the message bus. /// When an action is currently available, and the user enters that input, the action's message is dispatched on the message bus.
macro_rules! entry { macro_rules! entry {
// Pattern with canonical parameter // Pattern with canonical parameter
($input:expr_2021; $(modifiers=[$($modifier:ident),*],)? $(refresh_keys=[$($refresh:ident),* $(,)?],)? canonical, action_dispatch=$action_dispatch:expr_2021$(,)?) => { (
entry!($input; $($($modifier),*)?; $($($refresh),*)?; $action_dispatch; true) $input:expr_2021;
$(modifiers=[$($modifier:ident),*],)?
$(refresh_keys=[$($refresh:ident),* $(,)?],)?
canonical,
$(disabled=$disabled:expr,)?
action_dispatch=$action_dispatch:expr_2021$(,)?
) => {
entry!(
$input;
$($($modifier),*)?;
$($($refresh),*)?;
$action_dispatch;
true;
false $( || $disabled )?
)
}; };
// Pattern without canonical parameter // Pattern without canonical parameter
($input:expr_2021; $(modifiers=[$($modifier:ident),*],)? $(refresh_keys=[$($refresh:ident),* $(,)?],)? action_dispatch=$action_dispatch:expr_2021$(,)?) => { (
entry!($input; $($($modifier),*)?; $($($refresh),*)?; $action_dispatch; false) $input:expr_2021;
$(modifiers=[$($modifier:ident),*],)?
$(refresh_keys=[$($refresh:ident),* $(,)?],)?
$(disabled=$disabled:expr,)?
action_dispatch=$action_dispatch:expr_2021$(,)?
) => {
entry!(
$input;
$($($modifier),*)?;
$($($refresh),*)?;
$action_dispatch;
false;
false $( || $disabled )?
)
}; };
// Implementation macro to avoid code duplication // Implementation macro to avoid code duplication
($input:expr; $($modifier:ident),*; $($refresh:ident),*; $action_dispatch:expr; $canonical:expr) => { ($input:expr; $($modifier:ident),*; $($refresh:ident),*; $action_dispatch:expr; $canonical:expr; $disabled:expr) => {
&[&[ &[&[
// Cause the `action_dispatch` message to be sent when the specified input occurs. // Cause the `action_dispatch` message to be sent when the specified input occurs.
MappingEntry { MappingEntry {
@ -43,33 +70,37 @@ macro_rules! entry {
input: $input, input: $input,
modifiers: modifiers!($($modifier),*), modifiers: modifiers!($($modifier),*),
canonical: $canonical, canonical: $canonical,
disabled: $disabled,
}, },
// Also cause the `action_dispatch` message to be sent when any of the specified refresh keys change.
$( $(
MappingEntry { MappingEntry {
action: $action_dispatch.into(), action: $action_dispatch.into(),
input: InputMapperMessage::KeyDown(Key::$refresh), input: InputMapperMessage::KeyDown(Key::$refresh),
modifiers: modifiers!(), modifiers: modifiers!(),
canonical: $canonical, canonical: $canonical,
disabled: $disabled,
}, },
MappingEntry { MappingEntry {
action: $action_dispatch.into(), action: $action_dispatch.into(),
input: InputMapperMessage::KeyUp(Key::$refresh), input: InputMapperMessage::KeyUp(Key::$refresh),
modifiers: modifiers!(), modifiers: modifiers!(),
canonical: $canonical, canonical: $canonical,
disabled: $disabled,
}, },
MappingEntry { MappingEntry {
action: $action_dispatch.into(), action: $action_dispatch.into(),
input: InputMapperMessage::KeyDownNoRepeat(Key::$refresh), input: InputMapperMessage::KeyDownNoRepeat(Key::$refresh),
modifiers: modifiers!(), modifiers: modifiers!(),
canonical: $canonical, canonical: $canonical,
disabled: $disabled,
}, },
MappingEntry { MappingEntry {
action: $action_dispatch.into(), action: $action_dispatch.into(),
input: InputMapperMessage::KeyUpNoRepeat(Key::$refresh), input: InputMapperMessage::KeyUpNoRepeat(Key::$refresh),
modifiers: modifiers!(), modifiers: modifiers!(),
canonical: $canonical, canonical: $canonical,
disabled: $disabled,
}, },
)* )*
]] ]]
@ -97,6 +128,10 @@ macro_rules! mapping {
for entry_slice in $entry { for entry_slice in $entry {
// Each entry in the slice (usually just one, except when `refresh_keys` adds additional key entries) // Each entry in the slice (usually just one, except when `refresh_keys` adds additional key entries)
for entry in entry_slice.into_iter() { for entry in entry_slice.into_iter() {
if entry.disabled {
continue;
}
let corresponding_list = match entry.input { let corresponding_list = match entry.input {
InputMapperMessage::KeyDown(key) => &mut key_down[key as usize], InputMapperMessage::KeyDown(key) => &mut key_down[key as usize],
InputMapperMessage::KeyUp(key) => &mut key_up[key as usize], InputMapperMessage::KeyUp(key) => &mut key_up[key as usize],

View File

@ -29,16 +29,6 @@ impl Mapping {
list.match_mapping(keyboard_state, actions) list.match_mapping(keyboard_state, actions)
} }
pub fn remove(&mut self, target_entry: &MappingEntry) {
let list = self.associated_entries_mut(&target_entry.input);
list.remove(target_entry);
}
pub fn add(&mut self, new_entry: MappingEntry) {
let list = self.associated_entries_mut(&new_entry.input);
list.push(new_entry);
}
fn associated_entries(&self, message: &InputMapperMessage) -> &KeyMappingEntries { fn associated_entries(&self, message: &InputMapperMessage) -> &KeyMappingEntries {
match message { match message {
InputMapperMessage::KeyDown(key) => &self.key_down[*key as usize], InputMapperMessage::KeyDown(key) => &self.key_down[*key as usize],
@ -51,19 +41,6 @@ impl Mapping {
InputMapperMessage::PointerShake => &self.pointer_shake, InputMapperMessage::PointerShake => &self.pointer_shake,
} }
} }
fn associated_entries_mut(&mut self, message: &InputMapperMessage) -> &mut KeyMappingEntries {
match message {
InputMapperMessage::KeyDown(key) => &mut self.key_down[*key as usize],
InputMapperMessage::KeyUp(key) => &mut self.key_up[*key as usize],
InputMapperMessage::KeyDownNoRepeat(key) => &mut self.key_down_no_repeat[*key as usize],
InputMapperMessage::KeyUpNoRepeat(key) => &mut self.key_up_no_repeat[*key as usize],
InputMapperMessage::DoubleClick(key) => &mut self.double_click[*key as usize],
InputMapperMessage::WheelScroll => &mut self.wheel_scroll,
InputMapperMessage::PointerMove => &mut self.pointer_move,
InputMapperMessage::PointerShake => &mut self.pointer_shake,
}
}
} }
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
@ -125,6 +102,8 @@ pub struct MappingEntry {
pub modifiers: KeyStates, pub modifiers: KeyStates,
/// True indicates that this takes priority as the labeled hotkey shown in UI menus and tooltips instead of an alternate binding for the same action /// True indicates that this takes priority as the labeled hotkey shown in UI menus and tooltips instead of an alternate binding for the same action
pub canonical: bool, pub canonical: bool,
/// Whether this mapping is disabled
pub disabled: bool,
} }
#[derive(Debug, Clone, Eq, PartialEq, serde::Serialize, serde::Deserialize, specta::Type)] #[derive(Debug, Clone, Eq, PartialEq, serde::Serialize, serde::Deserialize, specta::Type)]

View File

@ -621,6 +621,13 @@ impl LayoutHolder for MenuBarMessageHandler {
.label("Window") .label("Window")
.flush(true) .flush(true)
.menu_list_children(vec![ .menu_list_children(vec![
vec![
MenuListEntry::new("Fullscreen")
.label("Fullscreen")
.icon("FullscreenEnter")
.tooltip_shortcut(action_shortcut!(AppWindowMessageDiscriminant::Fullscreen))
.on_commit(|_| AppWindowMessage::Fullscreen.into()),
],
vec![ vec![
MenuListEntry::new("Properties") MenuListEntry::new("Properties")
.label("Properties") .label("Properties")
@ -632,8 +639,6 @@ impl LayoutHolder for MenuBarMessageHandler {
.icon(if self.layers_panel_open { "CheckboxChecked" } else { "CheckboxUnchecked" }) .icon(if self.layers_panel_open { "CheckboxChecked" } else { "CheckboxUnchecked" })
.tooltip_shortcut(action_shortcut!(PortfolioMessageDiscriminant::ToggleLayersPanelOpen)) .tooltip_shortcut(action_shortcut!(PortfolioMessageDiscriminant::ToggleLayersPanelOpen))
.on_commit(|_| PortfolioMessage::ToggleLayersPanelOpen.into()), .on_commit(|_| PortfolioMessage::ToggleLayersPanelOpen.into()),
],
vec![
MenuListEntry::new("Data") MenuListEntry::new("Data")
.label("Data") .label("Data")
.icon(if self.data_panel_open { "CheckboxChecked" } else { "CheckboxUnchecked" }) .icon(if self.data_panel_open { "CheckboxChecked" } else { "CheckboxUnchecked" })

View File

@ -117,8 +117,9 @@ impl MessageHandler<PortfolioMessage, PortfolioMessageContext<'_>> for Portfolio
}); });
// Send shortcuts for widgets created in the frontend which need shortcut tooltips // Send shortcuts for widgets created in the frontend which need shortcut tooltips
responses.add(FrontendMessage::SendShortcutF11 { responses.add(FrontendMessage::SendShortcutFullscreen {
shortcut: action_shortcut_manual!(Key::F11), shortcut: action_shortcut_manual!(Key::F11),
shortcut_mac: action_shortcut_manual!(Key::Control, Key::Command, Key::KeyF),
}); });
responses.add(FrontendMessage::SendShortcutAltClick { responses.add(FrontendMessage::SendShortcutAltClick {
shortcut: action_shortcut_manual!(Key::Alt, Key::MouseLeft), shortcut: action_shortcut_manual!(Key::Alt, Key::MouseLeft),

View File

@ -11,6 +11,7 @@
import LayoutRow from "@graphite/components/layout/LayoutRow.svelte"; import LayoutRow from "@graphite/components/layout/LayoutRow.svelte";
import IconLabel from "@graphite/components/widgets/labels/IconLabel.svelte"; import IconLabel from "@graphite/components/widgets/labels/IconLabel.svelte";
import WidgetLayout from "@graphite/components/widgets/WidgetLayout.svelte"; import WidgetLayout from "@graphite/components/widgets/WidgetLayout.svelte";
import { isDesktop } from "/src/utility-functions/platform";
const appWindow = getContext<AppWindowState>("appWindow"); const appWindow = getContext<AppWindowState>("appWindow");
const editor = getContext<Editor>("editor"); const editor = getContext<Editor>("editor");
@ -19,7 +20,8 @@
let menuBarLayout: Layout = []; let menuBarLayout: Layout = [];
$: showFullscreenButton = $appWindow.platform === "Web" || $fullscreen.windowFullscreen; $: showFullscreenButton = $appWindow.platform === "Web" || $fullscreen.windowFullscreen || (isDesktop() && $appWindow.fullscreen);
$: isFullscreen = isDesktop() ? $appWindow.fullscreen : $fullscreen.windowFullscreen;
// On Mac, the menu bar height needs to be scaled by the inverse of the UI scale to fit its native window buttons // On Mac, the menu bar height needs to be scaled by the inverse of the UI scale to fit its native window buttons
$: height = $appWindow.platform === "Mac" ? 28 * (1 / $appWindow.uiScale) : 28; $: height = $appWindow.platform === "Mac" ? 28 * (1 / $appWindow.uiScale) : 28;
@ -39,20 +41,23 @@
{/if} {/if}
</LayoutRow> </LayoutRow>
<!-- Window frame --> <!-- Window frame -->
<LayoutRow class="window-frame" on:mousedown={() => editor.handle.appWindowDrag()} on:dblclick={() => editor.handle.appWindowMaximize()} /> <LayoutRow class="window-frame" on:mousedown={() => !isFullscreen && editor.handle.appWindowDrag()} on:dblclick={() => !isFullscreen && editor.handle.appWindowMaximize()} />
<!-- Window buttons --> <!-- Window buttons -->
<LayoutRow class="window-buttons" classes={{ fullscreen: showFullscreenButton, windows: $appWindow.platform === "Windows", linux: $appWindow.platform === "Linux" }}> <LayoutRow class="window-buttons" classes={{ fullscreen: showFullscreenButton, windows: $appWindow.platform === "Windows", linux: $appWindow.platform === "Linux" }}>
{#if $appWindow.platform !== "Mac"} {#if $appWindow.platform !== "Mac"}
{#if showFullscreenButton} {#if showFullscreenButton}
<LayoutRow <LayoutRow
tooltipLabel={$fullscreen.windowFullscreen ? "Exit Fullscreen" : "Enter Fullscreen"} tooltipLabel={isFullscreen ? "Exit Fullscreen" : "Enter Fullscreen"}
tooltipDescription={$appWindow.platform === "Web" && $fullscreen.keyboardLockApiSupported tooltipDescription={$appWindow.platform === "Web" && $fullscreen.keyboardLockApiSupported
? "While fullscreen, keyboard shortcuts normally reserved by the browser become available." ? "While fullscreen, keyboard shortcuts normally reserved by the browser become available."
: undefined} : undefined}
tooltipShortcut={$tooltip.f11Shortcut} tooltipShortcut={$tooltip.fullscreenShortcut}
on:click={() => ($fullscreen.windowFullscreen ? fullscreen.exitFullscreen : fullscreen.enterFullscreen)()} on:click={() => {
if (isDesktop()) editor.handle.appWindowFullscreen();
else ($fullscreen.windowFullscreen ? fullscreen.exitFullscreen : fullscreen.enterFullscreen)();
}}
> >
<IconLabel icon={$fullscreen.windowFullscreen ? "FullscreenExit" : "FullscreenEnter"} /> <IconLabel icon={isFullscreen ? "FullscreenExit" : "FullscreenEnter"} />
</LayoutRow> </LayoutRow>
{:else} {:else}
<LayoutRow tooltipLabel="Minimize" on:click={() => editor.handle.appWindowMinimize()}> <LayoutRow tooltipLabel="Minimize" on:click={() => editor.handle.appWindowMinimize()}>
@ -115,7 +120,7 @@
padding: 0 8px; padding: 0 8px;
} }
&.windows > .layout-row { &.windows:not(.fullscreen) > .layout-row {
padding: 0 17px; padding: 0 17px;
&:hover { &:hover {
@ -127,7 +132,7 @@
} }
} }
&.linux > .layout-row { &.linux:not(.fullscreen) > .layout-row {
padding: 0 12px; padding: 0 12px;
&:hover { &:hover {
@ -136,4 +141,6 @@
} }
} }
} }
// paddingpaddingpaddingpaddingpaddingpaddingpaddingpaddingpaddingpaddingpaddingpaddingpaddingpaddingpaddingpaddingpaddingpaddingpaddingpadding
</style> </style>

View File

@ -84,15 +84,23 @@ export function createInputManager(editor: Editor, dialog: DialogState, portfoli
// Cut, copy, and paste is handled in the backend on desktop // Cut, copy, and paste is handled in the backend on desktop
if (isDesktop() && accelKey && ["KeyX", "KeyC", "KeyV"].includes(key)) return true; if (isDesktop() && accelKey && ["KeyX", "KeyC", "KeyV"].includes(key)) return true;
// But on web, we want to not redirect paste
if (!isDesktop() && key === "KeyV" && accelKey) return false;
// Don't redirect user input from text entry into HTML elements // Don't redirect user input from text entry into HTML elements
if (targetIsTextField(e.target || undefined) && key !== "Escape" && !(accelKey && ["Enter", "NumpadEnter"].includes(key))) return false; if (targetIsTextField(e.target || undefined) && key !== "Escape" && !(accelKey && ["Enter", "NumpadEnter"].includes(key))) return false;
// Don't redirect paste in web // Don't redirect tab or enter if not in canvas (to allow navigating elements)
if (key === "KeyV" && accelKey) return false; potentiallyRestoreCanvasFocus(e);
if (!canvasFocused && !targetIsTextField(e.target || undefined) && ["Tab", "Enter", "NumpadEnter", "Space", "ArrowDown", "ArrowLeft", "ArrowRight", "ArrowUp"].includes(key)) return false;
// Don't redirect a fullscreen request // Don't redirect if a MenuList is open
if (key === "F11" && e.type === "keydown" && !e.repeat) { if (window.document.querySelector("[data-floating-menu-content]")) return false;
// Web-only keyboard shortcuts
if (!isDesktop()) {
// Don't redirect a fullscreen request, but process it immediately instead
if (((operatingSystem() !== "Mac" && key === "F11") || (operatingSystem() === "Mac" && e.ctrlKey && e.metaKey && key === "KeyF")) && e.type === "keydown" && !e.repeat) {
e.preventDefault(); e.preventDefault();
fullscreen.toggleFullscreen(); fullscreen.toggleFullscreen();
return false; return false;
@ -105,13 +113,7 @@ export function createInputManager(editor: Editor, dialog: DialogState, portfoli
// Don't redirect debugging tools // Don't redirect debugging tools
if (["F12", "F8"].includes(key)) return false; if (["F12", "F8"].includes(key)) return false;
if (["KeyC", "KeyI", "KeyJ"].includes(key) && accelKey && e.shiftKey) return false; if (["KeyC", "KeyI", "KeyJ"].includes(key) && accelKey && e.shiftKey) return false;
}
// Don't redirect tab or enter if not in canvas (to allow navigating elements)
potentiallyRestoreCanvasFocus(e);
if (!canvasFocused && !targetIsTextField(e.target || undefined) && ["Tab", "Enter", "NumpadEnter", "Space", "ArrowDown", "ArrowLeft", "ArrowRight", "ArrowUp"].includes(key)) return false;
// Don't redirect if a MenuList is open
if (window.document.querySelector("[data-floating-menu-content]")) return false;
// Redirect to the backend // Redirect to the backend
return true; return true;
@ -239,7 +241,7 @@ export function createInputManager(editor: Editor, dialog: DialogState, portfoli
} }
function onMouseDown(e: MouseEvent) { function onMouseDown(e: MouseEvent) {
// Block middle mouse button auto-scroll mode (the circlar gizmo that appears and allows quick scrolling by moving the cursor above or below it) // Block middle mouse button auto-scroll mode (the circular gizmo that appears and allows quick scrolling by moving the cursor above or below it)
if (e.button === BUTTON_MIDDLE) e.preventDefault(); if (e.button === BUTTON_MIDDLE) e.preventDefault();
} }

View File

@ -107,9 +107,11 @@ export class SendUIMetadata extends JsMessage {
readonly nodeTypes!: FrontendNodeType[]; readonly nodeTypes!: FrontendNodeType[];
} }
export class SendShortcutF11 extends JsMessage { export class SendShortcutFullscreen extends JsMessage {
@Transform(({ value }: { value: ActionShortcut }) => value || undefined) @Transform(({ value }: { value: ActionShortcut }) => value || undefined)
readonly shortcut!: ActionShortcut | undefined; readonly shortcut!: ActionShortcut | undefined;
@Transform(({ value }: { value: ActionShortcut }) => value || undefined)
readonly shortcutMac!: ActionShortcut | undefined;
} }
export class SendShortcutAltClick extends JsMessage { export class SendShortcutAltClick extends JsMessage {
@ -335,6 +337,8 @@ export class WindowPointerLockMove extends JsMessage {
readonly y!: number; readonly y!: number;
} }
export class WindowFullscreen extends JsMessage {}
// Rust enum `Key` // Rust enum `Key`
export type KeyRaw = string; export type KeyRaw = string;
// Serde converts a Rust `Key` enum variant into this format with both the `Key` variant name (called `RawKey` in TS) and the localized `label` for the key // Serde converts a Rust `Key` enum variant into this format with both the `Key` variant name (called `RawKey` in TS) and the localized `label` for the key
@ -1666,7 +1670,7 @@ export const messageMakers: Record<string, MessageMaker> = {
DisplayEditableTextboxTransform, DisplayEditableTextboxTransform,
DisplayRemoveEditableTextbox, DisplayRemoveEditableTextbox,
SendUIMetadata, SendUIMetadata,
SendShortcutF11, SendShortcutFullscreen,
SendShortcutAltClick, SendShortcutAltClick,
SendShortcutShiftClick, SendShortcutShiftClick,
TriggerAboutGraphiteLocalizedCommitDate, TriggerAboutGraphiteLocalizedCommitDate,
@ -1734,6 +1738,7 @@ export const messageMakers: Record<string, MessageMaker> = {
UpdateMaximized, UpdateMaximized,
UpdateFullscreen, UpdateFullscreen,
WindowPointerLockMove, WindowPointerLockMove,
WindowFullscreen,
UpdatePropertiesPanelLayout, UpdatePropertiesPanelLayout,
UpdatePropertiesPanelState, UpdatePropertiesPanelState,
UpdateStatusBarHintsLayout, UpdateStatusBarHintsLayout,

View File

@ -1,8 +1,9 @@
import { writable } from "svelte/store"; import { writable } from "svelte/store";
import { type Editor } from "@graphite/editor"; import { type Editor } from "@graphite/editor";
import { WindowFullscreen } from "@graphite/messages";
export function createFullscreenState(_: Editor) { export function createFullscreenState(editor: Editor) {
// Experimental Keyboard API: https://developer.mozilla.org/en-US/docs/Web/API/Navigator/keyboard // Experimental Keyboard API: https://developer.mozilla.org/en-US/docs/Web/API/Navigator/keyboard
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
const keyboardLockApiSupported: Readonly<boolean> = "keyboard" in navigator && (navigator as any).keyboard && "lock" in (navigator as any).keyboard; const keyboardLockApiSupported: Readonly<boolean> = "keyboard" in navigator && (navigator as any).keyboard && "lock" in (navigator as any).keyboard;
@ -50,6 +51,10 @@ export function createFullscreenState(_: Editor) {
}); });
} }
editor.subscriptions.subscribeJsMessage(WindowFullscreen, () => {
toggleFullscreen();
});
return { return {
subscribe, subscribe,
fullscreenModeChanged, fullscreenModeChanged,

View File

@ -1,7 +1,8 @@
import { writable } from "svelte/store"; import { writable } from "svelte/store";
import { type Editor } from "@graphite/editor"; import { type Editor } from "@graphite/editor";
import { SendShortcutAltClick, SendShortcutF11, SendShortcutShiftClick, type ActionShortcut } from "@graphite/messages"; import { SendShortcutAltClick, SendShortcutFullscreen, SendShortcutShiftClick, type ActionShortcut } from "@graphite/messages";
import { operatingSystem } from "@graphite/utility-functions/platform";
const SHOW_TOOLTIP_DELAY_MS = 500; const SHOW_TOOLTIP_DELAY_MS = 500;
@ -12,7 +13,7 @@ export function createTooltipState(editor: Editor) {
position: { x: 0, y: 0 }, position: { x: 0, y: 0 },
shiftClickShortcut: undefined as ActionShortcut | undefined, shiftClickShortcut: undefined as ActionShortcut | undefined,
altClickShortcut: undefined as ActionShortcut | undefined, altClickShortcut: undefined as ActionShortcut | undefined,
f11Shortcut: undefined as ActionShortcut | undefined, fullscreenShortcut: undefined as ActionShortcut | undefined,
}); });
let tooltipTimeout: ReturnType<typeof setTimeout> | undefined = undefined; let tooltipTimeout: ReturnType<typeof setTimeout> | undefined = undefined;
@ -63,9 +64,9 @@ export function createTooltipState(editor: Editor) {
return state; return state;
}); });
}); });
editor.subscriptions.subscribeJsMessage(SendShortcutF11, async (data) => { editor.subscriptions.subscribeJsMessage(SendShortcutFullscreen, async (data) => {
update((state) => { update((state) => {
state.f11Shortcut = data.shortcut; state.fullscreenShortcut = operatingSystem() === "Mac" ? data.shortcutMac : data.shortcut;
return state; return state;
}); });
}); });

View File

@ -295,6 +295,12 @@ impl EditorHandle {
self.dispatch(message); self.dispatch(message);
} }
#[wasm_bindgen(js_name = appWindowFullscreen)]
pub fn app_window_fullscreen(&self) {
let message = AppWindowMessage::Fullscreen;
self.dispatch(message);
}
/// Closes the application window /// Closes the application window
#[wasm_bindgen(js_name = appWindowClose)] #[wasm_bindgen(js_name = appWindowClose)]
pub fn app_window_close(&self) { pub fn app_window_close(&self) {