1
0
mirror of https://github.com/emilk/egui.git synced 2026-09-02 14:50:03 -04:00

Add AtomLayoutStyle (#8465)

Extracts a shared struct with things that once might want to override
for any atomlayout-based widget.

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Lucas Meurer
2026-09-02 11:27:17 +02:00
committed by GitHub
parent dadf573dde
commit 5cb3a4f89e
7 changed files with 161 additions and 68 deletions

View File

@@ -107,6 +107,13 @@ impl<'a> AtomLayout<'a> {
self self
} }
/// Set the gap between atoms, unless one was already set.
#[inline]
pub(crate) fn fallback_gap(mut self, gap: f32) -> Self {
self.gap = self.gap.or(Some(gap));
self
}
/// Set the [`Frame`]. /// Set the [`Frame`].
#[inline] #[inline]
pub fn frame(mut self, frame: Frame) -> Self { pub fn frame(mut self, frame: Frame) -> Self {

View File

@@ -6,7 +6,8 @@ use crate::{
class::HasClasses as _, class::HasClasses as _,
theme::StyleProvider, theme::StyleProvider,
widget_style::{ widget_style::{
ButtonStyle, CheckboxStyle, SeparatorStyle, StyleArgs, TextVisuals, WidgetState, AtomLayoutStyle, ButtonStyle, CheckboxStyle, SeparatorStyle, StyleArgs, TextVisuals,
WidgetState,
}, },
}; };
@@ -80,14 +81,26 @@ impl StyleProvider<ButtonStyle> for DefaultStyle {
painted_frame painted_frame
}; };
let text_style = TextVisuals::from_widget_visuals(style, TextStyle::Body, &widget_visuals);
let image_tint = if classes.has_class(&Button::CLASS_IMAGE_TINT_FOLLOWS_TEXT_COLOR) {
text_style.color
} else {
crate::Color32::WHITE
};
ButtonStyle { ButtonStyle {
min_size: if classes.has_class(&Button::CLASS_SMALL) { atom_layout: AtomLayoutStyle {
Vec2::ZERO min_size: if classes.has_class(&Button::CLASS_SMALL) {
} else { Vec2::ZERO
Vec2::new(0.0, spacing.interact_size.y) } else {
Vec2::new(0.0, spacing.interact_size.y)
},
gap: spacing.icon_spacing,
frame,
text_style,
image_tint,
..Default::default()
}, },
frame,
text_style: TextVisuals::from_widget_visuals(style, TextStyle::Body, &widget_visuals),
} }
} }
} }
@@ -99,7 +112,17 @@ impl StyleProvider<CheckboxStyle> for DefaultStyle {
let widget_visuals = *style.visuals.widgets.state(*state); let widget_visuals = *style.visuals.widgets.state(*state);
CheckboxStyle { CheckboxStyle {
frame: Frame::new(), atom_layout: AtomLayoutStyle {
min_size: Vec2::splat(spacing.interact_size.y),
gap: spacing.icon_spacing,
frame: Frame::new(),
text_style: TextVisuals::from_widget_visuals(
style,
TextStyle::Body,
&widget_visuals,
),
..Default::default()
},
checkbox_size: spacing.icon_width, checkbox_size: spacing.icon_width,
check_size: spacing.icon_width_inner, check_size: spacing.icon_width_inner,
checkbox_frame: Frame { checkbox_frame: Frame {
@@ -108,7 +131,6 @@ impl StyleProvider<CheckboxStyle> for DefaultStyle {
stroke: widget_visuals.bg_stroke, stroke: widget_visuals.bg_stroke,
..Default::default() ..Default::default()
}, },
text_style: TextVisuals::from_widget_visuals(style, TextStyle::Body, &widget_visuals),
check_stroke: widget_visuals.fg_stroke, check_stroke: widget_visuals.fg_stroke,
} }
} }

View File

