Skip to content

Commit

Permalink
Scrollable tab bar (#9)
Browse files Browse the repository at this point in the history
* Closes #3

---------

Co-authored-by: Emil Ernerfeldt <emil.ernerfeldt@gmail.com>
  • Loading branch information
bennjii and emilk authored Nov 10, 2023
1 parent 1e63bd2 commit 90922d3
Show file tree
Hide file tree
Showing 4 changed files with 223 additions and 59 deletions.
3 changes: 2 additions & 1 deletion examples/advanced.rs
Original file line number Diff line number Diff line change
Expand Up @@ -130,12 +130,13 @@ impl egui_tiles::Behavior<Pane> for TreeBehavior {
format!("View {}", view.nr).into()
}

fn top_bar_rtl_ui(
fn top_bar_right_ui(
&mut self,
_tiles: &egui_tiles::Tiles<Pane>,
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);
Expand Down
6 changes: 5 additions & 1 deletion src/behavior.rs
Original file line number Diff line number Diff line change
Expand Up @@ -119,12 +119,16 @@ pub trait Behavior<Pane> {
/// 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<Pane>,
_ui: &mut Ui,
_tile_id: TileId,
_tabs: &crate::Tabs,
_scroll_offset: &mut f32,
) {
// if ui.button("➕").clicked() {
// }
Expand Down
2 changes: 1 addition & 1 deletion src/container/linear.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
271 changes: 215 additions & 56 deletions src/container/tabs.rs
Original file line number Diff line number Diff line change
@@ -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))]
Expand All @@ -16,6 +19,116 @@ pub struct Tabs {
pub active: Option<TileId>,
}

/// 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<TileId>) -> Self {
let active = children.first().copied();
Expand Down Expand Up @@ -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<Pane>(
&self,
tree: &mut Tree<Pane>,
Expand All @@ -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::<ScrollState>(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));
});

// -----------
Expand Down

0 comments on commit 90922d3

Please sign in to comment.