egui icon indicating copy to clipboard operation
egui copied to clipboard

Improve `Area` sizing with dynamic content

Open abey79 opened this issue 1 year ago • 3 comments

Consider this example, which (maybe naively) tries to display a popup menu with content that can change over time:

use eframe::egui;

fn main() -> eframe::Result {
    env_logger::init();

    let options = eframe::NativeOptions {
        viewport: egui::ViewportBuilder::default().with_inner_size([320.0, 240.0]),
        ..Default::default()
    };

    let mut flag = false;
    let mut row_count = 5;

    eframe::run_simple_native("My egui App", options, move |ctx, _frame| {
        egui::CentralPanel::default().show(ctx, |ui| {
            ui.label(format!("Showing {} rows", row_count));
            if egui::menu::menu_button(ui, "BTN", |ui| {
                ui.radio_value(&mut flag, false, "False");
                ui.radio_value(&mut flag, true, "True");

                if flag {
                    egui::ScrollArea::vertical().show(ui, |ui| {
                        for _ in 0..row_count {
                            ui.add_space(30.0);
                            ui.label("Veeeeeeeeeeeery long text.");
                        }
                    });
                }
            })
            .response
            .clicked()
            {
                if row_count % 2 == 1 {
                    row_count -= 3;
                } else {
                    row_count += 5;
                }
            }
        });
    })
}

It results in something along these lines:

https://github.com/user-attachments/assets/170f482b-f458-48a3-834a-5912d31c477e

The popup menu/scroll bar combination needs a way to react (entire automatically or at least by way of an API) to the change of content size and resize if appropriate.

abey79 avatar Sep 20 '24 07:09 abey79

This diff proposed by @emilk improves the situation a bit. However, it means the the text will no longer wrap should its length grow, which can be an issue.

  egui::menu::menu_button(ui, "BTN", |ui| {
+     ui.with_layout(egui::Layout::top_down(egui::Align::LEFT), |ui| {
+         ui.set_max_height(400.0);
          ui.radio_value(&mut self.b, false, "False");
          ui.radio_value(&mut self.b, true, "True");

          if self.b {
              egui::ScrollArea::vertical().show(ui, |ui| {
+                 ui.style_mut().wrap_mode = Some(egui::TextWrapMode::Extend);
                  for _ in row_count {
                      ui.add_space(30.0);
                      ui.label("Veeeeeeeeeeeery long text.");
                  }
              });
          }
+     });
  })

abey79 avatar Sep 20 '24 07:09 abey79

I'm having the same problem in my app, my workaround is to call this at the beginning of the Area:

    let screen_size = ui.ctx().screen_rect().size();
    ui.set_max_size(screen_size);

But I think this is pretty ugly, maybe we can add a Area::remember_size(bool) or Area::limit_size(bool) option? I think this changed with the 0.28 release, maybe in https://github.com/emilk/egui/pull/4557?

lucasmerlin avatar Sep 20 '24 08:09 lucasmerlin

There are two problems here, one that is trivial, the other which is not.

Trivial problem: Why is the ScrollArea so small?

Let's look at this simpler case:

use eframe::egui;

fn main() -> eframe::Result {
    eframe::run_simple_native("My egui App", Default::default(), move |ctx, _frame| {
        egui::CentralPanel::default().show(ctx, |ui| {
            ui.menu_button("Menu", |ui| {
                if ui.button("Close menu").clicked() {
                    ui.close_menu();
                }
                ui.collapsing("Collapsing", |ui|{
                    egui::ScrollArea::both().show(ui, |ui|{
                        for _ in 0..10 {
                            ui.label("This is a long text label containing a lot of words and spans many lines");
                        }
                    });
                });
            });
        });
    })
}

Results in this:

image

Now why is the ScrollArea so small? Well, why wouldn't it be? There is nothing in the code preventing it from being small. Let's fix that:

-                    egui::ScrollArea::both().show(ui, |ui|{
+                    egui::ScrollArea::both()
+                        .min_scrolled_width(300.0)
+                        .min_scrolled_height(200.0)
+                        .show(ui, |ui|
+                    {

image

Great, now the scroll area has some size! But what happens when we collapse it?

image

The menu stays wide! This is the second problem.

Real problem: How to auto-size menus with dynamic content

A menu defaults to a justified layout, which means buttons span the full width. This is how drop-down menus in most apps work. But that also means the parent ui can't know it needs to shrink, because all the buttons use up all the available width.

The path to fixing this is non-obvious.

Report actual minimum sizes

We could have a system where all widgets and Uis report their actual required minimum size somehow. This only works in a few narrow cases (see the new intrinsic_size from https://github.com/emilk/egui/pull/5082). But in the general case that would require a big redesign of the egui layout code, and I doubt it would be possible even in that case. But maybe?

Leverage the existing sizing_pass

There is a UiBuilder::sizing_pass flag that tells all layouts and widgets "use as little space as possible". All menus (in fact, all Areas) set this flag the first frame they are shown.

During a sizing pass widgets are laid out a bit weird, so we should cover it up by calling the new Context::request_discard.

We somehow need to trigger this re-measuirement when the content changes (e.g. the collapsing header in the example changes).

How?

Manual call?

We could have something like ui.discard_measurements() that would trickle up the call-stack and cause the Area to forget its current size and do a new sizing pass the next frame. CollapsingHeader could call that for you, for instance. I'm not quite sure how to implement it, but it definitely should be doable.

Automatic size detection?

We could consider having the Area detect when the content changes, and trigger a new sizing pass in these cases.

For instance, it could be triggered if the area ends up with a different size that it had the previous frame. In this case, that would trigger a sizing_pass on several consecutive frames while the CollappsingHeader animates. Maybe this is fine, though it would also mean several frames in a row with request_discard, which would mean a performance hit.

This could be fixed with a logic along the lines of "if the content changes, mark Area as need of a new sizing pass, but don't run the sizing pass until the size is stable". This would mean the collapsing header would first collapse, and then once closed, the menu would become narrow again. Which I guess is exactly what we want?

Shape-size hashing

We can detect changes to the contents of an Area by hashing the sizes of all the shapes it produced (WidgetRect could contain a Range<ShapeIdx>). If any contained shape changed size, we trigger a sizing pass.

PRO: easy to implement, reasonably fast CON: may trigger sizing passes a lot when there is dynamic contents, leading to a lot of request_discard=slowdown.

We would also need a triple pass to properly support this:

  • During the first pass we discover a shape changed size. We discard this.
  • We run a sizing pass to calculate the new size of the area. We discard this.
  • We do the final pass with the actual size.

emilk avatar Sep 22 '24 17:09 emilk

The primary thing we want to try is to add extra book keeping to Region, adding a Region::intrinsic_size that keeps track of "actual minimal size required" or similar.

emilk avatar Dec 04 '24 15:12 emilk