Skip to content

Conversation

@nsabovic
Copy link

Addresses #7734. This PR modifies the widgets to provide a non-allocating, zero-cost abstraction to better integrate with Bevy but stay backwards API compatible and generate the same code.

The core of the problem is that widgets take &mut state. Let's look at e.g. the checkbox. The constructor takes checked: &'a mut bool and returns Checkbox<'a>. In Bevy, modification (DerefMut) has an action, it marks the state as changed. So the standard practice is to resort to tricks such as:

let state_copy = *state;
checkbox(&mut state_copy, ...);
if state_copy != state {
	*state = state_copy
}

This prevents always marking the state as changed which would happen if we unconditionally took the &mut state. This PR modifies the code to pass DerefMut<Target = T> instead of &mut T. We then call deref() when we read the value and deref_mut() when we write it. That way reads don't mark the state as changed. These are implicitely called when we do *value so we don't even have to change the widget implementation.

I've changed the function signature for checkbox() to be DerefMut. The stored state went from:

pub struct Checkbox<'a> {
    checked: &'a mut bool,
    // ...other fields
}

To:

pub struct Checkbox<T: AsMut<bool>> {
    checked: T,
    // ...other fields
}

I've done the same with DragValue, Slider, TextEdit and the simpler functions in Ui such as toggle_value(), radio_value(), selectable_value(), drag_angle(), drag_angle_tau() and the colors functions.

I will admit that in addition to integrating seamlessly with Bevy's ECS I have a more sinister motive—I want to layer a caching system which allows for variable frame rate, not using the CPU when neither the data nor the user input changed. Something like SwiftUI and Xilem. I think a caching immediate-mode renderer can be built piecemeal from a plain immediate-mode UI framework like egui.

Again, I want to underline that the idea is that the API surface stays the same, that the generated code stays identical and the abstraction that's introduced does not allocate.

Q&A

  1. Is this over-engineering for the common case?

The internal complexity is small, while the benefit for advanced use cases is significant.

  1. Does this increase the trait bound surface area?

AsMut<T> is a fundamental Rust trait. It's essentially a contract "I can give you a mutable reference to the inner T".

Open Questions

  1. Naming

I've kept all the same functions with the same signature (e.g. toggle_value(state: &mut bool)) but added new functions with suffix _state (toggle_value_state(state: DerefMut<Target = bool>)). This is because DerefMut is not Copy so this code would break:

let mut value: &mut bool = ...;
ui.toggle_value(value);
ui.toggle_value(value);

The second call would trigger "value used after moving". It could be fixed with reborrowing:

ui.toggle(&mut *value);

But that's ugly and would break existing call sites. I opted for adding _state as a suffix but that's long and doesn't describe the purpose well.

  1. DerefMut vs AsMut

The latter doesn't participate in Deref coercion and would be a bit more involved to get to work.

  1. #[inline]

It would make sense to add #[inline] to the generic _state functions to make sure this is literally zero cost. I don't know what the project conventions around this are.

To Do

  1. Popup/Window

Popup currently has an enum OpenKind of open states: Open, Closed, Bool(&mut bool), Memory(...). It would make sense to introduce State(Box<dyn DerefMut<Target = bool>>) but that's trivial, I didn't use it in my code and wanted to check the direction before continuing. I'd argue that Window would also benefit from using the same enum instead of the current Option<&mut bool>, where None == Closed.

  1. Rebase onto main

The branch doesn't include the latest changes on main so if the direction is good, I'll rebase it.

  1. Document the feature and add examples.

I don't know where. The example that I have is very much Bevy related:

use bevy::prelude::*;
use bevy_egui::{egui::Window, EguiContexts, EguiPlugin};

macro_rules! state_field {
    ($state:expr, $($field:tt)+) => {{
        $state.reborrow().map_unchanged(|value| &mut value.$($field)+)
    }};
}

fn main() {
    App::new()
        .add_plugins((DefaultPlugins, EguiPlugin))
        .init_resource::<UiState>()
        .add_systems(Update, checkbox_window)
        .run();
}

#[derive(Resource, Default)]
struct UiState {
    checkbox: bool,
    slider: f32,
    drag_value: f32,
    text_edit: String,
}

fn checkbox_window(mut contexts: EguiContexts, mut state: ResMut<UiState>) {
    Window::new("ECS Widgets").show(contexts.ctx_mut(), |ui| {
        ui.label(format!(
            "Values -> checkbox: {}, slider: {:.2}, drag: {:.2}, text: {}",
            state.checkbox, state.slider, state.drag_value, &state.text_edit
        ));

        ui.checkbox_state(state_field!(state, checkbox), "Checkbox");
        ui.slider_state(state_field!(state, slider), 0.0..=10.0);
        ui.drag_value_state(state_field!(state, drag_value));
        ui.text_edit_singleline_state_with(state_field!(state, text_edit), |edit| {
            edit.hint_text("Type here...")
        });
    });
}
  1. Test

On my machine, ./scripts/check fails. I probably took the main branch at the wrong time. But no new failures are introduced.

However there are no tests to check that the change was triggered only when needed. This shold be addressed.

@github-actions
Copy link

Preview is being built...

Preview will be available at https://egui-pr-preview.github.io/pr/7735-deref

View snapshot changes at kitdiff

@emilk
Copy link
Owner

emilk commented Nov 24, 2025

I'm a bit worried about the extra monomorphization this adds to egui 😬
I don't have time for a deep review though; not this week

@nsabovic
Copy link
Author

nsabovic commented Nov 28, 2025

I agree. I read the code some more and wrote a new implementation. I took the liberty of doing a forced push and since I rewrote the implementation completely so as to not monomorphize. I also changed the suffix to _ref and those functions now take DerefMut by reference to match how the rest of egui works. I omitted the Ui functions which are just helpers and don't actually include widgets, I can add that later if this code works. The main widgets are:

  1. Checkbox. I store a fat pointer into Checkbox so that there is no monomorphization.
  2. DragValue and Slider are already using closures so there is no additional monomorphization.
  3. TextEdit. I implemented TextBufferRef which wraps DerefMut<Target = TextBuffer> and used bytemuck::TransparentWrapper to get a TextBuffer from this reference. There is no additional monomorphization.

I've had to add bytemuck which is used in the workspace but wasn't used in egui proper before. I hope that's OK, I couldn't come up with a scenario where egui is built but bytemuck isn't so I think this won't adversely affect compile times.

I think this is both:

  • Minimal code instantiation for each additional reference wrapper.
  • Exactly equal to the current code in the existing use cases where &mut T is used for state.

You can review each of these three changes separately, I've made them as 3 separate commits.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add model change detection to the widget API

2 participants