diff --git a/winit-appkit/src/lib.rs b/winit-appkit/src/lib.rs index 949e2f799b..8de2c0a48e 100644 --- a/winit-appkit/src/lib.rs +++ b/winit-appkit/src/lib.rs @@ -84,6 +84,7 @@ mod window_delegate; use std::os::raw::c_void; +use dpi::LogicalSize; #[cfg(feature = "serde")] use serde::{Deserialize, Serialize}; #[doc(inline)] @@ -187,6 +188,18 @@ pub trait WindowExtMacOS { /// Getter for the [`WindowExtMacOS::set_unified_titlebar`]. fn unified_titlebar(&self) -> bool; + + /// Sets the offset for the window controls (traffic lights) in logical points. + /// Positive values move right (x) and down (y) relative to the default position. + /// + /// ```no_run + /// # use winit::dpi::LogicalSize; + /// # use winit::platform::macos::WindowExtMacOS; + /// # fn example(window: &dyn winit::window::Window) { + /// window.set_traffic_light_inset(LogicalSize::new(24.0, 8.0)); + /// # } + /// ``` + fn set_traffic_light_inset(&self, inset: LogicalSize); } impl WindowExtMacOS for dyn Window + '_ { @@ -297,6 +310,10 @@ impl WindowExtMacOS for dyn Window + '_ { let window = self.cast_ref::().unwrap(); window.maybe_wait_on_main(|w| w.unified_titlebar()) } + fn set_traffic_light_inset(&self, inset: LogicalSize) { + let window = self.cast_ref::().unwrap(); + window.maybe_wait_on_main(move |w| w.set_traffic_light_inset(inset)) + } } /// Corresponds to `NSApplicationActivationPolicy`. @@ -332,6 +349,7 @@ pub struct WindowAttributesMacOS { pub(crate) title_hidden: bool, pub(crate) titlebar_hidden: bool, pub(crate) titlebar_buttons_hidden: bool, + pub(crate) traffic_light_inset: Option>, pub(crate) fullsize_content_view: bool, pub(crate) disallow_hidpi: bool, pub(crate) has_shadow: bool, @@ -372,6 +390,24 @@ impl WindowAttributesMacOS { self } + /// Sets the offset for the window controls (traffic lights) in logical points. + /// Positive values move right (x) and down (y) relative to the default position. + /// + /// This applies an offset from the default position; it does not change the native + /// spacing between the buttons. No effect if titlebar buttons are hidden. + /// + /// ```no_run + /// # use winit::dpi::LogicalSize; + /// # use winit::platform::macos::WindowAttributesMacOS; + /// let attrs = + /// WindowAttributesMacOS::default().with_traffic_light_inset(LogicalSize::new(24.0, 8.0)); + /// ``` + #[inline] + pub fn with_traffic_light_inset(mut self, inset: LogicalSize) -> Self { + self.traffic_light_inset = Some(inset); + self + } + /// Hides the window title. #[inline] pub fn with_title_hidden(mut self, title_hidden: bool) -> Self { @@ -459,6 +495,7 @@ impl Default for WindowAttributesMacOS { title_hidden: false, titlebar_hidden: false, titlebar_buttons_hidden: false, + traffic_light_inset: None, fullsize_content_view: false, disallow_hidpi: false, has_shadow: true, diff --git a/winit-appkit/src/window_delegate.rs b/winit-appkit/src/window_delegate.rs index fe4da61efb..93ca70642a 100644 --- a/winit-appkit/src/window_delegate.rs +++ b/winit-appkit/src/window_delegate.rs @@ -62,6 +62,17 @@ use super::view::WinitView; use super::window::{WinitPanel, WinitWindow, window_id}; use crate::{OptionAsAlt, WindowAttributesMacOS, WindowExtMacOS}; +// Cached geometry for the native traffic-light buttons derived from +// NSWindow::standardWindowButton(...) frames (AppKit does not expose a +// dedicated struct for this). +// `spacing` is the horizontal delta between adjacent buttons. +#[derive(Clone, Copy, Debug)] +struct TrafficLightBase { + x: f64, + y: f64, + spacing: f64, +} + #[derive(Debug)] pub(crate) struct State { /// Strong reference to the global application state. @@ -79,6 +90,8 @@ pub(crate) struct State { /// The current resize increments for the window content. surface_resize_increments: Cell, + traffic_light_inset: Cell>>, + traffic_light_base: Cell>, /// Whether the window is showing decorations. decorations: Cell, resizable: Cell, @@ -143,6 +156,7 @@ define_class!( trace_scope!("windowDidResize:"); // NOTE: WindowEvent::SurfaceResized is reported using NSViewFrameDidChangeNotification. self.emit_move_event(); + self.apply_traffic_light_inset(); } #[unsafe(method(windowWillStartLiveResize:))] @@ -273,6 +287,7 @@ define_class!( trace_scope!("windowDidEnterFullScreen:"); self.ivars().initial_fullscreen.set(false); self.ivars().in_fullscreen_transition.set(false); + self.apply_traffic_light_inset(); if let Some(target_fullscreen) = self.ivars().target_fullscreen.take() { self.set_fullscreen(target_fullscreen); } @@ -285,6 +300,7 @@ define_class!( self.restore_state_from_fullscreen(); self.ivars().in_fullscreen_transition.set(false); + self.apply_traffic_light_inset(); if let Some(target_fullscreen) = self.ivars().target_fullscreen.take() { self.set_fullscreen(target_fullscreen); } @@ -806,6 +822,8 @@ impl WindowDelegate { previous_position: Cell::new(flip_window_screen_coordinates(window.frame())), previous_scale_factor: Cell::new(scale_factor), surface_resize_increments: Cell::new(surface_resize_increments), + traffic_light_inset: Cell::new(macos_attrs.traffic_light_inset), + traffic_light_base: Cell::new(None), decorations: Cell::new(attrs.decorations), resizable: Cell::new(attrs.resizable), maximized: Cell::new(attrs.maximized), @@ -853,6 +871,10 @@ impl WindowDelegate { // Set fullscreen mode after we setup everything delegate.set_fullscreen(attrs.fullscreen); + if let Some(inset) = macos_attrs.traffic_light_inset { + delegate.set_traffic_light_inset(inset); + } + // Setting the window as key has to happen *after* we set the fullscreen // state, since otherwise we'll briefly see the window at normal size // before it transitions. @@ -930,6 +952,73 @@ impl WindowDelegate { self.queue_event(WindowEvent::Moved(position)); } + fn apply_traffic_light_inset(&self) { + // Nothing to do if no inset was configured. + let Some(inset) = self.ivars().traffic_light_inset.get() else { + return; + }; + + // Fetch standard buttons; if any are hidden, clear cached base. + let window = self.window(); + let Some(close) = window.standardWindowButton(NSWindowButton::CloseButton) else { + return; + }; + if close.isHidden() { + self.ivars().traffic_light_base.set(None); + return; + } + let Some(miniaturize) = window.standardWindowButton(NSWindowButton::MiniaturizeButton) + else { + return; + }; + if miniaturize.isHidden() { + self.ivars().traffic_light_base.set(None); + return; + } + let Some(zoom) = window.standardWindowButton(NSWindowButton::ZoomButton) else { + return; + }; + if zoom.isHidden() { + self.ivars().traffic_light_base.set(None); + return; + } + + // Capture the current default geometry as a candidate base. + let close_rect = close.frame(); + let spacing = miniaturize.frame().origin.x - close_rect.origin.x; // Horizontal delta between buttons. + let current = TrafficLightBase { x: close_rect.origin.x, y: close_rect.origin.y, spacing }; + + // If frames no longer match cached base + inset (AppKit reset), refresh base. + let base = match self.ivars().traffic_light_base.get() { + Some(base) => { + let expected_x = base.x + inset.width; + let expected_y = base.y - inset.height; + let drift = (close_rect.origin.x - expected_x).abs() > 0.5 + || (close_rect.origin.y - expected_y).abs() > 0.5 + || (spacing - base.spacing).abs() > 0.5; + if drift { + self.ivars().traffic_light_base.set(Some(current)); + current + } else { + base + } + }, + None => { + self.ivars().traffic_light_base.set(Some(current)); + current + }, + }; + + // Apply inset relative to base while preserving native spacing. + let target_y = base.y - inset.height; + for (index, button) in [close, miniaturize, zoom].into_iter().enumerate() { + let mut rect = button.frame(); + rect.origin.x = base.x + inset.width + (index as f64 * base.spacing); + rect.origin.y = target_y; + button.setFrameOrigin(rect.origin); + } + } + fn set_style_mask(&self, mask: NSWindowStyleMask) { self.window().setStyleMask(mask); // If we don't do this, key handling will break @@ -938,7 +1027,9 @@ impl WindowDelegate { } pub fn set_title(&self, title: &str) { - self.window().setTitle(&NSString::from_str(title)) + self.window().setTitle(&NSString::from_str(title)); + // AppKit can reset standard button positions when the title changes. + self.apply_traffic_light_inset(); } pub fn set_transparent(&self, transparent: bool) { @@ -2029,6 +2120,11 @@ impl WindowExtMacOS for WindowDelegate { window.toolbar().is_some() && window.toolbarStyle() == NSWindowToolbarStyle::Unified } + + fn set_traffic_light_inset(&self, inset: LogicalSize) { + self.ivars().traffic_light_inset.set(Some(inset)); + self.apply_traffic_light_inset(); + } } const DEFAULT_STANDARD_FRAME: NSRect = diff --git a/winit/examples/traffic_lights.rs b/winit/examples/traffic_lights.rs new file mode 100644 index 0000000000..263648b98a --- /dev/null +++ b/winit/examples/traffic_lights.rs @@ -0,0 +1,147 @@ +//! macOS traffic-light inset demo. +//! +//! macOS only; on other platforms this example is a no-op for insets. +//! Arrow keys adjust the inset, Shift uses a larger step, R resets, Esc exits. + +use std::error::Error; + +use winit::application::ApplicationHandler; +use winit::dpi::LogicalSize; +use winit::event::{ElementState, KeyEvent, WindowEvent}; +use winit::event_loop::{ActiveEventLoop, EventLoop}; +use winit::keyboard::{Key, ModifiersState, NamedKey}; +#[cfg(macos_platform)] +use winit::platform::macos::{WindowAttributesMacOS, WindowExtMacOS}; +#[cfg(web_platform)] +use winit::platform::web::WindowAttributesWeb; +use winit::window::{Window, WindowAttributes, WindowId}; + +#[path = "util/fill.rs"] +mod fill; +#[path = "util/tracing.rs"] +mod tracing; + +const DEFAULT_INSET_X: f64 = 0.0; +const DEFAULT_INSET_Y: f64 = 0.0; +const STEP_FINE: f64 = 1.0; +const STEP_COARSE: f64 = 8.0; + +#[derive(Debug)] +struct App { + window: Option>, + inset: LogicalSize, + modifiers: ModifiersState, +} + +impl Default for App { + fn default() -> Self { + Self { + window: None, + inset: LogicalSize::new(DEFAULT_INSET_X, DEFAULT_INSET_Y), + modifiers: ModifiersState::default(), + } + } +} + +impl App { + fn title(&self) -> String { + format!("Traffic lights inset: x={:.1}, y={:.1}", self.inset.width, self.inset.height) + } + + fn apply_inset(&self) { + let Some(window) = self.window.as_ref() else { + return; + }; + + #[cfg(macos_platform)] + window.set_traffic_light_inset(self.inset); + window.set_title(&self.title()); + } + + fn set_inset(&mut self, inset: LogicalSize) { + self.inset = inset; + self.apply_inset(); + } + + fn nudge_inset(&mut self, dx: f64, dy: f64) { + self.inset.width += dx; + self.inset.height += dy; + self.apply_inset(); + } +} + +impl ApplicationHandler for App { + fn can_create_surfaces(&mut self, event_loop: &dyn ActiveEventLoop) { + let window_attributes = WindowAttributes::default().with_title(self.title()); + #[cfg(web_platform)] + let window_attributes = window_attributes + .with_platform_attributes(Box::new(WindowAttributesWeb::default().with_append(true))); + #[cfg(macos_platform)] + let window_attributes = window_attributes.with_platform_attributes(Box::new( + WindowAttributesMacOS::default().with_traffic_light_inset(self.inset), + )); + + self.window = match event_loop.create_window(window_attributes) { + Ok(window) => Some(window), + Err(err) => { + eprintln!("error creating window: {err}"); + event_loop.exit(); + return; + }, + }; + + self.apply_inset(); + } + + fn window_event(&mut self, event_loop: &dyn ActiveEventLoop, _: WindowId, event: WindowEvent) { + match event { + WindowEvent::CloseRequested => event_loop.exit(), + WindowEvent::ModifiersChanged(modifiers) => { + self.modifiers = modifiers.state(); + }, + WindowEvent::KeyboardInput { + event: KeyEvent { state: ElementState::Pressed, key_without_modifiers: key, .. }, + is_synthetic: false, + .. + } => { + let step = if self.modifiers.shift_key() { STEP_COARSE } else { STEP_FINE }; + + match key.as_ref() { + Key::Named(NamedKey::ArrowLeft) => self.nudge_inset(-step, 0.0), + Key::Named(NamedKey::ArrowRight) => self.nudge_inset(step, 0.0), + Key::Named(NamedKey::ArrowUp) => self.nudge_inset(0.0, -step), + Key::Named(NamedKey::ArrowDown) => self.nudge_inset(0.0, step), + Key::Character("r") => { + self.set_inset(LogicalSize::new(DEFAULT_INSET_X, DEFAULT_INSET_Y)) + }, + Key::Named(NamedKey::Escape) => event_loop.exit(), + _ => (), + } + }, + WindowEvent::SurfaceResized(_) => { + self.window.as_ref().expect("resize event without a window").request_redraw(); + }, + WindowEvent::RedrawRequested => { + let window = self.window.as_ref().expect("redraw request without a window"); + window.pre_present_notify(); + fill::fill_window(window.as_ref()); + }, + _ => (), + } + } +} + +fn main() -> Result<(), Box> { + #[cfg(web_platform)] + console_error_panic_hook::set_once(); + + tracing::init(); + + println!("Traffic-light inset demo (macOS only)."); + println!("Arrow keys adjust X/Y. Shift = coarse step."); + println!("R resets. Esc closes."); + + let event_loop = EventLoop::new()?; + event_loop.run_app(App::default())?; + Ok(()) +} diff --git a/winit/src/changelog/unreleased.md b/winit/src/changelog/unreleased.md index 273f62019d..229343ed5a 100644 --- a/winit/src/changelog/unreleased.md +++ b/winit/src/changelog/unreleased.md @@ -43,6 +43,8 @@ changelog entry. ### Added - Add `keyboard` support for OpenHarmony. +- On macOS, add `WindowAttributesMacOS::with_traffic_light_inset` and + `WindowExtMacOS::set_traffic_light_inset`. ### Changed