Better styling: per-widget styles
- Part of https://github.com/emilk/egui/issues/3284
As a step towards better stylling I want to introduce per-widget styling options.
Code (outline)
/// For generic widgets, that dont have/need their own style struct.
/// Also used by 3rd party widgets.
pub struct WidgetStyle {
/// Background color, stroke, margin, and shadow.
pub frame: Frame,
/// How text looks like
pub text: TextVisuals,
/// Color and width of e.g. checkbox checkmark.
pub stroke: Stroke
}
pub struct ButtonStyle {
pub frame: Frame,
pub text: TextVisuals,
}
pub struct CheckboxStyle {
// frame/margin around checkbox and text
pub frame: Frame,
pub text: TextVisuals,
pub checkbox_width: f32,
/// How the box is painted
pub checkbox_frame: Frame,
/// How the check is painted
pub stroke: Stroke,
}
pub struct SeparatorSyle {
/// How much space it allocated
pub size: f32,
/// How to paint it
pub stroke: Stroke,
}
…etc.
I'm referencing a new struct TextVisuals here:
struct TextVisuals {
pub font_id: FontId,
pub color: Color32,
// In the future we can expand this to be more like `epaint::TextFormat`.
}
Let's also introduce a new enum:
pub enum WidgetState {
/// This type of widget cannot be interacted with
Noninteractive,
/// An interactive widget that is not being interacted with
Inactive,
/// An interactive widget that is being hovered
Hovered,
/// An interactive widget that is being clicked or dragged
Active,
}
We then change e.g. checkbox.rs to query egui::Style about its CheckboxStyle:
let id = ui.next_auto_id(); // so we can read the interact state before the interaction
let response: OptionResponse = ui.ctx().read_response(id);
let state: WidgetState = response.map(|r| r.widget_state()).unwrap_or(WidgetState::Inactive);
let style: CheckboxStyle = ui.style().checkbox_style(state);
// format text according to `style.text`
// allocate space according to size of galley, and all the margins in `style`
// paint everything
This is an important change from how styles are currently computed!
Instead of having a fixed size and then only having different colors based on the response, we can now (theoretically) support widgets that grow when hovered.
However, as a start we can just hardcode checkbox_style:
impl Style {
pub fn widget_style(&self, state: WidgetState) -> WidgetStyle {
// … hard-coded based on WidgetVisuals. We will trait-ify this later!
}
fn checkbox_style(&self, state: WidgetState) -> CheckboxStyle {
let ws = self.widget_style(state);
// use the above as a basis, and fill in the rest of CheckboxStyle
}
}
Should the widget style override widget field ? Label can accept RichText with their own FontId, should it be override by LabelStyle->TextVisual->FontId ?
If the WidgetText has a color or font, it should override whatever is the default for the widget.
@emilk here's an idea to support maximum flexibility. Just throwing this out there because we have a lot of custom widgets that wouldn't fit in this model. For example:
- Buttons with multiple strokes.
- Checkboxes with different stroke for the box and the check mark.
- Checkboxes where the checkmark is filled instead of stroked.
- TextAreas with inner shadow.
- TextAreas with more states than the ones in
WidgetState(e.g.ActiveWithError, etc)
If we can decouple the widget logic from the widget painting we could allow for infinite customizability while still providing defaults to keep it opt-in.
So generally speaking, the application should be the one determining what data needs to passed to paint a widget (stroke, fill, another stroke, etc). I'm calling those tokens.
So my proposal is that instead of thinking about widget styles, we introduce the concept of widget painters (customizable by the app at startup in Style) and instead of using WidgetStyle/CheckboxStyle/etc to determine the look of a widget, the painter receives a "style token" iterator. Where StyleToken is something like:
enum StyleToken<'scope> {
Stroke(Stroke),
Fill(Color32),
Shadow(Shadow),
CornerRadius(CornerRadius),
Text(&'scope WidgetText), // Or atomics but I'm not familiar with that yet
TextBuffer(&'scope TextBuffer), // Maybe?
Tag(&'static str) // Can be used for class-like customization
User(&'scope dyn Any), // Something that the painter can downcast
}
By passing an iterator, we should avoid any additional allocations than the ones we already have.
The simple case would be buttons:
trait WidgetPainter {
fn paint(&mut self, painter: &Painter, response: &Response, style: &Style, tokens: Iterator<Item = StyleTokens>);
}
struct DefaultButton;
impl ButtonPainter for DefaultButton {
fn paint(&mut self, painter: &Painter, response: &Response, style: &Style, tokens: Iterator<Item = StyleTokens>) {
let mut fill = None;
let mut stroke = None;
let mut text = None;
for token in tokens {
match token {
StyleToken::Fill(color) => fill = Some(color)
...
}
}
fill = fill.unwrap_or_else(style.button_fill());
// ... you get the idea
}
}
By default, Button would pass no tokens (falling back to the defaults in style). The app can then define a custom painter for buttons and be able to customize each one with something like:
Button::new("Double Stroke!")
.token(StyleToken::Stroke((1.0, Color32::RED).into())
.token(StyleToken::Stroke((1.0, Color32::BLUE).into());
Or with convenience methods:
Button::new("Double Stroke!")
.stroke(((1.0, Color32::RED).into())
.stroke(((1.0, Color32::BLUE).into());
If the button painter doesn't support two strokes (e.g. the default one) it would just show a single blue stroke so no damage is done.
I think something like this could provide all the flexibility we could possibly need without compromising performance other than the indirect call. This is somewhat inspired by the demo @lucasmerlin did a few weeks ago btw 👌 but without doing string comparisons.
There are some other challenges of course, like widgets that allocate multiple responses. Perhaps the painter trait could have an allocate_responses or something like that and determine the position of things.
Curious to hear what you guys think!
@juancampa That's a nice idea, I agree that there is some style choice that does not fit the current proposition, but in your 5 examples I see 3 that can easily be solved :
- Checkboxes with different stroke for the box and the check mark.
If you check my draft PR in the style_trait file, the checkmark stroke is different of the box stroke.
- Checkboxes where the checkmark is filled instead of stroked.
I discussed this problem and I personally think it would be nice to add a checkmark enum with the most common one (a check, filled, square, etc) and a Custom option which take a Shape
- TextAreas with inner shadow.
The frame could be expanded to offer this option I think.
For the last 2 I need to search a bit more, but here some idea :
- Buttons with multiple strokes.
Maybe altering the Stroke struct would be better, as this is more a Stroke style in my opinion.
- TextAreas with more states than the ones in WidgetState (e.g. ActiveWithError, etc)
This could be easily solved by the class-like system that we want to implement : If the content of the TextArea is wrong, simply add the class 'wrong-input' which apply the wanted style.