This commit is contained in:
2026-10-29 22:40:40 +03:00
commit dce595fbc0
18 changed files with 7983 additions and 0 deletions
+228
View File
@@ -0,0 +1,228 @@
//! Полоса вкладок над редактором.
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<usize>,
/// Клик по крестику: вкладку нужно закрыть.
pub closed_tab: Option<usize>,
/// Клик по «+»: нужно открыть новый документ.
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
}