toolbar
This commit is contained in:
@@ -11,12 +11,13 @@
|
|||||||
| `src/main.rs` | Точка входа, окно, аргументы командной строки |
|
| `src/main.rs` | Точка входа, окно, аргументы командной строки |
|
||||||
| `src/constants.rs` | Константы окна, CLI и каталог сценариев |
|
| `src/constants.rs` | Константы окна, CLI и каталог сценариев |
|
||||||
| `src/editor.rs` | Доменная модель: документы, вкладки, языки |
|
| `src/editor.rs` | Доменная модель: документы, вкладки, языки |
|
||||||
| `src/api.rs` | `AppApi` / `PanelApi` / `TreeApi` — единственная поверхность управления |
|
| `src/api.rs` | `AppApi` / `PanelApi` / `TreeApi` / `ToolbarApi` — единственная поверхность управления |
|
||||||
| `src/script.rs` | Lua-движок, `app`, `include`, `require` |
|
| `src/script.rs` | Lua-движок, `app`, `include`, `require` |
|
||||||
| `src/keymap.rs` | `KeyBinding`, `KeyMap`, встроенные команды и Lua-обработчики |
|
| `src/keymap.rs` | `KeyBinding`, `KeyMap`, встроенные команды и Lua-обработчики |
|
||||||
| `src/app.rs` | GPUI-вид, отрисовка, события, автодополнение |
|
| `src/app.rs` | GPUI-вид, отрисовка, события, автодополнение |
|
||||||
| `src/completion.rs` | Всплывающий список, `CaretPopup`, преобразования LSP-элементов |
|
| `src/completion.rs` | Всплывающий список, `CaretPopup`, преобразования LSP-элементов |
|
||||||
| `src/panel.rs` | `PanelState` и блоки панелей |
|
| `src/panel.rs` | `PanelState` и блоки панелей |
|
||||||
|
| `src/toolbar.rs` | Состояние верхней панели: лейблы и кнопки |
|
||||||
| `src/tree.rs` | Абстрактное дерево для панелей |
|
| `src/tree.rs` | Абстрактное дерево для панелей |
|
||||||
| `src/icons.rs` | Каталог иконок Lucide |
|
| `src/icons.rs` | Каталог иконок Lucide |
|
||||||
| `src/lsp.rs` | Менеджер LSP-серверов, JSON-RPC, файловый вотчер |
|
| `src/lsp.rs` | Менеджер LSP-серверов, JSON-RPC, файловый вотчер |
|
||||||
|
|||||||
@@ -325,6 +325,39 @@ end
|
|||||||
> `--config` меняет только каталог `init.lua`, поэтому `root = "."` указывает
|
> `--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")
|
||||||
|
```
|
||||||
|
|
||||||
## Панели окна
|
## Панели окна
|
||||||
|
|
||||||
По умолчанию панели закрыты, размер меняется перетаскиванием.
|
По умолчанию панели закрыты, размер меняется перетаскиванием.
|
||||||
|
|||||||
@@ -135,6 +135,19 @@ print("[lua] init.lua выполнен")
|
|||||||
-- app.bottom_panel:toggle()
|
-- app.bottom_panel:toggle()
|
||||||
-- app.bottom_panel:is_open()
|
-- 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()
|
-- local tree = app.left_panel:add_tree()
|
||||||
@@ -277,7 +290,7 @@ local function diagnostic_rank(diagnostic)
|
|||||||
or 1
|
or 1
|
||||||
end
|
end
|
||||||
|
|
||||||
app:on_lsp_diagnostics(1000, function(servers)
|
app:on_lsp_diagnostics(100, function(servers)
|
||||||
diagnostics_tree:clear()
|
diagnostics_tree:clear()
|
||||||
|
|
||||||
-- Сначала собираем диагностику по файлам: одинаковые проблемы от разных
|
-- Сначала собираем диагностику по файлам: одинаковые проблемы от разных
|
||||||
@@ -383,3 +396,10 @@ diagnostics_tree:on_click(function(id)
|
|||||||
end)
|
end)
|
||||||
|
|
||||||
app.bottom_panel:open()
|
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)
|
||||||
|
|||||||
+202
@@ -18,6 +18,8 @@ use std::time::Duration;
|
|||||||
use mlua::IntoLua;
|
use mlua::IntoLua;
|
||||||
use serde_json::Number;
|
use serde_json::Number;
|
||||||
|
|
||||||
|
use gpui_kit::{Hsla, Rgba};
|
||||||
|
|
||||||
use crate::constants::{BOTTOM_PANEL_HEIGHT, LEFT_PANEL_WIDTH, RIGHT_PANEL_WIDTH};
|
use crate::constants::{BOTTOM_PANEL_HEIGHT, LEFT_PANEL_WIDTH, RIGHT_PANEL_WIDTH};
|
||||||
use crate::editor::{FocusAfterSync, Language, Workspace};
|
use crate::editor::{FocusAfterSync, Language, Workspace};
|
||||||
use crate::icons;
|
use crate::icons;
|
||||||
@@ -26,6 +28,7 @@ use crate::lsp::{
|
|||||||
DocumentDiagnostics, LspConfig, LspManager, WatcherConfig, document_uri, position_to_offset,
|
DocumentDiagnostics, LspConfig, LspManager, WatcherConfig, document_uri, position_to_offset,
|
||||||
};
|
};
|
||||||
use crate::panel::{PanelHandle, PanelState};
|
use crate::panel::{PanelHandle, PanelState};
|
||||||
|
use crate::toolbar::{ToolbarHandle, ToolbarItem, ToolbarKind, ToolbarSide, ToolbarState};
|
||||||
use crate::tree::{TreeHandle, TreeNode};
|
use crate::tree::{TreeHandle, TreeNode};
|
||||||
|
|
||||||
thread_local! {
|
thread_local! {
|
||||||
@@ -124,6 +127,8 @@ pub struct AppApi {
|
|||||||
#[lua(skip)]
|
#[lua(skip)]
|
||||||
bottom_panel: PanelHandle,
|
bottom_panel: PanelHandle,
|
||||||
#[lua(skip)]
|
#[lua(skip)]
|
||||||
|
toolbar: ToolbarHandle,
|
||||||
|
#[lua(skip)]
|
||||||
lsp: Arc<LspManager>,
|
lsp: Arc<LspManager>,
|
||||||
#[lua(skip)]
|
#[lua(skip)]
|
||||||
diagnostics_handler: Rc<RefCell<Option<DiagnosticsHandler>>>,
|
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 одного блока дерева.
|
/// API одного блока дерева.
|
||||||
///
|
///
|
||||||
/// Объект возвращает `app.left_panel:add_tree()`. Редактор не знает, что
|
/// Объект возвращает `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]
|
#[mlua::userdata_impl]
|
||||||
impl AppApi {
|
impl AppApi {
|
||||||
/// Делает переданные документы и карту клавиш текущими для
|
/// Делает переданные документы и карту клавиш текущими для
|
||||||
@@ -299,6 +401,7 @@ impl AppApi {
|
|||||||
left_panel: Rc::new(RefCell::new(PanelState::new(LEFT_PANEL_WIDTH))),
|
left_panel: Rc::new(RefCell::new(PanelState::new(LEFT_PANEL_WIDTH))),
|
||||||
right_panel: Rc::new(RefCell::new(PanelState::new(RIGHT_PANEL_WIDTH))),
|
right_panel: Rc::new(RefCell::new(PanelState::new(RIGHT_PANEL_WIDTH))),
|
||||||
bottom_panel: Rc::new(RefCell::new(PanelState::new(BOTTOM_PANEL_HEIGHT))),
|
bottom_panel: Rc::new(RefCell::new(PanelState::new(BOTTOM_PANEL_HEIGHT))),
|
||||||
|
toolbar: Rc::new(RefCell::new(ToolbarState::new())),
|
||||||
lsp: Arc::new(LspManager::new()),
|
lsp: Arc::new(LspManager::new()),
|
||||||
diagnostics_handler: Rc::new(RefCell::new(None)),
|
diagnostics_handler: Rc::new(RefCell::new(None)),
|
||||||
};
|
};
|
||||||
@@ -337,6 +440,12 @@ impl AppApi {
|
|||||||
self.bottom_panel.clone()
|
self.bottom_panel.clone()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Состояние верхней панели (для вида).
|
||||||
|
#[lua(skip)]
|
||||||
|
pub fn toolbar_handle(&self) -> ToolbarHandle {
|
||||||
|
self.toolbar.clone()
|
||||||
|
}
|
||||||
|
|
||||||
/// Менеджер LSP-серверов (для вида и тестов).
|
/// Менеджер LSP-серверов (для вида и тестов).
|
||||||
#[lua(skip)]
|
#[lua(skip)]
|
||||||
pub fn lsp_handle(&self) -> Arc<LspManager> {
|
pub fn lsp_handle(&self) -> Arc<LspManager> {
|
||||||
@@ -751,6 +860,16 @@ impl AppApi {
|
|||||||
PanelApi::new(self.bottom_panel.clone())
|
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)]
|
#[lua(infallible)]
|
||||||
pub fn close(&self) {
|
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`.
|
/// Разбирает массив узлов дерева, переданный в `TreeApi::append`.
|
||||||
fn parse_tree_nodes(nodes: &mlua::Table) -> mlua::Result<Vec<TreeNode>> {
|
fn parse_tree_nodes(nodes: &mlua::Table) -> mlua::Result<Vec<TreeNode>> {
|
||||||
let mut parsed = Vec::new();
|
let mut parsed = Vec::new();
|
||||||
|
|||||||
+178
-6
@@ -20,8 +20,11 @@ use gpui_kit::component::input::{
|
|||||||
Editor, EditorState, HoverProvider, InputEvent, Rope, TabSize, TextDecoration,
|
Editor, EditorState, HoverProvider, InputEvent, Rope, TabSize, TextDecoration,
|
||||||
TextDecorationCollection,
|
TextDecorationCollection,
|
||||||
};
|
};
|
||||||
|
use gpui_kit::component::label::Label;
|
||||||
use gpui_kit::component::resizable::HandleEdge;
|
use gpui_kit::component::resizable::HandleEdge;
|
||||||
|
use gpui_kit::component::status_bar::StatusBar;
|
||||||
use gpui_kit::component::tab::{Tab, TabBar};
|
use gpui_kit::component::tab::{Tab, TabBar};
|
||||||
|
use gpui_kit::component::tag::Tag;
|
||||||
use gpui_kit::component::text::TextView;
|
use gpui_kit::component::text::TextView;
|
||||||
use gpui_kit::component::{
|
use gpui_kit::component::{
|
||||||
ActiveTheme, Icon, Sizable, StyledExt, TitleBar, h_flex, h_resizable, resizable_panel,
|
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::lsp::{LspManager, document_uri, position_to_offset};
|
||||||
use crate::panel::{PanelBlock, PanelHandle};
|
use crate::panel::{PanelBlock, PanelHandle};
|
||||||
use crate::script::ScriptEngine;
|
use crate::script::ScriptEngine;
|
||||||
|
use crate::toolbar::{ToolbarHandle, ToolbarItem, ToolbarKind};
|
||||||
use crate::tree::{TreeAction, TreeHandle, TreeKeyResult};
|
use crate::tree::{TreeAction, TreeHandle, TreeKeyResult};
|
||||||
|
|
||||||
/// Payload перетаскивания разделителя левой панели.
|
/// Payload перетаскивания разделителя левой панели.
|
||||||
@@ -184,6 +188,8 @@ pub struct MyApp {
|
|||||||
right_panel: PanelHandle,
|
right_panel: PanelHandle,
|
||||||
/// Состояние нижней панели, общее с `app.bottom_panel` в Lua.
|
/// Состояние нижней панели, общее с `app.bottom_panel` в Lua.
|
||||||
bottom_panel: PanelHandle,
|
bottom_panel: PanelHandle,
|
||||||
|
/// Элементы верхней панели, общие с `app.toolbar` в Lua.
|
||||||
|
toolbar: ToolbarHandle,
|
||||||
/// Открытость панелей в предыдущем кадре: по переходу «закрыта →
|
/// Открытость панелей в предыдущем кадре: по переходу «закрыта →
|
||||||
/// открыта» вид возвращает фокус в дерево, которое было выбрано раньше.
|
/// открыта» вид возвращает фокус в дерево, которое было выбрано раньше.
|
||||||
panel_open_state: Cell<[bool; 3]>,
|
panel_open_state: Cell<[bool; 3]>,
|
||||||
@@ -377,6 +383,7 @@ impl MyApp {
|
|||||||
let left_panel = api.left_panel_handle();
|
let left_panel = api.left_panel_handle();
|
||||||
let right_panel = api.right_panel_handle();
|
let right_panel = api.right_panel_handle();
|
||||||
let bottom_panel = api.bottom_panel_handle();
|
let bottom_panel = api.bottom_panel_handle();
|
||||||
|
let toolbar = api.toolbar_handle();
|
||||||
|
|
||||||
// `capture_key_down` на корне вызывается уже после обработки action,
|
// `capture_key_down` на корне вызывается уже после обработки action,
|
||||||
// поэтому для стрелок он не успевал. Interceptor срабатывает до
|
// поэтому для стрелок он не успевал. Interceptor срабатывает до
|
||||||
@@ -400,6 +407,7 @@ impl MyApp {
|
|||||||
left_panel,
|
left_panel,
|
||||||
right_panel,
|
right_panel,
|
||||||
bottom_panel,
|
bottom_panel,
|
||||||
|
toolbar,
|
||||||
panel_open_state: Cell::new([false; 3]),
|
panel_open_state: Cell::new([false; 3]),
|
||||||
editor,
|
editor,
|
||||||
synced_revision: 0,
|
synced_revision: 0,
|
||||||
@@ -1638,11 +1646,13 @@ impl MyApp {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Редактор с левой, правой и нижней панелями.
|
/// Вкладки, полоса над редактором и сам редактор с панелями.
|
||||||
///
|
///
|
||||||
/// Все панели меняют размер перетаскиванием разделителя, а закрытые
|
/// Вкладки живут только над центральной колонкой и не заходят на
|
||||||
/// панели не занимают места. В каждой панели рисуются добавленные
|
/// боковые панели. Все панели меняют размер перетаскиванием разделителя,
|
||||||
/// сценарием блоки; правая панель без блоков показывает статус LSP.
|
/// а закрытые панели не занимают места. В каждой панели рисуются
|
||||||
|
/// добавленные сценарием блоки; правая панель без блоков показывает
|
||||||
|
/// статус LSP.
|
||||||
fn render_workspace(
|
fn render_workspace(
|
||||||
&mut self,
|
&mut self,
|
||||||
window: &mut Window,
|
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")
|
let area = h_resizable("main-area")
|
||||||
.child(resizable_panel().child(editor_area))
|
.child(resizable_panel().child(center))
|
||||||
.child(
|
.child(
|
||||||
resizable_panel()
|
resizable_panel()
|
||||||
.size(px(right_size))
|
.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: иконка, название, маркер
|
/// Полоса вкладок в стиле folder tabs: иконка, название, маркер
|
||||||
/// несохранённых изменений и крестик закрытия, а также «+».
|
/// несохранённых изменений и крестик закрытия, а также «+».
|
||||||
fn render_tabs(&self, cx: &mut Context<Self>) -> impl IntoElement {
|
fn render_tabs(&self, cx: &mut Context<Self>) -> impl IntoElement {
|
||||||
@@ -1891,8 +2044,8 @@ impl Render for MyApp {
|
|||||||
.child(
|
.child(
|
||||||
TitleBar::new().child(div().w_full().pr_2().text_sm().font_medium().child(title)),
|
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(div().flex_1().w_full().min_h_0().child(workspace))
|
||||||
|
.child(self.render_status_bar(cx))
|
||||||
.child(self.render_completion_popup(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.
|
/// Сохраняет активную вкладку, печатая ошибку в stderr.
|
||||||
fn save_active_tab() {
|
fn save_active_tab() {
|
||||||
if let Err(err) = AppApi::global().save() {
|
if let Err(err) = AppApi::global().save() {
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ mod keymap;
|
|||||||
mod lsp;
|
mod lsp;
|
||||||
mod panel;
|
mod panel;
|
||||||
mod script;
|
mod script;
|
||||||
|
mod toolbar;
|
||||||
mod tree;
|
mod tree;
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|||||||
+137
@@ -17,6 +17,7 @@ use crate::editor::{FocusAfterSync, Language, Workspace};
|
|||||||
use crate::keymap::{KeyBinding, KeyContext, KeyHandler, KeyMap, Modifiers as KeyModifiers};
|
use crate::keymap::{KeyBinding, KeyContext, KeyHandler, KeyMap, Modifiers as KeyModifiers};
|
||||||
use crate::panel::PanelBlock;
|
use crate::panel::PanelBlock;
|
||||||
use crate::script::ScriptEngine;
|
use crate::script::ScriptEngine;
|
||||||
|
use crate::toolbar::ToolbarKind;
|
||||||
use crate::tree::TreeNode;
|
use crate::tree::TreeNode;
|
||||||
|
|
||||||
fn install_api(workspace: Rc<RefCell<Workspace>>) -> (AppApi, Rc<RefCell<KeyMap>>) {
|
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);
|
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]
|
#[test]
|
||||||
fn lua_tree_block_appends_sorts_and_removes_nodes() {
|
fn lua_tree_block_appends_sorts_and_removes_nodes() {
|
||||||
let workspace = Rc::new(RefCell::new(Workspace::default()));
|
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());
|
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]
|
#[gpui_kit::test]
|
||||||
fn tree_block_renders_with_nested_nodes(cx: &mut TestAppContext) {
|
fn tree_block_renders_with_nested_nodes(cx: &mut TestAppContext) {
|
||||||
cx.update(gpui_kit::init);
|
cx.update(gpui_kit::init);
|
||||||
|
|||||||
+166
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user