diff --git a/crates/egui/src/lib.rs b/crates/egui/src/lib.rs index 89e0ffab5ff..c751af95152 100644 --- a/crates/egui/src/lib.rs +++ b/crates/egui/src/lib.rs @@ -460,7 +460,7 @@ pub use { layers::{LayerId, Order}, layout::*, load::SizeHint, - memory::{Memory, Options}, + memory::{Memory, Options, ThemePreference}, painter::Painter, response::{InnerResponse, Response}, sense::Sense, diff --git a/crates/egui/src/memory.rs b/crates/egui/src/memory.rs index a09f0b57e31..7ba1cf2e0e8 100644 --- a/crates/egui/src/memory.rs +++ b/crates/egui/src/memory.rs @@ -1103,6 +1103,14 @@ impl Areas { } } +#[derive(Clone, Copy, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] +pub enum ThemePreference { + Dark, + Light, + System, +} + // ---------------------------------------------------------------------------- #[test] diff --git a/crates/egui/src/widgets/mod.rs b/crates/egui/src/widgets/mod.rs index 9900117062e..7c1c1287fea 100644 --- a/crates/egui/src/widgets/mod.rs +++ b/crates/egui/src/widgets/mod.rs @@ -4,6 +4,8 @@ //! * `ui.add(Label::new("Text").text_color(color::red));` //! * `if ui.add(Button::new("Click me")).clicked() { … }` +use theme_switch::ThemeSwitch; + use crate::*; mod button; @@ -21,6 +23,7 @@ mod separator; mod slider; mod spinner; pub mod text_edit; +mod theme_switch; pub use self::{ button::Button, @@ -134,9 +137,17 @@ pub fn stroke_ui(ui: &mut crate::Ui, stroke: &mut epaint::Stroke, text: &str) { /// Show a small button to switch to/from dark/light mode (globally). pub fn global_dark_light_mode_switch(ui: &mut Ui) { - let style: crate::Style = (*ui.ctx().style()).clone(); - let new_visuals = style.visuals.light_dark_small_toggle_button(ui); - if let Some(visuals) = new_visuals { + let mut theme = if ui.ctx().style().visuals.dark_mode { + ThemePreference::Dark + } else { + ThemePreference::Light + }; + let response = ui.add(ThemeSwitch::new(&mut theme).show_follow_system(false)); + if response.changed { + let visuals = match theme { + ThemePreference::Dark | ThemePreference::System => Visuals::dark(), + ThemePreference::Light => Visuals::light(), + }; ui.ctx().set_visuals(visuals); } } diff --git a/crates/egui/src/widgets/theme_switch/arc.rs b/crates/egui/src/widgets/theme_switch/arc.rs new file mode 100644 index 00000000000..0b7bb85a38a --- /dev/null +++ b/crates/egui/src/widgets/theme_switch/arc.rs @@ -0,0 +1,106 @@ +use crate::{vec2, Color32, Painter, Pos2, Shape, Stroke, Vec2}; +use epaint::CubicBezierShape; +use std::f32::consts::FRAC_PI_2; +use std::ops::RangeInclusive; + +#[derive(Debug, Clone, PartialEq)] +pub struct ArcShape { + center: Pos2, + radius: f32, + range: RangeInclusive, + fill: Color32, + stroke: Stroke, +} + +impl ArcShape { + pub fn new( + center: impl Into, + radius: impl Into, + range: impl Into>, + fill: impl Into, + stroke: impl Into, + ) -> Self { + Self { + center: center.into(), + radius: radius.into(), + range: range.into(), + fill: fill.into(), + stroke: stroke.into(), + } + } + + pub fn approximate_as_beziers(&self) -> impl Iterator + Clone { + let fill = self.fill; + let stroke = self.stroke; + approximate_with_beziers(self.center, self.radius, self.range.clone()) + .map(move |p| CubicBezierShape::from_points_stroke(p, false, fill, stroke)) + } + + pub fn paint(&self, painter: &Painter) { + painter.extend(self.approximate_as_beziers().map(Shape::from)); + } +} + +// Implementation based on: +// Riškus, Aleksas. (2006). Approximation of a cubic bezier curve by circular arcs and vice versa. +// Information Technology and Control. 35. + +fn approximate_with_beziers( + center: Pos2, + radius: f32, + range: RangeInclusive, +) -> impl Iterator + Clone { + QuarterTurnsIter(Some(range)) + .map(move |r| approximate_with_bezier(center, radius, *r.start(), *r.end())) +} + +fn approximate_with_bezier(center: Pos2, radius: f32, start: f32, end: f32) -> [Pos2; 4] { + let p1 = center + radius * Vec2::angled(start); + let p4 = center + radius * Vec2::angled(end); + + let a = p1 - center; + let b = p4 - center; + let q1 = a.length_sq(); + let q2 = q1 + a.dot(b); + let k2 = (4.0 / 3.0) * ((2.0 * q1 * q2).sqrt() - q2) / (a.x * b.y - a.y * b.x); + + let p2 = center + vec2(a.x - k2 * a.y, a.y + k2 * a.x); + let p3 = center + vec2(b.x + k2 * b.y, b.y - k2 * b.x); + + [p1, p2, p3, p4] +} + +// We can approximate at most one quadrant of the circle +// so we divide it up into individual chunks that we then approximate +// using bezier curves. +#[derive(Clone)] +struct QuarterTurnsIter(Option>); + +const QUARTER_TURN: f32 = FRAC_PI_2; +impl Iterator for QuarterTurnsIter { + type Item = RangeInclusive; + + fn next(&mut self) -> Option { + let (start, end) = self.0.clone()?.into_inner(); + let distance = end - start; + if distance < QUARTER_TURN { + self.0 = None; + Some(start..=end) + } else { + let new_start = start + (QUARTER_TURN * distance.signum()); + self.0 = Some(new_start..=end); + Some(start..=new_start) + } + } + + fn size_hint(&self) -> (usize, Option) { + if let Some((start, end)) = self.0.clone().map(|x| x.into_inner()) { + let turns = (start - end).abs() / QUARTER_TURN; + let lower = turns.floor() as usize; + let upper = turns.ceil() as usize; + (lower, Some(upper)) + } else { + (0, None) + } + } +} diff --git a/crates/egui/src/widgets/theme_switch/cogwheel.rs b/crates/egui/src/widgets/theme_switch/cogwheel.rs new file mode 100644 index 00000000000..5ad0eb88619 --- /dev/null +++ b/crates/egui/src/widgets/theme_switch/cogwheel.rs @@ -0,0 +1,38 @@ +use super::painter_ext::PainterExt; +use crate::Painter; +use emath::{vec2, Pos2, Rect, Rot2}; +use epaint::{Color32, RectShape, Rounding}; +use std::f32::consts::TAU; + +pub(crate) fn cogwheel(painter: &Painter, center: Pos2, radius: f32, color: Color32) { + let inner_radius = 0.5 * radius; + let outer_radius = 0.8 * radius; + let thickness = 0.3 * radius; + + painter.circle( + center, + inner_radius + thickness / 2., + Color32::TRANSPARENT, + (thickness, color), + ); + + let cogs = 8; + let cog_width = radius / 3.; + let cog_rounding = radius / 16.; + let cog_length = radius - outer_radius + thickness / 2.; + + for n in 0..cogs { + let cog_center = center - vec2(0., outer_radius + cog_length / 2. - thickness / 2.); + let cog_size = vec2(cog_width, cog_length); + let cog = RectShape::filled( + Rect::from_center_size(cog_center, cog_size), + Rounding { + nw: cog_rounding, + ne: cog_rounding, + ..Default::default() + }, + color, + ); + painter.add_rotated(cog, Rot2::from_angle(TAU / cogs as f32 * n as f32), center); + } +} diff --git a/crates/egui/src/widgets/theme_switch/mod.rs b/crates/egui/src/widgets/theme_switch/mod.rs new file mode 100644 index 00000000000..f2a3225064d --- /dev/null +++ b/crates/egui/src/widgets/theme_switch/mod.rs @@ -0,0 +1,422 @@ +use emath::{Pos2, Rect}; +use epaint::Color32; + +use crate::{Painter, Response, ThemePreference, Ui, Widget}; + +mod arc; +mod cogwheel; +mod moon; +mod painter_ext; +mod sun; + +#[must_use = "You should put this widget in an ui with `ui.add(widget);`"] +pub struct ThemeSwitch<'a> { + value: &'a mut ThemePreference, + show_follow_system: bool, +} + +impl<'a> ThemeSwitch<'a> { + pub fn new(value: &'a mut ThemePreference) -> Self { + Self { + value, + show_follow_system: true, + } + } + + pub(crate) fn show_follow_system(mut self, show_follow_system: bool) -> Self { + self.show_follow_system = show_follow_system; + self + } +} + +impl<'a> Widget for ThemeSwitch<'a> { + fn ui(self, ui: &mut crate::Ui) -> crate::Response { + let (update, mut response) = switch(ui, *self.value, "Theme", self.options()); + + // TODO: union inner responses + if let Some(value) = update { + response.mark_changed(); + *self.value = value; + } + + response + } +} + +impl<'a> ThemeSwitch<'a> { + fn options(&self) -> Vec> { + let system = SwitchOption { + value: ThemePreference::System, + icon: cogwheel::cogwheel, + label: "Follow System", + }; + let dark = SwitchOption { + value: ThemePreference::Dark, + icon: moon::moon, + label: "Dark", + }; + let light = SwitchOption { + value: ThemePreference::Light, + icon: sun::sun, + label: "Light", + }; + + let mut options = Vec::with_capacity(3); + if self.show_follow_system { + options.push(system); + } + options.extend([dark, light]); + options + } +} + +#[derive(Debug)] +struct SwitchOption { + value: T, + icon: IconPainter, + label: &'static str, +} + +type IconPainter = fn(&Painter, Pos2, f32, Color32); + +fn switch( + ui: &mut Ui, + value: T, + label: &str, + options: Vec>, +) -> (Option, Response) +where + T: PartialEq + Clone, +{ + let mut space = space_allocation::allocate_space(ui, options); + + let updated_value = interactivity::update_value_on_click(&mut space, &value); + let value = updated_value.clone().unwrap_or(value); + + if ui.is_rect_visible(space.rect) { + painting::draw_switch_background(ui, &space); + painting::draw_active_indicator(ui, &space, &value); + + for button in &space.buttons { + painting::draw_button(ui, button, value == button.option.value); + } + } + + let response = accessibility::attach_widget_info(ui, space, label, &value); + + (updated_value, response) +} + +struct AllocatedSpace { + response: Response, + rect: Rect, + buttons: Vec>, + radius: f32, +} + +struct ButtonSpace { + center: Pos2, + response: Response, + radius: f32, + option: SwitchOption, +} + +mod space_allocation { + use super::*; + use crate::{Id, Sense}; + use emath::vec2; + + pub(super) fn allocate_space( + ui: &mut Ui, + options: Vec>, + ) -> AllocatedSpace { + let (rect, response, measurements) = allocate_switch(ui, &options); + let id = response.id; + + // Focusable elements always get an accessible node, so let's ensure that + // the parent is set correctly when the responses are created the first time. + with_accessibility_parent(ui, id, |ui| { + let buttons = options + .into_iter() + .enumerate() + .scan(rect, |remaining, (n, option)| { + Some(allocate_button(ui, remaining, id, &measurements, n, option)) + }) + .collect(); + + AllocatedSpace { + response, + rect, + buttons, + radius: measurements.radius, + } + }) + } + + fn allocate_switch( + ui: &mut Ui, + options: &[SwitchOption], + ) -> (Rect, Response, SwitchMeasurements) { + let diameter = ui.spacing().interact_size.y; + let radius = diameter / 2.0; + let padding = ui.spacing().button_padding.min_elem(); + let min_gap = 0.5 * ui.spacing().item_spacing.x; + let gap_count = options.len().saturating_sub(1) as f32; + let button_count = options.len() as f32; + + let min_size = vec2( + button_count * diameter + (gap_count * min_gap) + (2.0 * padding), + diameter + (2.0 * padding), + ); + let sense = Sense::focusable_noninteractive(); + let (rect, response) = ui.allocate_at_least(min_size, sense); + + // The space we're given might be larger so we calculate + // the margin based on the allocated rect. + let total_gap = rect.width() - (button_count * diameter) - (2.0 * padding); + let gap = total_gap / gap_count; + + let measurements = SwitchMeasurements { + gap, + radius, + padding, + buttons: options.len(), + }; + + (rect, response, measurements) + } + + struct SwitchMeasurements { + gap: f32, + radius: f32, + padding: f32, + buttons: usize, + } + + fn with_accessibility_parent(ui: &mut Ui, id: Id, f: impl FnOnce(&mut Ui) -> T) -> T { + let ctx = ui.ctx().clone(); + let mut result = None; + ctx.with_accessibility_parent(id, || result = Some(f(ui))); + result.expect("with_accessibility_parent() always calls f()") + } + + fn allocate_button( + ui: &mut Ui, + remaining: &mut Rect, + switch_id: Id, + measurements: &SwitchMeasurements, + n: usize, + option: SwitchOption, + ) -> ButtonSpace { + let (rect, center) = partition(remaining, measurements, n); + let response = ui.interact(rect, switch_id.with(n), Sense::click()); + ButtonSpace { + center, + response, + radius: measurements.radius, + option, + } + } + + fn partition( + remaining: &mut Rect, + measurements: &SwitchMeasurements, + n: usize, + ) -> (Rect, Pos2) { + let (leading, trailing) = offset(n, measurements); + let center = remaining.left_center() + vec2(leading + measurements.radius, 0.0); + let right = remaining.min.x + leading + 2.0 * measurements.radius + trailing; + let (rect, new_remaining) = remaining.split_left_right_at_x(right); + *remaining = new_remaining; + (rect, center) + } + + // Calculates the leading and trailing space for a button. + // The gap between buttons is divided up evenly so that the entire + // switch is clickable. + fn offset(n: usize, measurements: &SwitchMeasurements) -> (f32, f32) { + let leading = if n == 0 { + measurements.padding + } else { + measurements.gap / 2.0 + }; + let trailing = if n == measurements.buttons - 1 { + measurements.padding + } else { + measurements.gap / 2.0 + }; + (leading, trailing) + } +} + +mod interactivity { + use super::*; + + pub(super) fn update_value_on_click(space: &mut AllocatedSpace, value: &T) -> Option + where + T: PartialEq + Clone, + { + let clicked = space + .buttons + .iter_mut() + .find(|b| b.response.clicked()) + .filter(|b| &b.option.value != value)?; + clicked.response.mark_changed(); + Some(clicked.option.value.clone()) + } +} + +mod painting { + use super::*; + use crate::style::WidgetVisuals; + use crate::Id; + use emath::pos2; + use epaint::Stroke; + + pub(super) fn draw_switch_background(ui: &mut Ui, space: &AllocatedSpace) { + let rect = space.rect; + let rounding = 0.5 * rect.height(); + let WidgetVisuals { + bg_fill, bg_stroke, .. + } = switch_visuals(ui, &space.response); + ui.painter().rect(rect, rounding, bg_fill, bg_stroke); + } + + fn switch_visuals(ui: &Ui, response: &Response) -> WidgetVisuals { + if response.has_focus() { + ui.style().visuals.widgets.hovered + } else { + ui.style().visuals.widgets.inactive + } + } + + pub(super) fn draw_active_indicator( + ui: &mut Ui, + space: &AllocatedSpace, + value: &T, + ) { + let fill = ui.visuals().selection.bg_fill; + if let Some(pos) = space + .buttons + .iter() + .find(|button| &button.option.value == value) + .map(|button| button.center) + { + let pos = animate_active_indicator_position(ui, space.response.id, pos); + ui.painter().circle(pos, space.radius, fill, Stroke::NONE); + } + } + + fn animate_active_indicator_position(ui: &mut Ui, id: Id, pos: Pos2) -> Pos2 { + let animation_time = ui.style().animation_time; + let x = ui.ctx().animate_value_with_time(id, pos.x, animation_time); + pos2(x, pos.y) + } + + pub(super) fn draw_button(ui: &mut Ui, button: &ButtonSpace, selected: bool) { + let visuals = ui.style().interact_selectable(&button.response, selected); + let animation_factor = animate_click(ui, &button.response); + let radius = animation_factor * button.radius; + let icon_radius = 0.5 * radius * animation_factor; + let bg_fill = button_fill(&button.response, &visuals); + + let painter = ui.painter(); + painter.circle(button.center, radius, bg_fill, visuals.bg_stroke); + (button.option.icon)(painter, button.center, icon_radius, visuals.fg_stroke.color); + } + + // We want to avoid drawing a background when the button is either active itself or was previously active. + fn button_fill(response: &Response, visuals: &WidgetVisuals) -> Color32 { + if interacted(&response) { + visuals.bg_fill + } else { + Color32::TRANSPARENT + } + } + + fn interacted(response: &Response) -> bool { + response.clicked() || response.hovered() || response.has_focus() + } + + fn animate_click(ui: &Ui, response: &Response) -> f32 { + let ctx = ui.ctx(); + let animation_time = ui.style().animation_time; + let value = if response.is_pointer_button_down_on() { + 0.9 + } else { + 1.0 + }; + ctx.animate_value_with_time(response.id, value, animation_time) + } +} + +mod accessibility { + use super::*; + use crate::accesskit::{NodeBuilder, NodeId as AccessKitId, Role}; + use crate::{Id, WidgetInfo, WidgetType}; + + pub(super) fn attach_widget_info( + ui: &mut Ui, + space: AllocatedSpace, + label: &str, + value: &T, + ) -> Response { + let button_group = button_group(&space); + + configure_accesskit_radio_group(ui, space.rect, space.response.id, label); + + for button in space.buttons { + let selected = value == &button.option.value; + attach_widget_info_to_button(ui, button, &button_group, selected); + } + + space.response + } + + fn configure_accesskit_radio_group(ui: &mut Ui, rect: Rect, id: Id, label: &str) { + ui.ctx().accesskit_node_builder(id, |builder| { + builder.set_role(Role::RadioGroup); + builder.set_bounds(to_accesskit_rect(rect)); + builder.set_name(label); + }); + } + + fn button_group(space: &AllocatedSpace) -> Vec { + space + .buttons + .iter() + .map(|b| b.response.id.accesskit_id()) + .collect() + } + + fn attach_widget_info_to_button( + ui: &mut Ui, + button: ButtonSpace, + buttons: &[AccessKitId], + selected: bool, + ) { + let response = button.response; + let label = button.option.label; + response.widget_info(|| button_widget_info(ui, label, selected)); + configure_accesskit_radio_button(ui, response.id, buttons); + response.on_hover_text(label); + } + + fn button_widget_info(ui: &Ui, label: &str, selected: bool) -> WidgetInfo { + WidgetInfo::selected(WidgetType::RadioButton, ui.is_enabled(), selected, label) + } + + fn configure_accesskit_radio_button(ui: &mut Ui, id: Id, group: &[AccessKitId]) { + let writer = |b: &mut NodeBuilder| b.set_radio_group(group); + ui.ctx().accesskit_node_builder(id, writer); + } + + fn to_accesskit_rect(rect: Rect) -> accesskit::Rect { + accesskit::Rect { + x0: rect.min.x.into(), + y0: rect.min.y.into(), + x1: rect.max.x.into(), + y1: rect.max.y.into(), + } + } +} diff --git a/crates/egui/src/widgets/theme_switch/moon.rs b/crates/egui/src/widgets/theme_switch/moon.rs new file mode 100644 index 00000000000..c04a452d5ca --- /dev/null +++ b/crates/egui/src/widgets/theme_switch/moon.rs @@ -0,0 +1,72 @@ +use super::arc::ArcShape; +use crate::epaint::{CubicBezierShape, PathShape, PathStroke}; +use crate::{Color32, Painter, Pos2, Vec2}; +use std::f32::consts::{PI, TAU}; + +/// Draws an outlined moon symbol in the waxing crescent phase. +pub(crate) fn moon(painter: &Painter, center: Pos2, radius: f32, color: Color32) { + let stroke_width = radius / 5.0; + + let start = 0.04 * TAU; + let start_vec = radius * Vec2::angled(start); + let size = 0.65 * TAU; + let end_vec = radius * Vec2::angled(start + size); + + let direction_angle = start - (TAU - size) / 2.; + let direction = Vec2::angled(direction_angle); + + // We want to draw a circle with the same radius somewhere on the line + // `direction` such that it intersects with our first circle at `start` and `end`. + // The connection between the start and end points is a chord of our occluding circle. + let chord = start_vec - end_vec; + let angle = 2.0 * (chord.length() / (2.0 * radius)).asin(); + let sagitta = radius * (1.0 - (angle / 2.0).cos()); + let apothem = radius - sagitta; + let occluding_center = center + midpoint(start_vec, end_vec) + apothem * direction; + + let occlusion_start = direction_angle + PI - angle / 2.; + let occlusion_end = direction_angle + PI + angle / 2.; + + let main_arc = ArcShape::new( + center, + radius, + start..=(start + size), + Color32::TRANSPARENT, + (stroke_width, color), + ); + let occluding_arc = ArcShape::new( + occluding_center, + radius, + occlusion_end..=occlusion_start, + Color32::TRANSPARENT, + (stroke_width, color), + ); + + // We join the beziers together to a path which improves + // the drawing of the joints somewhat. + let path = to_path( + main_arc + .approximate_as_beziers() + .chain(occluding_arc.approximate_as_beziers()), + (stroke_width, color), + ); + + painter.add(path); +} + +fn midpoint(a: Vec2, b: Vec2) -> Vec2 { + 0.5 * (a + b) +} + +fn to_path( + beziers: impl IntoIterator, + stroke: impl Into, +) -> PathShape { + let points = beziers.into_iter().flat_map(|b| b.flatten(None)).collect(); + PathShape { + points, + closed: false, + fill: Default::default(), + stroke: stroke.into(), + } +} diff --git a/crates/egui/src/widgets/theme_switch/painter_ext.rs b/crates/egui/src/widgets/theme_switch/painter_ext.rs new file mode 100644 index 00000000000..844ac54be96 --- /dev/null +++ b/crates/egui/src/widgets/theme_switch/painter_ext.rs @@ -0,0 +1,30 @@ +use crate::layers::ShapeIdx; +use crate::{ClippedPrimitive, Context, Mesh, Painter, Pos2, Shape}; +use emath::Rot2; +use epaint::{ClippedShape, Primitive}; + +pub(crate) trait PainterExt { + fn add_rotated(&self, shape: impl Into, rot: Rot2, origin: Pos2) -> ShapeIdx; +} + +impl PainterExt for Painter { + fn add_rotated(&self, shape: impl Into, rot: Rot2, origin: Pos2) -> ShapeIdx { + let clip_rect = self.clip_rect(); + let shape = shape.into(); + let mut mesh = tesselate(self.ctx(), ClippedShape { clip_rect, shape }); + mesh.rotate(rot, origin); + self.add(mesh) + } +} + +pub(crate) fn tesselate(ctx: &Context, shape: ClippedShape) -> Mesh { + let primitives = ctx.tessellate(vec![shape], ctx.pixels_per_point()); + match primitives.into_iter().next() { + Some(ClippedPrimitive { + primitive: Primitive::Mesh(mesh), + .. + }) => mesh, + Some(_) => unreachable!(), + None => Mesh::default(), + } +} diff --git a/crates/egui/src/widgets/theme_switch/sun.rs b/crates/egui/src/widgets/theme_switch/sun.rs new file mode 100644 index 00000000000..a358784eb6a --- /dev/null +++ b/crates/egui/src/widgets/theme_switch/sun.rs @@ -0,0 +1,28 @@ +use super::painter_ext::PainterExt; +use crate::Painter; +use emath::{vec2, Pos2, Rect, Rot2, Vec2}; +use epaint::{Color32, RectShape, Stroke}; +use std::f32::consts::TAU; + +pub(crate) fn sun(painter: &Painter, center: Pos2, radius: f32, color: Color32) { + let clipped = painter.with_clip_rect(Rect::from_center_size(center, Vec2::splat(radius * 2.))); + let sun_radius = radius * 0.5; + + clipped.circle(center, sun_radius, color, Stroke::NONE); + + let rays = 8; + let ray_radius = radius / 4.; + let ray_spacing = radius / 7.5; + let ray_length = radius - sun_radius - ray_spacing; + + for n in 0..rays { + let ray_center = center - vec2(0., sun_radius + ray_spacing + ray_length / 2.); + let ray_size = vec2(ray_radius, ray_length); + let ray = RectShape::filled( + Rect::from_center_size(ray_center, ray_size), + ray_radius, + color, + ); + clipped.add_rotated(ray, Rot2::from_angle(TAU / rays as f32 * n as f32), center); + } +}