diff --git a/AGENTS.md b/AGENTS.md index 1053fea..ddfd53c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -11,12 +11,13 @@ | `src/main.rs` | Точка входа, окно, аргументы командной строки | | `src/constants.rs` | Константы окна, CLI и каталог сценариев | | `src/editor.rs` | Доменная модель: документы, вкладки, языки | -| `src/api.rs` | `AppApi` / `PanelApi` / `TreeApi` — единственная поверхность управления | +| `src/api.rs` | `AppApi` / `PanelApi` / `TreeApi` / `ToolbarApi` — единственная поверхность управления | | `src/script.rs` | Lua-движок, `app`, `include`, `require` | | `src/keymap.rs` | `KeyBinding`, `KeyMap`, встроенные команды и Lua-обработчики | | `src/app.rs` | GPUI-вид, отрисовка, события, автодополнение | | `src/completion.rs` | Всплывающий список, `CaretPopup`, преобразования LSP-элементов | | `src/panel.rs` | `PanelState` и блоки панелей | +| `src/toolbar.rs` | Состояние верхней панели: лейблы и кнопки | | `src/tree.rs` | Абстрактное дерево для панелей | | `src/icons.rs` | Каталог иконок Lucide | | `src/lsp.rs` | Менеджер LSP-серверов, JSON-RPC, файловый вотчер | diff --git a/DOCS.md b/DOCS.md index 982a639..f7807ea 100644 --- a/DOCS.md +++ b/DOCS.md @@ -325,6 +325,39 @@ end > `--config` меняет только каталог `init.lua`, поэтому `root = "."` указывает > на текущий каталог запуска — проект должен быть текущим рабочим каталогом. +## Верхняя панель редактора + +Полоса между вкладками и редактором. У панели две области: элементы левой +выстраиваются от левого края вправо, элементы правой — от правого края влево. + +| Метод | Описание | +| --- | --- | +| `app.toolbar:add_label(text, icon, background, id, side)` | Добавить лейбл | +| `app.toolbar:add_button(text, icon, background, id, side, fn)` | Добавить кнопку | +| `app.toolbar:hide(id)` | Скрыть элемент; `false`, если элемента с таким `id` нет | +| `app.toolbar:show(id)` | Снова показать скрытый элемент | +| `app.toolbar:clear()` | Удалить все элементы панели | + +- `icon` — имя иконки Lucide (`play`, `settings`, `file`, ...) или `nil`. +- `background` — `#RGB`, `#RGBA`, `#RRGGBB`, `#RRGGBBAA` или `nil` (прозрачный + фон). Цвет текста подбирается автоматически: тёмный на светлом фоне, светлый + на тёмном. +- `id` — идентификатор элемента для `hide` и `show`. Повторное добавление с тем + же `id` заменяет элемент, а не создаёт дубликат. +- `side` — `"left"` или `"right"`. +- `fn` — обработчик нажатия; вызывается без аргументов. + +```lua +app.toolbar:add_label("Готово", nil, nil, "status", "left") +app.toolbar:add_label("UTF-8", nil, "#3f3f46", "encoding", "right") +app.toolbar:add_button("Запустить", "play", nil, "run", "right", function() + print("Запуск") +end) + +app.toolbar:hide("run") +app.toolbar:show("run") +``` + ## Панели окна По умолчанию панели закрыты, размер меняется перетаскиванием. diff --git a/init.lua b/init.lua index 2fb12a4..7120a88 100644 --- a/init.lua +++ b/init.lua @@ -135,6 +135,19 @@ print("[lua] init.lua выполнен") -- app.bottom_panel:toggle() -- app.bottom_panel:is_open() -- +-- Верхняя панель редактора (полоса между вкладками и редактором). +-- Элементы левой области идут от левого края вправо, правой — от правого +-- края влево. id нужен, чтобы скрыть или снова показать элемент. +-- app.toolbar:add_label(text, icon, background, id, side) +-- app.toolbar:add_button(text, icon, background, id, side, fn) +-- -- fn() вызывается при нажатии; +-- -- background — "#RRGGBB", "#RRGGBBAA" или nil; +-- -- icon — имя иконки Lucide или nil; +-- -- side — "left" или "right" +-- app.toolbar:hide(id) -- true, если элемент найден +-- app.toolbar:show(id) -- снова показать скрытый элемент +-- app.toolbar:clear() -- удалить все элементы +-- -- Блоки панелей. Сценарий сам решает, что показывает блок: редактор умеет -- рисовать только абстрактное дерево. -- local tree = app.left_panel:add_tree() @@ -277,7 +290,7 @@ local function diagnostic_rank(diagnostic) or 1 end -app:on_lsp_diagnostics(1000, function(servers) +app:on_lsp_diagnostics(100, function(servers) diagnostics_tree:clear() -- Сначала собираем диагностику по файлам: одинаковые проблемы от разных @@ -383,3 +396,10 @@ diagnostics_tree:on_click(function(id) end) app.bottom_panel:open() + +-- Верхняя панель: пример лейблов и кнопок. +-- app.toolbar:add_label("Готово", nil, nil, "status", "left") +app.toolbar:add_label("UTF-8", nil, "#efefe6", "encoding", "right") +app.toolbar:add_button("Запустить", "play", nil, "run", "right", function() + print("[toolbar] кнопка «Запустить» нажата") +end) diff --git a/src/api.rs b/src/api.rs index c4f96f9..8162d94 100644 --- a/src/api.rs +++ b/src/api.rs @@ -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, #[lua(skip)] diagnostics_handler: Rc>>, @@ -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, + background: Option, + 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, + background: Option, + 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 { @@ -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 { + 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::parse(side).ok_or_else(|| { + mlua::Error::RuntimeError(format!( + "неизвестная сторона панели: {side}; ожидается \"left\" или \"right\"" + )) + }) +} + +/// Проверяет имя иконки по каталогу Lucide. +/// +/// `nil` и пустая строка означают «без иконки»; неизвестное имя — ошибка, +/// чтобы опечатка в сценарии не пропадала молча. +fn parse_toolbar_icon(icon: Option) -> mlua::Result> { + 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) -> mlua::Result> { + 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 { + 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> { let mut parsed = Vec::new(); diff --git a/src/app.rs b/src/app.rs index 77a6cff..3a61f10 100644 --- a/src/app.rs +++ b/src/app.rs @@ -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) -> impl IntoElement { + let entity = cx.entity(); + let (left, right) = { + let toolbar = self.toolbar.borrow(); + ( + toolbar + .left() + .iter() + .filter(|item| item.visible) + .cloned() + .collect::>(), + toolbar + .right() + .iter() + .filter(|item| item.visible) + .cloned() + .collect::>(), + ) + }; + + let left_items = left + .into_iter() + .map(|item| Self::render_toolbar_item(item, &entity, cx)) + .collect::>(); + let right_items = right + .into_iter() + .map(|item| Self::render_toolbar_item(item, &entity, cx)) + .collect::>(); + + 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, + cx: &mut Context, + ) -> 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) -> 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) -> 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() { diff --git a/src/main.rs b/src/main.rs index a85c60e..15e04da 100644 --- a/src/main.rs +++ b/src/main.rs @@ -15,6 +15,7 @@ mod keymap; mod lsp; mod panel; mod script; +mod toolbar; mod tree; #[cfg(test)] diff --git a/src/tests.rs b/src/tests.rs index 30010a6..809c25e 100644 --- a/src/tests.rs +++ b/src/tests.rs @@ -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>) -> (AppApi, Rc>) { @@ -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::() + .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); diff --git a/src/toolbar.rs b/src/toolbar.rs new file mode 100644 index 0000000..7a9d138 --- /dev/null +++ b/src/toolbar.rs @@ -0,0 +1,166 @@ +//! Состояние верхней панели редактора. +//! +//! Панель состоит из двух областей. Элементы левой области выстраиваются от +//! левого края вправо, элементы правой — от правого края влево. Сценарий +//! добавляет в области лейблы и кнопки через `app.toolbar`. +//! +//! Как и панели окна, состояние лежит в `Rc>`: 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>; + +/// Область панели, к которой прижат элемент. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ToolbarSide { + /// От левого края вправо. + Left, + /// От правого края влево. + Right, +} + +impl ToolbarSide { + /// Разбирает сторону из строки Lua: `left` или `right` без учёта регистра. + pub fn parse(value: &str) -> Option { + 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, + /// Цвет фона; `None` — прозрачный фон. + pub background: Option, + /// Скрытый элемент остаётся в модели, но не рисуется. + pub visible: bool, + /// Вид элемента: лейбл или кнопка. + pub kind: ToolbarKind, +} + +impl ToolbarItem { + /// Создаёт видимый элемент указанного вида. + pub fn new( + id: impl Into, + text: impl Into, + icon: Option, + background: Option, + kind: ToolbarKind, + ) -> Self { + Self { + id: id.into(), + text: text.into(), + icon, + background, + visible: true, + kind, + } + } +} + +/// Элементы верхней панели по областям. +#[derive(Clone, Default)] +pub struct ToolbarState { + left: Vec, + right: Vec, +} + +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); + } +}