From 12401f42e19459c0e465eb22dbf6c6fb46d3acb0 Mon Sep 17 00:00:00 2001 From: Simon Hausmann Date: Tue, 19 May 2026 17:03:52 +0200 Subject: [PATCH] winit-uikit: detect system dark/light theme Apps targeting iOS could neither query nor react to the system appearance. Implement `Window::theme`, `Window::set_theme`, `WindowAttributes::preferred_theme` via `userInterfaceStyle`, and emit `WindowEvent::ThemeChanged` via `registerForTraitChanges` (iOS 17.0+), so winit apps participate in iOS appearance changes like they do on macOS. cc #3994 --- winit-core/src/event.rs | 3 +- winit-core/src/window.rs | 6 ++-- winit-uikit/Cargo.toml | 2 ++ winit-uikit/src/view.rs | 46 ++++++++++++++++++++++++++++--- winit-uikit/src/window.rs | 42 ++++++++++++++++++++++++---- winit/src/changelog/unreleased.md | 1 + 6 files changed, 88 insertions(+), 12 deletions(-) diff --git a/winit-core/src/event.rs b/winit-core/src/event.rs index e6500c2133..aba75af645 100644 --- a/winit-core/src/event.rs +++ b/winit-core/src/event.rs @@ -383,7 +383,8 @@ pub enum WindowEvent { /// /// ## Platform-specific /// - /// - **iOS / Android / X11 / Wayland / Orbital:** Unsupported. + /// - **Android / X11 / Wayland / Orbital:** Unsupported. + /// - **iOS:** Requires iOS 17.0+. ThemeChanged(Theme), /// The window has been occluded (completely hidden from view). diff --git a/winit-core/src/window.rs b/winit-core/src/window.rs index 5355369acc..cd950cc465 100644 --- a/winit-core/src/window.rs +++ b/winit-core/src/window.rs @@ -1267,7 +1267,8 @@ pub trait Window: AsAny + Send + Sync + fmt::Debug { /// get the system preference. /// - **X11:** Sets `_GTK_THEME_VARIANT` hint to `dark` or `light` and if `None` is used, it /// will default to [`Theme::Dark`]. - /// - **iOS / Android / Web / Orbital:** Unsupported. + /// - **Android / Web / Orbital:** Unsupported. + /// - **iOS:** Requires iOS 13.0+. fn set_theme(&self, theme: Option); /// Returns the current window theme. @@ -1276,7 +1277,8 @@ pub trait Window: AsAny + Send + Sync + fmt::Debug { /// /// ## Platform-specific /// - /// - **iOS / Android / x11 / Orbital:** Unsupported. + /// - **Android / x11 / Orbital:** Unsupported. + /// - **iOS:** Requires iOS 13.0+. /// - **Wayland:** Only returns theme overrides. fn theme(&self) -> Option; diff --git a/winit-uikit/Cargo.toml b/winit-uikit/Cargo.toml index 9fdc74bbc7..854c5add3f 100644 --- a/winit-uikit/Cargo.toml +++ b/winit-uikit/Cargo.toml @@ -53,6 +53,7 @@ objc2-ui-kit = { workspace = true, features = [ "UIEvent", "UIGeometry", "UIGestureRecognizer", + "UIInterface", "UITextInput", "UITextInputTraits", "UIOrientation", @@ -64,6 +65,7 @@ objc2-ui-kit = { workspace = true, features = [ "UIScreenMode", "UITapGestureRecognizer", "UITouch", + "UITrait", "UITraitCollection", "UIView", "UIViewController", diff --git a/winit-uikit/src/view.rs b/winit-uikit/src/view.rs index 19cbeacf6f..697fbe0fc8 100644 --- a/winit-uikit/src/view.rs +++ b/winit-uikit/src/view.rs @@ -3,15 +3,16 @@ use std::cell::{Cell, RefCell}; use dpi::PhysicalPosition; use objc2::rc::Retained; -use objc2::runtime::{NSObjectProtocol, ProtocolObject}; -use objc2::{DefinedClass, MainThreadMarker, available, define_class, msg_send, sel}; +use objc2::runtime::{AnyObject, NSObjectProtocol, ProtocolObject}; +use objc2::{ClassType, DefinedClass, MainThreadMarker, available, define_class, msg_send, sel}; use objc2_core_foundation::{CGFloat, CGPoint, CGRect}; -use objc2_foundation::{NSObject, NSSet, NSString}; +use objc2_foundation::{NSArray, NSObject, NSSet, NSString}; use objc2_ui_kit::{ UIEvent, UIForceTouchCapability, UIGestureRecognizer, UIGestureRecognizerDelegate, UIGestureRecognizerState, UIKeyInput, UIPanGestureRecognizer, UIPinchGestureRecognizer, UIResponder, UIRotationGestureRecognizer, UITapGestureRecognizer, UITextInputTraits, UITouch, - UITouchPhase, UITouchType, UITraitEnvironment, UIView, + UITouchPhase, UITouchType, UITraitEnvironment, UITraitUserInterfaceStyle, UIUserInterfaceStyle, + UIView, }; use tracing::{debug, debug_span, trace_span}; use winit_core::event::{ @@ -22,6 +23,7 @@ use winit_core::keyboard::{Key, KeyCode, KeyLocation, NamedKey, NativeKeyCode, P use super::app_state::{self, EventWrapper}; use super::window::WinitUIWindow; +use crate::window::ui_style_to_theme; pub struct WinitViewState { pinch_gesture_recognizer: RefCell>>, @@ -133,6 +135,28 @@ define_class!( self.setNeedsDisplay(); } + #[unsafe(method(winitDidChangeUserInterfaceStyle:))] + fn winit_did_change_user_interface_style(&self, _environment: &AnyObject) { + let _entered = debug_span!("winitDidChangeUserInterfaceStyle:").entered(); + + let Some(window) = self.window() else { return }; + + // Mirror AppKit's semantics: don't emit when the user has set an override; the change + // was driven by `Window::set_theme`, not by the system. + if window.overrideUserInterfaceStyle() != UIUserInterfaceStyle::Unspecified { + return; + } + + let style = unsafe { self.traitCollection().userInterfaceStyle() }; + let Some(new_theme) = ui_style_to_theme(style) else { return }; + + let mtm = MainThreadMarker::new().unwrap(); + app_state::handle_nonuser_event(mtm, EventWrapper::Window { + window_id: window.id(), + event: WindowEvent::ThemeChanged(new_theme), + }); + } + #[unsafe(method(touchesBegan:withEvent:))] fn touches_began(&self, touches: &NSSet, _event: Option<&UIEvent>) { let _entered = debug_span!("touchesBegan:withEvent:").entered(); @@ -380,6 +404,20 @@ impl WinitView { this.setContentScaleFactor(scale_factor as _); } + if available!(ios = 17.0, tvos = 17.0, visionos = 1.0) { + let trait_class: &AnyObject = + ::class().as_ref(); + let traits = NSArray::from_slice(&[trait_class]); + let _: Retained = unsafe { + msg_send![ + &*this, + registerForTraitChanges: &*traits, + withTarget: &*this, + action: sel!(winitDidChangeUserInterfaceStyle:), + ] + }; + } + this } diff --git a/winit-uikit/src/window.rs b/winit-uikit/src/window.rs index d0576f949f..cf64380054 100644 --- a/winit-uikit/src/window.rs +++ b/winit-uikit/src/window.rs @@ -14,7 +14,8 @@ use objc2_core_foundation::{CGFloat, CGPoint, CGRect, CGSize}; use objc2_foundation::{NSObject, NSObjectProtocol}; use objc2_ui_kit::{ UIApplication, UICoordinateSpace, UIEdgeInsets, UIResponder, UIScreen, - UIScreenOverscanCompensation, UIViewController, UIWindow, + UIScreenOverscanCompensation, UITraitEnvironment, UIUserInterfaceStyle, UIViewController, + UIWindow, }; use tracing::{debug, debug_span, warn}; use winit_core::cursor::Cursor; @@ -455,8 +456,12 @@ impl Inner { } pub fn theme(&self) -> Option { - warn!("`Window::theme` is ignored on iOS"); - None + if available!(ios = 13.0, tvos = 13.0, visionos = 1.0) { + let trait_collection = self.view.traitCollection(); + ui_style_to_theme(unsafe { trait_collection.userInterfaceStyle() }) + } else { + None + } } pub fn set_content_protected(&self, _protected: bool) {} @@ -466,8 +471,12 @@ impl Inner { } #[inline] - pub fn set_theme(&self, _theme: Option) { - warn!("`Window::set_theme` is ignored on iOS"); + pub fn set_theme(&self, theme: Option) { + if available!(ios = 13.0, tvos = 13.0, visionos = 1.0) { + self.window.setOverrideUserInterfaceStyle(theme_to_ui_style(theme)); + } else { + warn!("`Window::set_theme` requires iOS 13.0+"); + } } pub fn title(&self) -> String { @@ -540,6 +549,13 @@ impl Window { let view_controller = WinitViewController::new(mtm, &ios_attributes, &view); let window = WinitUIWindow::new(mtm, &window_attributes, frame, &view_controller); + + if let Some(preferred_theme) = window_attributes.preferred_theme { + if available!(ios = 13.0, tvos = 13.0, visionos = 1.0) { + window.setOverrideUserInterfaceStyle(theme_to_ui_style(Some(preferred_theme))); + } + } + window.makeKeyAndVisible(); let inner = Inner { @@ -911,3 +927,19 @@ impl Inner { self.window.convertRect_fromCoordinateSpace(rect, &screen_space) } } + +pub(crate) fn ui_style_to_theme(style: UIUserInterfaceStyle) -> Option { + match style { + UIUserInterfaceStyle::Light => Some(Theme::Light), + UIUserInterfaceStyle::Dark => Some(Theme::Dark), + _ => None, + } +} + +pub(crate) fn theme_to_ui_style(theme: Option) -> UIUserInterfaceStyle { + match theme { + Some(Theme::Light) => UIUserInterfaceStyle::Light, + Some(Theme::Dark) => UIUserInterfaceStyle::Dark, + None => UIUserInterfaceStyle::Unspecified, + } +} diff --git a/winit/src/changelog/unreleased.md b/winit/src/changelog/unreleased.md index 729bfd639b..95760af5d4 100644 --- a/winit/src/changelog/unreleased.md +++ b/winit/src/changelog/unreleased.md @@ -44,6 +44,7 @@ changelog entry. - Add `keyboard` support for OpenHarmony. - On iOS, add Apple Pencil support with force, altitude, and azimuth data. +- On iOS, implement `Window::theme`, `Window::set_theme`, and `WindowAttributes::with_theme` (iOS 13.0+), and `WindowEvent::ThemeChanged` (iOS 17.0+). - On Redox, add support for missing keyboard scancodes. - Implement `Send` and `Sync` for `OwnedDisplayHandle`. - Use new macOS 15 cursors for resize icons.