//! Полоса вкладок над редактором. use eframe::egui; use crate::constants::TAB_BAR_HEIGHT; use crate::editor::Document; use crate::ui::icons::{IconType, custom_icon_button}; /// Действия пользователя с вкладками за текущий кадр. #[derive(Default)] pub struct TabBarActions { /// Клик по вкладке: её нужно сделать активной. pub clicked_tab: Option, /// Клик по крестику: вкладку нужно закрыть. pub closed_tab: Option, /// Клик по «+»: нужно открыть новый документ. pub add_clicked: bool, } const TAB_MIN_WIDTH: f32 = 96.0; const TAB_MAX_WIDTH: f32 = 220.0; const TAB_PADDING_LEFT: f32 = 12.0; const TAB_PADDING_RIGHT: f32 = 6.0; const TAB_LABEL_GAP: f32 = 6.0; const TAB_CLOSE_SIZE: f32 = 18.0; const TAB_CORNER_RADIUS: u8 = 6; /// Рисует полосу вкладок и возвращает действия пользователя. pub fn render(ui: &mut egui::Ui, documents: &[Document], active: usize) -> TabBarActions { let mut actions = TabBarActions::default(); let bar_rect = egui::Rect::from_min_size( ui.cursor().min, egui::vec2(ui.available_width(), TAB_BAR_HEIGHT), ); let visuals = ui.visuals().clone(); // Фон полосы совпадает с фоном заголовка, поэтому она выглядит его продолжением. ui.painter() .rect_filled(bar_rect, 0.0, visuals.window_fill()); // Тонкая линия-разделитель под вкладками. Активная вкладка перекрывает её // и сливается с фоном редактора. let separator = egui::Rect::from_min_max( egui::pos2(bar_rect.left(), bar_rect.bottom() - 1.0), bar_rect.right_bottom(), ); ui.painter().rect_filled( separator, 0.0, visuals.widgets.noninteractive.bg_stroke.color, ); ui.allocate_ui_with_layout( egui::vec2(bar_rect.width(), TAB_BAR_HEIGHT), egui::Layout::left_to_right(egui::Align::Center), |ui| { ui.spacing_mut().item_spacing.x = 2.0; ui.add_space(6.0); for (index, doc) in documents.iter().enumerate() { let tab = tab(ui, doc, index == active); if tab.close_clicked { actions.closed_tab = Some(index); } else if tab.clicked { actions.clicked_tab = Some(index); } } ui.add_space(4.0); let add = custom_icon_button(ui, egui::vec2(20.0, 20.0), IconType::Plus); if add.on_hover_text("Новая вкладка").clicked() { actions.add_clicked = true; } }, ); actions } struct TabResponse { clicked: bool, close_clicked: bool, } /// Одна вкладка: фон, название и кнопка закрытия. fn tab(ui: &mut egui::Ui, doc: &Document, is_active: bool) -> TabResponse { let font_id = egui::FontId::proportional(12.5); let label_width = ui.fonts_mut(|fonts| { fonts .layout_no_wrap(doc.name.clone(), font_id.clone(), egui::Color32::WHITE) .size() .x }); let width = (label_width + TAB_PADDING_LEFT + TAB_LABEL_GAP + TAB_CLOSE_SIZE + TAB_PADDING_RIGHT) .clamp(TAB_MIN_WIDTH, TAB_MAX_WIDTH); let (rect, response) = ui.allocate_exact_size(egui::vec2(width, TAB_BAR_HEIGHT), egui::Sense::click()); if response.hovered() { ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand); } let visuals = ui.visuals().clone(); let corner = egui::CornerRadius { nw: TAB_CORNER_RADIUS, ne: TAB_CORNER_RADIUS, sw: 0, se: 0, }; let fill = if is_active { visuals.extreme_bg_color } else if response.hovered() { // Полтона от цвета hover-виджетов: вкладка должна подсвечиваться мягко. visuals .window_fill() .lerp_to_gamma(visuals.widgets.hovered.weak_bg_fill, 0.5) } else { egui::Color32::TRANSPARENT }; ui.painter().rect_filled(rect, corner, fill); // Акцентная полоска у активной вкладки. if is_active { let stripe = egui::Rect::from_min_size(rect.left_top(), egui::vec2(rect.width(), 2.0)); ui.painter() .rect_filled(stripe, corner, visuals.selection.bg_fill); } let close_rect = egui::Rect::from_center_size( egui::pos2( rect.right() - TAB_PADDING_RIGHT - TAB_CLOSE_SIZE / 2.0, rect.center().y, ), egui::Vec2::splat(TAB_CLOSE_SIZE), ); let text_color = if is_active { visuals.strong_text_color() } else if response.hovered() { visuals.text_color() } else { visuals.weak_text_color() }; // Название рисуем через `LayoutJob`, чтобы длинные имена обрезались многоточием. let mut job = egui::text::LayoutJob::single_section( doc.name.clone(), egui::TextFormat::simple(font_id, text_color), ); job.wrap = egui::text::TextWrapping { max_width: (close_rect.left() - TAB_LABEL_GAP - (rect.left() + TAB_PADDING_LEFT)).max(0.0), max_rows: 1, break_anywhere: true, overflow_character: Some('…'), }; let galley = ui.fonts_mut(|fonts| fonts.layout_job(job)); ui.painter().galley( egui::pos2( rect.left() + TAB_PADDING_LEFT, rect.center().y - galley.size().y / 2.0, ), galley, text_color, ); // Кнопка закрытия регистрируется после вкладки, поэтому выигрывает у неё hit-test // в своей области (так же, как кнопки заголовка регистрируются после drag-зоны). let close = close_button( ui, close_rect, ui.scope_id().with(("tab_close", doc.id)), is_active || response.hovered(), doc.is_dirty(), ); TabResponse { clicked: response.clicked(), close_clicked: close.clicked(), } } /// Маленький крестик вкладки; у изменённого документа вместо него — точка. fn close_button( ui: &mut egui::Ui, rect: egui::Rect, id: egui::Id, show_cross: bool, dirty: bool, ) -> egui::Response { // Невидимый крестик не должен ловить клики: иначе клик по пустому месту // закрывал бы вкладку. let sense = if show_cross { egui::Sense::click() } else { egui::Sense::hover() }; let response = ui.interact(rect, id, sense); let visuals = ui.visuals(); let painter = ui.painter(); if response.hovered() { ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand); painter.rect_filled(rect, 4.0, visuals.widgets.hovered.bg_fill); } let center = rect.center(); if dirty && !response.hovered() { painter.circle_filled(center, 3.0, visuals.selection.bg_fill); } else if show_cross { let stroke = egui::Stroke::new(1.2, visuals.weak_text_color()); let r = 3.5; painter.line_segment( [center + egui::vec2(-r, r), center + egui::vec2(r, -r)], stroke, ); painter.line_segment( [center + egui::vec2(-r, -r), center + egui::vec2(r, r)], stroke, ); } response }