emilk / egui

egui: an easy-to-use immediate mode GUI in Rust that runs on both web and native

Home Page:https://www.egui.rs/

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

CSS-like styling

emilk opened this issue · comments

Some half-finished ideas around how to improve the styling and theming story for egui.

Background

Styling for egui is currently supplied by egui::Style which controls spacing, colors, etc for the whole of egui. There is no convenient way of changing the syling of a portion of the UI, except for changing out or modifying the Style temporarily, and then changing it back.

We would like to have a system that can support CSS-like selectors, so that users can easily style their ui based on the Style Modifiers (see below):

It would be very beneficial if such styling could be set in a single text file and live-loaded.

Action plan

  • #3936
  • #4232
  • #4019
  • Implement the new WidgetStyle and use that for all widgets, with first iteration of StyleModifiers
  • Implement the WidgetStyle selection it via a plugin system (ThemePlugin).
  • Cache the output of the ThemePlugin
  • Implement some hierarchical "class" system and add that as part of StyleModifiers
  • The actual CSS rule engine, which can now be fully a separate crate, and opt-in

Proposal

Style modifiers

Here are some things that could influence the style of a widget:

  • widget type (button, slider, label, …)
  • interact state (disabled, inactive, active, hovered, active)
  • text modifier (header, small, weak, strong, code, …)
  • per-Ui identifier (”settings_panel”)
  • per-widget identifier (”exit_button”)

For instance, a user may want to change the sizes of all buttons within the "settings_panel".

The per-Ui identifier would need to be a hierarchial stack, so the query to a theme would be something like:

Give me the WidgetStyle for a button that is hovered that is nested in a “options”→”internals”

We could also consider having dark/light mode as a modifier, allowing users to specify both variants in one theme file.

WidgetStyle

Let’s start with this:

pub struct WidgetStyle {
    /// Background color, stroke, margin, and shadow.
    pub frame: Frame,
  
    /// What font to use and at what size.
    pub text: TextStyle,
  
    /// Color and width of e.g. checkbox checkmark.
    /// Also text color.
    ///
    /// Note that this is different from the frame border color.
    pub stroke: Stroke,
}

pub struct TextStyle {
    pub font: FontId,
    pub underlined: bool,}

