toolbar
This commit is contained in:
+202
@@ -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
@@ -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() {
|
||||
|
||||
@@ -15,6 +15,7 @@ mod keymap;
|
||||
mod lsp;
|
||||
mod panel;
|
||||
mod script;
|
||||
mod toolbar;
|
||||
mod tree;
|
||||
|
||||
#[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::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
@@ -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