@@ -3,10 +3,12 @@
#![cfg_attr(not(feature = "experimental"), allow(dead_code, unused_imports))] #![cfg_attr(not(feature = "experimental"), allow(dead_code, unused_imports))]
use core::fmt::Debug; use core::fmt::Debug;
use epaint::{Color32, FontId, Stroke, Vec2};
use emath::{Align2, Vec2};
use epaint::{Color32, FontId, Stroke};
use crate::{ use crate::{
Context, FontSelection, Frame, Response, Style, UiStack, AtomLayout, Context, FontSelection, Frame, Response, Style, UiStack,
class::{Classes, HasClasses as _}, class::{Classes, HasClasses as _},
style::{WidgetVisuals, Widgets}, style::{WidgetVisuals, Widgets},
}; };
@@ -48,14 +50,85 @@ impl TextVisuals {
} }
} }
/// Visual and layout style shared by widgets built from an [`AtomLayout`].
#[derive(Debug, Clone)]
pub struct AtomLayoutStyle {
/// Alignment of the atoms within the allocated rectangle.
///
/// `None` uses the alignment of the surrounding [`crate::Ui`].
pub align2: Option<Align2>,
/// Minimum size of the atom layout.
pub min_size: Vec2,
/// Space between adjacent atoms.
pub gap: f32,
/// Frame around the atoms.
pub frame: Frame,
/// Fallback visuals for text atoms.
pub text_style: TextVisuals,
/// Fallback tint for images whose tint is [`Color32::WHITE`] (untinted).
pub image_tint: Color32,
}
impl Default for AtomLayoutStyle {
fn default() -> Self {
Self {
align2: None,
min_size: Vec2::ZERO,
gap: 0.0,
frame: Frame::default(),
text_style: TextVisuals {
font_id: FontId::default(),
color: Color32::WHITE,
},
image_tint: Color32::WHITE,
}
}
}
impl AtomLayoutStyle {
/// Apply this style to an [`AtomLayout`].
///
/// A per-widget [`AtomLayout::gap`] wins over [`Self::gap`], so widgets like
/// [`crate::DragValue`] can pack their atoms tighter than the theme does.
pub fn apply(self, mut layout: AtomLayout<'_>) -> AtomLayout<'_> {
let Self {
align2,
min_size,
gap,
frame,
text_style,
image_tint,
} = self;
layout.map_images(|image| {
let current_tint = image.image_options().tint;
// Multiply the tints so they are combined
image.tint(current_tint * image_tint)
});
let layout = layout
.min_size(min_size)
.fallback_gap(gap)
.frame(frame)
.fallback_font(text_style.font_id)
.fallback_text_color(text_style.color);
if let Some(align2) = align2 {
layout.align2(align2)
} else {
layout
}
}
}
/// Dedicated button style /// Dedicated button style
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct ButtonStyle { pub struct ButtonStyle {
/// The minimum size of the button before any per-button override. pub atom_layout: AtomLayoutStyle,
pub min_size: Vec2,
pub frame: Frame,
pub text_style: TextVisuals,
} }
impl WidgetStyle for ButtonStyle {} impl WidgetStyle for ButtonStyle {}
@@ -63,11 +136,8 @@ impl WidgetStyle for ButtonStyle {}
/// Dedicated checkbox style /// Dedicated checkbox style
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct CheckboxStyle { pub struct CheckboxStyle {
/// Frame around /// Style of the checkbox's atom layout.
pub frame: Frame, pub atom_layout: AtomLayoutStyle,
/// Text next to it
pub text_style: TextVisuals,
/// Checkbox size /// Checkbox size
pub checkbox_size: f32, pub checkbox_size: f32,

View File

@@ -32,7 +32,6 @@ pub struct Button<'a> {
min_size: Vec2, min_size: Vec2,
corner_radius: Option<CornerRadius>, corner_radius: Option<CornerRadius>,
selected: Option<bool>, selected: Option<bool>,
image_tint_follows_text_color: bool,
limit_image_size: bool, limit_image_size: bool,
classes: Classes, classes: Classes,
} }
@@ -54,6 +53,10 @@ impl<'a> Button<'a> {
pub const CLASS_HIDE_FRAME_WHEN_INACTIVE: ClassName = pub const CLASS_HIDE_FRAME_WHEN_INACTIVE: ClassName =
ClassName::from_static("egui::button::hide_frame_when_inactive"); ClassName::from_static("egui::button::hide_frame_when_inactive");
/// Present when untinted images should follow the button text color.
pub const CLASS_IMAGE_TINT_FOLLOWS_TEXT_COLOR: ClassName =
ClassName::from_static("egui::button::image_tint_follows_text_color");
pub fn new(atoms: impl IntoAtoms<'a>) -> Self { pub fn new(atoms: impl IntoAtoms<'a>) -> Self {
Self { Self {
layout: AtomLayout::new(atoms.into_atoms()) layout: AtomLayout::new(atoms.into_atoms())
@@ -64,7 +67,6 @@ impl<'a> Button<'a> {
min_size: Vec2::ZERO, min_size: Vec2::ZERO,
corner_radius: None, corner_radius: None,
selected: None, selected: None,
image_tint_follows_text_color: false,
limit_image_size: false, limit_image_size: false,
classes: Classes::default(), classes: Classes::default(),
} }
@@ -224,15 +226,19 @@ impl<'a> Button<'a> {
self self
} }
/// If true, the tint of the image is multiplied by the widget text color. /// If true, use the widget text color as the fallback tint for images.
/// ///
/// This makes sense for images that are white, that should have the same color as the text color. /// This makes sense for monochrome images that should have the same color as the text. It also
/// This will also make the icon color depend on hover state. /// makes the image color depend on hover state. A non-white tint set on an image takes
/// precedence over this fallback; [`Color32::WHITE`] means untinted.
/// ///
/// Default: `false`. /// Default: `false`.
#[inline] #[inline]
pub fn image_tint_follows_text_color(mut self, image_tint_follows_text_color: bool) -> Self { pub fn image_tint_follows_text_color(mut self, image_tint_follows_text_color: bool) -> Self {
self.image_tint_follows_text_color = image_tint_follows_text_color; self.set_class(
Self::CLASS_IMAGE_TINT_FOLLOWS_TEXT_COLOR,
image_tint_follows_text_color,
);
self self
} }
@@ -318,10 +324,9 @@ impl<'a> Button<'a> {
mut layout, mut layout,
fill, fill,
stroke, stroke,
mut min_size, min_size,
corner_radius, corner_radius,
selected, selected,
image_tint_follows_text_color,
limit_image_size, limit_image_size,
classes, classes,
} = self; } = self;
@@ -340,39 +345,29 @@ impl<'a> Button<'a> {
let id = ui.next_auto_id(); let id = ui.next_auto_id();
let ButtonStyle { let ButtonStyle {
mut frame, atom_layout: mut atom_layout_style,
text_style,
min_size: style_min_size,
} = ui.widget_style(id, &classes); } = ui.widget_style(id, &classes);
min_size = min_size.at_least(style_min_size); let min_size = min_size.at_least(atom_layout_style.min_size);
// Override global style by local style // Override global style by local style
if let Some(stroke) = stroke {
atom_layout_style.frame = atom_layout_style.frame.stroke(stroke);
}
if let Some(fill) = fill { if let Some(fill) = fill {
frame = frame.fill(fill); atom_layout_style.frame = atom_layout_style.frame.fill(fill);
} }
if let Some(corner_radius) = corner_radius { if let Some(corner_radius) = corner_radius {
frame = frame.corner_radius(corner_radius); atom_layout_style.frame = atom_layout_style.frame.corner_radius(corner_radius);
}
if let Some(stroke) = stroke {
frame = frame.stroke(stroke);
} }
// Apply the style font and color as fallback let prepared = atom_layout_style
layout = layout .apply(layout)
.fallback_font(text_style.font_id.clone()) .min_size(min_size)
.fallback_text_color(text_style.color); .allocate(ui);
let mut prepared = layout.frame(frame).min_size(min_size).allocate(ui);
// Get AtomLayoutResponse, empty if not visible // Get AtomLayoutResponse, empty if not visible
let response = if ui.is_rect_visible(prepared.response.rect) { let response = if ui.is_rect_visible(prepared.response.rect) {
if image_tint_follows_text_color {
prepared.map_images(|image| image.tint(text_style.color));
}
prepared.fallback_text_color = text_style.color;
prepared.paint(ui) prepared.paint(ui)
} else { } else {
AtomLayoutResponse::empty(prepared.response) AtomLayoutResponse::empty(prepared.response)

View File

@@ -73,16 +73,14 @@ impl Widget for Checkbox<'_> {
// Get the widget style by reading the response from the previous pass // Get the widget style by reading the response from the previous pass
let id = ui.next_auto_id(); let id = ui.next_auto_id();
let CheckboxStyle { let CheckboxStyle {
atom_layout,
check_size, check_size,
checkbox_frame, checkbox_frame,
checkbox_size, checkbox_size,
frame,
check_stroke, check_stroke,
text_style,
} = ui.widget_style(id, &classes); } = ui.widget_style(id, &classes);
let mut min_size = Vec2::splat(ui.spacing().interact_size.y); let min_size = atom_layout.min_size.at_least(Vec2::new(0.0, checkbox_size));
min_size.y = min_size.y.at_least(checkbox_size);
// In order to center the checkbox based on min_size we set the icon height to at least min_size.y // In order to center the checkbox based on min_size we set the icon height to at least min_size.y
let mut icon_size = Vec2::splat(checkbox_size); let mut icon_size = Vec2::splat(checkbox_size);
@@ -92,11 +90,8 @@ impl Widget for Checkbox<'_> {
let text = atoms.text().map(String::from); let text = atoms.text().map(String::from);
let mut prepared = AtomLayout::new(atoms) let layout = AtomLayout::new(atoms).sense(Sense::click());
.sense(Sense::click()) let mut prepared = atom_layout.apply(layout).min_size(min_size).allocate(ui);
.min_size(min_size)
.frame(frame)
.allocate(ui);
if prepared.response.clicked() { if prepared.response.clicked() {
*checked = !*checked; *checked = !*checked;
@@ -120,7 +115,6 @@ impl Widget for Checkbox<'_> {
}); });
if ui.is_rect_visible(prepared.response.rect) { if ui.is_rect_visible(prepared.response.rect) {
prepared.fallback_text_color = text_style.color;
let response = prepared.paint(ui); let response = prepared.paint(ui);
if let Some(rect) = response.rect(rect_id) { if let Some(rect) = response.rect(rect_id) {

View File

@@ -7,7 +7,7 @@ use eframe::egui::{
self, CentralPanel, Color32, Frame, Panel, TextStyle, self, CentralPanel, Color32, Frame, Panel, TextStyle,
class::HasClasses as _, class::HasClasses as _,
theme::StyleProvider, theme::StyleProvider,
widget_style::{ButtonStyle, StyleArgs, TextVisuals, WidgetState}, widget_style::{AtomLayoutStyle, ButtonStyle, StyleArgs, TextVisuals, WidgetState},
}; };
/// Buttons with this class are styled as a destructive action. /// Buttons with this class are styled as a destructive action.
@@ -61,13 +61,17 @@ impl StyleProvider<ButtonStyle> for MyTheme {
}; };
ButtonStyle { ButtonStyle {
min_size: egui::vec2(0.0, style.spacing.interact_size.y), atom_layout: AtomLayoutStyle {
frame: Frame::new() min_size: egui::vec2(0.0, style.spacing.interact_size.y),
.fill(fill) gap: style.spacing.icon_spacing,
.corner_radius(self.corner_radius) frame: Frame::new()
.inner_margin(8), .fill(fill)
// Resolve the font from the style, so we follow the user's font sizes: .corner_radius(self.corner_radius)
text_style: TextVisuals::new(style, TextStyle::Button, Color32::WHITE), .inner_margin(8),
// Resolve the font from the style, so we follow the user's font sizes:
text_style: TextVisuals::new(style, TextStyle::Button, Color32::WHITE),
..Default::default()
},
} }
} }
} }

View File

@@ -139,6 +139,7 @@ fn test_atom_layout_nesting_and_direction() {
stack: ui.stack(), stack: ui.stack(),
style, style,
}) })
.atom_layout
.frame; .frame;
let row = |direction: Direction| { let row = |direction: Direction| {