If each widget as given a WidgetStyle it could then use it both for sizing (frame margins and font size) and its visual styling. The current theme would select a WidgetStyle based on some given style modifiers, and its interaction state (computed at the start of the frame, thanks to #3936).

WidgetStyle would be used by all built-in widgets (button, checkbox, slider, …) but also each Window and Ui.

Example

fn button_ui(ui: &mut Ui, text: &str) {
    let id = ui.next_auto_id(); // so we can read the interact state
    let style = ui.style_of_interactive(id, "button");
    let galley = ui.format_text(style, text);
    let (rect, response) = ui.allocate(galley.size + style.margin.size);
    style.frame.paint(rect, ui);
    style.painter().text(rect, galley);
}

Speed

We must make sure egui isn’t slowed down by this new theming. We should be able to aggressively cache the WidgetStyle lookups based on a hash of the input modifiers.

Theme plugins

We could start by having a plugin system for the theming, something like:

trait ThemePlugin {
    fn widget_visuals(&self, modifiers: &StyleModifiers) -> WidgetStyle;
}

We could then start with a simple rule engine, but still allow users to implement much more advanced ones (e.g. more and more CSS-like).

Rule-engine

Eventually we want a fully customizable sytem where rules set in one theme file will control the look of the whole UI. Such a rule system has a few open questions to resolve:

  • How do we distinguish between different modifier types? Do we need to?
  • How do we specify if a rule applies to:
    • The widget
    • The widget and all children
    • Just the children

Rules

The rules can apply partial settings or modifiers. For instance, a rule can set the font and increase the brightness of the text.

Exactly how to specify the rules (i.e. in what language) is outside the scope of this issue, but here is a few examples of the kind of rules one could maybe want to do:

button hovered: {
    stroke.color.intensity: +2
}

// Make disabled things less bright:
disabled: {
    frame.fill.intensity: -2
    stroke.color.intensity: -2
}

// Make hovered interactive widgets brighter:
interactive hovered: {
    frame.fill.intensity -2
    stoke.colors.intensity: -2
}

small: {
    text.size: -2
}

heading: {
    text.size: 20
}

code: {
    text.font: "monospace"
    frame.fill: "gray"
}

weak: {
    frame.fill.intensity: -2
    stoke.colors.intensity: -2
}

strong: {
    frame.fill.intensity: +2
    stoke.colors.intensity: +2
}

hyperlink: {
    stoke.colors.intensity: "blue"
    text.underlined: true
}

window: {
    frame.fill: "gray" // wait, this will add fill for all children of windows!?
}

Color palette

We also need a color palette, indexable by brightness and opacity

https://www.radix-ui.com/colors/docs/palette-composition/understanding-the-scale

// Color modifiers
intensity +2  // modify
opacity   50% // set

In the GUI code users should be able to refer to colors both using aliases (”blue”, “header”, …) and hard-coded colors (#ff0000).

Dark mode vs light mode

We should also consider supporting both light and dark mode within the same theme. That is, one theme file should be able to set both a dark and a light theme. Perhaps “dark” and “light” is just another style modifier?

Re: dark mode vs. light mode, I believe the heavy lifting is done by just swapping the corresponding Radix color tables. The "coordinates" (tint, index) can remain the same.

image image

I am probably spewing a stupid idea here. but Dioxus is implementing CSS for native rendering. maybe it is worth seeing if you could either straight up use that, or bundle your dev power and make a generic lib that would work for both. I do realise this is very optimistic, probably even naive. But just wanted to have shared the thought

Ah my bad - I see they have experimental WGPU support now via their Blitz renderer.

Original comment

@chris-kruining - as far as I can tell Dioxus isn't actually native, right? It's webview based : https://dioxuslabs.com/learn/0.4/getting_started/desktop#desktop-overview

Ooh my bad if I got that wrong, I seem to remember the dude in the video saying "building a browser is hard" when he talked about css. So I made the presumption that they were implementing there own rendering and not just a webview.

https://youtu.be/aSxdmXjZutI?si=zmXi9mPbuFna4L6t&t=1690

Love this idea!

Personally faced a lot of inconvenience when trying to style individual widgets in the past, so this would be amazing!

Is there any roadmap for this or is it still in idea phase?

This and RTL support are gonna be dream come true

I'm interested in writing a parser for the style language / rule engine / css clone thing. The following is a (hopefully) thought-out attempt to fill holes in the original proposal:

👉 Expand Proposal


Style Language

I would make the rule engine (which will henceforth, in this document, be referred to as the style language) closer to CSS. Mostly, this is because it reduces the learning curve (I don't think it's controversial to say a lot of people know CSS).

I do like accessing properties with the dot syntax as it makes the syntax of the style language agree with rust's.

CSS Selectors

// original proposal
button hovered: {
    stroke.color.intensity: +2
}

disabled: {
    frame.fill.intensity: -2
    stroke.color.intensity: -2
}

interactive hovered: {
    frame.fill.intensity: -2
    stoke.colors.intensity: -2
}
/* Just remembered CSS doesn't have single line comments :-( */
/* this proposal: */
button:hover {
    stroke.color.intensity: +2
}

:disabled {
    frame.fill.intensity: -2
    stroke.color.intensity: -2
}

:interactive:hover {
    frame.fill.intensity: -2
    stoke.colors.intensity: -2
}

Any "built-in" selectors that are not dynamic like :hover or :disabled have, you guessed it, a colon in front of them. This would include interaction state, and text modifiers.

Selectors for widgets and custom-styled elements are written differently. Widgets are sort of like HTML elements if you squint really hard, so I think having them be written plain (e.g. button) is fine. Likewise, per-widget or per-ui identifiers are like id in HTML (that's crazy), so they could have a # before them.

Dark / Light theme could simply be a :dark or :light selector anywhere.

CSS Combinators

Taking the next logical step, we could implement CSS combinators, which would solve the question of what the rule applies to:

/* just the element */
button

/* all children of element */
button > *
/* element and all children */
button,
button > *

/* all descendants of element */
button *

/* element and all descendants */
button,
button *

Class?

I'm not sure if implementing something similar to class in HTML is necessary. It would be nice to be able to generalize styles though.

For completeness, I'll describe the implementation:

// ui
ui.add_class("class")

// widget (take a generic parameter)
ui.add_with_class("class", Button::new("button"));
ui.add_with_class(["class1", "class2"], Button::new("button"));

// alternate syntax
ui.add(Button::new("button").class("class"));
ui.add(Button::new("button").class(["class1", "class2"]));
/* class selector */
.class

Implementation Notes

I don't think we should allow crazy combinators like :is and :has, just the basic ones. Even so, basic CSS selectors can get complicated.

main:dark > #menu_bar button:hover

In addition to being annoying to implement, isn't this kind of overkill for a ui library? We basically have to make our own DOM every frame. If it's relatively straightforward to implement, I think we should to allow the flexibility.

If not, we should probably force the selectors to be simple and make this determination for whether the rules apply to the hierarchy some other way .

/* just the element */
button

/* I personally think the children selector is unecessary -- would also complicate the implementation */
/* all children of element */
button >

/* element and all children */
button & >

/* all descendants of element */
button *

/* element and all descendants */
button &*

/* selectors after the hierarchy selector are not allowed */
button * :hover :light

/* no multi-part selectors */
button:hover child
/* Splitting individual selectors with spaces is not allowed to prevent *
 * confusion with CSS's descendant combinator                           */

Also, if a rule is invalid or something, should we just ignore it and emit a warning? Or should the whole style sheet be disallowed to load?

User-Defined Styles

I'm not sure if this was addressed, or intended, in the original proposal, but I think I've figured out a pretty nice way to do custom, user-defined Style structs as an alternative to the Plugin system.

It seems pretty clear that any user-defined Style structs, like the ones in the initial proposal, would need to implement some sort of trait to convert from a set of properties:

/// stand-in for the actual structs
struct StyleProperties(HashMap<String, String>);

struct FromStylePropertiesErr<'a> {
    /// properties that were not present on the struct
    not_found: Box<[&'a str]>,
    /// properties that threw an error when converting from a string
    /// FromStr::Err doesn't have any bounds but it should at least implement Display
    error: Box<[(&'a str, Box<dyn Display>)]>,
}

/// `FromStyleProperties` is just an ugly name and the actual implementation of
/// this trait will probably be different, but `Style` just doesn't feel
/// descriptive enough.
///
/// if anyone has a better name shoot
trait Style : Default {
    fn from_style_properties<'a>(props: &'a StyleProperties) -> (Self, FromStylePropertiesErr<'a>);

    // ...
}

With a derive macro, all a user would need to do to define a style struct is:

#[derive(Style)]
struct MenuBarStyle {
    my_color: Color32,
    // ...
}

The egui context could then just retrieve the relevant style rules and apply them.

fn menu_bar(ui: &mut Ui) {
    ui.horizontal_top(|ui| {
        // now all the styles for the menu bar are defined and used in one place!
        let style: &MenuBarStyle = ui.id("menu_bar").get_style();

        // etc...
    });
}

Naturally, get_style would reference the style rules to return the correct styles in this context.

I don't think it's possible to cache the whole CustomStyle without a completely different API than the one described here. This is probably fine because if properties are cached, it should be relatively cheap to construct. Maybe egui internals like WidgetStyle and TextStyle could be cached, but also maybe it's such a tiny performance hit it doesn't matter. We can benchmark it and come to conclusions later, but for now I think this API is nice.

Applying Identifiers

ui.add_with_id("id", Button::new("button"));

// alternate syntax
ui.add(Button::new("button").id("id"));

// alternate syntax
impl Widget for Foo {
    fn ui(ui: &mut Ui) -> Response {
        ui.style_of("foo");
    }
}
let style: &Style = ui.set_id("id").get_style();

// more similar to original proposal
let style: &Style = ui.style_of("id");

I think the best solution is the latter one iun both cases. It's closer to the original proposal and half-solves another issue...

ID Ambiguity

I'm not completely sure if we should call the identifier used to style, id, to avoid confusion with Id, which has the same exact name.

If we did keep it as id, and used Ui::set_id, Ui::id and Ui::set_id would refer to different id's. Ui::id retrieving the Ui's Id, and Ui::set_id setting the Ui's style id. See how confusing this is?

Unfortunately id is far and above the best and most obvious name for this. I'm going to use id for now, but I am very much open to suggestions.

Options for dealing with this are:

  • Only using class and forgoing id entirely (id is basically just a less flexible class anyway)
  • Calling id something else
  • Not allowing the ui / widget to be given a class or id. Ui::style_of takes in a selector. Still a little confusing but much better

One-Off Styles

I think it would be nice to be able to apply a one-off style definition to a Ui for reasons hopefully self-apparent.

Unfortunately, Ui::style and Ui::set_style are already taken. I'm honestly not sure what to name this, if implemented. Is one_off_style good enough?

// generic parameters my beloved
ui.one_off_style("custom.property", "value");
ui.one_off_style("custom.property", Color32::RED);

oh my god im finally done writing this thing im free its been hours

Summary / Unanswered Questions

"Wow it's almost like a real RFC!"

Questions with recommendations have their recommendations (italicized). Unanswered questions are bolded.

Style Language

  • Should we make the style language more like CSS? (yes)
    • If so, do we want CSS-like combinators?
      • Which of the hierarchical combinators are feasible to implement? ( , >, +, ~)
    • If not, what would our own solution be ? (described in proposal)
    • Should we permit errors in stylesheets or reject them entirely? (permit)
  • class?

User Defined Styles

  • Should we allow user-defined styles? (decided this was a bad idea)

Applying Identifiers

  • How should we assign Uis and Widgets with identifiers?
  • Should we rename style id to prevent confusion with Id?

One-Off Styles

  • Should we allow one-off style definitions?
    • What should the API be?

I was going to start working on this right after finishing writing the proposal but now I'm worn out haha

The way I want to approach this is step-by-step:

First implement the new WidgetStyle and use that for all widgets. That already is quite a bit of work, but is mostly refactoring.
At this point the WidgetStyle would be selected by something hard-coded in the current struct Style.

Next up would be to implement the WidgetStyle selection it via a plugin system (ThemePlugin). This would require also implementing the first portion of StyleModifiers.
This would also be the point we add a cache in front of it to speed up repeated queries for slow plugins.

Next up is designing and implementing a hierarchical "class" system and add that as part of StyleModifiers.

And last is the actual CSS language and engine, which can now be fully a separate crate, and opt-in.

That's understandable. I thought doing the CSS parser, being standalone, was better for me as I'm completely unfamiliar with the project internals.

Do you think it would be reasonable for me to attempt the refactor? Or should it be left up to someone more experienced?

Also, a clarification with the plugin system. Where/How would the plugins be registered? With the top level egui context or in the Widget impl?

An action plan has been added to #3284

@emilk

This can be a very large and difficult task that takes a long time.
CSS is a complex topic, and there are specialists who focus solely on CSS.
It is also possible to implement only simple configurations.
(Of course, this is the right approach at first.)

It should be possible to use the egui library without using a theme file.
When using the egui library, users should not be forced to use a theme file.