use egui::{Color32, Id, Rect};
use crate::frame::FrameStyle;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum TabAction {
New,
Show(usize),
Close(usize),
}
const CLOSE: f32 = 18.0;
const PLUS: f32 = 32.0;
pub struct TabStrip<'a, T: AsRef<str>> {
titles: &'a [T],
shown: usize,
bar: Color32,
span: Option<egui::Rangef>,
id: Id,
new_hint: &'a str,
}
impl<'a, T: AsRef<str>> TabStrip<'a, T> {
pub fn new(titles: &'a [T], shown: usize, style: &FrameStyle) -> Self {
Self { titles, shown, bar: style.title_bar, span: None, id: Id::new("patina_tabs"), new_hint: "New Tab (Ctrl+Shift+T)" }
}
pub fn span(mut self, span: egui::Rangef) -> Self {
self.span = Some(span);
self
}
pub fn id_salt(mut self, salt: impl egui::AsId) -> Self {
self.id = Id::new(salt);
self
}
pub fn new_hint(mut self, hint: &'a str) -> Self {
self.new_hint = hint;
self
}
pub fn tab_id(&self, i: usize) -> (Id, Id) {
(self.id.with(("tab", i)), self.id.with(("close", i)))
}
pub fn show(self, ui: &mut egui::Ui) -> Option<TabAction> {
let global = ui.ctx().global_style();
let shown = if self.bar == global.visuals.panel_fill { global.visuals.extreme_bg_color } else { global.visuals.panel_fill };
let ink = ui.visuals().widgets.inactive.text_color();
let mut action = None;
ui.separator();
ui.spacing_mut().item_spacing.x = 0.0;
let n = self.titles.len().max(1) as f32;
let width = ((ui.available_width() - PLUS) / n).clamp(48.0, 200.0);
for (i, title) in self.titles.iter().enumerate() {
let title = title.as_ref();
let (fill, ink) = if i == self.shown { (Some(shown), global.visuals.strong_text_color()) } else { (None, ink) };
let (id, close_id) = self.tab_id(i);
let response = tab(ui, id, width, self.span, title, fill, ink).on_hover_text(title);
if ui.rect_contains_pointer(response.rect) && close(ui, close_id, response.rect, ink) {
action = Some(TabAction::Close(i));
} else if response.clicked() {
action = Some(TabAction::Show(i));
}
if response.middle_clicked() {
action = Some(TabAction::Close(i));
}
response.context_menu(|ui| {
if ui.button("New Tab").clicked() {
action = Some(TabAction::New);
ui.close();
}
if ui.button("Close Tab").clicked() {
action = Some(TabAction::Close(i));
ui.close();
}
});
}
if tab(ui, self.id.with("new"), PLUS, self.span, "+", None, ink).on_hover_text(self.new_hint).clicked() {
action = Some(TabAction::New);
}
action
}
}
pub fn keys(ctx: &egui::Context, count: usize, shown: usize) -> Option<TabAction> {
ctx.input_mut(|i| {
let mut action = None;
if i.consume_key(egui::Modifiers::CTRL | egui::Modifiers::SHIFT, egui::Key::T) {
action = Some(TabAction::New);
}
if count > 0 && i.consume_key(egui::Modifiers::CTRL, egui::Key::PageUp) {
action = Some(TabAction::Show((shown + count - 1) % count));
}
if count > 0 && i.consume_key(egui::Modifiers::CTRL, egui::Key::PageDown) {
action = Some(TabAction::Show((shown + 1) % count));
}
action
})
}
fn tab(ui: &mut egui::Ui, id: Id, width: f32, span: Option<egui::Rangef>, text: &str, fill: Option<Color32>, ink: Color32) -> egui::Response {
let (row, _) = ui.allocate_exact_size(egui::vec2(width, ui.spacing().interact_size.y), egui::Sense::hover());
let rect = Rect::from_x_y_ranges(row.x_range(), span.unwrap_or(row.y_range()));
let response = ui.interact(rect, id, egui::Sense::click());
let painter = ui.painter().with_clip_rect(rect);
if let Some(fill) = fill.or_else(|| ui.rect_contains_pointer(rect).then(|| ui.visuals().widgets.hovered.weak_bg_fill)) {
painter.rect_filled(rect, 0.0, fill);
}
let room = if width > 4.0 * CLOSE { width - 2.0 * CLOSE - 8.0 } else { width - 16.0 };
let galley = egui::WidgetText::from(egui::RichText::new(text).color(ink)).into_galley(ui, Some(egui::TextWrapMode::Truncate), room, egui::TextStyle::Body);
painter.galley(rect.center() - galley.size() / 2.0, galley, ink);
response
}
fn close(ui: &mut egui::Ui, id: Id, tab: Rect, ink: Color32) -> bool {
let rect = Rect::from_center_size(egui::pos2(tab.right() - 6.0 - CLOSE / 2.0, tab.center().y), egui::Vec2::splat(CLOSE));
let response = ui.interact(rect, id, egui::Sense::click()).on_hover_text("Close Tab");
if response.hovered() {
ui.painter().rect_filled(rect, 0.0, ui.visuals().widgets.active.weak_bg_fill);
}
let (c, d) = (rect.center(), 4.0);
let stroke = egui::Stroke::new(1.2, ink);
ui.painter().line_segment([c + egui::vec2(-d, -d), c + egui::vec2(d, d)], stroke);
ui.painter().line_segment([c + egui::vec2(-d, d), c + egui::vec2(d, -d)], stroke);
response.clicked()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::frame::{self, TitleBar};
use egui::{Event, PointerButton, RawInput, pos2};
use std::cell::Cell;
const TITLES: [&str; 2] = ["one", "two"];
thread_local! {
static TIME: Cell<f64> = const { Cell::new(0.0) };
}
fn bar(ctx: &egui::Context, events: Vec<Event>) -> Option<TabAction> {
let time = TIME.with(|t| {
t.set(t.get() + 0.05);
t.get()
});
let screen = Rect::from_min_size(pos2(0.0, 0.0), egui::vec2(800.0, 600.0));
let input = RawInput { screen_rect: Some(screen), events, time: Some(time), ..Default::default() };
let mut action = None;
let _ = ctx.run_ui(input, |ui| {
let style = FrameStyle::from_visuals(ui.visuals());
let header = frame::header_frame(ui.style(), &style, 0);
egui::Panel::top("header").frame(header).show(ui, |ui| {
let span = (ui.max_rect() + header.inner_margin).y_range();
TitleBar::new("", &style).menu_bar(true).show(ui, |ui| action = TabStrip::new(&TITLES, 1, &style).span(span).show(ui), |_| {});
});
});
action
}
fn ids(i: usize) -> (Id, Id) {
TabStrip::new(&TITLES, 1, &FrameStyle::from_visuals(&egui::Visuals::dark())).tab_id(i)
}
fn button(pos: egui::Pos2, pressed: bool) -> Event {
Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Default::default() }
}
fn click(ctx: &egui::Context, at: egui::Pos2) -> Option<TabAction> {
bar(ctx, vec![Event::PointerMoved(at)]);
bar(ctx, vec![button(at, true)]);
bar(ctx, vec![button(at, false)])
}
#[test]
fn a_tab_takes_clicks_from_the_top_of_the_bar() {
let ctx = egui::Context::default();
bar(&ctx, vec![]);
let first = ctx.read_response(ids(0).0).unwrap().rect;
assert_eq!(first.top(), 0.0, "the tab starts at the window's top edge");
assert_eq!(click(&ctx, pos2(first.center().x, 2.0)), Some(TabAction::Show(0)));
}
#[test]
fn a_hovered_tab_closes_from_its_cross() {
let ctx = egui::Context::default();
bar(&ctx, vec![]);
let tab = ctx.read_response(ids(0).0).unwrap().rect;
bar(&ctx, vec![Event::PointerMoved(tab.center())]);
let cross = ctx.read_response(ids(0).1).expect("hovered, the tab has a cross").rect;
assert_eq!(click(&ctx, cross.center()), Some(TabAction::Close(0)));
}
#[test]
fn plus_opens_a_tab() {
let ctx = egui::Context::default();
bar(&ctx, vec![]);
let last = ctx.read_response(ids(1).0).unwrap().rect;
assert_eq!(click(&ctx, pos2(last.right() + PLUS / 2.0, last.center().y)), Some(TabAction::New));
}
#[test]
fn page_keys_cycle_round() {
let ctx = egui::Context::default();
let key = |key, modifiers| Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers };
let cycle = |key_event, shown| {
let mut action = None;
let input = RawInput { events: vec![key_event], ..Default::default() };
let _ = ctx.run_ui(input, |ui| action = keys(ui.ctx(), 3, shown));
action
};
assert_eq!(cycle(key(egui::Key::PageUp, egui::Modifiers::CTRL), 0), Some(TabAction::Show(2)));
assert_eq!(cycle(key(egui::Key::PageDown, egui::Modifiers::CTRL), 2), Some(TabAction::Show(0)));
assert_eq!(cycle(key(egui::Key::T, egui::Modifiers::CTRL | egui::Modifiers::SHIFT), 0), Some(TabAction::New));
assert_eq!(cycle(key(egui::Key::T, egui::Modifiers::CTRL), 0), None);
}
}