Skip to content

Commit

Permalink
Add Ui::is_sizing_pass for better size estimation of Areas, and m…
Browse files Browse the repository at this point in the history
…enus in particular (emilk#4557)

* Part of emilk#4535
* Closes emilk#3974

This adds a special `sizing_pass` mode to `Ui`, in which we have no
centered or justified layouts, and everything is hidden. This is used by
`Area` to use the first frame to measure the size of its contents so
that it can then set the perfectly correct size the subsequent frames.

For menus, where buttons are justified (span the full width), this
finally the problem of auto-sizing. Before you would have to pick a
width manually, and all buttons would expand to that width. If it was
too wide, it looked weird. If it was too narrow, text would wrap. Now
all menus are exactly the width they need to be. By default menus will
wrap at `Spacing::menu_width`.

This affects all situations when you have something that should be as
small as possible, but still span the full width/height of the parent.
For instance: the `egui::Separator` widget now checks the
`ui.is_sizing_pass` flag before deciding on a size. In the sizing pass a
horizontal separator is always 0 wide, and only in subsequent passes
will it span the full width.
  • Loading branch information
emilk authored and hacknus committed Oct 30, 2024
1 parent 79af1c8 commit d46cb35
Show file tree
Hide file tree
Showing 13 changed files with 199 additions and 45 deletions.
8 changes: 8 additions & 0 deletions Cargo.lock
Original file line number Diff line number Diff line change
Expand Up @@ -3628,6 +3628,14 @@ dependencies = [
"env_logger",
]

[[package]]
name = "test_size_pass"
version = "0.1.0"
dependencies = [
"eframe",
"env_logger",
]

[[package]]
name = "test_viewports"
version = "0.1.0"
Expand Down
86 changes: 71 additions & 15 deletions crates/egui/src/containers/area.rs
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ pub struct Area {
constrain_rect: Option<Rect>,
order: Order,
default_pos: Option<Pos2>,
default_size: Vec2,
pivot: Align2,
anchor: Option<(Align2, Vec2)>,
new_pos: Option<Pos2>,
Expand All @@ -87,6 +88,7 @@ impl Area {
enabled: true,
order: Order::Middle,
default_pos: None,
default_size: Vec2::NAN,
new_pos: None,
pivot: Align2::LEFT_TOP,
anchor: None,
Expand Down Expand Up @@ -163,6 +165,35 @@ impl Area {
self
}

/// The size used for the [`Ui::max_rect`] the first frame.
///
/// Text will wrap at this width, and images that expand to fill the available space
/// will expand to this size.
///
/// If the contents are smaller than this size, the area will shrink to fit the contents.
/// If the contents overflow, the area will grow.
///
/// If not set, [`style::Spacing::default_area_size`] will be used.
#[inline]
pub fn default_size(mut self, default_size: impl Into<Vec2>) -> Self {
self.default_size = default_size.into();
self
}

/// See [`Self::default_size`].
#[inline]
pub fn default_width(mut self, default_width: f32) -> Self {
self.default_size.x = default_width;
self
}

/// See [`Self::default_size`].
#[inline]
pub fn default_height(mut self, default_height: f32) -> Self {
self.default_size.y = default_height;
self
}

/// Positions the window and prevents it from being moved
#[inline]
pub fn fixed_pos(mut self, fixed_pos: impl Into<Pos2>) -> Self {
Expand Down Expand Up @@ -247,7 +278,7 @@ pub(crate) struct Prepared {
/// This is so that we use the first frame to calculate the window size,
/// and then can correctly position the window and its contents the next frame,
/// without having one frame where the window is wrongly positioned or sized.
temporarily_invisible: bool,
sizing_pass: bool,
}

impl Area {
Expand All @@ -272,6 +303,7 @@ impl Area {
interactable,
enabled,
default_pos,
default_size,
new_pos,
pivot,
anchor,
Expand All @@ -292,11 +324,29 @@ impl Area {
if is_new {
ctx.request_repaint(); // if we don't know the previous size we are likely drawing the area in the wrong place
}
let mut state = state.unwrap_or_else(|| State {
pivot_pos: default_pos.unwrap_or_else(|| automatic_area_position(ctx)),
pivot,
size: Vec2::ZERO,
interactable,
let mut state = state.unwrap_or_else(|| {
// during the sizing pass we will use this as the max size
let mut size = default_size;

let default_area_size = ctx.style().spacing.default_area_size;
if size.x.is_nan() {
size.x = default_area_size.x;
}
if size.y.is_nan() {
size.y = default_area_size.y;
}

if constrain {
let constrain_rect = constrain_rect.unwrap_or_else(|| ctx.screen_rect());
size = size.at_most(constrain_rect.size());
}

State {
pivot_pos: default_pos.unwrap_or_else(|| automatic_area_position(ctx)),
pivot,
size,
interactable,
}
});
state.pivot_pos = new_pos.unwrap_or(state.pivot_pos);
state.interactable = interactable;
Expand Down Expand Up @@ -365,7 +415,7 @@ impl Area {
enabled,
constrain,
constrain_rect,
temporarily_invisible: is_new,
sizing_pass: is_new,
}
}

Expand Down Expand Up @@ -431,12 +481,7 @@ impl Prepared {
}
};

let max_rect = Rect::from_min_max(
self.state.left_top_pos(),
constrain_rect
.max
.at_least(self.state.left_top_pos() + Vec2::splat(32.0)),
);
let max_rect = Rect::from_min_size(self.state.left_top_pos(), self.state.size);

let clip_rect = constrain_rect; // Don't paint outside our bounds

Expand All @@ -448,7 +493,9 @@ impl Prepared {
clip_rect,
);
ui.set_enabled(self.enabled);
ui.set_visible(!self.temporarily_invisible);
if self.sizing_pass {
ui.set_sizing_pass();
}
ui
}

Expand All @@ -461,11 +508,20 @@ impl Prepared {
enabled: _,
constrain: _,
constrain_rect: _,
temporarily_invisible: _,
sizing_pass,
} = self;

state.size = content_ui.min_size();

if sizing_pass {
// If during the sizing pass we measure our width to `123.45` and
// then try to wrap to exactly that next frame,
// we may accidentally wrap the last letter of some text.
// We only do this after the initial sizing pass though;
// otherwise we could end up with for-ever expanding areas.
state.size = state.size.ceil();
}

ctx.memory_mut(|m| m.areas_mut().set_state(layer_id, state));

move_response
Expand Down
3 changes: 2 additions & 1 deletion crates/egui/src/containers/frame.rs
Original file line number Diff line number Diff line change
Expand Up @@ -266,7 +266,8 @@ impl Frame {
self.show_dyn(ui, Box::new(add_contents))
}

fn show_dyn<'c, R>(
/// Show using dynamic dispatch.
pub fn show_dyn<'c, R>(
self,
ui: &mut Ui,
add_contents: Box<dyn FnOnce(&mut Ui) -> R + 'c>,
Expand Down
8 changes: 2 additions & 6 deletions crates/egui/src/containers/popup.rs
Original file line number Diff line number Diff line change
Expand Up @@ -260,15 +260,11 @@ fn show_tooltip_area_dyn<'c, R>(
Area::new(area_id)
.order(Order::Tooltip)
.fixed_pos(window_pos)
.default_width(ctx.style().spacing.tooltip_width)
.constrain_to(ctx.screen_rect())
.interactable(false)
.show(ctx, |ui| {
Frame::popup(&ctx.style())
.show(ui, |ui| {
ui.set_max_width(ui.spacing().tooltip_width);
add_contents(ui)
})
.inner
Frame::popup(&ctx.style()).show_dyn(ui, add_contents).inner
})
}

Expand Down
20 changes: 5 additions & 15 deletions crates/egui/src/menu.rs
Original file line number Diff line number Diff line change
Expand Up @@ -133,10 +133,10 @@ pub(crate) fn submenu_button<R>(
}

/// wrapper for the contents of every menu.
pub(crate) fn menu_ui<'c, R>(
fn menu_popup<'c, R>(
ctx: &Context,
menu_id: Id,
menu_state_arc: &Arc<RwLock<MenuState>>,
menu_id: Id,
add_contents: impl FnOnce(&mut Ui) -> R + 'c,
) -> InnerResponse<R> {
let pos = {
Expand All @@ -150,14 +150,14 @@ pub(crate) fn menu_ui<'c, R>(
.fixed_pos(pos)
.constrain_to(ctx.screen_rect())
.interactable(true)
.default_width(ctx.style().spacing.menu_width)
.sense(Sense::hover());

let area_response = area.show(ctx, |ui| {
set_menu_style(ui.style_mut());

Frame::menu(ui.style())
.show(ui, |ui| {
ui.set_max_width(ui.spacing().menu_width);
ui.set_menu_state(Some(menu_state_arc.clone()));
ui.with_layout(Layout::top_down_justified(Align::LEFT), add_contents)
.inner
Expand Down Expand Up @@ -306,8 +306,7 @@ impl MenuRoot {
add_contents: impl FnOnce(&mut Ui) -> R,
) -> (MenuResponse, Option<InnerResponse<R>>) {
if self.id == button.id {
let inner_response =
MenuState::show(&button.ctx, &self.menu_state, self.id, add_contents);
let inner_response = menu_popup(&button.ctx, &self.menu_state, self.id, add_contents);
let menu_state = self.menu_state.read();

if menu_state.response.is_close() {
Expand Down Expand Up @@ -593,23 +592,14 @@ impl MenuState {
self.response = MenuResponse::Close;
}

pub fn show<R>(
ctx: &Context,
menu_state: &Arc<RwLock<Self>>,
id: Id,
add_contents: impl FnOnce(&mut Ui) -> R,
) -> InnerResponse<R> {
crate::menu::menu_ui(ctx, id, menu_state, add_contents)
}

fn show_submenu<R>(
&mut self,
ctx: &Context,
id: Id,
add_contents: impl FnOnce(&mut Ui) -> R,
) -> Option<R> {
let (sub_response, response) = self.submenu(id).map(|sub| {
let inner_response = Self::show(ctx, sub, id, add_contents);
let inner_response = menu_popup(ctx, sub, id, add_contents);
(sub.read().response, inner_response.inner)
})?;
self.cascade_close_response(sub_response);
Expand Down
21 changes: 19 additions & 2 deletions crates/egui/src/style.rs
Original file line number Diff line number Diff line change
Expand Up @@ -316,10 +316,21 @@ pub struct Spacing {
/// This is the spacing between the icon and the text
pub icon_spacing: f32,

/// The size used for the [`Ui::max_rect`] the first frame.
///
/// Text will wrap at this width, and images that expand to fill the available space
/// will expand to this size.
///
/// If the contents are smaller than this size, the area will shrink to fit the contents.
/// If the contents overflow, the area will grow.
pub default_area_size: Vec2,

/// Width of a tooltip (`on_hover_ui`, `on_hover_text` etc).
pub tooltip_width: f32,

/// The default width of a menu.
/// The default wrapping width of a menu.
///
/// Items longer than this will wrap to a new line.
pub menu_width: f32,

/// Horizontal distance between a menu and a submenu.
Expand Down Expand Up @@ -1073,8 +1084,9 @@ impl Default for Spacing {
icon_width: 14.0,
icon_width_inner: 8.0,
icon_spacing: 4.0,
default_area_size: vec2(600.0, 400.0),
tooltip_width: 600.0,
menu_width: 150.0,
menu_width: 400.0,
menu_spacing: 2.0,
combo_height: 200.0,
scroll: Default::default(),
Expand Down Expand Up @@ -1459,6 +1471,7 @@ impl Spacing {
icon_width,
icon_width_inner,
icon_spacing,
default_area_size,
tooltip_width,
menu_width,
menu_spacing,
Expand Down Expand Up @@ -1509,6 +1522,10 @@ impl Spacing {
ui.add(DragValue::new(combo_width).clamp_range(0.0..=1000.0));
ui.end_row();

ui.label("Default area size");
ui.add(two_drag_values(default_area_size, 0.0..=1000.0));
ui.end_row();

ui.label("TextEdit width");
ui.add(DragValue::new(text_edit_width).clamp_range(0.0..=1000.0));
ui.end_row();
Expand Down
37 changes: 36 additions & 1 deletion crates/egui/src/ui.rs
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,10 @@ pub struct Ui {
/// and all widgets will assume a gray style.
enabled: bool,

/// Set to true in special cases where we do one frame
/// where we size up the contents of the Ui, without actually showing it.
sizing_pass: bool,

/// Indicates whether this Ui belongs to a Menu.
menu_state: Option<Arc<RwLock<MenuState>>>,
}
Expand All @@ -82,6 +86,7 @@ impl Ui {
style,
placer: Placer::new(max_rect, Layout::default()),
enabled: true,
sizing_pass: false,
menu_state: None,
};

Expand All @@ -108,9 +113,18 @@ impl Ui {
pub fn child_ui_with_id_source(
&mut self,
max_rect: Rect,
layout: Layout,
mut layout: Layout,
id_source: impl Hash,
) -> Self {
if self.sizing_pass {
// During the sizing pass we want widgets to use up as little space as possible,
// so that we measure the only the space we _need_.
layout.cross_justify = false;
if layout.cross_align == Align::Center {
layout.cross_align = Align::Min;
}
}

debug_assert!(!max_rect.any_nan());
let next_auto_id_source = Id::new(self.next_auto_id_source).with("child").value();
self.next_auto_id_source = self.next_auto_id_source.wrapping_add(1);
Expand All @@ -121,6 +135,7 @@ impl Ui {
style: self.style.clone(),
placer: Placer::new(max_rect, layout),
enabled: self.enabled,
sizing_pass: self.sizing_pass,
menu_state: self.menu_state.clone(),
};

Expand All @@ -140,6 +155,26 @@ impl Ui {

// -------------------------------------------------

/// Set to true in special cases where we do one frame
/// where we size up the contents of the Ui, without actually showing it.
///
/// This will also turn the Ui invisible.
/// Should be called right after [`Self::new`], if at all.
#[inline]
pub fn set_sizing_pass(&mut self) {
self.sizing_pass = true;
self.set_visible(false);
}

/// Set to true in special cases where we do one frame
/// where we size up the contents of the Ui, without actually showing it.
#[inline]
pub fn is_sizing_pass(&self) -> bool {
self.sizing_pass
}

// -------------------------------------------------

/// A unique identity of this [`Ui`].
#[inline]
pub fn id(&self) -> Id {
Expand Down
Loading

0 comments on commit d46cb35

Please sign in to comment.