use egui::{
Color32, Id, Rect, Response, Rgba, Sense, Stroke, TextStyle, Ui, Vec2, Visuals, WidgetInfo,
WidgetText, WidgetType, vec2,
};
use super::{ResizeState, SimplificationOptions, Tile, TileId, Tiles, UiResponse};
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum EditAction {
TileResized,
TileDragged,
TileDropped,
TabSelected,
}
#[derive(Clone, Debug, Default)]
pub struct TabState {
pub active: bool,
pub is_being_dragged: bool,
pub closable: bool,
}
pub(crate) struct LayoutContext<'a> {
pub gap_width: f32,
pub tab_bar_height: f32,
pub grid_auto_column_count: &'a dyn Fn(usize, Rect, f32) -> usize,
pub tab_auto_selected: &'a std::cell::Cell<bool>,
}
pub(crate) fn layout_tiles<Pane, TilesPane>(
tiles: &mut Tiles<TilesPane>,
root: Option<TileId>,
behavior: &dyn Behavior<Pane>,
style: &egui::Style,
rect: Rect,
) -> bool {
let Some(root) = root else {
return false;
};
let grid_auto_column_count = |num_visible_children, rect, gap| {
behavior.grid_auto_column_count(num_visible_children, rect, gap)
};
let tab_auto_selected = std::cell::Cell::new(false);
let layout = LayoutContext {
gap_width: behavior.gap_width(style),
tab_bar_height: behavior.tab_bar_height(style),
grid_auto_column_count: &grid_auto_column_count,
tab_auto_selected: &tab_auto_selected,
};
tiles.layout_tile(&layout, rect, root);
tab_auto_selected.get()
}
pub trait Behavior<Pane> {
fn pane_ui(&mut self, ui: &mut Ui, tile_id: TileId, pane: &mut Pane) -> UiResponse;
fn tab_title_for_pane(&mut self, pane: &Pane) -> WidgetText;
fn tab_hover_cursor_icon(&self) -> egui::CursorIcon {
egui::CursorIcon::Grab
}
fn is_tab_closable(&self, _tiles: &Tiles<Pane>, _tile_id: TileId) -> bool {
false
}
fn on_tab_close(&mut self, _tiles: &mut Tiles<Pane>, _tile_id: TileId) -> bool {
true
}
fn close_button_outer_size(&self) -> f32 {
12.0
}
fn close_button_inner_margin(&self) -> f32 {
2.0
}
fn tab_title_for_tile(&mut self, tiles: &Tiles<Pane>, tile_id: TileId) -> WidgetText {
if let Some(tile) = tiles.get(tile_id) {
match tile {
Tile::Pane(pane) => self.tab_title_for_pane(pane),
Tile::Container(container) => format!("{:?}", container.kind()).into(),
}
} else {
"MISSING TILE".into()
}
}
fn tab_ui(
&mut self,
tiles: &mut Tiles<Pane>,
ui: &mut Ui,
id: Id,
tile_id: TileId,
state: &TabState,
) -> Response {
let text = self.tab_title_for_tile(tiles, tile_id);
let close_btn_size = Vec2::splat(self.close_button_outer_size());
let close_btn_left_padding = 4.0;
let font_id = TextStyle::Button.resolve(ui.style());
let galley = text.into_galley(ui, Some(egui::TextWrapMode::Extend), f32::INFINITY, font_id);
let x_margin = self.tab_title_spacing(ui.visuals());
let button_width = galley.size().x
+ 2.0 * x_margin
+ f32::from(state.closable) * (close_btn_left_padding + close_btn_size.x);
let (_, tab_rect) = ui.allocate_space(vec2(button_width, ui.available_height()));
let draggable = self.is_tile_draggable(tiles, tile_id);
let sense = if draggable {
Sense::click_and_drag()
} else {
Sense::click()
};
let tab_response = ui.interact(tab_rect, id, sense);
let tab_response = if draggable {
tab_response.on_hover_cursor(self.tab_hover_cursor_icon())
} else {
tab_response
};
tab_response.widget_info(|| {
WidgetInfo::selected(
WidgetType::Button,
ui.is_enabled(),
state.active,
galley.text(),
)
});
if ui.is_rect_visible(tab_rect) && !state.is_being_dragged {
let bg_color = self.tab_bg_color(ui.visuals(), tiles, tile_id, state);
let stroke = self.tab_outline_stroke(ui.visuals(), tiles, tile_id, state);
ui.painter().rect(
tab_rect.shrink(0.5),
0.0,
bg_color,
stroke,
egui::StrokeKind::Inside,
);
if state.active {
ui.painter().hline(
tab_rect.x_range(),
tab_rect.bottom(),
Stroke::new(stroke.width + 1.0, bg_color),
);
}
let text_color = self.tab_text_color(ui.visuals(), tiles, tile_id, state);
let text_position = egui::Align2::LEFT_CENTER
.align_size_within_rect(galley.size(), tab_rect.shrink(x_margin))
.min;
ui.painter().galley(text_position, galley, text_color);
if state.closable {
let close_btn_rect = egui::Align2::RIGHT_CENTER
.align_size_within_rect(close_btn_size, tab_rect.shrink(x_margin));
let close_btn_id = ui.auto_id_with("tab_close_btn");
let close_btn_response = ui
.interact(close_btn_rect, close_btn_id, Sense::click_and_drag())
.on_hover_cursor(egui::CursorIcon::Default);
close_btn_response.widget_info(|| {
WidgetInfo::labeled(WidgetType::Button, ui.is_enabled(), "Close")
});
let visuals = ui.style().interact(&close_btn_response);
let rect = close_btn_rect
.shrink(self.close_button_inner_margin())
.expand(visuals.expansion);
let stroke = visuals.fg_stroke;
ui.painter() .line_segment([rect.left_top(), rect.right_bottom()], stroke);
ui.painter() .line_segment([rect.right_top(), rect.left_bottom()], stroke);
if close_btn_response.clicked()
|| tab_response.clicked_by(egui::PointerButton::Middle)
{
log::debug!("Tab close requested for tile: {tile_id:?}");
if self.on_tab_close(tiles, tile_id) {
log::debug!("Implementation confirmed close request for tile: {tile_id:?}");
tiles.remove(tile_id);
} else {
log::debug!("Implementation denied close request for tile: {tile_id:?}");
}
}
}
}
self.on_tab_button(tiles, tile_id, tab_response)
}
fn drag_ui(&mut self, tiles: &Tiles<Pane>, ui: &mut Ui, tile_id: TileId) {
let mut frame = egui::Frame::popup(ui.style());
frame.fill = frame.fill.gamma_multiply(0.5); frame.show(ui, |ui| {
let text = self.tab_title_for_tile(tiles, tile_id);
ui.label(text);
});
}
fn on_tab_button(
&mut self,
_tiles: &mut Tiles<Pane>,
_tile_id: TileId,
button_response: Response,
) -> Response {
button_response
}
fn retain_pane(&mut self, _pane: &Pane) -> bool {
true
}
fn top_bar_right_ui(
&mut self,
_tiles: &Tiles<Pane>,
_ui: &mut Ui,
_tile_id: TileId,
_tabs: &crate::Tabs,
_scroll_offset: &mut f32,
) {
}
fn tab_bar_left_ui(
&mut self,
_tiles: &Tiles<Pane>,
_ui: &mut Ui,
_tile_id: TileId,
_tabs: &crate::Tabs,
) {
}
fn tab_bar_trailing_ui(
&mut self,
_tiles: &Tiles<Pane>,
_ui: &mut Ui,
_tile_id: TileId,
_tabs: &crate::Tabs,
) {
}
fn tab_bar_height(&self, _style: &egui::Style) -> f32 {
24.0
}
fn gap_width(&self, _style: &egui::Style) -> f32 {
1.0
}
fn min_size(&self) -> f32 {
32.0
}
fn preview_dragged_panes(&self) -> bool {
false
}
fn dragged_overlay_color(&self, visuals: &Visuals) -> Color32 {
visuals.panel_fill.gamma_multiply(0.5)
}
fn simplification_options(&self) -> SimplificationOptions {
SimplificationOptions::default()
}
fn paint_on_top_of_tile(
&self,
_painter: &egui::Painter,
_style: &egui::Style,
_tile_id: TileId,
_rect: Rect,
) {
}
fn resize_stroke(&self, style: &egui::Style, resize_state: ResizeState) -> Stroke {
match resize_state {
ResizeState::Idle => {
Stroke::new(self.gap_width(style), self.tab_bar_color(&style.visuals))
}
ResizeState::Hovering => style.visuals.widgets.hovered.fg_stroke,
ResizeState::Dragging => style.visuals.widgets.active.fg_stroke,
}
}
fn tab_title_spacing(&self, _visuals: &Visuals) -> f32 {
8.0
}
fn tab_bar_color(&self, visuals: &Visuals) -> Color32 {
if visuals.dark_mode {
visuals.extreme_bg_color
} else {
(Rgba::from(visuals.panel_fill) * Rgba::from_gray(0.8)).into()
}
}
fn tab_bg_color(
&self,
visuals: &Visuals,
_tiles: &Tiles<Pane>,
_tile_id: TileId,
state: &TabState,
) -> Color32 {
if state.active {
visuals.panel_fill } else {
Color32::TRANSPARENT }
}
fn tab_outline_stroke(
&self,
visuals: &Visuals,
_tiles: &Tiles<Pane>,
_tile_id: TileId,
state: &TabState,
) -> Stroke {
if state.active {
Stroke::new(1.0, visuals.widgets.active.bg_fill)
} else {
Stroke::NONE
}
}
fn tab_bar_hline_stroke(&self, visuals: &Visuals) -> Stroke {
Stroke::new(1.0, visuals.widgets.noninteractive.bg_stroke.color)
}
fn tab_text_color(
&self,
visuals: &Visuals,
_tiles: &Tiles<Pane>,
_tile_id: TileId,
state: &TabState,
) -> Color32 {
if state.active {
visuals.widgets.active.text_color()
} else {
visuals.widgets.noninteractive.text_color()
}
}
fn drag_preview_stroke(&self, visuals: &Visuals) -> Stroke {
visuals.selection.stroke
}
fn drag_preview_color(&self, visuals: &Visuals) -> Color32 {
visuals.selection.stroke.color.gamma_multiply(0.5)
}
fn paint_drag_preview(
&self,
visuals: &Visuals,
painter: &egui::Painter,
parent_rect: Option<Rect>,
preview_rect: Rect,
) {
let preview_stroke = self.drag_preview_stroke(visuals);
let preview_color = self.drag_preview_color(visuals);
if let Some(parent_rect) = parent_rect {
painter.rect_stroke(parent_rect, 1.0, preview_stroke, egui::StrokeKind::Inside);
}
painter.rect(
preview_rect,
1.0,
preview_color,
preview_stroke,
egui::StrokeKind::Inside,
);
}
fn grid_auto_column_count(&self, num_visible_children: usize, rect: Rect, gap: f32) -> usize {
num_columns_heuristic(
num_visible_children,
rect.size(),
gap,
self.ideal_tile_aspect_ratio(),
)
}
fn ideal_tile_aspect_ratio(&self) -> f32 {
4.0 / 3.0
}
fn is_tile_draggable(&self, _tiles: &Tiles<Pane>, _tile_id: TileId) -> bool {
true
}
fn is_container_resizable(&self, _tiles: &Tiles<Pane>, _tile_id: TileId) -> bool {
true
}
fn on_edit(&mut self, _edit_action: EditAction) {}
}
fn num_columns_heuristic(n: usize, size: Vec2, gap: f32, desired_aspect: f32) -> usize {
let mut best_loss = f32::INFINITY;
let mut best_num_columns = 1;
for ncols in 1..=n {
if 4 <= n && ncols == n - 1 {
continue;
}
let nrows = n.div_ceil(ncols);
let cell_width = (size.x - gap * (ncols as f32 - 1.0)) / (ncols as f32);
let cell_height = (size.y - gap * (nrows as f32 - 1.0)) / (nrows as f32);
let cell_aspect = cell_width / cell_height;
let aspect_diff = (desired_aspect - cell_aspect).abs();
let num_empty_cells = ncols * nrows - n;
let loss = aspect_diff * n as f32 + 2.0 * num_empty_cells as f32;
if loss < best_loss {
best_loss = loss;
best_num_columns = ncols;
}
}
best_num_columns
}
#[test]
fn test_num_columns_heuristic() {
let n = 4;
let gap = 0.0;
let ideal_tile_aspect_ratio = 4.0 / 3.0;
for i in 0..=100 {
let size = Vec2::new(100.0, egui::remap(i as f32, 0.0..=100.0, 1.0..=1000.0));
let ncols = num_columns_heuristic(n, size, gap, ideal_tile_aspect_ratio);
assert!(
ncols == 1 || ncols == 2 || ncols == 4,
"Size {size:?} got {ncols} columns"
);
}
}