diff --git a/examples/advanced.rs b/examples/advanced.rs index 1006b7c..f972f11 100644 --- a/examples/advanced.rs +++ b/examples/advanced.rs @@ -130,12 +130,13 @@ impl egui_tiles::Behavior for TreeBehavior { format!("View {}", view.nr).into() } - fn top_bar_rtl_ui( + fn top_bar_right_ui( &mut self, _tiles: &egui_tiles::Tiles, ui: &mut egui::Ui, tile_id: egui_tiles::TileId, _tabs: &egui_tiles::Tabs, + _scroll_offset: &mut f32, ) { if ui.button("➕").clicked() { self.add_child_to = Some(tile_id); diff --git a/src/behavior.rs b/src/behavior.rs index fd94e0f..6b8b611 100644 --- a/src/behavior.rs +++ b/src/behavior.rs @@ -119,12 +119,16 @@ pub trait Behavior { /// You can use this to, for instance, add a button for adding new tabs. /// /// The widgets will be added right-to-left. - fn top_bar_rtl_ui( + /// + /// `_scroll_offset` is a mutable reference to the tab scroll value. + /// Adding to this value will scroll the tabs to the right, subtracting to the left. + fn top_bar_right_ui( &mut self, _tiles: &Tiles, _ui: &mut Ui, _tile_id: TileId, _tabs: &crate::Tabs, + _scroll_offset: &mut f32, ) { // if ui.button("➕").clicked() { // } diff --git a/src/container/linear.rs b/src/container/linear.rs index 623c3ec..b89aa68 100644 --- a/src/container/linear.rs +++ b/src/container/linear.rs @@ -121,7 +121,7 @@ impl Linear { /// /// The `fraction` is the fraction of the total width that the first child should get. pub fn new_binary(dir: LinearDir, children: [TileId; 2], fraction: f32) -> Self { - debug_assert!(0.0 <= fraction && fraction <= 1.0); + debug_assert!((0.0..=1.0).contains(&fraction)); let mut slf = Self { children: children.into(), dir, diff --git a/src/container/tabs.rs b/src/container/tabs.rs index 5ee1e76..8594e1f 100644 --- a/src/container/tabs.rs +++ b/src/container/tabs.rs @@ -1,10 +1,13 @@ -use egui::{vec2, Rect}; +use egui::{scroll_area::ScrollBarVisibility, vec2, NumExt, Rect, Vec2}; use crate::{ is_being_dragged, Behavior, ContainerInsertion, DropContext, InsertionPoint, SimplifyAction, TileId, Tiles, Tree, }; +/// Fixed size icons for `⏴` and `⏵` +const SCROLL_ARROW_SIZE: Vec2 = Vec2::splat(20.0); + /// A container with tabs. Only one tab is open (active) at a time. #[derive(Clone, Debug, Default, PartialEq, Eq)] #[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] @@ -16,6 +19,116 @@ pub struct Tabs { pub active: Option, } +/// The current tab scrolling state +#[derive(Clone, Copy, Debug, Default)] +struct ScrollState { + /// The current horizontal scroll offset. + /// + /// Positive: scroll right. + /// Negatie: scroll left. + pub offset: f32, + + /// Outstanding offset to apply smoothly over the next few frames. + /// This is what the buttons update. + pub offset_debt: f32, + + /// The size of all the tabs last frame. + pub content_size: Vec2, + + /// The available size for the tabs. + pub available: Vec2, + + /// Show the left scroll-arrow this frame? + pub show_left_arrow: bool, + + /// Show the right scroll-arrow this frame? + pub show_right_arrow: bool, + + /// Did we show the left scroll-arrow last frame? + pub showed_left_arrow_prev: bool, + + /// Did we show the right scroll-arrow last frame? + pub showed_right_arrow_prev: bool, +} + +impl ScrollState { + /// Returns the space left for the tabs after the scroll arrows. + pub fn update(&mut self, ui: &egui::Ui) -> f32 { + let mut scroll_area_width = ui.available_width(); + + let button_and_spacing_width = SCROLL_ARROW_SIZE.x + ui.spacing().item_spacing.x; + + let margin = 0.1; + + self.show_left_arrow = SCROLL_ARROW_SIZE.x < self.offset; + + if self.show_left_arrow { + scroll_area_width -= button_and_spacing_width; + } + + self.show_right_arrow = self.offset + scroll_area_width + margin < self.content_size.x; + + // Compensate for showing/hiding of arrow: + self.offset += button_and_spacing_width + * ((self.show_left_arrow as i32 as f32) - (self.showed_left_arrow_prev as i32 as f32)); + + if self.show_right_arrow { + scroll_area_width -= button_and_spacing_width; + } + + self.showed_left_arrow_prev = self.show_left_arrow; + self.showed_right_arrow_prev = self.show_right_arrow; + + if self.offset_debt != 0.0 { + const SPEED: f32 = 500.0; + + let dt = ui.input(|i| i.stable_dt).min(0.1); + let max_movement = dt * SPEED; + if self.offset_debt.abs() <= max_movement { + self.offset += self.offset_debt; + self.offset_debt = 0.0; + } else { + let movement = self.offset_debt.signum() * max_movement; + self.offset += movement; + self.offset_debt -= movement; + ui.ctx().request_repaint(); + } + } + + scroll_area_width + } + + fn scroll_increment(&self) -> f32 { + (self.available.x / 3.0).at_least(20.0) + } + + pub fn left_arrow(&mut self, ui: &mut egui::Ui) { + if !self.show_left_arrow { + return; + } + + if ui + .add_sized(SCROLL_ARROW_SIZE, egui::Button::new("⏴")) + .clicked() + { + self.offset_debt -= self.scroll_increment(); + } + } + + pub fn right_arrow(&mut self, ui: &mut egui::Ui) { + if !self.show_right_arrow { + return; + } + + if ui + .add_sized(SCROLL_ARROW_SIZE, egui::Button::new("⏵")) + .clicked() + { + self.offset_debt += self.scroll_increment(); + } + } +} + impl Tabs { pub fn new(children: Vec) -> Self { let active = children.first().copied(); @@ -86,6 +199,7 @@ impl Tabs { } /// Returns the next active tab (e.g. the one clicked, or the current). + #[allow(clippy::too_many_lines)] fn tab_bar_ui( &self, tree: &mut Tree, @@ -108,64 +222,109 @@ impl Tabs { .rect_filled(ui.max_rect(), 0.0, behavior.tab_bar_color(ui.visuals())); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - // Add buttons such as "add new tab" - behavior.top_bar_rtl_ui(&tree.tiles, ui, tile_id, self); - - ui.spacing_mut().item_spacing.x = 0.0; // Tabs have spacing built-in - - ui.with_layout(egui::Layout::left_to_right(egui::Align::Center), |ui| { - ui.set_clip_rect(ui.max_rect()); // Don't cover the `rtl_ui` buttons. - - if !tree.is_root(tile_id) { - // Make the background behind the buttons draggable (to drag the parent container tile): - if ui - .interact( - ui.max_rect(), - ui.id().with("background"), - egui::Sense::drag(), - ) - .on_hover_cursor(egui::CursorIcon::Grab) - .drag_started() - { - behavior.on_edit(); - ui.memory_mut(|mem| mem.set_dragged_id(tile_id.egui_id())); - } - } + let scroll_state_id = ui.make_persistent_id(tile_id); + let mut scroll_state = ui.ctx().memory_mut(|m| { + m.data + .get_temp::(scroll_state_id) + .unwrap_or_default() + }); - for (i, &child_id) in self.children.iter().enumerate() { - if !tree.is_visible(child_id) { - continue; - } - - let is_being_dragged = is_being_dragged(ui.ctx(), child_id); - - let selected = self.is_active(child_id); - let id = child_id.egui_id(); - - let response = - behavior.tab_ui(&tree.tiles, ui, id, child_id, selected, is_being_dragged); - let response = response.on_hover_cursor(egui::CursorIcon::Grab); - if response.clicked() { - behavior.on_edit(); - next_active = Some(child_id); - } - - if let Some(mouse_pos) = drop_context.mouse_pos { - if drop_context.dragged_tile_id.is_some() - && response.rect.contains(mouse_pos) - { - // Expand this tab - maybe the user wants to drop something into it! - behavior.on_edit(); - next_active = Some(child_id); + // Allow user to add buttons such as "add new tab". + // They can also read and modify the scroll state if they want. + behavior.top_bar_right_ui(&tree.tiles, ui, tile_id, self, &mut scroll_state.offset); + + let scroll_area_width = scroll_state.update(ui); + + // We're in a right-to-left layout, so start with the right scroll-arrow: + scroll_state.right_arrow(ui); + + ui.allocate_ui_with_layout( + ui.available_size(), + egui::Layout::left_to_right(egui::Align::Center), + |ui| { + scroll_state.left_arrow(ui); + + // Prepare to show the scroll area with the tabs: + + scroll_state.offset = scroll_state + .offset + .at_most(scroll_state.content_size.x - ui.available_width()); + scroll_state.offset = scroll_state.offset.at_least(0.0); + + let scroll_area = egui::ScrollArea::horizontal() + .scroll_bar_visibility(ScrollBarVisibility::AlwaysHidden) + .max_width(scroll_area_width) + .auto_shrink([false; 2]) + .horizontal_scroll_offset(scroll_state.offset); + + let output = scroll_area.show(ui, |ui| { + if !tree.is_root(tile_id) { + // Make the background behind the buttons draggable (to drag the parent container tile): + if ui + .interact( + ui.max_rect(), + ui.id().with("background"), + egui::Sense::drag(), + ) + .on_hover_cursor(egui::CursorIcon::Grab) + .drag_started() + { + behavior.on_edit(); + ui.memory_mut(|mem| mem.set_dragged_id(tile_id.egui_id())); + } } - } - button_rects.insert(child_id, response.rect); - if is_being_dragged { - dragged_index = Some(i); - } - } - }); + ui.spacing_mut().item_spacing.x = 0.0; // Tabs have spacing built-in + + for (i, &child_id) in self.children.iter().enumerate() { + if !tree.is_visible(child_id) { + continue; + } + + let is_being_dragged = is_being_dragged(ui.ctx(), child_id); + + let selected = self.is_active(child_id); + let id = child_id.egui_id(); + + let response = behavior.tab_ui( + &tree.tiles, + ui, + id, + child_id, + selected, + is_being_dragged, + ); + let response = response.on_hover_cursor(egui::CursorIcon::Grab); + if response.clicked() { + behavior.on_edit(); + next_active = Some(child_id); + } + + if let Some(mouse_pos) = drop_context.mouse_pos { + if drop_context.dragged_tile_id.is_some() + && response.rect.contains(mouse_pos) + { + // Expand this tab - maybe the user wants to drop something into it! + behavior.on_edit(); + next_active = Some(child_id); + } + } + + button_rects.insert(child_id, response.rect); + if is_being_dragged { + dragged_index = Some(i); + } + } + }); + + scroll_state.offset = output.state.offset.x; + scroll_state.content_size = output.content_size; + scroll_state.available = output.inner_rect.size(); + }, + ); + + ui.ctx() + .memory_mut(|m| m.data.insert_temp(scroll_state_id, scroll_state)); }); // -----------