From f9bb9f71c4cd180697ccd61bb585c585b0b7097f Mon Sep 17 00:00:00 2001 From: Emil Ernerfeldt Date: Sun, 17 May 2020 12:26:17 +0200 Subject: [PATCH] Add button to collapse windows --- emigui/README.md | 13 +- emigui/src/containers/collapsing_header.rs | 213 ++++++++++++--------- emigui/src/containers/window.rs | 140 +++++++++----- emigui/src/context.rs | 3 +- emigui/src/types.rs | 11 +- emigui/src/ui.rs | 29 ++- emigui/src/widgets.rs | 4 + example_glium/src/main.rs | 15 +- 8 files changed, 276 insertions(+), 152 deletions(-) diff --git a/emigui/README.md b/emigui/README.md index 7508fdbb..fb5b1aae 100644 --- a/emigui/README.md +++ b/emigui/README.md @@ -16,6 +16,7 @@ This is the core library crate Emigui. It is fully platform independent without * [ ] Windows should open from `UI`s and be boxed by parent ui. * Then we could open the example app inside a window in the example app, recursively. * [ ] Resize any side and corner on windows + * [ ] Fix autoshrink * [ ] Scroll areas * [x] Vertical scrolling * [ ] Horizontal scrolling @@ -29,7 +30,7 @@ This is the core library crate Emigui. It is fully platform independent without * [ ] Text input * [x] Input events (key presses) * [x] Text focus - * [ ] Cursor movement + * [x] Cursor movement * [ ] Text selection * [ ] Clipboard copy/paste * [ ] Move focus with tab @@ -41,6 +42,7 @@ This is the core library crate Emigui. It is fully platform independent without * [ ] Generalize Layout (separate from Ui) * [ ] Cascading layout: same lite if it fits, else next line. Like text. * [ ] Grid layout + * [ ] Point list * [ ] Image support ### Web version: @@ -49,10 +51,12 @@ This is the core library crate Emigui. It is fully platform independent without * [ ] Make it a JS library for easily creating your own stuff * [ ] Read url fragment and redirect to a subpage (e.g. different examples apps) -### Painting -* [ ] Pixel-perfect painting (round positions to nearest pixel). +### Visuals +* [ ] Simplify button style to make for nicer collapsible headers. Maybe weak outline? Or just subtle different text color? +* [/] Pixel-perfect painting (round positions to nearest pixel). * [ ] Make sure alpha blending is correct (different between web and glium) -* [ ] sRGBA correct colors +* [ ] Color picker widgets +* [ ] Fix thin rounded corners rendering bug (too bright) ### Animations Add extremely quick animations for some things, maybe 2-3 frames. For instance: @@ -94,6 +98,7 @@ Add extremely quick animations for some things, maybe 2-3 frames. For instance: * [x] Combine Emigui and Context? * [x] Solve which parts of Context are behind a mutex * [x] Rename Region to Ui +* [ ] Move Path and Mesh to own crate * [ ] Maybe find a shorter name for the library like `egui`? ### Global widget search diff --git a/emigui/src/containers/collapsing_header.rs b/emigui/src/containers/collapsing_header.rs index 79ab529c..4791ac41 100644 --- a/emigui/src/containers/collapsing_header.rs +++ b/emigui/src/containers/collapsing_header.rs @@ -22,6 +22,111 @@ impl Default for State { } } +impl State { + pub fn from_memory_with_default_open(ui: &Ui, id: Id, default_open: bool) -> Self { + ui.memory() + .collapsing_headers + .entry(id) + .or_insert(State { + open: default_open, + ..Default::default() + }) + .clone() + } + + pub fn toggle(&mut self, ui: &Ui) { + self.open = !self.open; + self.toggle_time = ui.input().time; + } + + /// 0 for closed, 1 for open, with tweening + pub fn openness(&self, ui: &Ui) -> f32 { + let animation_time = ui.style().animation_time; + let time_since_toggle = (ui.input().time - self.toggle_time) as f32; + let time_since_toggle = time_since_toggle + ui.input().dt; // Instant feedback + if self.open { + remap_clamp(time_since_toggle, 0.0..=animation_time, 0.0..=1.0) + } else { + remap_clamp(time_since_toggle, 0.0..=animation_time, 1.0..=0.0) + } + } + + /// Paint the arrow icon that indicated if the region is open or not + pub fn paint_icon(&self, ui: &mut Ui, interact: &InteractInfo) { + let stroke_color = ui.style().interact(interact).stroke_color; + let stroke_width = ui.style().interact(interact).stroke_width; + + let rect = interact.rect; + + let openness = self.openness(ui); + + // Draw a pointy triangle arrow: + let rect = Rect::from_center_size(rect.center(), vec2(rect.width(), rect.height()) * 0.75); + let mut points = [rect.left_top(), rect.right_top(), rect.center_bottom()]; + let rotation = Vec2::angled(remap(openness, 0.0..=1.0, -TAU / 4.0..=0.0)); + for p in &mut points { + let v = *p - rect.center(); + let v = rotation.rotate_other(v); + *p = rect.center() + v; + } + + ui.add_paint_cmd(PaintCmd::Path { + path: mesher::Path::from_point_loop(&points), + closed: true, + fill_color: None, + outline: Some(Outline::new(stroke_width, stroke_color)), + }); + } + + /// Show contents if we are open, with a nice animation between closed and open + pub fn add_contents( + &mut self, + ui: &mut Ui, + add_contents: impl FnOnce(&mut Ui), + ) -> Option { + let openness = self.openness(ui); + let animate = 0.0 < openness && openness < 1.0; + if animate { + Some(ui.add_custom(|child_ui| { + let max_height = if self.open { + if let Some(full_height) = self.open_height { + remap_clamp(openness, 0.0..=1.0, 0.0..=full_height) + } else { + // First frame of expansion. + // We don't know full height yet, but we will next frame. + // Just use a placehodler value that shows some movement: + 10.0 + } + } else { + let full_height = self.open_height.unwrap_or_default(); + remap_clamp(openness, 0.0..=1.0, 0.0..=full_height) + }; + + let mut clip_rect = child_ui.clip_rect(); + clip_rect.max.y = clip_rect.max.y.min(child_ui.rect().top() + max_height); + child_ui.set_clip_rect(clip_rect); + + let top_left = child_ui.top_left(); + add_contents(child_ui); + + self.open_height = Some(child_ui.bounding_size().y); + + // Pretend children took up less space: + let mut child_bounds = child_ui.child_bounds(); + child_bounds.max.y = child_bounds.max.y.min(top_left.y + max_height); + child_ui.force_set_child_bounds(child_bounds); + })) + } else if self.open { + let interact = ui.add_custom(add_contents); + let full_size = interact.rect.size(); + self.open_height = Some(full_size.y); + Some(interact) + } else { + None + } + } +} + pub struct CollapsingHeader { label: Label, default_open: bool, @@ -75,32 +180,25 @@ impl CollapsingHeader { ); let text_pos = pos2(text_pos.x, interact.rect.center().y - galley.size.y / 2.0); - let mut state = { - let mut memory = ui.memory(); - let mut state = memory.collapsing_headers.entry(id).or_insert(State { - open: default_open, - ..Default::default() - }); - if interact.clicked { - state.open = !state.open; - state.toggle_time = ui.input().time; - } - *state - }; - - let animation_time = ui.style().animation_time; - let time_since_toggle = (ui.input().time - state.toggle_time) as f32; - let time_since_toggle = time_since_toggle + ui.input().dt; // Instant feedback - let openness = if state.open { - remap_clamp(time_since_toggle, 0.0..=animation_time, 0.0..=1.0) - } else { - remap_clamp(time_since_toggle, 0.0..=animation_time, 1.0..=0.0) - }; - let animate = time_since_toggle < animation_time; + let mut state = State::from_memory_with_default_open(ui, id, default_open); + if interact.clicked { + state.toggle(ui); + } let where_to_put_background = ui.paint_list_len(); - paint_icon(ui, &interact, openness); + { + let (mut icon_rect, _) = ui.style().icon_rectangles(interact.rect); + icon_rect.set_center(pos2( + interact.rect.left() + ui.style().indent / 2.0, + interact.rect.center().y, + )); + let icon_interact = InteractInfo { + rect: icon_rect, + ..interact + }; + state.paint_icon(ui, &icon_interact); + } ui.add_galley( text_pos, @@ -121,74 +219,11 @@ impl CollapsingHeader { ui.expand_to_include_child(interact.rect); // TODO: remove, just a test - if animate { - ui.indent(id, |child_ui| { - let max_height = if state.open { - if let Some(full_height) = state.open_height { - remap(time_since_toggle, 0.0..=animation_time, 0.0..=full_height) - } else { - // First frame of expansion. - // We don't know full height yet, but we will next frame. - // Just use a placehodler value that shows some movement: - 10.0 - } - } else { - let full_height = state.open_height.unwrap_or_default(); - remap_clamp(time_since_toggle, 0.0..=animation_time, full_height..=0.0) - }; - - let mut clip_rect = child_ui.clip_rect(); - clip_rect.max.y = clip_rect.max.y.min(child_ui.rect().top() + max_height); - child_ui.set_clip_rect(clip_rect); - - let top_left = child_ui.top_left(); - add_contents(child_ui); - - state.open_height = Some(child_ui.bounding_size().y); - - // Pretend children took up less space: - let mut child_bounds = child_ui.child_bounds(); - child_bounds.max.y = child_bounds.max.y.min(top_left.y + max_height); - child_ui.force_set_child_bounds(child_bounds); - }); - } else if state.open { - let full_size = ui.indent(id, add_contents).rect.size(); - state.open_height = Some(full_size.y); - } + state.add_contents(ui, |ui| { + ui.indent(id, add_contents); + }); ui.memory().collapsing_headers.insert(id, state); ui.response(interact) } } - -fn paint_icon(ui: &mut Ui, interact: &InteractInfo, openness: f32) { - let stroke_color = ui.style().interact(interact).stroke_color; - let stroke_width = ui.style().interact(interact).stroke_width; - - let (mut small_icon_rect, _) = ui.style().icon_rectangles(interact.rect); - small_icon_rect.set_center(pos2( - interact.rect.left() + ui.style().indent / 2.0, - interact.rect.center().y, - )); - - // Draw a pointy triangle arrow: - let rect = Rect::from_center_size( - small_icon_rect.center(), - vec2(small_icon_rect.width(), small_icon_rect.height()) * 0.75, - ); - let mut points = [rect.left_top(), rect.right_top(), rect.center_bottom()]; - let rotation = Vec2::angled(remap(openness, 0.0..=1.0, -TAU / 4.0..=0.0)); - for p in &mut points { - let v = *p - rect.center(); - let v = rotation.rotate_other(v); - *p = rect.center() + v; - } - // } - - ui.add_paint_cmd(PaintCmd::Path { - path: mesher::Path::from_point_loop(&points), - closed: true, - fill_color: None, - outline: Some(Outline::new(stroke_width, stroke_color)), - }); -} diff --git a/emigui/src/containers/window.rs b/emigui/src/containers/window.rs index 402e9758..a5f7df8b 100644 --- a/emigui/src/containers/window.rs +++ b/emigui/src/containers/window.rs @@ -122,7 +122,11 @@ impl<'open> Window<'open> { } impl<'open> Window<'open> { - pub fn show(self, ctx: &Arc, add_contents: impl FnOnce(&mut Ui)) -> InteractInfo { + pub fn show( + self, + ctx: &Arc, + add_contents: impl FnOnce(&mut Ui), + ) -> Option { let Window { title_label, open, @@ -133,67 +137,110 @@ impl<'open> Window<'open> { } = self; if matches!(open, Some(false)) { - return Default::default(); + return None; } let frame = frame.unwrap_or_else(|| Frame::window(&ctx.style())); - if true { - // TODO: easier way to compose these - area.show(ctx, |ui| { - frame.show(ui, |ui| { + Some(area.show(ctx, |ui| { + frame.show(ui, |ui| { + let collapsing_id = ui.make_child_id("collapsing"); + let default_expanded = true; + let mut collapsing = collapsing_header::State::from_memory_with_default_open( + ui, + collapsing_id, + default_expanded, + ); + let show_close_button = open.is_some(); + let title_bar = show_title_bar( + ui, + title_label, + show_close_button, + collapsing_id, + &mut collapsing, + ); + ui.memory() + .collapsing_headers + .insert(collapsing_id, collapsing); + + let content = collapsing.add_contents(ui, |ui| { resize.show(ui, |ui| { - show_title_bar(ui, title_label, open); + ui.add(Separator::new().line_width(1.0)); // TODO: nicer way to split window title from contents if let Some(scroll) = scroll { scroll.show(ui, add_contents) } else { add_contents(ui) } }) - }) + }); + + if let Some(open) = open { + // Add close button now that we know our full width: + + let right = content + .map(|c| c.rect.right()) + .unwrap_or(title_bar.rect.right()); + + let button_size = ui.style().start_icon_width; + let button_rect = Rect::from_min_size( + pos2( + right - ui.style().item_spacing.x - button_size, + title_bar.rect.center().y - 0.5 * button_size, + ), + Vec2::splat(button_size), + ); + + if close_button(ui, button_rect).clicked { + *open = false; + } + } }) - } else { - // TODO: something like this, with collapsing contents - area.show(ctx, |ui| { - frame.show(ui, |ui| { - CollapsingHeader::new(title_label.text()).show(ui, |ui| { - resize.show(ui, |ui| { - if let Some(scroll) = scroll { - scroll.show(ui, add_contents) - } else { - add_contents(ui) - } - }) - }); - }) - }) - } + })) } } -fn show_title_bar(ui: &mut Ui, title_label: Label, open: Option<&mut bool>) { - let button_size = ui.style().clickable_diameter; +fn show_title_bar( + ui: &mut Ui, + title_label: Label, + show_close_button: bool, + collapsing_id: Id, + collapsing: &mut collapsing_header::State, +) -> InteractInfo { + ui.inner_layout(Layout::horizontal(Align::Center), |ui| { + ui.set_desired_height(title_label.font_height(ui)); - // TODO: show collapse button + let item_spacing = ui.style().item_spacing; + let button_size = ui.style().start_icon_width; - let title_rect = ui.add(title_label).rect; + { + // TODO: make clickable radius larger + ui.reserve_space(vec2(0.0, 0.0), None); // HACK: will add left spacing - if let Some(open) = open { - let close_max_x = title_rect.right() + ui.style().item_spacing.x + button_size; - let close_max_x = close_max_x.max(ui.rect_finite().right()); - let close_rect = Rect::from_min_size( - pos2( - close_max_x - button_size, - title_rect.center().y - 0.5 * button_size, - ), - Vec2::splat(button_size), - ); - if close_button(ui, close_rect).clicked { - *open = false; + let collapse_button_interact = + ui.reserve_space(Vec2::splat(button_size), Some(collapsing_id)); + if collapse_button_interact.clicked { + // TODO: also do this when double-clicking window title + collapsing.toggle(ui); + } + collapsing.paint_icon(ui, &collapse_button_interact); } - } - ui.add(Separator::new().line_width(1.0)); // TODO: nicer way to split window title from contents + let title_rect = ui.add(title_label).rect; + + if show_close_button { + // Reserve space for close button which will be added later: + let close_max_x = title_rect.right() + item_spacing.x + button_size + item_spacing.x; + let close_max_x = close_max_x.max(ui.rect_finite().right()); + let close_rect = Rect::from_min_size( + pos2( + close_max_x - button_size, + title_rect.center().y - 0.5 * button_size, + ), + Vec2::splat(button_size), + ); + ui.expand_to_include_child(close_rect); + } + }) } fn close_button(ui: &mut Ui, rect: Rect) -> InteractInfo { @@ -201,15 +248,6 @@ fn close_button(ui: &mut Ui, rect: Rect) -> InteractInfo { let interact = ui.interact_rect(rect, close_id); ui.expand_to_include_child(interact.rect); - // ui.add_paint_cmd(PaintCmd::Rect { - // corner_radius: ui.style().interact(&interact).corner_radius, - // fill_color: ui.style().interact(&interact).bg_fill_color, - // outline: ui.style().interact(&interact).rect_outline, - // rect: interact.rect, - // }); - - let rect = rect.expand(-4.0); - let stroke_color = ui.style().interact(&interact).stroke_color; let stroke_width = ui.style().interact(&interact).stroke_width; ui.add_paint_cmd(PaintCmd::line_segment( diff --git a/emigui/src/context.rs b/emigui/src/context.rs index fbc69b0c..244daf38 100644 --- a/emigui/src/context.rs +++ b/emigui/src/context.rs @@ -297,7 +297,8 @@ impl Context { rect: Rect, interaction_id: Option, ) -> InteractInfo { - let hovered = self.contains_mouse(layer, clip_rect, rect); + let interact_rect = rect.expand2(0.5 * self.style().item_spacing); // make it easier to click. TODO: nice way to do this + let hovered = self.contains_mouse(layer, clip_rect, interact_rect); let mut memory = self.memory(); let active = interaction_id.is_some() && memory.active_id == interaction_id; diff --git a/emigui/src/types.rs b/emigui/src/types.rs index 2547f3bd..41325e44 100644 --- a/emigui/src/types.rs +++ b/emigui/src/types.rs @@ -42,7 +42,7 @@ impl Default for CursorIcon { // ---------------------------------------------------------------------------- -#[derive(Clone, Copy, Debug, Default, Serialize)] +#[derive(Clone, Copy, Debug, Serialize)] pub struct InteractInfo { /// The mouse is hovering above this thing pub hovered: bool, @@ -58,6 +58,15 @@ pub struct InteractInfo { } impl InteractInfo { + pub fn nothing() -> Self { + Self { + hovered: false, + clicked: false, + active: false, + rect: Rect::nothing(), + } + } + pub fn union(self, other: Self) -> Self { Self { hovered: self.hovered || other.hovered, diff --git a/emigui/src/ui.rs b/emigui/src/ui.rs index adb52103..7f1688ca 100644 --- a/emigui/src/ui.rs +++ b/emigui/src/ui.rs @@ -339,7 +339,16 @@ impl Ui { /// 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. + /// + /// TODO: remove, or redesign or something and start using allocate_space pub fn reserve_space(&mut self, child_size: Vec2, interaction_id: Option) -> InteractInfo { + let rect = self.allocate_space(child_size); + + self.ctx + .interact(self.layer, self.clip_rect, rect, interaction_id) + } + + pub fn allocate_space(&mut self, child_size: Vec2) -> Rect { let child_size = self.round_vec_to_pixels(child_size); self.cursor = self.round_pos_to_pixels(self.cursor); @@ -376,8 +385,7 @@ impl Ui { } } - self.ctx - .interact(self.layer, self.clip_rect, rect, interaction_id) + rect } /// Reserve this much space and move the cursor. @@ -517,12 +525,25 @@ impl Ui { /// Just because you ask for a lot of space does not mean you have to use it! /// After `add_contents` is called the contents of `bounding_size` /// will decide how much space will be used in the parent ui. - pub fn add_custom_contents(&mut self, size: Vec2, add_contents: impl FnOnce(&mut Ui)) { + pub fn add_custom_contents( + &mut self, + size: Vec2, + add_contents: impl FnOnce(&mut Ui), + ) -> InteractInfo { let size = size.min(self.available().size()); let child_rect = Rect::from_min_size(self.cursor, size); let mut child_ui = self.child_ui(child_rect); add_contents(&mut child_ui); - self.reserve_space(child_ui.bounding_size(), None); + self.reserve_space(child_ui.bounding_size(), None) + } + + /// Create a child ui + pub fn add_custom(&mut self, add_contents: impl FnOnce(&mut Ui)) -> InteractInfo { + let child_rect = self.available(); + let mut child_ui = self.child_ui(child_rect); + add_contents(&mut child_ui); + let size = child_ui.bounding_size(); + self.reserve_space(size, None) } /// Create a child ui which is indented to the right diff --git a/emigui/src/widgets.rs b/emigui/src/widgets.rs index 148f1d0e..f4f79e7a 100644 --- a/emigui/src/widgets.rs +++ b/emigui/src/widgets.rs @@ -72,6 +72,10 @@ impl Label { } } + pub fn font_height(&self, ui: &Ui) -> f32 { + ui.fonts()[self.text_style].height() + } + // TODO: this should return a LabelLayout which has a paint method. // We can then split Widget::Ui in two: layout + allocating space, and painting. // this allows us to assemble lables, THEN detect interaction, THEN chose color style based on that. diff --git a/example_glium/src/main.rs b/example_glium/src/main.rs index ccaff00d..8f0f87bf 100644 --- a/example_glium/src/main.rs +++ b/example_glium/src/main.rs @@ -62,8 +62,10 @@ fn main() { let pixels_per_point = display.gl_window().get_hidpi_factor() as f32; - let mut ctx = Context::new(pixels_per_point); - let mut painter = emigui_glium::Painter::new(&display); + let mut ctx = profile("initializing emilib", || Context::new(pixels_per_point)); + let mut painter = profile("initializing painter", || { + emigui_glium::Painter::new(&display) + }); let mut raw_input = emigui::RawInput { screen_size: { @@ -143,6 +145,7 @@ fn main() { emigui_glium::handle_output(output, &display, clipboard.as_mut()); } + // Save state to disk: window_settings.pos = display .gl_window() .get_position() @@ -162,3 +165,11 @@ fn main() { ) .unwrap(); } + +fn profile(name: &str, action: impl FnOnce() -> R) -> R { + let start = Instant::now(); + let r = action(); + let elapsed = start.elapsed(); + eprintln!("{}: {} ms", name, elapsed.as_millis()); + r +}