This commit is contained in:
2026-10-31 01:50:35 +03:00
parent 19e05e7ed0
commit afd7600150
8 changed files with 740 additions and 8 deletions
+202
View File
@@ -18,6 +18,8 @@ use std::time::Duration;
use mlua::IntoLua;
use serde_json::Number;
use gpui_kit::{Hsla, Rgba};
use crate::constants::{BOTTOM_PANEL_HEIGHT, LEFT_PANEL_WIDTH, RIGHT_PANEL_WIDTH};
use crate::editor::{FocusAfterSync, Language, Workspace};
use crate::icons;
@@ -26,6 +28,7 @@ use crate::lsp::{
DocumentDiagnostics, LspConfig, LspManager, WatcherConfig, document_uri, position_to_offset,
};
use crate::panel::{PanelHandle, PanelState};
use crate::toolbar::{ToolbarHandle, ToolbarItem, ToolbarKind, ToolbarSide, ToolbarState};
use crate::tree::{TreeHandle, TreeNode};
thread_local! {
@@ -124,6 +127,8 @@ pub struct AppApi {
#[lua(skip)]
bottom_panel: PanelHandle,
#[lua(skip)]
toolbar: ToolbarHandle,
#[lua(skip)]
lsp: Arc<LspManager>,
#[lua(skip)]
diagnostics_handler: Rc<RefCell<Option<DiagnosticsHandler>>>,
@@ -147,6 +152,23 @@ impl PanelApi {
}
}
/// API верхней панели редактора.
///
/// В Lua доступно как `app.toolbar`. Элементы добавляются в левую или правую
/// область панели и опознаются по `id`, чтобы их можно было скрыть и снова
/// показать.
#[derive(mlua::UserData)]
pub struct ToolbarApi {
#[lua(skip)]
state: ToolbarHandle,
}
impl ToolbarApi {
fn new(state: ToolbarHandle) -> Self {
Self { state }
}
}
/// API одного блока дерева.
///
/// Объект возвращает `app.left_panel:add_tree()`. Редактор не знает, что
@@ -287,6 +309,86 @@ impl PanelApi {
}
}
#[mlua::userdata_impl]
impl ToolbarApi {
/// Добавляет текстовый лейбл в левую или правую область панели.
///
/// `text` — отображаемый текст;
/// `icon` — имя иконки Lucide или `nil`;
/// `background` — цвет фона в виде `#RRGGBB`, `#RRGGBBAA` или `nil`;
/// `id` — идентификатор для `hide`/`show`;
/// `side` — `"left"` или `"right"`.
///
/// Элемент с уже существующим `id` заменяется.
pub fn add_label(
&self,
text: String,
icon: Option<String>,
background: Option<String>,
id: String,
side: String,
) -> mlua::Result<()> {
let id = parse_toolbar_id(id)?;
let side = parse_toolbar_side(&side)?;
let icon = parse_toolbar_icon(icon)?;
let background = parse_toolbar_color(background)?;
self.state.borrow_mut().push(
side,
ToolbarItem::new(id, text, icon, background, ToolbarKind::Label),
);
Ok(())
}
/// Добавляет кнопку в левую или правую область панели.
///
/// Параметры совпадают с [`ToolbarApi::add_label`]; дополнительный
/// `callback` вызывается при нажатии без аргументов.
///
/// `background = nil` оставляет стандартный фон кнопки.
pub fn add_button(
&self,
text: String,
icon: Option<String>,
background: Option<String>,
id: String,
side: String,
callback: mlua::Function,
) -> mlua::Result<()> {
let id = parse_toolbar_id(id)?;
let side = parse_toolbar_side(&side)?;
let icon = parse_toolbar_icon(icon)?;
let background = parse_toolbar_color(background)?;
self.state.borrow_mut().push(
side,
ToolbarItem::new(id, text, icon, background, ToolbarKind::Button { callback }),
);
Ok(())
}
/// Скрывает элемент с идентификатором `id`.
///
/// Возвращает `false`, если элемента с таким `id` нет. Элемент остаётся
/// в модели и снова показывается через [`ToolbarApi::show`].
#[lua(infallible)]
pub fn hide(&self, id: String) -> bool {
self.state.borrow_mut().hide(&id)
}
/// Снова показывает скрытый элемент с идентификатором `id`.
///
/// Возвращает `false`, если элемента с таким `id` нет.
#[lua(infallible)]
pub fn show(&self, id: String) -> bool {
self.state.borrow_mut().show(&id)
}
/// Удаляет все элементы верхней панели.
#[lua(infallible)]
pub fn clear(&self) {
self.state.borrow_mut().clear();
}
}
#[mlua::userdata_impl]
impl AppApi {
/// Делает переданные документы и карту клавиш текущими для
@@ -299,6 +401,7 @@ impl AppApi {
left_panel: Rc::new(RefCell::new(PanelState::new(LEFT_PANEL_WIDTH))),
right_panel: Rc::new(RefCell::new(PanelState::new(RIGHT_PANEL_WIDTH))),
bottom_panel: Rc::new(RefCell::new(PanelState::new(BOTTOM_PANEL_HEIGHT))),
toolbar: Rc::new(RefCell::new(ToolbarState::new())),
lsp: Arc::new(LspManager::new()),
diagnostics_handler: Rc::new(RefCell::new(None)),
};
@@ -337,6 +440,12 @@ impl AppApi {
self.bottom_panel.clone()
}
/// Состояние верхней панели (для вида).
#[lua(skip)]
pub fn toolbar_handle(&self) -> ToolbarHandle {
self.toolbar.clone()
}
/// Менеджер LSP-серверов (для вида и тестов).
#[lua(skip)]
pub fn lsp_handle(&self) -> Arc<LspManager> {
@@ -751,6 +860,16 @@ impl AppApi {
PanelApi::new(self.bottom_panel.clone())
}
/// Верхняя панель редактора.
///
/// В Lua доступна как `app.toolbar` с методами `add_label`, `add_button`,
/// `hide`, `show` и `clear`.
#[allow(dead_code)]
#[lua(getter, infallible)]
pub fn toolbar(&self) -> ToolbarApi {
ToolbarApi::new(self.toolbar.clone())
}
/// Закрывает окно приложения.
#[lua(infallible)]
pub fn close(&self) {
@@ -1070,6 +1189,89 @@ fn parse_key_handler(
}
}
/// Проверяет идентификатор элемента верхней панели.
fn parse_toolbar_id(id: String) -> mlua::Result<String> {
if id.trim().is_empty() {
return Err(mlua::Error::RuntimeError(
"id элемента верхней панели не может быть пустым".to_owned(),
));
}
Ok(id)
}
/// Разбирает сторону верхней панели: `left` или `right`.
fn parse_toolbar_side(side: &str) -> mlua::Result<ToolbarSide> {
ToolbarSide::parse(side).ok_or_else(|| {
mlua::Error::RuntimeError(format!(
"неизвестная сторона панели: {side}; ожидается \"left\" или \"right\""
))
})
}
/// Проверяет имя иконки по каталогу Lucide.
///
/// `nil` и пустая строка означают «без иконки»; неизвестное имя — ошибка,
/// чтобы опечатка в сценарии не пропадала молча.
fn parse_toolbar_icon(icon: Option<String>) -> mlua::Result<Option<String>> {
let Some(name) = icon else {
return Ok(None);
};
if name.trim().is_empty() {
return Ok(None);
}
icons::canonical_name(&name)
.map(|canonical| Some(canonical.to_owned()))
.ok_or_else(|| mlua::Error::RuntimeError(format!("неизвестная иконка: {name}")))
}
/// Разбирает цвет фона элемента верхней панели.
///
/// Поддерживаются `#RGB`, `#RGBA`, `#RRGGBB` и `#RRGGBBAA`; `nil`,
/// пустая строка и `transparent` означают прозрачный фон.
fn parse_toolbar_color(color: Option<String>) -> mlua::Result<Option<Hsla>> {
let Some(color) = color else {
return Ok(None);
};
let color = color.trim();
if color.is_empty() || color.eq_ignore_ascii_case("transparent") {
return Ok(None);
}
parse_hex_color(color).map(Some).ok_or_else(|| {
mlua::Error::RuntimeError(format!(
"некорректный цвет фона: {color}; ожидается #RGB, #RGBA, #RRGGBB или #RRGGBBAA"
))
})
}
/// Переводит шестнадцатеричный цвет в `Hsla`.
fn parse_hex_color(value: &str) -> Option<Hsla> {
let hex = value.strip_prefix('#').unwrap_or(value);
if !hex.chars().all(|ch| ch.is_ascii_hexdigit()) {
return None;
}
let nibble = |index: usize| {
u8::from_str_radix(&hex[index..index + 1], 16)
.ok()
.map(|value| value * 17)
};
let byte = |index: usize| u8::from_str_radix(&hex[index..index + 2], 16).ok();
let (r, g, b, a) = match hex.len() {
3 => (nibble(0)?, nibble(1)?, nibble(2)?, 255),
4 => (nibble(0)?, nibble(1)?, nibble(2)?, nibble(3)?),
6 => (byte(0)?, byte(2)?, byte(4)?, 255),
8 => (byte(0)?, byte(2)?, byte(4)?, byte(6)?),
_ => return None,
};
Some(Hsla::from(Rgba {
r: f32::from(r) / 255.0,
g: f32::from(g) / 255.0,
b: f32::from(b) / 255.0,
a: f32::from(a) / 255.0,
}))
}
/// Разбирает массив узлов дерева, переданный в `TreeApi::append`.
fn parse_tree_nodes(nodes: &mlua::Table) -> mlua::Result<Vec<TreeNode>> {
let mut parsed = Vec::new();
+178 -6
View File
@@ -20,8 +20,11 @@ use gpui_kit::component::input::{
Editor, EditorState, HoverProvider, InputEvent, Rope, TabSize, TextDecoration,
TextDecorationCollection,
};
use gpui_kit::component::label::Label;
use gpui_kit::component::resizable::HandleEdge;
use gpui_kit::component::status_bar::StatusBar;
use gpui_kit::component::tab::{Tab, TabBar};
use gpui_kit::component::tag::Tag;
use gpui_kit::component::text::TextView;
use gpui_kit::component::{
ActiveTheme, Icon, Sizable, StyledExt, TitleBar, h_flex, h_resizable, resizable_panel,
@@ -43,6 +46,7 @@ use crate::keymap::{AppCommand, CompletionCommand, KeyContext, KeyHandler, KeyMa
use crate::lsp::{LspManager, document_uri, position_to_offset};
use crate::panel::{PanelBlock, PanelHandle};
use crate::script::ScriptEngine;
use crate::toolbar::{ToolbarHandle, ToolbarItem, ToolbarKind};
use crate::tree::{TreeAction, TreeHandle, TreeKeyResult};
/// Payload перетаскивания разделителя левой панели.
@@ -184,6 +188,8 @@ pub struct MyApp {
right_panel: PanelHandle,
/// Состояние нижней панели, общее с `app.bottom_panel` в Lua.
bottom_panel: PanelHandle,
/// Элементы верхней панели, общие с `app.toolbar` в Lua.
toolbar: ToolbarHandle,
/// Открытость панелей в предыдущем кадре: по переходу «закрыта →
/// открыта» вид возвращает фокус в дерево, которое было выбрано раньше.
panel_open_state: Cell<[bool; 3]>,
@@ -377,6 +383,7 @@ impl MyApp {
let left_panel = api.left_panel_handle();
let right_panel = api.right_panel_handle();
let bottom_panel = api.bottom_panel_handle();
let toolbar = api.toolbar_handle();
// `capture_key_down` на корне вызывается уже после обработки action,
// поэтому для стрелок он не успевал. Interceptor срабатывает до
@@ -400,6 +407,7 @@ impl MyApp {
left_panel,
right_panel,
bottom_panel,
toolbar,
panel_open_state: Cell::new([false; 3]),
editor,
synced_revision: 0,
@@ -1638,11 +1646,13 @@ impl MyApp {
}
}
/// Редактор с левой, правой и нижней панелями.
/// Вкладки, полоса над редактором и сам редактор с панелями.
///
/// Все панели меняют размер перетаскиванием разделителя, а закрытые
/// панели не занимают места. В каждой панели рисуются добавленные
/// сценарием блоки; правая панель без блоков показывает статус LSP.
/// Вкладки живут только над центральной колонкой и не заходят на
/// боковые панели. Все панели меняют размер перетаскиванием разделителя,
/// а закрытые панели не занимают места. В каждой панели рисуются
/// добавленные сценарием блоки; правая панель без блоков показывает
/// статус LSP.
fn render_workspace(
&mut self,
window: &mut Window,
@@ -1683,8 +1693,19 @@ impl MyApp {
)),
);
// Вкладки и полоса над редактором относятся только к центральной
// колонке: боковые панели тянутся на всю высоту и их не пересекают.
let center = div()
.size_full()
.min_w_0()
.flex()
.flex_col()
.child(self.render_tabs(cx))
.child(self.render_editor_toolbar(cx))
.child(div().flex_1().min_h_0().child(editor_area));
let area = h_resizable("main-area")
.child(resizable_panel().child(editor_area))
.child(resizable_panel().child(center))
.child(
resizable_panel()
.size(px(right_size))
@@ -1727,6 +1748,138 @@ impl MyApp {
})
}
/// Полоса между вкладками и редактором.
///
/// Элементы добавляются сценарием через `app.toolbar`. Левые элементы
/// выстраиваются от левого края вправо, правые — от правого края влево.
fn render_editor_toolbar(&self, cx: &mut Context<Self>) -> impl IntoElement {
let entity = cx.entity();
let (left, right) = {
let toolbar = self.toolbar.borrow();
(
toolbar
.left()
.iter()
.filter(|item| item.visible)
.cloned()
.collect::<Vec<_>>(),
toolbar
.right()
.iter()
.filter(|item| item.visible)
.cloned()
.collect::<Vec<_>>(),
)
};
let left_items = left
.into_iter()
.map(|item| Self::render_toolbar_item(item, &entity, cx))
.collect::<Vec<_>>();
let right_items = right
.into_iter()
.map(|item| Self::render_toolbar_item(item, &entity, cx))
.collect::<Vec<_>>();
h_flex()
.w_full()
.items_center()
.gap_2()
.px_2()
.py_1()
.border_b_1()
.border_color(cx.theme().border)
// Тот же токен, что и у активной folder-вкладки: полоса
// продолжает её фон и меняется вместе с темой.
.bg(cx.theme().tokens.tab_active)
.child(h_flex().items_center().gap_2().children(left_items))
.child(div().flex_1())
.child(
h_flex()
// Первый добавленный элемент оказывается у правого края.
.flex_row_reverse()
.items_center()
.gap_2()
.children(right_items),
)
}
/// Рисует один элемент верхней панели.
fn render_toolbar_item(
item: ToolbarItem,
entity: &Entity<Self>,
cx: &mut Context<Self>,
) -> AnyElement {
let icon = item.icon.as_deref().and_then(icons::from_name);
match item.kind {
ToolbarKind::Label => {
let Some(background) = item.background else {
// Прозрачный лейбл остаётся без отступов и с обычным
// размером текста — как компонент `Label`.
let mut label = h_flex()
.items_center()
.gap_1()
.line_height(relative(1.25))
.text_color(cx.theme().foreground);
if let Some(icon) = icon {
label = label.child(Icon::new(icon).xsmall());
}
return label.child(item.text).into_any_element();
};
// Лейбл с фоном повторяет компактный `Tag` из примера:
// маленький текст и небольшие отступы.
let color = contrast_foreground(background);
let mut tag = Tag::new()
.small()
.rounded_full()
.bg(background)
.text_color(color);
if let Some(icon) = icon {
tag = tag.child(Icon::new(icon).xsmall().text_color(color));
}
tag.child(item.text).into_any_element()
}
ToolbarKind::Button { callback } => {
let mut button = Button::new(format!("toolbar-{}", item.id)).xsmall().ghost();
if let Some(icon) = icon {
button = button.icon(icon);
}
if !item.text.is_empty() {
button = button.label(item.text);
}
if let Some(background) = item.background {
button = button
.bg(background)
.text_color(contrast_foreground(background));
}
let entity = entity.clone();
button
.on_click(move |_, _, cx| {
run_toolbar_callback(&callback);
// Обработчик мог скрыть кнопку или изменить состояние.
entity.update(cx, |_, cx| cx.notify());
})
.into_any_element()
}
}
}
/// Строка состояния во всю ширину окна.
///
/// Содержимое пока демонстрационное: текст слева и кнопка справа.
fn render_status_bar(&self, _cx: &mut Context<Self>) -> impl IntoElement {
StatusBar::new().left(Label::new("Готово")).right(
Button::new("status-settings")
.xsmall()
.ghost()
.icon(AssetIcon::Settings)
.label("Настройки")
.tooltip("Демонстрационная кнопка"),
)
}
/// Полоса вкладок в стиле folder tabs: иконка, название, маркер
/// несохранённых изменений и крестик закрытия, а также «+».
fn render_tabs(&self, cx: &mut Context<Self>) -> impl IntoElement {
@@ -1891,8 +2044,8 @@ impl Render for MyApp {
.child(
TitleBar::new().child(div().w_full().pr_2().text_sm().font_medium().child(title)),
)
.child(self.render_tabs(cx))
.child(div().flex_1().w_full().min_h_0().child(workspace))
.child(self.render_status_bar(cx))
.child(self.render_completion_popup(cx))
}
}
@@ -1958,6 +2111,25 @@ fn report_tree_error(result: mlua::Result<()>) {
}
}
/// Вызывает Lua-обработчик кнопки верхней панели.
///
/// Ошибка печатается в stderr и не мешает отрисовке: сценарий может упасть,
/// но окно продолжает работать.
fn run_toolbar_callback(callback: &mlua::Function) {
if let Err(err) = callback.call::<()>(()) {
eprintln!("Lua: ошибка в обработчике кнопки верхней панели: {err}");
}
}
/// Цвет текста поверх пользовательского фона: чёрный на светлом, белый на тёмном.
fn contrast_foreground(background: Hsla) -> Hsla {
if background.l > 0.6 {
gpui_kit::component::black()
} else {
gpui_kit::component::white()
}
}
/// Сохраняет активную вкладку, печатая ошибку в stderr.
fn save_active_tab() {
if let Err(err) = AppApi::global().save() {
+1
View File
@@ -15,6 +15,7 @@ mod keymap;
mod lsp;
mod panel;
mod script;
mod toolbar;
mod tree;
#[cfg(test)]
+137
View File
@@ -17,6 +17,7 @@ use crate::editor::{FocusAfterSync, Language, Workspace};
use crate::keymap::{KeyBinding, KeyContext, KeyHandler, KeyMap, Modifiers as KeyModifiers};
use crate::panel::PanelBlock;
use crate::script::ScriptEngine;
use crate::toolbar::ToolbarKind;
use crate::tree::TreeNode;
fn install_api(workspace: Rc<RefCell<Workspace>>) -> (AppApi, Rc<RefCell<KeyMap>>) {
@@ -364,6 +365,97 @@ fn lua_script_can_control_panels() {
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn lua_script_configures_toolbar_and_hides_items_by_id() {
let workspace = Rc::new(RefCell::new(Workspace::default()));
let (api, _keymap) = install_api(workspace);
let toolbar = api.toolbar_handle();
let dir = temp_script_dir("toolbar");
let script = dir.join("toolbar.lua");
std::fs::write(
&script,
r##"
app.toolbar:add_label("Готово", nil, "#808080", "status", "left")
app.toolbar:add_button("Запустить", "play", nil, "run", "right", function() end)
app.toolbar:add_label("UTF-8", nil, nil, "encoding", "right")
assert(app.toolbar:hide("run") == true)
assert(app.toolbar:show("run") == true)
assert(app.toolbar:hide("missing") == false)
assert(app.toolbar:show("missing") == false)
-- Неизвестная сторона и иконка — ошибка, а не молчаливый пропуск.
assert(not pcall(function()
app.toolbar:add_label("x", nil, nil, "bad", "middle")
end))
assert(not pcall(function()
app.toolbar:add_label("x", "not-an-icon", nil, "bad-icon", "left")
end))
assert(not pcall(function()
app.toolbar:add_label("x", nil, "not-a-color", "bad-color", "left")
end))
"##,
)
.expect("toolbar.lua");
let engine = ScriptEngine::new(api).expect("движок Lua");
engine
.run_file(&script)
.expect("верхняя панель должна настраиваться из Lua");
let toolbar = toolbar.borrow();
assert_eq!(toolbar.left().len(), 1);
assert_eq!(toolbar.left()[0].id, "status");
assert_eq!(toolbar.left()[0].text, "Готово");
assert!(toolbar.left()[0].background.is_some());
assert!(toolbar.left()[0].visible);
// Первый добавленный элемент правой области рисуется у правого края.
assert_eq!(toolbar.right().len(), 2);
assert_eq!(toolbar.right()[0].id, "run");
assert!(matches!(
toolbar.right()[0].kind,
ToolbarKind::Button { .. }
));
assert!(toolbar.right()[0].visible);
assert_eq!(toolbar.right()[1].id, "encoding");
assert!(matches!(toolbar.right()[1].kind, ToolbarKind::Label));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn toolbar_replaces_items_with_the_same_id() {
let workspace = Rc::new(RefCell::new(Workspace::default()));
let (api, _keymap) = install_api(workspace);
let toolbar_api = api.toolbar();
let toolbar = api.toolbar_handle();
toolbar_api
.add_label("Старый".into(), None, None, "item".into(), "left".into())
.expect("лейбл");
toolbar_api
.add_label(
"Новый".into(),
None,
Some("#112233".into()),
"item".into(),
"right".into(),
)
.expect("замена");
let toolbar = toolbar.borrow();
assert!(toolbar.left().is_empty());
assert_eq!(toolbar.right().len(), 1);
assert_eq!(toolbar.right()[0].text, "Новый");
drop(toolbar);
toolbar_api.clear();
assert!(!toolbar_api.hide("item".to_string()));
assert!(api.toolbar_handle().borrow().right().is_empty());
}
#[test]
fn lua_tree_block_appends_sorts_and_removes_nodes() {
let workspace = Rc::new(RefCell::new(Workspace::default()));
@@ -1128,6 +1220,51 @@ fn opened_panels_render_without_panicking(cx: &mut TestAppContext) {
assert!(api.bottom_panel_handle().borrow().is_open());
}
#[gpui_kit::test]
fn toolbar_items_render_from_api(cx: &mut TestAppContext) {
cx.update(gpui_kit::init);
let (app, visual) = cx.add_window_view(MyApp::new);
// Функция создаётся в отдельном Lua-состоянии: для проверки отрисовки
// достаточно, чтобы обработчик существовал.
let lua = mlua::Lua::new();
let callback = lua
.load("return function() end")
.eval::<mlua::Function>()
.unwrap();
let toolbar = AppApi::global().toolbar();
toolbar
.add_label(
"Слева".into(),
Some("file".into()),
Some("#808080".into()),
"label".into(),
"left".into(),
)
.expect("лейбл");
toolbar
.add_button(
"Кнопка".into(),
Some("play".into()),
None,
"button".into(),
"right".into(),
callback,
)
.expect("кнопка");
visual.update(|_window, cx| {
app.update(cx, |_, cx| cx.notify());
});
visual.run_until_parked();
let toolbar = AppApi::global().toolbar_handle();
assert_eq!(toolbar.borrow().left().len(), 1);
assert_eq!(toolbar.borrow().right().len(), 1);
}
#[gpui_kit::test]
fn tree_block_renders_with_nested_nodes(cx: &mut TestAppContext) {
cx.update(gpui_kit::init);
+166
View File
@@ -0,0 +1,166 @@
//! Состояние верхней панели редактора.
//!
//! Панель состоит из двух областей. Элементы левой области выстраиваются от
//! левого края вправо, элементы правой — от правого края влево. Сценарий
//! добавляет в области лейблы и кнопки через `app.toolbar`.
//!
//! Как и панели окна, состояние лежит в `Rc<RefCell<_>>`: Lua-сценарий меняет
//! его через API, а вид [`MyApp`](crate::app::MyApp) читает при отрисовке.
use std::cell::RefCell;
use std::rc::Rc;
use gpui_kit::Hsla;
use mlua::Function;
/// Общая ссылка на состояние верхней панели.
pub type ToolbarHandle = Rc<RefCell<ToolbarState>>;
/// Область панели, к которой прижат элемент.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ToolbarSide {
/// От левого края вправо.
Left,
/// От правого края влево.
Right,
}
impl ToolbarSide {
/// Разбирает сторону из строки Lua: `left` или `right` без учёта регистра.
pub fn parse(value: &str) -> Option<Self> {
match value.trim().to_ascii_lowercase().as_str() {
"left" => Some(Self::Left),
"right" => Some(Self::Right),
_ => None,
}
}
}
/// Что делает элемент панели.
#[derive(Clone)]
pub enum ToolbarKind {
/// Только текст и, возможно, иконка.
Label,
/// Кнопка: по нажатию вызывает Lua-обработчик.
Button { callback: Function },
}
/// Элемент верхней панели.
#[derive(Clone)]
pub struct ToolbarItem {
/// Идентификатор для [`ToolbarState::hide`] и [`ToolbarState::show`].
pub id: String,
/// Отображаемый текст.
pub text: String,
/// Каноническое имя иконки Lucide.
pub icon: Option<String>,
/// Цвет фона; `None` — прозрачный фон.
pub background: Option<Hsla>,
/// Скрытый элемент остаётся в модели, но не рисуется.
pub visible: bool,
/// Вид элемента: лейбл или кнопка.
pub kind: ToolbarKind,
}
impl ToolbarItem {
/// Создаёт видимый элемент указанного вида.
pub fn new(
id: impl Into<String>,
text: impl Into<String>,
icon: Option<String>,
background: Option<Hsla>,
kind: ToolbarKind,
) -> Self {
Self {
id: id.into(),
text: text.into(),
icon,
background,
visible: true,
kind,
}
}
}
/// Элементы верхней панели по областям.
#[derive(Clone, Default)]
pub struct ToolbarState {
left: Vec<ToolbarItem>,
right: Vec<ToolbarItem>,
}
impl ToolbarState {
/// Создаёт пустую панель.
pub fn new() -> Self {
Self::default()
}
/// Элементы левой области в порядке добавления.
pub fn left(&self) -> &[ToolbarItem] {
&self.left
}
/// Элементы правой области в порядке добавления.
///
/// Первый добавленный элемент рисуется у правого края, остальные
/// выстраиваются левее него.
pub fn right(&self) -> &[ToolbarItem] {
&self.right
}
/// Добавляет элемент в область.
///
/// Элемент с уже занятым `id` заменяется, поэтому повторная настройка
/// панели из сценария не создаёт дубликатов.
pub fn push(&mut self, side: ToolbarSide, item: ToolbarItem) {
self.remove(&item.id);
match side {
ToolbarSide::Left => self.left.push(item),
ToolbarSide::Right => self.right.push(item),
}
}
/// Скрывает элемент с идентификатором `id`.
///
/// Возвращает `false`, если такого элемента нет.
pub fn hide(&mut self, id: &str) -> bool {
self.set_visible(id, false)
}
/// Снова показывает элемент с идентификатором `id`.
///
/// Возвращает `false`, если такого элемента нет.
pub fn show(&mut self, id: &str) -> bool {
self.set_visible(id, true)
}
/// Удаляет все элементы обеих областей.
pub fn clear(&mut self) {
self.left.clear();
self.right.clear();
}
/// Меняет видимость элемента в любой области.
fn set_visible(&mut self, id: &str, visible: bool) -> bool {
let item = self
.left
.iter_mut()
.chain(self.right.iter_mut())
.find(|item| item.id == id);
match item {
Some(item) => {
item.visible = visible;
true
}
None => false,
}
}
/// Убирает элемент с идентификатором `id` из обеих областей.
///
/// Нужен для замены при повторном добавлении.
fn remove(&mut self, id: &str) {
self.left.retain(|item| item.id != id);
self.right.retain(|item| item.id != id);
}
}