229 lines
7.7 KiB
Rust
229 lines
7.7 KiB
Rust
//! Полоса вкладок над редактором.
|
|
|
|
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
|
|
}
|