Follow the System Theme in egui (#4860)

* Some initial progress towards #4490

This PR just moves `Theme` and the "follow system theme" settings to
egui and adds `RawInput.system_theme`.
A follow-up PR can then introduce the two separate `dark_mode_style` and
`light_mode_style` fields on `Options`.


<!--
Please read the "Making a PR" section of
[`CONTRIBUTING.md`](https://github.com/emilk/egui/blob/master/CONTRIBUTING.md)
before opening a Pull Request!

* If applicable, add a screenshot or gif.
* If it is a non-trivial addition, consider adding a demo for it to
`egui_demo_lib`, or a new example.
* Do NOT open PR:s from your `master` branch, as that makes it hard for
maintainers to test and add commits to your PR.
* Remember to run `cargo fmt` and `cargo clippy`.
* Open the PR as a draft until you have self-reviewed it and run
`./scripts/check.sh`.
* When you have addressed a PR comment, mark it as resolved.

Please be patient! I will review your PR, but my time is limited!
-->


* [x] I have followed the instructions in the PR template


### Breaking changes

The options `follow_system_theme` and `default_theme` has been moved
from `eframe` into `egui::Options`, settable with `ctx.options_mut`

---------

Co-authored-by: Emil Ernerfeldt <emil.ernerfeldt@gmail.com>
This commit is contained in:
Tau Gärtli 2024-08-06 20:17:51 +02:00 committed by GitHub
parent ed0254288a
commit 2dac4a4fc6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 129 additions and 137 deletions

View File

@ -297,21 +297,6 @@ pub struct NativeOptions {
#[cfg(any(feature = "glow", feature = "wgpu"))]
pub renderer: Renderer,
/// Try to detect and follow the system preferred setting for dark vs light mode.
///
/// The theme will automatically change when the dark vs light mode preference is changed.
///
/// Does not work on Linux (see <https://github.com/rust-windowing/winit/issues/1549>).
///
/// See also [`Self::default_theme`].
pub follow_system_theme: bool,
/// Which theme to use in case [`Self::follow_system_theme`] is `false`
/// or eframe fails to detect the system theme.
///
/// Default: [`Theme::Dark`].
pub default_theme: Theme,
/// This controls what happens when you close the main eframe window.
///
/// If `true`, execution will continue after the eframe window is closed.
@ -417,8 +402,6 @@ impl Default for NativeOptions {
#[cfg(any(feature = "glow", feature = "wgpu"))]
renderer: Renderer::default(),
follow_system_theme: cfg!(target_os = "macos") || cfg!(target_os = "windows"),
default_theme: Theme::Dark,
run_and_return: true,
#[cfg(any(feature = "glow", feature = "wgpu"))]
@ -449,19 +432,6 @@ impl Default for NativeOptions {
/// Options when using `eframe` in a web page.
#[cfg(target_arch = "wasm32")]
pub struct WebOptions {
/// Try to detect and follow the system preferred setting for dark vs light mode.
///
/// See also [`Self::default_theme`].
///
/// Default: `true`.
pub follow_system_theme: bool,
/// Which theme to use in case [`Self::follow_system_theme`] is `false`
/// or system theme detection fails.
///
/// Default: `Theme::Dark`.
pub default_theme: Theme,
/// Sets the number of bits in the depth buffer.
///
/// `egui` doesn't need the depth buffer, so the default value is 0.
@ -492,8 +462,6 @@ pub struct WebOptions {
impl Default for WebOptions {
fn default() -> Self {
Self {
follow_system_theme: true,
default_theme: Theme::Dark,
depth_buffer: 0,
#[cfg(feature = "glow")]
@ -509,31 +477,6 @@ impl Default for WebOptions {
// ----------------------------------------------------------------------------
/// Dark or Light theme.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
pub enum Theme {
/// Dark mode: light text on a dark background.
Dark,
/// Light mode: dark text on a light background.
Light,
}
impl Theme {
/// Get the egui visuals corresponding to this theme.
///
/// Use with [`egui::Context::set_visuals`].
pub fn egui_visuals(self) -> egui::Visuals {
match self {
Self::Dark => egui::Visuals::dark(),
Self::Light => egui::Visuals::light(),
}
}
}
// ----------------------------------------------------------------------------
/// WebGL Context options
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
@ -814,11 +757,6 @@ pub struct IntegrationInfo {
#[cfg(target_arch = "wasm32")]
pub web_info: WebInfo,
/// Does the OS use dark or light mode?
///
/// `None` means "don't know".
pub system_theme: Option<Theme>,
/// Seconds of cpu usage (in seconds) on the previous frame.
///
/// This includes [`App::update`] as well as rendering (except for vsync waiting).

View File

@ -10,7 +10,7 @@ use raw_window_handle::{HasDisplayHandle as _, HasWindowHandle as _};
use egui::{DeferredViewportUiCallback, NumExt as _, ViewportBuilder, ViewportId};
use egui_winit::{EventResponse, WindowSettings};
use crate::{epi, Theme};
use crate::epi;
pub fn viewport_builder(
egui_zoom_factor: f32,
@ -158,7 +158,6 @@ pub struct EpiIntegration {
close: bool,
can_drag_window: bool,
follow_system_theme: bool,
#[cfg(feature = "persistence")]
persist_window: bool,
app_icon_setter: super::app_icon::AppTitleIconSetter,
@ -169,7 +168,6 @@ impl EpiIntegration {
pub fn new(
egui_ctx: egui::Context,
window: &winit::window::Window,
system_theme: Option<Theme>,
app_name: &str,
native_options: &crate::NativeOptions,
storage: Option<Box<dyn epi::Storage>>,
@ -180,10 +178,7 @@ impl EpiIntegration {
#[cfg(feature = "wgpu")] wgpu_render_state: Option<egui_wgpu::RenderState>,
) -> Self {
let frame = epi::Frame {
info: epi::IntegrationInfo {
system_theme,
cpu_usage: None,
},
info: epi::IntegrationInfo { cpu_usage: None },
storage,
#[cfg(feature = "glow")]
gl,
@ -217,7 +212,6 @@ impl EpiIntegration {
pending_full_output: Default::default(),
close: false,
can_drag_window: false,
follow_system_theme: native_options.follow_system_theme,
#[cfg(feature = "persistence")]
persist_window: native_options.persist_window,
app_icon_setter,
@ -251,11 +245,6 @@ impl EpiIntegration {
state: ElementState::Pressed,
..
} => self.can_drag_window = true,
WindowEvent::ThemeChanged(winit_theme) if self.follow_system_theme => {
let theme = theme_from_winit_theme(*winit_theme);
self.frame.info.system_theme = Some(theme);
self.egui_ctx.set_visuals(theme.egui_visuals());
}
_ => {}
}
@ -398,10 +387,3 @@ pub fn load_egui_memory(_storage: Option<&dyn epi::Storage>) -> Option<egui::Mem
#[cfg(not(feature = "persistence"))]
None
}
pub(crate) fn theme_from_winit_theme(theme: winit::window::Theme) -> Theme {
match theme {
winit::window::Theme::Dark => Theme::Dark,
winit::window::Theme::Light => Theme::Light,
}
}

View File

@ -228,14 +228,11 @@ impl GlowWinitApp {
}
}
let system_theme =
winit_integration::system_theme(&glutin.window(ViewportId::ROOT), &self.native_options);
let painter = Rc::new(RefCell::new(painter));
let integration = EpiIntegration::new(
egui_ctx,
&glutin.window(ViewportId::ROOT),
system_theme,
&self.app_name,
&self.native_options,
storage,
@ -281,9 +278,6 @@ impl GlowWinitApp {
}
}
let theme = system_theme.unwrap_or(self.native_options.default_theme);
integration.egui_ctx.set_visuals(theme.egui_visuals());
if self
.native_options
.viewport
@ -1120,6 +1114,7 @@ impl GlutinWindowContext {
viewport_id,
event_loop,
Some(window.scale_factor() as f32),
window.theme(),
self.max_texture_side,
)
});

View File

@ -203,11 +203,9 @@ impl WgpuWinitApp {
let wgpu_render_state = painter.render_state();
let system_theme = winit_integration::system_theme(&window, &self.native_options);
let integration = EpiIntegration::new(
egui_ctx.clone(),
&window,
system_theme,
&self.app_name,
&self.native_options,
storage,
@ -243,6 +241,7 @@ impl WgpuWinitApp {
ViewportId::ROOT,
event_loop,
Some(window.scale_factor() as f32),
window.theme(),
painter.max_texture_side(),
);
@ -251,8 +250,6 @@ impl WgpuWinitApp {
let event_loop_proxy = self.repaint_proxy.lock().clone();
egui_winit.init_accesskit(&window, event_loop_proxy);
}
let theme = system_theme.unwrap_or(self.native_options.default_theme);
egui_ctx.set_visuals(theme.egui_visuals());
let app_creator = std::mem::take(&mut self.app_creator)
.expect("Single-use AppCreator has unexpectedly already been taken");
@ -872,6 +869,7 @@ impl Viewport {
viewport_id,
event_loop,
Some(window.scale_factor() as f32),
window.theme(),
painter.max_texture_side(),
));

View File

@ -118,16 +118,6 @@ pub enum EventResult {
Exit,
}
pub fn system_theme(window: &Window, options: &crate::NativeOptions) -> Option<crate::Theme> {
if options.follow_system_theme {
window
.theme()
.map(super::epi_integration::theme_from_winit_theme)
} else {
None
}
}
#[cfg(feature = "accesskit")]
pub(crate) fn on_accesskit_window_event(
egui_winit: &mut egui_winit::State,

View File

@ -38,18 +38,11 @@ impl AppRunner {
) -> Result<Self, String> {
let painter = super::ActiveWebPainter::new(canvas, &web_options).await?;
let system_theme = if web_options.follow_system_theme {
super::system_theme()
} else {
None
};
let info = epi::IntegrationInfo {
web_info: epi::WebInfo {
user_agent: super::user_agent().unwrap_or_default(),
location: super::web_location(),
},
system_theme,
cpu_usage: None,
};
let storage = LocalStorage::default();
@ -68,9 +61,6 @@ impl AppRunner {
o.zoom_factor = 1.0;
});
let theme = system_theme.unwrap_or(web_options.default_theme);
egui_ctx.set_visuals(theme.egui_visuals());
let cc = epi::CreationContext {
egui_ctx: egui_ctx.clone(),
integration_info: info.clone(),
@ -132,6 +122,7 @@ impl AppRunner {
.entry(egui::ViewportId::ROOT)
.or_default()
.native_pixels_per_point = Some(super::native_pixels_per_point());
runner.input.raw.system_theme = super::system_theme();
Ok(runner)
}

View File

@ -94,6 +94,7 @@ pub(crate) fn install_event_handlers(runner_ref: &WebRunner) -> Result<(), JsVal
install_wheel(runner_ref, &canvas)?;
install_drag_and_drop(runner_ref, &canvas)?;
install_window_events(runner_ref, &window)?;
install_color_scheme_change_event(runner_ref, &window)?;
Ok(())
}
@ -353,17 +354,17 @@ fn install_window_events(runner_ref: &WebRunner, window: &EventTarget) -> Result
Ok(())
}
pub(crate) fn install_color_scheme_change_event(runner_ref: &WebRunner) -> Result<(), JsValue> {
let window = web_sys::window().unwrap();
if let Some(media_query_list) = prefers_color_scheme_dark(&window)? {
fn install_color_scheme_change_event(
runner_ref: &WebRunner,
window: &web_sys::Window,
) -> Result<(), JsValue> {
if let Some(media_query_list) = prefers_color_scheme_dark(window)? {
runner_ref.add_event_listener::<web_sys::MediaQueryListEvent>(
&media_query_list,
"change",
|event, runner| {
let theme = theme_from_dark_mode(event.matches());
runner.frame.info.system_theme = Some(theme);
runner.egui_ctx().set_visuals(theme.egui_visuals());
runner.input.raw.system_theme = Some(theme);
runner.needs_repaint.repaint_asap();
},
)?;

View File

@ -45,8 +45,6 @@ use web_sys::MediaQueryList;
use input::*;
use crate::Theme;
// ----------------------------------------------------------------------------
pub(crate) fn string_from_js_value(value: &JsValue) -> String {
@ -103,7 +101,7 @@ pub fn native_pixels_per_point() -> f32 {
/// Ask the browser about the preferred system theme.
///
/// `None` means unknown.
pub fn system_theme() -> Option<Theme> {
pub fn system_theme() -> Option<egui::Theme> {
let dark_mode = prefers_color_scheme_dark(&web_sys::window()?)
.ok()??
.matches();
@ -114,11 +112,11 @@ fn prefers_color_scheme_dark(window: &web_sys::Window) -> Result<Option<MediaQue
window.match_media("(prefers-color-scheme: dark)")
}
fn theme_from_dark_mode(dark_mode: bool) -> Theme {
fn theme_from_dark_mode(dark_mode: bool) -> egui::Theme {
if dark_mode {
Theme::Dark
egui::Theme::Dark
} else {
Theme::Light
egui::Theme::Light
}
}

View File

@ -63,8 +63,6 @@ impl WebRunner {
) -> Result<(), JsValue> {
self.destroy();
let follow_system_theme = web_options.follow_system_theme;
let text_agent = TextAgent::attach(self)?;
let runner = AppRunner::new(canvas, web_options, app_creator, text_agent).await?;
@ -83,10 +81,6 @@ impl WebRunner {
{
events::install_event_handlers(self)?;
if follow_system_theme {
events::install_color_scheme_change_event(self)?;
}
// The resize observer handles calling `request_animation_frame` to start the render loop.
events::install_resize_observer(self)?;
}

View File

@ -14,7 +14,7 @@ pub use accesskit_winit;
pub use egui;
#[cfg(feature = "accesskit")]
use egui::accesskit;
use egui::{Pos2, Rect, Vec2, ViewportBuilder, ViewportCommand, ViewportId, ViewportInfo};
use egui::{Pos2, Rect, Theme, Vec2, ViewportBuilder, ViewportCommand, ViewportId, ViewportInfo};
pub use winit;
pub mod clipboard;
@ -111,6 +111,7 @@ impl State {
viewport_id: ViewportId,
display_target: &dyn HasDisplayHandle,
native_pixels_per_point: Option<f32>,
theme: Option<winit::window::Theme>,
max_texture_side: Option<usize>,
) -> Self {
crate::profile_function!();
@ -150,6 +151,7 @@ impl State {
.entry(ViewportId::ROOT)
.or_default()
.native_pixels_per_point = native_pixels_per_point;
slf.egui_input.system_theme = theme.map(to_egui_theme);
if let Some(max_texture_side) = max_texture_side {
slf.set_max_texture_side(max_texture_side);
@ -403,6 +405,13 @@ impl State {
consumed: false,
}
}
WindowEvent::ThemeChanged(winit_theme) => {
self.egui_input.system_theme = Some(to_egui_theme(*winit_theme));
EventResponse {
repaint: true,
consumed: false,
}
}
WindowEvent::HoveredFile(path) => {
self.egui_input.hovered_files.push(egui::HoveredFile {
path: Some(path.clone()),
@ -462,7 +471,6 @@ impl State {
| WindowEvent::Occluded(_)
| WindowEvent::Resized(_)
| WindowEvent::Moved(_)
| WindowEvent::ThemeChanged(_)
| WindowEvent::TouchpadPressure { .. }
| WindowEvent::CloseRequested => EventResponse {
repaint: true,
@ -890,6 +898,13 @@ impl State {
}
}
fn to_egui_theme(theme: winit::window::Theme) -> Theme {
match theme {
winit::window::Theme::Dark => Theme::Dark,
winit::window::Theme::Light => Theme::Light,
}
}
pub fn inner_rect_in_points(window: &Window, pixels_per_point: f32) -> Option<Rect> {
let inner_pos_px = window.inner_position().ok()?;
let inner_pos_px = egui::pos2(inner_pos_px.x as f32, inner_pos_px.y as f32);

View File

@ -2,7 +2,7 @@
use epaint::ColorImage;
use crate::{emath::*, Key, ViewportId, ViewportIdMap};
use crate::{emath::*, Key, Theme, ViewportId, ViewportIdMap};
/// What the integrations provides to egui at the start of each frame.
///
@ -73,6 +73,11 @@ pub struct RawInput {
///
/// False when the user alt-tab away from the application, for instance.
pub focused: bool,
/// Does the OS use dark or light mode?
///
/// `None` means "don't know".
pub system_theme: Option<Theme>,
}
impl Default for RawInput {
@ -89,6 +94,7 @@ impl Default for RawInput {
hovered_files: Default::default(),
dropped_files: Default::default(),
focused: true, // integrations opt into global focus tracking
system_theme: None,
}
}
}
@ -117,6 +123,7 @@ impl RawInput {
hovered_files: self.hovered_files.clone(),
dropped_files: std::mem::take(&mut self.dropped_files),
focused: self.focused,
system_theme: self.system_theme,
}
}
@ -134,6 +141,7 @@ impl RawInput {
mut hovered_files,
mut dropped_files,
focused,
system_theme,
} = newer;
self.viewport_id = viewport_ids;
@ -147,6 +155,7 @@ impl RawInput {
self.hovered_files.append(&mut hovered_files);
self.dropped_files.append(&mut dropped_files);
self.focused = focused;
self.system_theme = system_theme;
}
}
@ -189,7 +198,7 @@ pub struct ViewportInfo {
/// This should always be set, if known.
///
/// On web this takes browser scaling into account,
/// and orresponds to [`window.devicePixelRatio`](https://developer.mozilla.org/en-US/docs/Web/API/Window/devicePixelRatio) in JavaScript.
/// and corresponds to [`window.devicePixelRatio`](https://developer.mozilla.org/en-US/docs/Web/API/Window/devicePixelRatio) in JavaScript.
pub native_pixels_per_point: Option<f32>,
/// Current monitor size in egui points.
@ -1044,6 +1053,7 @@ impl RawInput {
hovered_files,
dropped_files,
focused,
system_theme,
} = self;
ui.label(format!("Active viwport: {viewport_id:?}"));
@ -1068,6 +1078,7 @@ impl RawInput {
ui.label(format!("hovered_files: {}", hovered_files.len()));
ui.label(format!("dropped_files: {}", dropped_files.len()));
ui.label(format!("focused: {focused}"));
ui.label(format!("system_theme: {system_theme:?}"));
ui.scope(|ui| {
ui.set_min_height(150.0);
ui.label(format!("events: {events:#?}"))

View File

@ -460,7 +460,7 @@ pub use {
layers::{LayerId, Order},
layout::*,
load::SizeHint,
memory::{Memory, Options},
memory::{Memory, Options, Theme},
painter::Painter,
response::{InnerResponse, Response},
sense::Sense,

View File

@ -8,6 +8,9 @@ use crate::{
ViewportId, ViewportIdMap, ViewportIdSet,
};
mod theme;
pub use theme::Theme;
// ----------------------------------------------------------------------------
/// The data that egui persists between frames.
@ -169,6 +172,21 @@ pub struct Options {
#[cfg_attr(feature = "serde", serde(skip))]
pub(crate) style: std::sync::Arc<Style>,
/// Whether to update the visuals according to the system theme or not.
///
/// Default: `true`.
pub follow_system_theme: bool,
/// Which theme to use in case [`Self::follow_system_theme`] is set
/// and egui fails to detect the system theme.
///
/// Default: [`crate::Theme::Dark`].
pub fallback_theme: Theme,
/// Used to detect changes in system theme
#[cfg_attr(feature = "serde", serde(skip))]
system_theme: Option<Theme>,
/// Global zoom factor of the UI.
///
/// This is used to calculate the `pixels_per_point`
@ -262,6 +280,9 @@ impl Default for Options {
Self {
style: Default::default(),
follow_system_theme: true,
fallback_theme: Theme::Dark,
system_theme: None,
zoom_factor: 1.0,
zoom_with_keyboard: true,
tessellation_options: Default::default(),
@ -278,11 +299,35 @@ impl Default for Options {
}
}
impl Options {
pub(crate) fn begin_frame(&mut self, new_raw_input: &RawInput) {
if self.follow_system_theme {
let theme_from_visuals = Theme::from_dark_mode(self.style.visuals.dark_mode);
let current_system_theme = self.system_theme.unwrap_or(theme_from_visuals);
let new_system_theme = new_raw_input.system_theme.unwrap_or(self.fallback_theme);
// Only update the visuals if the system theme has changed.
// This allows users to change the visuals without them
// getting reset on the next frame.
if current_system_theme != new_system_theme || self.system_theme.is_none() {
self.system_theme = Some(new_system_theme);
if theme_from_visuals != new_system_theme {
let visuals = new_system_theme.default_visuals();
std::sync::Arc::make_mut(&mut self.style).visuals = visuals;
}
}
}
}
}
impl Options {
/// Show the options in the ui.
pub fn ui(&mut self, ui: &mut crate::Ui) {
let Self {
style, // covered above
style, // covered above
follow_system_theme: _,
fallback_theme: _,
system_theme: _,
zoom_factor: _, // TODO(emilk)
zoom_with_keyboard,
tessellation_options,
@ -665,6 +710,8 @@ impl Memory {
// self.interactions is handled elsewhere
self.options.begin_frame(new_raw_input);
self.focus
.entry(self.viewport_id)
.or_default()

View File

@ -0,0 +1,29 @@
/// Dark or Light theme.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
pub enum Theme {
/// Dark mode: light text on a dark background.
Dark,
/// Light mode: dark text on a light background.
Light,
}
impl Theme {
/// Default visuals for this theme.
pub fn default_visuals(self) -> crate::Visuals {
match self {
Self::Dark => crate::Visuals::dark(),
Self::Light => crate::Visuals::light(),
}
}
/// Chooses between [`Self::Dark`] or [`Self::Light`] based on a boolean value.
pub fn from_dark_mode(dark_mode: bool) -> Self {
if dark_mode {
Self::Dark
} else {
Self::Light
}
}
}

View File

@ -192,7 +192,8 @@ impl winit::application::ApplicationHandler<UserEvent> for GlowApp {
let gl = std::sync::Arc::new(gl);
gl_window.window().set_visible(true);
let egui_glow = egui_glow::EguiGlow::new(event_loop, gl.clone(), None, None, true);
let egui_glow =
egui_glow::EguiGlow::new(event_loop, &gl_window.window, gl.clone(), None, None, true);
let event_loop_proxy = egui::mutex::Mutex::new(self.proxy.clone());
egui_glow

View File

@ -24,6 +24,7 @@ impl EguiGlow {
/// For automatic shader version detection set `shader_version` to `None`.
pub fn new(
event_loop: &winit::event_loop::ActiveEventLoop,
window: &winit::window::Window,
gl: std::sync::Arc<glow::Context>,
shader_version: Option<ShaderVersion>,
native_pixels_per_point: Option<f32>,
@ -42,6 +43,7 @@ impl EguiGlow {
ViewportId::ROOT,
event_loop,
native_pixels_per_point,
window.theme(),
Some(painter.max_texture_side()),
);