diff --git a/CHANGELOG.md b/CHANGELOG.md index b817ea51..feccba59 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,12 +8,19 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ### Added ⭐ -* `SelectableLabel` (`ui.selectable_label` and `ui.selectable_value`): a text-button that can be selected +* Wrapping layouts: + * `ui.horizontal_wrapped(|ui| ...)`: Add widgets on a row but wrap at `max_size`. + * `ui.horizontal_wrapped_for_text`: Like `horizontal_wrapped`, but with spacing made for embedding text. +* `egui::Layout` now supports justified layouts where contents is _also_ centered, right-aligned, etc. +* `ui.allocate_ui(size, |ui| ...)`: Easily created a sized child-`Ui`. +* `SelectableLabel` (`ui.selectable_label` and `ui.selectable_value`): A text-button that can be selected. +* `ui.small_button`: A smaller button that looks good embedded in text. * Add `Resize::id_source` and `ScrollArea::id_source` to let the user avoid Id clashes. ### Changed 🔧 * Changed default font to [Ubuntu-Light](https://fonts.google.com/specimen/Ubuntu). +* Refactored `egui::Layout` substantially, changing its interface. ### Removed 🔥 diff --git a/egui/src/align.rs b/egui/src/align.rs index 4324e7e1..25cbe5a0 100644 --- a/egui/src/align.rs +++ b/egui/src/align.rs @@ -16,6 +16,21 @@ pub enum Align { Max, } +impl Align { + pub fn left() -> Self { + Self::Min + } + pub fn right() -> Self { + Self::Max + } + pub fn top() -> Self { + Self::Min + } + pub fn bottom() -> Self { + Self::Max + } +} + impl Default for Align { fn default() -> Align { Align::Min diff --git a/egui/src/containers/collapsing_header.rs b/egui/src/containers/collapsing_header.rs index 3531b792..12d0e798 100644 --- a/egui/src/containers/collapsing_header.rs +++ b/egui/src/containers/collapsing_header.rs @@ -1,7 +1,6 @@ use std::hash::Hash; use crate::{ - layout::Direction, paint::{PaintCmd, TextStyle}, widgets::Label, *, @@ -168,7 +167,7 @@ struct Prepared { impl CollapsingHeader { fn begin(self, ui: &mut Ui) -> Prepared { assert!( - ui.layout().dir() == Direction::Vertical, + ui.layout().main_dir().is_vertical(), "Horizontal collapsing is unimplemented" ); let Self { @@ -181,7 +180,7 @@ impl CollapsingHeader { let id = ui.make_persistent_id(id_source); - let available = ui.available_finite(); + let available = ui.available_rect_before_wrap_finite(); let text_pos = available.min + vec2(ui.style().spacing.indent, 0.0); let galley = label.layout_width(ui, available.right() - text_pos.x); let text_max_x = text_pos.x + galley.size.x; diff --git a/egui/src/containers/combo_box.rs b/egui/src/containers/combo_box.rs index 8c894150..30bcd200 100644 --- a/egui/src/containers/combo_box.rs +++ b/egui/src/containers/combo_box.rs @@ -60,10 +60,13 @@ pub fn combo_box( let frame = Frame::popup(ui.style()); let frame_margin = frame.margin; frame.show(ui, |ui| { - ui.with_layout(Layout::justified(Direction::Vertical), |ui| { - ui.set_min_width(button_response.rect.width() - 2.0 * frame_margin.x); - menu_contents(ui); - }); + ui.with_layout( + Layout::top_down(Align::left()).with_cross_justify(true), + |ui| { + ui.set_min_width(button_response.rect.width() - 2.0 * frame_margin.x); + menu_contents(ui); + }, + ); }) }); @@ -84,7 +87,7 @@ fn button_frame( add_contents: impl FnOnce(&mut Ui), ) -> Response { let margin = ui.style().spacing.button_padding; - let outer_rect_bounds = ui.available(); + let outer_rect_bounds = ui.available_rect_before_wrap(); let inner_rect = outer_rect_bounds.shrink2(margin); let where_to_put_background = ui.painter().add(PaintCmd::Noop); let mut content_ui = ui.child_ui(inner_rect, *ui.layout()); diff --git a/egui/src/containers/frame.rs b/egui/src/containers/frame.rs index 1ae61bc5..bd23ee52 100644 --- a/egui/src/containers/frame.rs +++ b/egui/src/containers/frame.rs @@ -99,7 +99,7 @@ pub struct Prepared { impl Frame { pub fn begin(self, ui: &mut Ui) -> Prepared { let where_to_put_background = ui.painter().add(PaintCmd::Noop); - let outer_rect_bounds = ui.available(); + let outer_rect_bounds = ui.available_rect_before_wrap(); let inner_rect = outer_rect_bounds.shrink2(self.margin); let content_ui = ui.child_ui(inner_rect, *ui.layout()); diff --git a/egui/src/containers/resize.rs b/egui/src/containers/resize.rs index 7585dc9f..6a3ead3c 100644 --- a/egui/src/containers/resize.rs +++ b/egui/src/containers/resize.rs @@ -153,7 +153,7 @@ struct Prepared { impl Resize { fn begin(&mut self, ui: &mut Ui) -> Prepared { - let position = ui.available().min; + let position = ui.available_rect_before_wrap().min; let id = self.id.unwrap_or_else(|| { let id_source = self.id_source.unwrap_or_else(|| Id::new("resize")); ui.make_persistent_id(id_source) diff --git a/egui/src/containers/scroll_area.rs b/egui/src/containers/scroll_area.rs index ded2c6e0..716482c4 100644 --- a/egui/src/containers/scroll_area.rs +++ b/egui/src/containers/scroll_area.rs @@ -103,12 +103,12 @@ impl ScrollArea { }; let outer_size = vec2( - ui.available().width(), - ui.available().height().at_most(max_height), + ui.available_width(), + ui.available_size_before_wrap().y.at_most(max_height), ); let inner_size = outer_size - vec2(current_scroll_bar_width, 0.0); - let inner_rect = Rect::from_min_size(ui.available().min, inner_size); + let inner_rect = Rect::from_min_size(ui.available_rect_before_wrap().min, inner_size); let mut content_ui = ui.child_ui( Rect::from_min_size( diff --git a/egui/src/demos/app.rs b/egui/src/demos/app.rs index a4a47c74..3c9fcf61 100644 --- a/egui/src/demos/app.rs +++ b/egui/src/demos/app.rs @@ -106,7 +106,7 @@ impl FrameHistory { // TODO: we should not use `slider_width` as default graph width. let height = ui.style().spacing.slider_width; - let rect = ui.allocate_space(vec2(ui.available_finite().width(), height)); + let rect = ui.allocate_space(vec2(ui.available_size_before_wrap_finite().x, height)); let style = ui.style().noninteractive(); let mut cmds = vec![PaintCmd::Rect { diff --git a/egui/src/demos/dancing_strings.rs b/egui/src/demos/dancing_strings.rs index f41a31ff..f699c2c3 100644 --- a/egui/src/demos/dancing_strings.rs +++ b/egui/src/demos/dancing_strings.rs @@ -32,7 +32,7 @@ impl View for DancingStrings { ui.ctx().request_repaint(); let time = ui.input().time; - let desired_size = ui.available().width() * vec2(1.0, 0.35); + let desired_size = ui.available_width() * vec2(1.0, 0.35); let rect = ui.allocate_space(desired_size); let mut cmds = vec![]; diff --git a/egui/src/demos/demo_window.rs b/egui/src/demos/demo_window.rs index 76e10a45..ee191231 100644 --- a/egui/src/demos/demo_window.rs +++ b/egui/src/demos/demo_window.rs @@ -265,7 +265,7 @@ impl Painting { } fn content(&mut self, ui: &mut Ui) { - let rect = ui.allocate_space(ui.available_finite().size()); + let rect = ui.allocate_space(ui.available_size_before_wrap_finite()); let response = ui.interact(rect, ui.id(), Sense::drag()); let rect = response.rect; let clip_rect = ui.clip_rect().intersect(rect); // Make sure we don't paint out of bounds @@ -304,81 +304,134 @@ use crate::layout::*; #[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] #[cfg_attr(feature = "serde", serde(default))] struct LayoutDemo { - dir: Direction, - align: Option, // None == justified - reversed: bool, + // Identical to contents of `egui::Layout` + main_dir: Direction, + main_wrap: bool, + cross_align: Align, + cross_justify: bool, + + // Extra for testing wrapping: + wrap_column_width: f32, + wrap_row_height: f32, } impl Default for LayoutDemo { fn default() -> Self { Self { - dir: Direction::Vertical, - align: Some(Align::Center), - reversed: false, + main_dir: Direction::TopDown, + main_wrap: false, + cross_align: Align::Min, + cross_justify: false, + wrap_column_width: 150.0, + wrap_row_height: 20.0, } } } impl LayoutDemo { fn layout(&self) -> Layout { - let layout = Layout::from_dir_align(self.dir, self.align); - if self.reversed { - layout.reverse() - } else { - layout - } + Layout::from_main_dir_and_cross_align(self.main_dir, self.cross_align) + .with_main_wrap(self.main_wrap) + .with_cross_justify(self.cross_justify) } pub fn ui(&mut self, ui: &mut Ui) { + self.content_ui(ui); Resize::default() - .default_size([200.0, 100.0]) + .default_size([300.0, 200.0]) .show(ui, |ui| { - ui.with_layout(self.layout(), |ui| self.content_ui(ui)) + if self.main_wrap { + if self.main_dir.is_horizontal() { + ui.allocate_ui( + vec2( + ui.available_size_before_wrap_finite().x, + self.wrap_row_height, + ), + |ui| ui.with_layout(self.layout(), |ui| self.demo_ui(ui)), + ); + } else { + ui.allocate_ui( + vec2( + self.wrap_column_width, + ui.available_size_before_wrap_finite().y, + ), + |ui| ui.with_layout(self.layout(), |ui| self.demo_ui(ui)), + ); + } + } else { + ui.with_layout(self.layout(), |ui| self.demo_ui(ui)); + } }); + ui.label("Resize to see effect"); } pub fn content_ui(&mut self, ui: &mut Ui) { - // ui.label(format!("Available space: {:?}", ui.available().size())); - if ui.button("Reset").clicked { - *self = Default::default(); - } - ui.separator(); - ui.label("Direction:"); - - // TODO: enum iter - - for &dir in &[Direction::Horizontal, Direction::Vertical] { - if ui - .add(RadioButton::new(self.dir == dir, format!("{:?}", dir))) - .clicked - { - self.dir = dir; + ui.horizontal(|ui| { + if ui.button("Top-down").clicked { + *self = Default::default(); } - } - - ui.checkbox(&mut self.reversed, "Reversed"); - - ui.separator(); - - ui.label("Align:"); - - for &align in &[Align::Min, Align::Center, Align::Max] { - if ui - .add(RadioButton::new( - self.align == Some(align), - format!("{:?}", align), - )) - .clicked - { - self.align = Some(align); + if ui.button("Top-down, centered and justified").clicked { + *self = Default::default(); + self.cross_align = Align::Center; + self.cross_justify = true; } + if ui.button("Horizontal wrapped").clicked { + *self = Default::default(); + self.main_dir = Direction::LeftToRight; + self.cross_align = Align::Center; + self.main_wrap = true; + } + }); + + ui.horizontal(|ui| { + ui.label("Main Direction:"); + for &dir in &[ + Direction::LeftToRight, + Direction::RightToLeft, + Direction::TopDown, + Direction::BottomUp, + ] { + ui.radio_value(&mut self.main_dir, dir, format!("{:?}", dir)); + } + }); + + ui.horizontal(|ui| { + ui.checkbox(&mut self.main_wrap, "Main wrap") + .on_hover_text("Wrap when next widget doesn't fit the current row/column"); + + if self.main_wrap { + if self.main_dir.is_horizontal() { + ui.add(Slider::f32(&mut self.wrap_row_height, 0.0..=200.0).text("Row height")); + } else { + ui.add( + Slider::f32(&mut self.wrap_column_width, 0.0..=200.0).text("Column width"), + ); + } + } + }); + + ui.horizontal(|ui| { + ui.label("Cross Align:"); + for &align in &[Align::Min, Align::Center, Align::Max] { + ui.radio_value(&mut self.cross_align, align, format!("{:?}", align)); + } + }); + + ui.checkbox(&mut self.cross_justify, "Cross Justified") + .on_hover_text("Try to fill full width/height (e.g. buttons)"); + } + + pub fn demo_ui(&mut self, ui: &mut Ui) { + ui.monospace("Example widgets:"); + for _ in 0..3 { + ui.label("label"); } - if ui - .add(RadioButton::new(self.align == None, "Justified")) - .on_hover_text("Try to fill full width/height (e.g. buttons)") - .clicked - { - self.align = None; + for _ in 0..3 { + let mut dummy = false; + ui.checkbox(&mut dummy, "checkbox"); + } + for _ in 0..3 { + let _ = ui.button("button"); } } } diff --git a/egui/src/demos/demo_windows.rs b/egui/src/demos/demo_windows.rs index 77840c95..03121777 100644 --- a/egui/src/demos/demo_windows.rs +++ b/egui/src/demos/demo_windows.rs @@ -345,7 +345,7 @@ fn show_menu_bar(ui: &mut Ui, windows: &mut OpenWindows, seconds_since_midnight: (time % 1.0 * 100.0).floor() ); - ui.with_layout(Layout::horizontal(Align::Center).reverse(), |ui| { + ui.with_layout(Layout::right_to_left(), |ui| { if ui .add(Button::new(time).text_style(TextStyle::Monospace)) .clicked diff --git a/egui/src/demos/drag_and_drop.rs b/egui/src/demos/drag_and_drop.rs index 69a38bc3..162e7dff 100644 --- a/egui/src/demos/drag_and_drop.rs +++ b/egui/src/demos/drag_and_drop.rs @@ -44,7 +44,7 @@ pub fn drop_target( let margin = Vec2::splat(4.0); - let outer_rect_bounds = ui.available(); + let outer_rect_bounds = ui.available_rect_before_wrap(); let inner_rect = outer_rect_bounds.shrink2(margin); let where_to_put_background = ui.painter().add(PaintCmd::Noop); let mut content_ui = ui.child_ui(inner_rect, *ui.layout()); diff --git a/egui/src/demos/fractal_clock.rs b/egui/src/demos/fractal_clock.rs index 16cff679..573d3a13 100644 --- a/egui/src/demos/fractal_clock.rs +++ b/egui/src/demos/fractal_clock.rs @@ -52,8 +52,12 @@ impl FractalClock { ui.ctx().request_repaint(); } - let painter = Painter::new(ui.ctx().clone(), ui.layer_id(), ui.available_finite()); - self.fractal_ui(&painter); + let painter = Painter::new( + ui.ctx().clone(), + ui.layer_id(), + ui.available_rect_before_wrap_finite(), + ); + self.paint(&painter); Frame::popup(ui.style()) .fill(Rgba::luminance_alpha(0.02, 0.5).into()) @@ -97,7 +101,7 @@ impl FractalClock { ); } - fn fractal_ui(&mut self, painter: &Painter) { + fn paint(&mut self, painter: &Painter) { let rect = painter.clip_rect(); struct Hand { diff --git a/egui/src/demos/widgets.rs b/egui/src/demos/widgets.rs index 16577e5e..21bf7b37 100644 --- a/egui/src/demos/widgets.rs +++ b/egui/src/demos/widgets.rs @@ -46,28 +46,34 @@ impl Default for Widgets { impl Widgets { pub fn ui(&mut self, ui: &mut Ui) { - ui.add(crate::__egui_github_link_file_line!()); + ui.add(__egui_github_link_file_line!()); - ui.horizontal(|ui| { - ui.style_mut().spacing.item_spacing.x = 0.0; - ui.add(Label::new("Text can have ").text_color(srgba(110, 255, 110, 255))); - ui.add(Label::new("color ").text_color(srgba(128, 140, 255, 255))); + ui.horizontal_wrapped_for_text(TextStyle::Body, |ui| { + ui.label("Long text will wrap, just as you would expect."); + ui.add(Label::new("Text can have").text_color(srgba(110, 255, 110, 255))); + ui.add(Label::new("color").text_color(srgba(128, 140, 255, 255))); ui.add(Label::new("and tooltips.")).on_hover_text( "This is a multiline tooltip that demonstrates that you can easily add tooltips to any element.\nThis is the second line.\nThis is the third.", ); - }); - ui.label("Tooltips can be more than just simple text.") - .on_hover_ui(|ui| { - ui.heading("The name of the tooltip"); - ui.horizontal(|ui| { - ui.label("This tooltip was created with"); - ui.monospace(".on_hover_ui(...)"); - }); - let _ = ui.button("A button you can never press"); - }); - ui.label("Ευρηκα! τ = 2×π") - .on_hover_text("The current font supports only a few non-latin characters and Egui does not currently support right-to-left text."); + ui.label("You can mix in other widgets into text, like this"); + let _ = ui.small_button("button"); + ui.label("."); + + ui.label("There is also (limited) non-ASCII support: Ευρηκα! τ = 2×π") + .on_hover_text("The current font supports only a few non-latin characters and Egui does not currently support right-to-left text."); + }); + + let tooltip_ui = |ui: &mut Ui| { + ui.heading("The name of the tooltip"); + ui.horizontal(|ui| { + ui.label("This tooltip was created with"); + ui.monospace(".on_hover_ui(...)"); + }); + let _ = ui.button("A button you can never press"); + }; + ui.label("Tooltips can be more than just simple text.") + .on_hover_ui(tooltip_ui); ui.horizontal(|ui| { ui.radio_value(&mut self.radio, Enum::First, "First"); diff --git a/egui/src/introspection.rs b/egui/src/introspection.rs index 7e303afb..7a7a8735 100644 --- a/egui/src/introspection.rs +++ b/egui/src/introspection.rs @@ -16,8 +16,8 @@ impl Texture { return; } let mut size = vec2(self.width as f32, self.height as f32); - if size.x > ui.available().width() { - size *= ui.available().width() / size.x; + if size.x > ui.available_width() { + size *= ui.available_width() / size.x; } let rect = ui.allocate_space(size); let mut triangles = Triangles::default(); diff --git a/egui/src/layout.rs b/egui/src/layout.rs index f816a8ca..0640a767 100644 --- a/egui/src/layout.rs +++ b/egui/src/layout.rs @@ -2,18 +2,92 @@ use crate::{math::*, Align}; // ---------------------------------------------------------------------------- -/// `Layout` direction (horizontal or vertical). +/// This describes the bounds and existing contents of an `Ui`. +/// It is what is used and updated by `Layout` when adding new widgets. +#[derive(Clone, Copy, Debug)] +pub struct Region { + /// This is the minimal size of the `Ui`. + /// When adding new widgets, this will generally expand. + /// + /// Always finite. + /// + /// The bounding box of all child widgets, but not necessarily a tight bounding box + /// since `Ui` can start with a non-zero min_rect size. + pub min_rect: Rect, + + /// The maximum size of this `Ui`. This is a *soft max* + /// meaning new widgets will *try* not to expand beyond it, + /// but if they have to, they will. + /// + /// Text will wrap at `max_rect.right()`. + /// Some widgets (like separator lines) will try to fill the full `max_rect` width of the ui. + /// + /// `max_rect` will always be at least the size of `min_rect`. + /// + /// If the `max_rect` size is zero, it is a signal that child widgets should be as small as possible. + /// If the `max_rect` size is infinite, it is a signal that child widgets should take up as much room as they want. + pub max_rect: Rect, + + /// Where the next widget will be put. + /// If something has already been added, this will point ot `style.spacing.item_spacing` beyond the latest child. + /// The cursor can thus be `style.spacing.item_spacing` pixels outside of the min_rect. + pub(crate) cursor: Pos2, +} + +impl Region { + /// This is like `max_rect`, but will never be infinite. + /// If the desired rect is infinite ("be as big as you want") + /// this will be bounded by `min_rect` instead. + pub fn max_rect_finite(&self) -> Rect { + let mut result = self.max_rect; + if !result.min.x.is_finite() { + result.min.x = self.min_rect.min.x; + } + if !result.min.y.is_finite() { + result.min.y = self.min_rect.min.y; + } + if !result.max.x.is_finite() { + result.max.x = self.min_rect.max.x; + } + if !result.max.y.is_finite() { + result.max.y = self.min_rect.max.y; + } + result + } + + /// Expand the `min_rect` and `max_rect` of this ui to include a child at the given rect. + pub fn expand_to_include_rect(&mut self, rect: Rect) { + self.min_rect = self.min_rect.union(rect); + self.max_rect = self.max_rect.union(rect); + } +} + +// ---------------------------------------------------------------------------- + +/// Main layout direction #[derive(Clone, Copy, Debug, PartialEq)] #[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] #[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] pub enum Direction { - Horizontal, - Vertical, + LeftToRight, + RightToLeft, + TopDown, + BottomUp, } -impl Default for Direction { - fn default() -> Direction { - Direction::Vertical +impl Direction { + pub fn is_horizontal(self) -> bool { + match self { + Direction::LeftToRight | Direction::RightToLeft => true, + Direction::TopDown | Direction::BottomUp => false, + } + } + + pub fn is_vertical(self) -> bool { + match self { + Direction::LeftToRight | Direction::RightToLeft => false, + Direction::TopDown | Direction::BottomUp => true, + } } } @@ -23,256 +97,393 @@ impl Default for Direction { #[derive(Clone, Copy, Debug, PartialEq)] // #[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] pub struct Layout { - /// Lay out things horizontally or vertically? Main axis. - dir: Direction, + /// Main axis direction + main_dir: Direction, + + /// If true, wrap around when reading the end of the main direction. + /// For instance, for `main_dir == Direction::LeftToRight` this will + /// wrap to a new row when we reach the right side of the `max_rect`. + main_wrap: bool, /// How to align things on the cross axis. /// For vertical layouts: put things to left, center or right? /// For horizontal layouts: put things to top, center or bottom? - /// `None` means justified, which means full width (vertical layout) or height (horizontal layouts). - align: Option, + cross_align: Align, - /// Lay out things in reversed order, i.e. from the right or bottom-up. - reversed: bool, + /// Justify the cross axis? + /// For vertical layouts justify mean all widgets get maximum width. + /// For horizontal layouts justify mean all widgets get maximum height. + cross_justify: bool, } impl Default for Layout { fn default() -> Self { + // TODO: Get from `Style` instead. + // This is a very euro-centric default. Self { - dir: Direction::Vertical, - align: Some(Align::Min), - reversed: false, + main_dir: Direction::TopDown, + main_wrap: false, + cross_align: Align::left(), + cross_justify: false, } } } impl Layout { - /// None align means justified, e.g. fill full width/height. - pub fn from_dir_align(dir: Direction, align: Option) -> Self { + pub(crate) fn from_main_dir_and_cross_align(main_dir: Direction, cross_align: Align) -> Self { Self { - dir, - align, - reversed: false, + main_dir, + main_wrap: false, + cross_align, + cross_justify: false, } } - pub fn vertical(align: Align) -> Self { + pub fn left_to_right() -> Self { Self { - dir: Direction::Vertical, - align: Some(align), - reversed: false, + main_dir: Direction::LeftToRight, + main_wrap: false, + cross_align: Align::Center, + cross_justify: false, } } - pub fn horizontal(align: Align) -> Self { + pub fn right_to_left() -> Self { Self { - dir: Direction::Horizontal, - align: Some(align), - reversed: false, + main_dir: Direction::RightToLeft, + main_wrap: false, + cross_align: Align::Center, + cross_justify: false, } } - /// Full-width layout. - /// Nice for menus etc where each button is full width. - pub fn justified(dir: Direction) -> Self { + pub fn top_down(cross_align: Align) -> Self { Self { - dir, - align: None, - reversed: false, + main_dir: Direction::TopDown, + main_wrap: false, + cross_align, + cross_justify: false, } } - #[must_use] - pub fn reverse(self) -> Self { + pub fn bottom_up(cross_align: Align) -> Self { Self { - dir: self.dir, - align: self.align, - reversed: !self.reversed, + main_dir: Direction::BottomUp, + main_wrap: false, + cross_align, + cross_justify: false, } } - #[must_use] - pub fn with_reversed(self, reversed: bool) -> Self { - if reversed { - self.reverse() + #[deprecated = "Use `top_down`"] + pub fn vertical(cross_align: Align) -> Self { + Self::top_down(cross_align) + } + + #[deprecated = "Use `left_to_right`"] + pub fn horizontal(cross_align: Align) -> Self { + Self::left_to_right().with_cross_align(cross_align) + } + + pub fn with_main_wrap(self, main_wrap: bool) -> Self { + Self { main_wrap, ..self } + } + + pub fn with_cross_align(self, cross_align: Align) -> Self { + Self { + cross_align, + ..self + } + } + + pub fn with_cross_justify(self, cross_justify: bool) -> Self { + Self { + cross_justify, + ..self + } + } + + // ------------------------------------------------------------------------ + + pub fn main_dir(self) -> Direction { + self.main_dir + } + + pub fn main_wrap(self) -> bool { + self.main_wrap + } + + pub fn cross_align(self) -> Align { + self.cross_align + } + + pub fn cross_justify(self) -> bool { + self.cross_justify + } + + pub fn is_horizontal(self) -> bool { + self.main_dir().is_horizontal() + } + + pub fn is_vertical(self) -> bool { + self.main_dir().is_vertical() + } + + pub fn prefer_right_to_left(self) -> bool { + self.main_dir == Direction::RightToLeft + || self.main_dir.is_vertical() && self.cross_align == Align::Max + } + + fn horizontal_align(self) -> Align { + match self.main_dir { + // Direction::LeftToRight => Align::left(), + // Direction::RightToLeft => Align::right(), + Direction::LeftToRight | Direction::RightToLeft => Align::Center, // looks better to e.g. center text within a button + + Direction::TopDown | Direction::BottomUp => self.cross_align, + } + } + + fn vertical_align(self) -> Align { + match self.main_dir { + // Direction::TopDown => Align::top(), + // Direction::BottomUp => Align::bottom(), + Direction::TopDown | Direction::BottomUp => Align::Center, // looks better to e.g. center text within a button + + Direction::LeftToRight | Direction::RightToLeft => self.cross_align, + } + } + + pub fn align_size_within_rect(&self, size: Vec2, outer: Rect) -> Rect { + let x = match self.horizontal_align() { + Align::Min => outer.left(), + Align::Center => outer.center().x - size.x / 2.0, + Align::Max => outer.right() - size.x, + }; + let y = match self.vertical_align() { + Align::Min => outer.top(), + Align::Center => outer.center().y - size.y / 2.0, + Align::Max => outer.bottom() - size.y, + }; + + Rect::from_min_size(Pos2::new(x, y), size) + } + + // ------------------------------------------------------------------------ + + fn initial_cursor(self, max_rect: Rect) -> Pos2 { + match self.main_dir { + Direction::LeftToRight => max_rect.left_top(), + Direction::RightToLeft => max_rect.right_top(), + Direction::TopDown => max_rect.left_top(), + Direction::BottomUp => max_rect.left_bottom(), + } + } + + pub fn region_from_max_rect(&self, max_rect: Rect) -> Region { + let cursor = self.initial_cursor(max_rect); + let min_rect = Rect::from_min_size(cursor, Vec2::zero()); + Region { + min_rect, + max_rect, + cursor, + } + } + + pub(crate) fn available_rect_before_wrap(&self, region: &Region) -> Rect { + self.available_from_cursor_max_rect(region.cursor, region.max_rect) + } + + pub(crate) fn available_size_before_wrap(&self, region: &Region) -> Vec2 { + self.available_rect_before_wrap(region).size() + } + + pub(crate) fn available_rect_before_wrap_finite(&self, region: &Region) -> Rect { + self.available_from_cursor_max_rect(region.cursor, region.max_rect_finite()) + } + + pub(crate) fn available_size_before_wrap_finite(&self, region: &Region) -> Vec2 { + self.available_rect_before_wrap_finite(region).size() + } + + /// Amount of space available for a widget. + /// Wor wrapping layouts, this is the maximum (after wrap) + pub fn available_size(&self, r: &Region) -> Vec2 { + if self.main_wrap { + if self.main_dir.is_horizontal() { + vec2(r.max_rect.width(), r.max_rect.bottom() - r.cursor.y) + } else { + vec2(r.max_rect.right() - r.cursor.x, r.max_rect.height()) + } } else { - self - } - } - - pub fn dir(self) -> Direction { - self.dir - } - - pub fn align(self) -> Option { - self.align - } - - pub fn is_reversed(self) -> bool { - self.reversed - } - - pub fn initial_cursor(self, max_rect: Rect) -> Pos2 { - match self.dir { - Direction::Horizontal => { - if self.reversed { - max_rect.right_top() - } else { - max_rect.left_top() - } - } - Direction::Vertical => { - if self.reversed { - max_rect.left_bottom() - } else { - max_rect.left_top() - } - } + self.available_from_cursor_max_rect(r.cursor, r.max_rect) + .size() } } /// Given the cursor in the region, how much space is available /// for the next widget? - pub fn available(self, cursor: Pos2, max_rect: Rect) -> Rect { + fn available_from_cursor_max_rect(self, cursor: Pos2, max_rect: Rect) -> Rect { let mut rect = max_rect; - match self.dir { - Direction::Horizontal => { - rect.min.y = cursor.y; - if self.reversed { - rect.max.x = cursor.x; - } else { - rect.min.x = cursor.x; - } - } - Direction::Vertical => { + + match self.main_dir { + Direction::LeftToRight => { rect.min.x = cursor.x; - if self.reversed { - rect.max.y = cursor.y; - } else { - rect.min.y = cursor.y; - } + rect.min.y = cursor.y; + } + Direction::RightToLeft => { + rect.max.x = cursor.x; + rect.min.y = cursor.y; + } + Direction::TopDown => { + rect.min.x = cursor.x; + rect.min.y = cursor.y; + } + Direction::BottomUp => { + rect.min.x = cursor.x; + rect.max.y = cursor.y; } } + rect } + /// Returns where to put the next widget that is of the given size. + /// The returned "outer" `Rect` will always be justified along the cross axis. + /// This is what you then pass to `advance_after_outer_rect`. + /// Use `justify_or_align` to get the inner `Rect`. + #[allow(clippy::collapsible_if)] + pub fn next_space(self, region: &Region, mut child_size: Vec2, item_spacing: Vec2) -> Rect { + let mut cursor = region.cursor; + + if self.main_wrap { + let available_size = self.available_size_before_wrap(region); + match self.main_dir { + Direction::LeftToRight => { + if available_size.x < child_size.x && region.max_rect.left() < cursor.x { + // New row + cursor = pos2( + region.max_rect.left(), + region.max_rect.bottom() + item_spacing.y, + ); + } + } + Direction::RightToLeft => { + if available_size.x < child_size.x && cursor.x < region.max_rect.right() { + // New row + cursor = pos2( + region.max_rect.right(), + region.max_rect.bottom() + item_spacing.y, + ); + } + } + Direction::TopDown => { + if available_size.y < child_size.y && region.max_rect.top() < cursor.y { + // New column + cursor = pos2( + region.max_rect.right() + item_spacing.x, + region.max_rect.top(), + ); + } + } + Direction::BottomUp => { + if available_size.y < child_size.y && cursor.y < region.max_rect.bottom() { + // New column + cursor = pos2( + region.max_rect.right() + item_spacing.x, + region.max_rect.bottom(), + ); + } + } + } + } + + let available_size = self.available_size_before_wrap_finite(region); + if self.main_dir.is_horizontal() { + // Fill full height + child_size.y = child_size.y.max(available_size.y); + } else { + // Fill full width + child_size.x = child_size.x.max(available_size.x); + } + + let child_pos = match self.main_dir { + Direction::LeftToRight => cursor, + Direction::RightToLeft => cursor + vec2(-child_size.x, 0.0), + Direction::TopDown => cursor, + Direction::BottomUp => cursor + vec2(0.0, -child_size.y), + }; + + Rect::from_min_size(child_pos, child_size) + } + + /// Apply justify or alignment after calling `next_space`. + pub fn justify_or_align(self, mut rect: Rect, child_size: Vec2) -> Rect { + if self.main_dir.is_horizontal() { + debug_assert!((rect.width() - child_size.x).abs() < 0.1); + if self.cross_justify { + rect // fill full height + } else { + rect.min.y += match self.cross_align { + Align::Min => 0.0, + Align::Center => 0.5 * (rect.size().y - child_size.y), + Align::Max => rect.size().y - child_size.y, + }; + rect.max.y = rect.min.y + child_size.y; + rect + } + } else { + debug_assert!((rect.height() - child_size.y).abs() < 0.1); + if self.cross_justify { + rect // justified: fill full width + } else { + rect.min.x += match self.cross_align { + Align::Min => 0.0, + Align::Center => 0.5 * (rect.size().x - child_size.x), + Align::Max => rect.size().x - child_size.x, + }; + rect.max.x = rect.min.x + child_size.x; + rect + } + } + } + /// Advance the cursor by this many points. - pub fn advance_cursor(self, cursor: &mut Pos2, amount: f32) { - match self.dir() { - Direction::Horizontal => { - if self.is_reversed() { - cursor.x -= amount; - } else { - cursor.x += amount; - } - } - Direction::Vertical => { - if self.is_reversed() { - cursor.y -= amount; - } else { - cursor.y += amount; - } - } + pub fn advance_cursor(self, region: &mut Region, amount: f32) { + match self.main_dir { + Direction::LeftToRight => region.cursor.x += amount, + Direction::RightToLeft => region.cursor.x -= amount, + Direction::TopDown => region.cursor.y += amount, + Direction::BottomUp => region.cursor.y -= amount, } } /// Advance the cursor by this spacing - pub fn advance_cursor2(self, cursor: &mut Pos2, amount: Vec2) { - match self.dir() { - Direction::Horizontal => self.advance_cursor(cursor, amount.x), - Direction::Vertical => self.advance_cursor(cursor, amount.y), - } - } - - pub fn rect_from_cursor_size(self, cursor: Pos2, size: Vec2) -> Rect { - let mut rect = Rect::from_min_size(cursor, size); - - match self.dir { - Direction::Horizontal => { - if self.reversed { - rect.min.x = cursor.x - size.x; - rect.max.x = rect.min.x - size.x - } - } - Direction::Vertical => { - if self.reversed { - rect.min.y = cursor.y - size.y; - rect.max.y = rect.min.y - size.y - } - } - } - - rect - } - - /// Reserve this much space and move the cursor. - /// Returns where to put the widget. - /// - /// ## How sizes are negotiated - /// Each widget should have a *minimum desired size* and a *desired size*. - /// When asking for space, ask AT LEAST for you minimum, and don't ask for more than you need. - /// If you want to fill the space, ask about `available().size()` and use that. - /// - /// You may get MORE space than you asked for, for instance - /// for `Justified` aligned layouts, like in menus. - /// - /// You may get LESS space than you asked for if the current layout won't fit what you asked for. - pub fn allocate_space( - self, - cursor: &mut Pos2, - available_size: Vec2, - minimum_child_size: Vec2, - ) -> Rect { - let available_size = available_size.at_least(minimum_child_size); - - let mut child_size = minimum_child_size; - let mut child_move = Vec2::default(); - let mut cursor_change = Vec2::default(); - - match self.dir { - Direction::Horizontal => { - if let Some(align) = self.align { - child_move.y += match align { - Align::Min => 0.0, - Align::Center => 0.5 * (available_size.y - child_size.y), - Align::Max => available_size.y - child_size.y, - }; - } else { - // justified: fill full height - child_size.y = child_size.y.max(available_size.y); - } - - cursor_change.x += child_size.x; - } - Direction::Vertical => { - if let Some(align) = self.align { - child_move.x += match align { - Align::Min => 0.0, - Align::Center => 0.5 * (available_size.x - child_size.x), - Align::Max => available_size.x - child_size.x, - }; - } else { - // justified: fill full width - child_size.x = child_size.x.max(available_size.x); - }; - cursor_change.y += child_size.y; - } - } - - if self.is_reversed() { - let child_pos = *cursor + child_move; - let child_pos = match self.dir { - Direction::Horizontal => child_pos + vec2(-child_size.x, 0.0), - Direction::Vertical => child_pos + vec2(0.0, -child_size.y), - }; - *cursor -= cursor_change; - Rect::from_min_size(child_pos, child_size) + pub fn advance_cursor2(self, region: &mut Region, amount: Vec2) { + if self.main_dir.is_horizontal() { + self.advance_cursor(region, amount.x) } else { - let child_pos = *cursor + child_move; - *cursor += cursor_change; - Rect::from_min_size(child_pos, child_size) + self.advance_cursor(region, amount.y) } } + + /// Advance cursor after a widget was added to a specific rectangle. + /// `outer_rect` is a hack needed because the Vec2 cursor is not quite sufficient to keep track + /// of what is happening when we are doing wrapping layouts. + pub fn advance_after_outer_rect( + self, + region: &mut Region, + outer_rect: Rect, + inner_rect: Rect, + item_spacing: Vec2, + ) { + region.cursor = match self.main_dir { + Direction::LeftToRight => pos2(inner_rect.right() + item_spacing.x, outer_rect.top()), + Direction::RightToLeft => pos2(inner_rect.left() - item_spacing.x, outer_rect.top()), + Direction::TopDown => pos2(outer_rect.left(), inner_rect.bottom() + item_spacing.y), + Direction::BottomUp => pos2(outer_rect.left(), inner_rect.top() - item_spacing.y), + }; + } } // ---------------------------------------------------------------------------- @@ -280,31 +491,32 @@ impl Layout { /// ## Debug stuff impl Layout { /// Shows where the next widget is going to be placed - pub fn debug_paint_cursor(&self, cursor: Pos2, painter: &crate::Painter) { + pub fn debug_paint_cursor(&self, region: &Region, painter: &crate::Painter) { use crate::paint::*; + + let cursor = region.cursor; + let color = color::GREEN; let stroke = Stroke::new(2.0, color); let align; - match self.dir { - Direction::Horizontal => { - if self.reversed { - painter.debug_arrow(cursor, vec2(-1.0, 0.0), stroke); - align = (Align::Max, Align::Min); - } else { - painter.debug_arrow(cursor, vec2(1.0, 0.0), stroke); - align = (Align::Min, Align::Min); - } + match self.main_dir { + Direction::LeftToRight => { + painter.debug_arrow(cursor, vec2(1.0, 0.0), stroke); + align = (Align::Min, Align::Min); } - Direction::Vertical => { - if self.reversed { - painter.debug_arrow(cursor, vec2(0.0, -1.0), stroke); - align = (Align::Min, Align::Max); - } else { - painter.debug_arrow(cursor, vec2(0.0, 1.0), stroke); - align = (Align::Min, Align::Min); - } + Direction::RightToLeft => { + painter.debug_arrow(cursor, vec2(-1.0, 0.0), stroke); + align = (Align::Max, Align::Min); + } + Direction::TopDown => { + painter.debug_arrow(cursor, vec2(0.0, 1.0), stroke); + align = (Align::Min, Align::Min); + } + Direction::BottomUp => { + painter.debug_arrow(cursor, vec2(0.0, -1.0), stroke); + align = (Align::Min, Align::Max); } } diff --git a/egui/src/menu.rs b/egui/src/menu.rs index 11be4ddf..46bd1486 100644 --- a/egui/src/menu.rs +++ b/egui/src/menu.rs @@ -54,7 +54,7 @@ pub fn bar(ui: &mut Ui, add_contents: impl FnOnce(&mut Ui) -> R) -> (R, Respo // Take full width and fixed height: let height = ui.style().spacing.interact_size.y; - ui.set_min_size(vec2(ui.available().width(), height)); + ui.set_min_size(vec2(ui.available_width(), height)); add_contents(ui) }) @@ -111,7 +111,10 @@ fn menu_impl<'c>( style.visuals.widgets.inactive.bg_fill = TRANSPARENT; style.visuals.widgets.inactive.bg_stroke = Stroke::none(); ui.set_style(style); - ui.with_layout(Layout::justified(Direction::Vertical), add_contents); + ui.with_layout( + Layout::top_down(Align::left()).with_cross_justify(true), + add_contents, + ); }) }); diff --git a/egui/src/paint/font.rs b/egui/src/paint/font.rs index a5509499..9c7bf5d0 100644 --- a/egui/src/paint/font.rs +++ b/egui/src/paint/font.rs @@ -119,6 +119,10 @@ impl Font { self.glyph_infos.read().get(&c).and_then(|gi| gi.uv_rect) } + pub fn glyph_width(&self, c: char) -> f32 { + self.glyph_info(c).advance_width + } + /// `\n` will (intentionally) show up as '?' (`REPLACEMENT_CHAR`) fn glyph_info(&self, c: char) -> GlyphInfo { { @@ -163,7 +167,20 @@ impl Font { galley } + /// Always returns at least one row. pub fn layout_multiline(&self, text: String, max_width_in_points: f32) -> Galley { + self.layout_multiline_with_indentation_and_max_width(text, 0.0, max_width_in_points) + } + + /// * `first_row_indentation`: extra space before the very first character (in points). + /// * `max_width_in_points`: wrapping width. + /// Always returns at least one row. + pub fn layout_multiline_with_indentation_and_max_width( + &self, + text: String, + first_row_indentation: f32, + max_width_in_points: f32, + ) -> Galley { let row_height = self.row_height(); let mut cursor_y = 0.0; let mut rows = Vec::new(); @@ -178,8 +195,16 @@ impl Font { assert!(paragraph_start <= paragraph_end); let paragraph_text = &text[paragraph_start..paragraph_end]; - let mut paragraph_rows = - self.layout_paragraph_max_width(paragraph_text, max_width_in_points); + let line_indentation = if rows.is_empty() { + first_row_indentation + } else { + 0.0 + }; + let mut paragraph_rows = self.layout_paragraph_max_width( + paragraph_text, + line_indentation, + max_width_in_points, + ); assert!(!paragraph_rows.is_empty()); paragraph_rows.last_mut().unwrap().ends_with_newline = next_newline.is_some(); @@ -248,10 +273,16 @@ impl Font { /// A paragraph is text with no line break character in it. /// The text will be wrapped by the given `max_width_in_points`. - fn layout_paragraph_max_width(&self, text: &str, max_width_in_points: f32) -> Vec { + /// Always returns at least one row. + fn layout_paragraph_max_width( + &self, + text: &str, + mut first_row_indentation: f32, + max_width_in_points: f32, + ) -> Vec { if text == "" { return vec![Row { - x_offsets: vec![0.0], + x_offsets: vec![first_row_indentation], y_min: 0.0, y_max: self.row_height(), ends_with_newline: false, @@ -260,12 +291,7 @@ impl Font { let full_x_offsets = self.layout_single_row_fragment(text); - let mut row_start_x = full_x_offsets[0]; - - { - #![allow(clippy::float_cmp)] - assert_eq!(row_start_x, 0.0); - } + let mut row_start_x = 0.0; // NOTE: BEFORE the `first_row_indentation`. let mut cursor_y = 0.0; let mut row_start_idx = 0; @@ -277,40 +303,40 @@ impl Font { for (i, (x, chr)) in full_x_offsets.iter().skip(1).zip(text.chars()).enumerate() { debug_assert!(chr != '\n'); - let potential_row_width = x - row_start_x; + let potential_row_width = first_row_indentation + x - row_start_x; if potential_row_width > max_width_in_points { if let Some(last_space_idx) = last_space { - let include_trailing_space = true; - let row = if include_trailing_space { - Row { - x_offsets: full_x_offsets[row_start_idx..=last_space_idx + 1] - .iter() - .map(|x| x - row_start_x) - .collect(), - y_min: cursor_y, - y_max: cursor_y + self.row_height(), - ends_with_newline: false, - } - } else { - Row { - x_offsets: full_x_offsets[row_start_idx..=last_space_idx] - .iter() - .map(|x| x - row_start_x) - .collect(), - y_min: cursor_y, - y_max: cursor_y + self.row_height(), - ends_with_newline: false, - } + // We include the trailing space in the row: + let row = Row { + x_offsets: full_x_offsets[row_start_idx..=last_space_idx + 1] + .iter() + .map(|x| first_row_indentation + x - row_start_x) + .collect(), + y_min: cursor_y, + y_max: cursor_y + self.row_height(), + ends_with_newline: false, }; row.sanity_check(); out_rows.push(row); row_start_idx = last_space_idx + 1; - row_start_x = full_x_offsets[row_start_idx]; + row_start_x = first_row_indentation + full_x_offsets[row_start_idx]; last_space = None; - cursor_y += self.row_height(); - cursor_y = self.round_to_pixel(cursor_y); + cursor_y = self.round_to_pixel(cursor_y + self.row_height()); + } else if out_rows.is_empty() && first_row_indentation > 0.0 { + assert_eq!(row_start_idx, 0); + // Allow the first row to be completely empty, because we know there will be more space on the next row: + let row = Row { + x_offsets: vec![first_row_indentation], + y_min: cursor_y, + y_max: cursor_y + self.row_height(), + ends_with_newline: false, + }; + row.sanity_check(); + out_rows.push(row); + cursor_y = self.round_to_pixel(cursor_y + self.row_height()); + first_row_indentation = 0.0; // Continue all other rows as if there is no indentation } } @@ -324,7 +350,7 @@ impl Font { let row = Row { x_offsets: full_x_offsets[row_start_idx..] .iter() - .map(|x| x - row_start_x) + .map(|x| first_row_indentation + x - row_start_x) .collect(), y_min: cursor_y, y_max: cursor_y + self.row_height(), diff --git a/egui/src/paint/galley.rs b/egui/src/paint/galley.rs index 02202ef9..57f91f58 100644 --- a/egui/src/paint/galley.rs +++ b/egui/src/paint/galley.rs @@ -196,6 +196,13 @@ impl Row { self.y_max - self.y_min } + pub fn rect(&self) -> Rect { + Rect::from_min_max( + pos2(self.min_x(), self.y_min), + pos2(self.max_x(), self.y_max), + ) + } + /// Closest char at the desired x coordinate. /// Returns something in the range `[0, char_count_excluding_newline()]`. pub fn char_at(&self, desired_x: f32) -> usize { diff --git a/egui/src/ui.rs b/egui/src/ui.rs index 03b3216f..4706a5be 100644 --- a/egui/src/ui.rs +++ b/egui/src/ui.rs @@ -22,41 +22,19 @@ pub struct Ui { /// They are therefore only good for Id:s that has no state. next_auto_id: u64, + /// Specifies paint layer, clip rectangle and a reference to `Context`. painter: Painter, - /// This is the minimal size of the `Ui`. - /// When adding new widgets, this will generally expand. - /// - /// Always finite. - /// - /// The bounding box of all child widgets, but not necessarily a tight bounding box - /// since `Ui` can start with a non-zero min_rect size. - min_rect: Rect, - - /// The maximum size of this `Ui`. This is a *soft max* - /// meaning new widgets will *try* not to expand beyond it, - /// but if they have to, they will. - /// - /// Text will wrap at `max_rect.right()`. - /// Some widgets (like separator lines) will try to fill the full `max_rect` width of the ui. - /// - /// `max_rect` will always be at least the size of `min_rect`. - /// - /// If the `max_rect` size is zero, it is a signal that child widgets should be as small as possible. - /// If the `max_rect` size is infinite, it is a signal that child widgets should take up as much room as they want. - max_rect: Rect, - - /// Override default style in this ui + /// The `Style` (visuals, spacing, etc) of this ui. + /// Commonly many `Ui`:s share the same `Style`. + /// The `Ui` implements copy-on-write for this. style: Arc