This commit is contained in:
2026-10-31 01:50:35 +03:00
parent 19e05e7ed0
commit afd7600150
8 changed files with 740 additions and 8 deletions
+202
View File
@@ -18,6 +18,8 @@ use std::time::Duration;
use mlua::IntoLua;
use serde_json::Number;
use gpui_kit::{Hsla, Rgba};
use crate::constants::{BOTTOM_PANEL_HEIGHT, LEFT_PANEL_WIDTH, RIGHT_PANEL_WIDTH};
use crate::editor::{FocusAfterSync, Language, Workspace};
use crate::icons;
@@ -26,6 +28,7 @@ use crate::lsp::{
DocumentDiagnostics, LspConfig, LspManager, WatcherConfig, document_uri, position_to_offset,
};
use crate::panel::{PanelHandle, PanelState};
use crate::toolbar::{ToolbarHandle, ToolbarItem, ToolbarKind, ToolbarSide, ToolbarState};
use crate::tree::{TreeHandle, TreeNode};
thread_local! {
@@ -124,6 +127,8 @@ pub struct AppApi {
#[lua(skip)]
bottom_panel: PanelHandle,
#[lua(skip)]
toolbar: ToolbarHandle,
#[lua(skip)]
lsp: Arc<LspManager>,
#[lua(skip)]
diagnostics_handler: Rc<RefCell<Option<DiagnosticsHandler>>>,
@@ -147,6 +152,23 @@ impl PanelApi {
}
}
/// API верхней панели редактора.
///
/// В Lua доступно как `app.toolbar`. Элементы добавляются в левую или правую
/// область панели и опознаются по `id`, чтобы их можно было скрыть и снова
/// показать.
#[derive(mlua::UserData)]
pub struct ToolbarApi {
#[lua(skip)]
state: ToolbarHandle,
}
impl ToolbarApi {
fn new(state: ToolbarHandle) -> Self {
Self { state }
}
}
/// API одного блока дерева.
///
/// Объект возвращает `app.left_panel:add_tree()`. Редактор не знает, что
@@ -287,6 +309,86 @@ impl PanelApi {
}
}
#[mlua::userdata_impl]
impl ToolbarApi {
/// Добавляет текстовый лейбл в левую или правую область панели.
///
/// `text` — отображаемый текст;
/// `icon` — имя иконки Lucide или `nil`;
/// `background` — цвет фона в виде `#RRGGBB`, `#RRGGBBAA` или `nil`;
/// `id` — идентификатор для `hide`/`show`;
/// `side` — `"left"` или `"right"`.
///
/// Элемент с уже существующим `id` заменяется.
pub fn add_label(
&self,
text: String,
icon: Option<String>,
background: Option<String>,
id: String,
side: String,
) -> mlua::Result<()> {
let id = parse_toolbar_id(id)?;
let side = parse_toolbar_side(&side)?;
let icon = parse_toolbar_icon(icon)?;
let background = parse_toolbar_color(background)?;
self.state.borrow_mut().push(
side,
ToolbarItem::new(id, text, icon, background, ToolbarKind::Label),
);
Ok(())
}
/// Добавляет кнопку в левую или правую область панели.
///
/// Параметры совпадают с [`ToolbarApi::add_label`]; дополнительный
/// `callback` вызывается при нажатии без аргументов.
///
/// `background = nil` оставляет стандартный фон кнопки.
pub fn add_button(
&self,
text: String,
icon: Option<String>,
background: Option<String>,
id: String,
side: String,
callback: mlua::Function,
) -> mlua::Result<()> {
let id = parse_toolbar_id(id)?;
let side = parse_toolbar_side(&side)?;
let icon = parse_toolbar_icon(icon)?;
let background = parse_toolbar_color(background)?;
self.state.borrow_mut().push(
side,
ToolbarItem::new(id, text, icon, background, ToolbarKind::Button { callback }),
);
Ok(())
}
/// Скрывает элемент с идентификатором `id`.
///
/// Возвращает `false`, если элемента с таким `id` нет. Элемент остаётся
/// в модели и снова показывается через [`ToolbarApi::show`].
#[lua(infallible)]
pub fn hide(&self, id: String) -> bool {
self.state.borrow_mut().hide(&id)
}
/// Снова показывает скрытый элемент с идентификатором `id`.
///
/// Возвращает `false`, если элемента с таким `id` нет.
#[lua(infallible)]
pub fn show(&self, id: String) -> bool {
self.state.borrow_mut().show(&id)
}
/// Удаляет все элементы верхней панели.
#[lua(infallible)]
pub fn clear(&self) {
self.state.borrow_mut().clear();
}
}
#[mlua::userdata_impl]
impl AppApi {
/// Делает переданные документы и карту клавиш текущими для
@@ -299,6 +401,7 @@ impl AppApi {
left_panel: Rc::new(RefCell::new(PanelState::new(LEFT_PANEL_WIDTH))),
right_panel: Rc::new(RefCell::new(PanelState::new(RIGHT_PANEL_WIDTH))),
bottom_panel: Rc::new(RefCell::new(PanelState::new(BOTTOM_PANEL_HEIGHT))),
toolbar: Rc::new(RefCell::new(ToolbarState::new())),
lsp: Arc::new(LspManager::new()),
diagnostics_handler: Rc::new(RefCell::new(None)),
};
@@ -337,6 +440,12 @@ impl AppApi {
self.bottom_panel.clone()
}
/// Состояние верхней панели (для вида).
#[lua(skip)]
pub fn toolbar_handle(&self) -> ToolbarHandle {
self.toolbar.clone()
}
/// Менеджер LSP-серверов (для вида и тестов).
#[lua(skip)]
pub fn lsp_handle(&self) -> Arc<LspManager> {
@@ -751,6 +860,16 @@ impl AppApi {
PanelApi::new(self.bottom_panel.clone())
}
/// Верхняя панель редактора.
///
/// В Lua доступна как `app.toolbar` с методами `add_label`, `add_button`,
/// `hide`, `show` и `clear`.
#[allow(dead_code)]
#[lua(getter, infallible)]
pub fn toolbar(&self) -> ToolbarApi {
ToolbarApi::new(self.toolbar.clone())
}
/// Закрывает окно приложения.
#[lua(infallible)]
pub fn close(&self) {
@@ -1070,6 +1189,89 @@ fn parse_key_handler(
}
}
/// Проверяет идентификатор элемента верхней панели.
fn parse_toolbar_id(id: String) -> mlua::Result<String> {
if id.trim().is_empty() {
return Err(mlua::Error::RuntimeError(
"id элемента верхней панели не может быть пустым".to_owned(),
));
}
Ok(id)
}
/// Разбирает сторону верхней панели: `left` или `right`.
fn parse_toolbar_side(side: &str) -> mlua::Result<ToolbarSide> {
ToolbarSide::parse(side).ok_or_else(|| {
mlua::Error::RuntimeError(format!(
"неизвестная сторона панели: {side}; ожидается \"left\" или \"right\""
))
})
}
/// Проверяет имя иконки по каталогу Lucide.
///
/// `nil` и пустая строка означают «без иконки»; неизвестное имя — ошибка,
/// чтобы опечатка в сценарии не пропадала молча.
fn parse_toolbar_icon(icon: Option<String>) -> mlua::Result<Option<String>> {
let Some(name) = icon else {
return Ok(None);
};
if name.trim().is_empty() {
return Ok(None);
}
icons::canonical_name(&name)
.map(|canonical| Some(canonical.to_owned()))
.ok_or_else(|| mlua::Error::RuntimeError(format!("неизвестная иконка: {name}")))
}
/// Разбирает цвет фона элемента верхней панели.
///
/// Поддерживаются `#RGB`, `#RGBA`, `#RRGGBB` и `#RRGGBBAA`; `nil`,
/// пустая строка и `transparent` означают прозрачный фон.
fn parse_toolbar_color(color: Option<String>) -> mlua::Result<Option<Hsla>> {
let Some(color) = color else {
return Ok(None);
};
let color = color.trim();
if color.is_empty() || color.eq_ignore_ascii_case("transparent") {
return Ok(None);
}
parse_hex_color(color).map(Some).ok_or_else(|| {
mlua::Error::RuntimeError(format!(
"некорректный цвет фона: {color}; ожидается #RGB, #RGBA, #RRGGBB или #RRGGBBAA"
))
})
}
/// Переводит шестнадцатеричный цвет в `Hsla`.
fn parse_hex_color(value: &str) -> Option<Hsla> {
let hex = value.strip_prefix('#').unwrap_or(value);
if !hex.chars().all(|ch| ch.is_ascii_hexdigit()) {
return None;
}
let nibble = |index: usize| {
u8::from_str_radix(&hex[index..index + 1], 16)
.ok()
.map(|value| value * 17)
};
let byte = |index: usize| u8::from_str_radix(&hex[index..index + 2], 16).ok();
let (r, g, b, a) = match hex.len() {
3 => (nibble(0)?, nibble(1)?, nibble(2)?, 255),
4 => (nibble(0)?, nibble(1)?, nibble(2)?, nibble(3)?),
6 => (byte(0)?, byte(2)?, byte(4)?, 255),
8 => (byte(0)?, byte(2)?, byte(4)?, byte(6)?),
_ => return None,
};
Some(Hsla::from(Rgba {
r: f32::from(r) / 255.0,
g: f32::from(g) / 255.0,
b: f32::from(b) / 255.0,
a: f32::from(a) / 255.0,
}))
}
/// Разбирает массив узлов дерева, переданный в `TreeApi::append`.
fn parse_tree_nodes(nodes: &mlua::Table) -> mlua::Result<Vec<TreeNode>> {
let mut parsed = Vec::new();