1346 lines
54 KiB
Rust
1346 lines
54 KiB
Rust
//! Глобальный API приложения.
|
||
//!
|
||
//! Единственная точка входа для команд управления окном и редактором: UI-код
|
||
//! и Lua-сценарии не трогают `Window` и состояние вида напрямую, а вызывают
|
||
//! методы [`AppApi`].
|
||
//!
|
||
//! Команды окна не выполняются в момент вызова: Lua-обработчик выполняется вне
|
||
//! контекста `App`/`Window`, поэтому [`AppApi`] складывает их в очередь, а вид
|
||
//! вычитывает её в начале кадра.
|
||
|
||
use std::cell::{Cell, RefCell};
|
||
use std::collections::VecDeque;
|
||
use std::path::{Path, PathBuf};
|
||
use std::rc::Rc;
|
||
use std::sync::Arc;
|
||
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;
|
||
use crate::keymap::{AppCommand, CompletionCommand, KeyBinding, KeyContext, KeyHandler, KeyMap};
|
||
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! {
|
||
/// Текущий экземпляр API. `thread_local`, потому что UI GPUI привязан к
|
||
/// потоку с event loop; заодно это делает тесты независимыми друг от друга.
|
||
static INSTANCE: RefCell<Option<AppApi>> = const { RefCell::new(None) };
|
||
|
||
/// Команды окна, ожидающие ближайшего кадра.
|
||
static WINDOW_COMMANDS: RefCell<VecDeque<WindowCommand>> = const { RefCell::new(VecDeque::new()) };
|
||
|
||
/// Развёрнуто ли окно. Вид обновляет значение в каждом кадре, чтобы
|
||
/// `app:is_maximized()` из Lua видел актуальное состояние.
|
||
static WINDOW_MAXIMIZED: Cell<bool> = const { Cell::new(false) };
|
||
}
|
||
|
||
/// Действие над окном, отложенное до ближайшего кадра.
|
||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||
pub enum WindowCommand {
|
||
Close,
|
||
Minimize,
|
||
ToggleMaximize,
|
||
SetMaximized(bool),
|
||
}
|
||
|
||
/// Запоминает, развёрнуто ли окно сейчас.
|
||
pub fn set_window_maximized(maximized: bool) {
|
||
WINDOW_MAXIMIZED.with(|state| state.set(maximized));
|
||
}
|
||
|
||
/// Забирает накопленные команды окна.
|
||
///
|
||
/// Очередь очищается: выполнять команды нужно ровно один раз.
|
||
pub fn drain_window_commands() -> Vec<WindowCommand> {
|
||
WINDOW_COMMANDS.with(|queue| queue.borrow_mut().drain(..).collect())
|
||
}
|
||
|
||
/// Кладёт команду в очередь.
|
||
fn push_window_command(command: WindowCommand) {
|
||
WINDOW_COMMANDS.with(|queue| queue.borrow_mut().push_back(command));
|
||
}
|
||
|
||
/// Краткая информация об открытой вкладке.
|
||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||
pub struct TabInfo {
|
||
/// Номер вкладки (с нуля), слева направо.
|
||
pub index: usize,
|
||
/// Название — то же, что показано в заголовке вкладки.
|
||
pub title: String,
|
||
/// Имя иконки вкладки; по умолчанию — `file`.
|
||
pub icon: String,
|
||
}
|
||
|
||
/// Элемент каталога, возвращаемый `app:list_dir(path)`.
|
||
///
|
||
/// `id` — путь относительно текущего каталога процесса: его можно сразу
|
||
/// положить в `id` узла дерева и передать в `app:open(id)`.
|
||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||
pub struct DirEntryInfo {
|
||
/// Путь элемента относительно текущего каталога.
|
||
pub id: String,
|
||
/// Имя файла или каталога без пути.
|
||
pub name: String,
|
||
/// Является ли элемент каталогом.
|
||
pub is_dir: bool,
|
||
}
|
||
|
||
/// Зарегистрированный Lua-обработчик диагностики.
|
||
///
|
||
/// `delay` — пауза тишины, после которой обработчик вызывается. Если сервер
|
||
/// присылает сообщения чаще, вызов откладывается до последнего изменения.
|
||
#[derive(Clone)]
|
||
struct DiagnosticsHandler {
|
||
delay: Duration,
|
||
callback: mlua::Function,
|
||
}
|
||
|
||
/// Глобальный объект с функциями управления приложением.
|
||
///
|
||
/// Использование: `AppApi::global().close()`, `AppApi::global().tabs()`.
|
||
/// Экземпляр устанавливается один раз при создании приложения (см.
|
||
/// [`AppApi::install`]) и указывает на общие с видом документы и карту клавиш.
|
||
///
|
||
/// Методы этого `impl`-блока автоматически экспортируются в Lua через
|
||
/// `#[mlua::userdata_impl]`: в сценариях они доступны как `app:method(...)`.
|
||
/// Чтобы не экспортировать метод, пометьте его `#[lua(skip)]`.
|
||
#[derive(Clone, mlua::UserData)]
|
||
pub struct AppApi {
|
||
#[lua(skip)]
|
||
workspace: Rc<RefCell<Workspace>>,
|
||
#[lua(skip)]
|
||
keymap: Rc<RefCell<KeyMap>>,
|
||
#[lua(skip)]
|
||
left_panel: PanelHandle,
|
||
#[lua(skip)]
|
||
right_panel: PanelHandle,
|
||
#[lua(skip)]
|
||
bottom_panel: PanelHandle,
|
||
#[lua(skip)]
|
||
toolbar: ToolbarHandle,
|
||
#[lua(skip)]
|
||
lsp: Arc<LspManager>,
|
||
#[lua(skip)]
|
||
diagnostics_handler: Rc<RefCell<Option<DiagnosticsHandler>>>,
|
||
}
|
||
|
||
/// API одной панели окна.
|
||
///
|
||
/// Один и тот же тип используется для `app.left_panel`, `app.right_panel` и
|
||
/// `app.bottom_panel`: отличается только состояние, на которое ссылается
|
||
/// объект. Поэтому панели управляются одинаково: `app.left_panel:open()`,
|
||
/// `app.bottom_panel:toggle()` и так далее.
|
||
#[derive(mlua::UserData)]
|
||
pub struct PanelApi {
|
||
#[lua(skip)]
|
||
state: PanelHandle,
|
||
}
|
||
|
||
impl PanelApi {
|
||
fn new(state: PanelHandle) -> Self {
|
||
Self { state }
|
||
}
|
||
}
|
||
|
||
/// 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()`. Редактор не знает, что
|
||
/// означают узлы: сценарий сам решает, будет это файловое дерево проекта или
|
||
/// что-то ещё, и передаёт готовый список узлов в [`TreeApi::append`].
|
||
#[derive(mlua::UserData)]
|
||
pub struct TreeApi {
|
||
#[lua(skip)]
|
||
state: TreeHandle,
|
||
}
|
||
|
||
impl TreeApi {
|
||
fn new(state: TreeHandle) -> Self {
|
||
Self { state }
|
||
}
|
||
}
|
||
|
||
#[mlua::userdata_impl]
|
||
impl TreeApi {
|
||
/// Добавляет узлы к родительскому узлу `parent`.
|
||
///
|
||
/// `nodes` — массив таблиц:
|
||
///
|
||
/// ```lua
|
||
/// {
|
||
/// id = "src/main.rs", -- строковый идентификатор (путь файла)
|
||
/// label = "main.rs", -- отображаемый текст
|
||
/// icon = "file-code", -- имя иконки Lucide (необязательно)
|
||
/// expandable = false, -- раскрываемая ли нода (папка)
|
||
/// expanded = false, -- раскрыта ли она сейчас
|
||
/// }
|
||
/// ```
|
||
///
|
||
/// Пустая строка в `parent` означает корень дерева. Существующие узлы не
|
||
/// удаляются: повторная передача того же `id` обновляет узел.
|
||
pub fn append(&self, nodes: mlua::Table, parent: Option<String>) -> mlua::Result<()> {
|
||
let nodes = parse_tree_nodes(&nodes)?;
|
||
let parent = parent.unwrap_or_default();
|
||
self.state
|
||
.borrow_mut()
|
||
.append(&parent, nodes)
|
||
.map_err(mlua::Error::RuntimeError)
|
||
}
|
||
|
||
/// Удаляет узел по `id` вместе со всеми дочерними узлами рекурсивно.
|
||
///
|
||
/// Возвращает `false`, если узла с таким идентификатором нет.
|
||
#[lua(infallible)]
|
||
pub fn remove(&self, id: String) -> bool {
|
||
self.state.borrow_mut().remove(&id)
|
||
}
|
||
|
||
/// Удаляет все узлы дерева и сбрасывает выбор.
|
||
///
|
||
/// Обработчики `on_toggle` и `on_click` продолжают действовать: дерево
|
||
/// можно наполнить заново. Удобно для списков, которые перерисовываются
|
||
/// целиком, например для диагностики.
|
||
#[lua(infallible)]
|
||
pub fn clear(&self) {
|
||
self.state.borrow_mut().clear();
|
||
}
|
||
|
||
/// Назначает обработчик раскрытия/сворачивания узла.
|
||
///
|
||
/// Обработчик получает `(expanded, id, loaded)`, где `expanded` — новая
|
||
/// нода раскрыта, `id` — идентификатор узла, `loaded` — были ли в узел уже
|
||
/// загружены дочерние элементы. После первого добавления детей всегда
|
||
/// приходит `true`.
|
||
#[lua(infallible)]
|
||
pub fn on_toggle(&self, callback: mlua::Function) {
|
||
self.state.borrow_mut().set_toggle_callback(Some(callback));
|
||
}
|
||
|
||
/// Назначает обработчик клика по конечной ноде.
|
||
///
|
||
/// Обработчик получает `(id)` — идентификатор узла.
|
||
#[lua(infallible)]
|
||
pub fn on_click(&self, callback: mlua::Function) {
|
||
self.state.borrow_mut().set_click_callback(Some(callback));
|
||
}
|
||
|
||
/// Идентификатор выбранной строки или `nil`.
|
||
#[lua(infallible)]
|
||
pub fn selected(&self) -> Option<String> {
|
||
self.state.borrow().selected().map(str::to_owned)
|
||
}
|
||
|
||
/// Раскрывает всех закрытых родителей узла `id`, выбирает его и передаёт
|
||
/// фокус дереву.
|
||
///
|
||
/// Возвращает `false`, если узла с таким идентификатором нет.
|
||
#[lua(infallible)]
|
||
pub fn focus(&self, id: String) -> bool {
|
||
self.state.borrow_mut().focus(&id)
|
||
}
|
||
}
|
||
|
||
#[mlua::userdata_impl]
|
||
impl PanelApi {
|
||
/// Открывает панель.
|
||
#[lua(infallible)]
|
||
pub fn open(&self) {
|
||
self.state.borrow_mut().open();
|
||
}
|
||
|
||
/// Закрывает панель.
|
||
#[lua(infallible)]
|
||
pub fn close(&self) {
|
||
self.state.borrow_mut().close();
|
||
}
|
||
|
||
/// Переключает панель и возвращает новое состояние.
|
||
#[lua(infallible)]
|
||
pub fn toggle(&self) -> bool {
|
||
self.state.borrow_mut().toggle()
|
||
}
|
||
|
||
/// Открыта ли панель прямо сейчас.
|
||
#[lua(infallible)]
|
||
pub fn is_open(&self) -> bool {
|
||
self.state.borrow().is_open()
|
||
}
|
||
|
||
/// Добавляет в панель блок дерева и возвращает его API.
|
||
///
|
||
/// `title` — заголовок вкладки нижней панели. Боковые панели заголовок
|
||
/// сохраняют, но не рисуют.
|
||
///
|
||
/// ```lua
|
||
/// local tree = app.left_panel:add_tree()
|
||
/// tree:append({ { id = "a", label = "a.txt" } }, "")
|
||
///
|
||
/// local diagnostics = app.bottom_panel:add_tree("Диагностика")
|
||
/// ```
|
||
#[lua(infallible, name = "add_tree")]
|
||
pub fn add_tree(&self, title: Option<String>) -> TreeApi {
|
||
TreeApi::new(self.state.borrow_mut().add_tree(title))
|
||
}
|
||
}
|
||
|
||
#[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 {
|
||
/// Делает переданные документы и карту клавиш текущими для
|
||
/// [`AppApi::global`].
|
||
#[lua(skip)]
|
||
pub fn install(workspace: Rc<RefCell<Workspace>>, keymap: Rc<RefCell<KeyMap>>) -> Self {
|
||
let api = Self {
|
||
workspace,
|
||
keymap,
|
||
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)),
|
||
};
|
||
INSTANCE.with(|slot| *slot.borrow_mut() = Some(api.clone()));
|
||
api
|
||
}
|
||
|
||
/// Глобальный экземпляр API.
|
||
///
|
||
/// # Panics
|
||
/// Если [`AppApi::install`] ещё не вызывался.
|
||
#[lua(skip)]
|
||
pub fn global() -> Self {
|
||
INSTANCE.with(|slot| {
|
||
slot.borrow()
|
||
.clone()
|
||
.expect("AppApi не инициализирован: сначала вызовите AppApi::install")
|
||
})
|
||
}
|
||
|
||
/// Состояние левой панели (для вида).
|
||
#[lua(skip)]
|
||
pub fn left_panel_handle(&self) -> PanelHandle {
|
||
self.left_panel.clone()
|
||
}
|
||
|
||
/// Состояние правой панели (для вида).
|
||
#[lua(skip)]
|
||
pub fn right_panel_handle(&self) -> PanelHandle {
|
||
self.right_panel.clone()
|
||
}
|
||
|
||
/// Состояние нижней панели (для вида).
|
||
#[lua(skip)]
|
||
pub fn bottom_panel_handle(&self) -> PanelHandle {
|
||
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> {
|
||
self.lsp.clone()
|
||
}
|
||
|
||
/// Все открытые вкладки в порядке слева направо.
|
||
#[allow(dead_code)]
|
||
// Часть публичного API приложения: вызывается внешним кодом и тестами.
|
||
#[lua(infallible)]
|
||
pub fn tabs(&self) -> Vec<TabInfo> {
|
||
self.workspace
|
||
.borrow()
|
||
.documents()
|
||
.iter()
|
||
.enumerate()
|
||
.map(|(index, doc)| TabInfo {
|
||
index,
|
||
title: doc.name.clone(),
|
||
icon: doc.icon_name().to_owned(),
|
||
})
|
||
.collect()
|
||
}
|
||
|
||
/// Индекс активной вкладки в списке [`AppApi::tabs`].
|
||
#[allow(dead_code)]
|
||
// Часть публичного API приложения: вызывается внешним кодом и тестами.
|
||
#[lua(infallible, name = "active_tab")]
|
||
pub fn active_tab_index(&self) -> usize {
|
||
self.workspace.borrow().active_index()
|
||
}
|
||
|
||
/// Переключает на следующую вкладку (по кругу) и возвращает её индекс.
|
||
#[allow(dead_code)]
|
||
// Часть публичного API приложения: вызывается внешним кодом и тестами.
|
||
#[lua(infallible)]
|
||
pub fn next_tab(&self) -> usize {
|
||
self.workspace.borrow_mut().cycle_active(1)
|
||
}
|
||
|
||
/// Переключает на предыдущую вкладку (по кругу) и возвращает её индекс.
|
||
#[allow(dead_code)]
|
||
// Часть публичного API приложения: вызывается внешним кодом и тестами.
|
||
#[lua(infallible)]
|
||
pub fn previous_tab(&self) -> usize {
|
||
self.workspace.borrow_mut().cycle_active(-1)
|
||
}
|
||
|
||
/// Закрывает вкладку по её номеру в списке [`AppApi::tabs`].
|
||
///
|
||
/// Возвращает `false`, если вкладки с таким номером нет. Если закрывается
|
||
/// последняя вкладка, вместо неё открывается пустой документ.
|
||
#[lua(infallible)]
|
||
pub fn close_tab(&self, index: usize) -> bool {
|
||
let mut workspace = self.workspace.borrow_mut();
|
||
let Some(document) = workspace.document(index) else {
|
||
return false;
|
||
};
|
||
let uri = document_uri(&document.name);
|
||
workspace.close(index);
|
||
drop(workspace);
|
||
|
||
if let Some(uri) = uri {
|
||
self.lsp.close_document(&uri);
|
||
}
|
||
true
|
||
}
|
||
|
||
/// Сохраняет содержимое активной вкладки в файл, названный как сама вкладка.
|
||
///
|
||
/// Путь считается относительно текущего рабочего каталога процесса.
|
||
/// При успешной записи с вкладки снимается маркер несохранённых изменений.
|
||
#[lua(skip)]
|
||
pub fn save(&self) -> std::io::Result<()> {
|
||
let (path, content) = {
|
||
let workspace = self.workspace.borrow();
|
||
let doc = workspace.active();
|
||
(doc.name.clone(), doc.content.clone())
|
||
};
|
||
std::fs::write(&path, &content)?;
|
||
if let Some(uri) = document_uri(&path) {
|
||
self.lsp.did_save(&uri, &content);
|
||
}
|
||
self.workspace.borrow_mut().mark_active_saved();
|
||
Ok(())
|
||
}
|
||
|
||
/// Lua-версия [`AppApi::save`]: ошибка ввода-вывода становится ошибкой Lua.
|
||
#[lua(name = "save")]
|
||
fn lua_save(&self) -> mlua::Result<()> {
|
||
self.save()
|
||
.map_err(|err| mlua::Error::RuntimeError(err.to_string()))
|
||
}
|
||
|
||
/// Назначает обработчик на нажатие или отпускание клавиши.
|
||
///
|
||
/// `keys` записывается в духе nvim:
|
||
///
|
||
/// * `a`, `1` — нажатие клавиши без модификаторов;
|
||
/// * `<S-c>`, `<C-z>`, `<A-r>`, `<D-s>` — нажатие с Shift, Ctrl, Alt или
|
||
/// платформенным модификатором (Cmd/Super);
|
||
/// * `!a` — отпускание клавиши;
|
||
/// * `<A-!r>` — отпускание клавиши при зажатом Alt.
|
||
///
|
||
/// Второй аргумент — Lua-функция, имя встроенной команды (`save`,
|
||
/// `next_tab`, `toggle_left_panel` и т. п.) или `nil`, чтобы удалить
|
||
/// привязку. Повторное назначение той же клавиши заменяет обработчик.
|
||
///
|
||
/// # Errors
|
||
/// Возвращает ошибку Lua, если запись привязки или имя команды некорректны.
|
||
pub fn map_key(&self, keys: String, handler: mlua::Value) -> mlua::Result<()> {
|
||
self.bind_key(KeyContext::Global, &keys, handler)
|
||
}
|
||
|
||
/// Назначает обработчик клавиши, пока открыт список автодополнения.
|
||
///
|
||
/// Синтаксис совпадает с [`AppApi::map_key`]. Встроенные команды этого
|
||
/// слоя: `completion_trigger`, `completion_next`, `completion_previous`,
|
||
/// `completion_page_up`, `completion_page_down`, `completion_accept`,
|
||
/// `completion_cancel`, `completion_dismiss`.
|
||
#[lua(name = "map_completion_key")]
|
||
pub fn map_completion_key(&self, keys: String, handler: mlua::Value) -> mlua::Result<()> {
|
||
self.bind_key(KeyContext::Completion, &keys, handler)
|
||
}
|
||
|
||
#[lua(skip)]
|
||
fn bind_key(&self, context: KeyContext, keys: &str, handler: mlua::Value) -> mlua::Result<()> {
|
||
let binding = KeyBinding::parse(keys).map_err(mlua::Error::RuntimeError)?;
|
||
match parse_key_handler(context, handler)? {
|
||
Some(handler) => self.keymap.borrow_mut().set(context, binding, handler),
|
||
None => {
|
||
self.keymap.borrow_mut().unbind(context, &binding);
|
||
}
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
/// Содержимое активной вкладки.
|
||
#[lua(infallible)]
|
||
pub fn active_text(&self) -> String {
|
||
self.workspace.borrow().active().content.clone()
|
||
}
|
||
|
||
/// Заменяет содержимое активной вкладки.
|
||
#[lua(skip)]
|
||
pub fn set_active_text(&self, text: impl Into<String>) {
|
||
self.workspace.borrow_mut().set_active_text(text);
|
||
}
|
||
|
||
/// Lua-версия [`AppApi::set_active_text`] (generic-методы не экспортируются).
|
||
#[lua(infallible, name = "set_active_text")]
|
||
fn lua_set_active_text(&self, text: String) {
|
||
self.set_active_text(text);
|
||
}
|
||
|
||
/// Открывает новую пустую вкладку, делает её активной и возвращает её индекс.
|
||
#[lua(infallible)]
|
||
pub fn open_tab(&self) -> usize {
|
||
let mut workspace = self.workspace.borrow_mut();
|
||
workspace.open_new();
|
||
workspace.active_index()
|
||
}
|
||
|
||
/// Открывает файл с диска на редактирование (Rust-версия `app:open`).
|
||
///
|
||
/// Если файла нет, создаётся пустая вкладка с таким именем — как в vim.
|
||
/// Язык подсветки определяется по расширению, если для него есть грамматика.
|
||
/// Фокус редактора не меняется: для этого есть [`AppApi::open_focused`]
|
||
/// и Lua-форма `app:open(path, {...})`.
|
||
#[lua(skip)]
|
||
pub fn open(&self, path: impl AsRef<Path>) -> std::io::Result<usize> {
|
||
self.open_impl(path, None, false)
|
||
}
|
||
|
||
/// Открывает файл и переводит фокус в редактор (Rust-версия
|
||
/// `app:open(path, position)`).
|
||
///
|
||
/// `position` — строка и столбец с нуля, как в диагностике LSP; столбец
|
||
/// считается в UTF-16. `None` оставляет курсор там, где он был.
|
||
#[lua(skip)]
|
||
pub fn open_focused(
|
||
&self,
|
||
path: impl AsRef<Path>,
|
||
position: Option<(u32, u32)>,
|
||
) -> std::io::Result<usize> {
|
||
self.open_impl(path, position, true)
|
||
}
|
||
|
||
#[lua(skip)]
|
||
fn open_impl(
|
||
&self,
|
||
path: impl AsRef<Path>,
|
||
position: Option<(u32, u32)>,
|
||
focus_editor: bool,
|
||
) -> std::io::Result<usize> {
|
||
let path = path.as_ref();
|
||
let content = match std::fs::read_to_string(path) {
|
||
Ok(content) => content,
|
||
Err(err) if err.kind() == std::io::ErrorKind::NotFound => String::new(),
|
||
Err(err) => return Err(err),
|
||
};
|
||
|
||
let cursor = position.map(|(line, column)| {
|
||
position_to_offset(&content, lsp_types::Position::new(line, column))
|
||
});
|
||
let focus = if focus_editor {
|
||
FocusAfterSync::Editor(cursor)
|
||
} else {
|
||
FocusAfterSync::Keep
|
||
};
|
||
|
||
let name = path.to_string_lossy().into_owned();
|
||
let language = Language::from_path(&name);
|
||
let mut workspace = self.workspace.borrow_mut();
|
||
let index = workspace.open_file(name, content, language);
|
||
workspace.set_focus_after_sync(focus);
|
||
Ok(index)
|
||
}
|
||
|
||
/// Lua-версия [`AppApi::open`]: `app:open("main.go")`.
|
||
///
|
||
/// Второй аргумент необязателен:
|
||
///
|
||
/// * `nil`/нет аргумента — открыть вкладку, не трогая фокус;
|
||
/// * `{}` — перевести фокус в редактор, курсор оставить на месте;
|
||
/// * `{line, column}` — перевести фокус и поставить курсор (с нуля).
|
||
///
|
||
/// Возвращает номер вкладки (с нуля). Ошибка чтения становится ошибкой Lua.
|
||
#[lua(name = "open")]
|
||
fn lua_open(&self, path: String, position: mlua::Value) -> mlua::Result<usize> {
|
||
match parse_open_focus(position)? {
|
||
OpenFocus::Keep => self.open(&path),
|
||
OpenFocus::Editor(position) => self.open_focused(&path, position),
|
||
}
|
||
.map_err(|err| mlua::Error::RuntimeError(format!("не удалось открыть {path}: {err}")))
|
||
}
|
||
|
||
/// Читает содержимое каталога (Rust-версия `app:list_dir`).
|
||
///
|
||
/// Пустой путь или `"."` означают текущий рабочий каталог процесса.
|
||
/// Элементы сортируются по имени без учёта регистра. В `id` попадает путь
|
||
/// относительно текущего каталога, если `path` тоже относительный.
|
||
#[lua(skip)]
|
||
pub fn list_dir(&self, path: impl AsRef<Path>) -> std::io::Result<Vec<DirEntryInfo>> {
|
||
let raw = path.as_ref().as_os_str().to_string_lossy().into_owned();
|
||
let relative_to_cwd = raw.is_empty() || raw == ".";
|
||
let directory = if raw.is_empty() {
|
||
Path::new(".")
|
||
} else {
|
||
path.as_ref()
|
||
};
|
||
|
||
let mut entries = Vec::new();
|
||
for entry in std::fs::read_dir(directory)? {
|
||
let entry = entry?;
|
||
let name = entry.file_name().to_string_lossy().into_owned();
|
||
let is_dir = entry.file_type().map(|kind| kind.is_dir()).unwrap_or(false);
|
||
let id = if relative_to_cwd {
|
||
name.clone()
|
||
} else {
|
||
Path::new(&raw)
|
||
.join(&name)
|
||
.to_string_lossy()
|
||
.replace('\\', "/")
|
||
};
|
||
entries.push(DirEntryInfo { id, name, is_dir });
|
||
}
|
||
|
||
entries.sort_by(|a, b| {
|
||
a.name
|
||
.to_lowercase()
|
||
.cmp(&b.name.to_lowercase())
|
||
.then_with(|| a.name.cmp(&b.name))
|
||
});
|
||
Ok(entries)
|
||
}
|
||
|
||
/// Список содержимого каталога `path` относительно текущего каталога.
|
||
///
|
||
/// Каждый элемент — таблица `{ id = "src/main.rs", name = "main.rs",
|
||
/// is_dir = false }`. Пустая строка или `"."` — текущий каталог. Метод
|
||
/// предназначен для построения деревьев и списков из Lua.
|
||
#[lua(name = "list_dir")]
|
||
fn lua_list_dir(&self, path: Option<String>) -> mlua::Result<Vec<DirEntryInfo>> {
|
||
let path = path.unwrap_or_default();
|
||
self.list_dir(&path).map_err(|err| {
|
||
let shown = if path.is_empty() { "." } else { path.as_str() };
|
||
mlua::Error::RuntimeError(format!("не удалось прочитать каталог {shown}: {err}"))
|
||
})
|
||
}
|
||
|
||
/// Делает вкладку активной по её номеру в списке [`AppApi::tabs`].
|
||
///
|
||
/// Возвращает `false`, если вкладки с таким номером нет.
|
||
#[lua(infallible)]
|
||
pub fn set_active_tab(&self, index: usize) -> bool {
|
||
let mut workspace = self.workspace.borrow_mut();
|
||
if index >= workspace.documents().len() {
|
||
return false;
|
||
}
|
||
workspace.set_active(index);
|
||
true
|
||
}
|
||
|
||
/// Назначает иконку вкладке `index`.
|
||
///
|
||
/// `icon` — имя иконки Lucide: `file`, `file-code`, `book-open`, `settings`
|
||
/// и т. п. Регистр не важен; `_` и пробелы равны дефису, префикс `icons/`
|
||
/// и расширение `.svg` можно не писать.
|
||
///
|
||
/// Возвращает `false`, если вкладки нет или иконка с таким именем не входит
|
||
/// в каталог.
|
||
#[lua(skip)]
|
||
pub fn set_tab_icon(&self, index: usize, icon: &str) -> bool {
|
||
let Some(name) = icons::canonical_name(icon) else {
|
||
return false;
|
||
};
|
||
self.workspace.borrow_mut().set_tab_icon(index, name)
|
||
}
|
||
|
||
/// Lua-версия [`AppApi::set_tab_icon`].
|
||
#[lua(infallible, name = "set_tab_icon")]
|
||
fn lua_set_tab_icon(&self, index: usize, icon: String) -> bool {
|
||
self.set_tab_icon(index, &icon)
|
||
}
|
||
|
||
/// Возвращает вкладке иконку по умолчанию (`file`).
|
||
///
|
||
/// Возвращает `false`, если вкладки с таким номером нет.
|
||
#[lua(infallible)]
|
||
pub fn clear_tab_icon(&self, index: usize) -> bool {
|
||
self.workspace.borrow_mut().clear_tab_icon(index)
|
||
}
|
||
|
||
/// Имя иконки вкладки: назначенное через [`AppApi::set_tab_icon`] или
|
||
/// `file` по умолчанию. `nil`, если вкладки нет.
|
||
#[lua(infallible)]
|
||
pub fn tab_icon(&self, index: usize) -> Option<String> {
|
||
self.workspace
|
||
.borrow()
|
||
.document(index)
|
||
.map(|doc| doc.icon_name().to_owned())
|
||
}
|
||
|
||
/// Включает подсветку синтаксиса для вкладки `index`.
|
||
///
|
||
/// `language` — название языка: `rust`, `go`, `js`, `json` или `python`
|
||
/// (принимаются также `rs`, `golang`, `javascript`, `py`; регистр не важен).
|
||
/// Подсветка по умолчанию выключена и включается только этим методом.
|
||
///
|
||
/// Возвращает `false`, если вкладки нет или язык не поддерживается.
|
||
#[lua(skip)]
|
||
pub fn set_language(&self, index: usize, language: &str) -> bool {
|
||
let Some(language) = Language::from_name(language) else {
|
||
return false;
|
||
};
|
||
self.workspace.borrow_mut().set_language(index, language)
|
||
}
|
||
|
||
/// Lua-версия [`AppApi::set_language`].
|
||
#[lua(infallible, name = "set_language")]
|
||
fn lua_set_language(&self, index: usize, language: String) -> bool {
|
||
self.set_language(index, &language)
|
||
}
|
||
|
||
/// Язык подсветки вкладки: `"rust"`, `"go"`, `"js"`, `"json"`, `"python"`
|
||
/// или `nil`, если подсветка выключена / вкладки нет.
|
||
#[lua(infallible)]
|
||
pub fn language(&self, index: usize) -> Option<String> {
|
||
self.workspace
|
||
.borrow()
|
||
.document(index)
|
||
.and_then(|doc| doc.language)
|
||
.map(|language| language.name().to_owned())
|
||
}
|
||
|
||
/// Выключает подсветку синтаксиса для вкладки.
|
||
///
|
||
/// Возвращает `false`, если вкладки с таким номером нет.
|
||
#[lua(infallible)]
|
||
pub fn clear_language(&self, index: usize) -> bool {
|
||
self.workspace.borrow_mut().clear_language(index)
|
||
}
|
||
|
||
/// Левая панель окна.
|
||
///
|
||
/// В Lua доступна как `app.left_panel` с методами `open`, `close`,
|
||
/// `toggle` и `is_open`.
|
||
#[allow(dead_code)]
|
||
#[lua(getter, infallible)]
|
||
pub fn left_panel(&self) -> PanelApi {
|
||
PanelApi::new(self.left_panel.clone())
|
||
}
|
||
|
||
/// Правая панель окна.
|
||
///
|
||
/// В Lua доступна как `app.right_panel` с методами `open`, `close`,
|
||
/// `toggle` и `is_open`.
|
||
#[allow(dead_code)]
|
||
#[lua(getter, infallible)]
|
||
pub fn right_panel(&self) -> PanelApi {
|
||
PanelApi::new(self.right_panel.clone())
|
||
}
|
||
|
||
/// Нижняя панель окна.
|
||
///
|
||
/// В Lua доступна как `app.bottom_panel` с теми же методами, что и
|
||
/// `app.right_panel`.
|
||
#[allow(dead_code)]
|
||
#[lua(getter, infallible)]
|
||
pub fn bottom_panel(&self) -> PanelApi {
|
||
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) {
|
||
push_window_command(WindowCommand::Close);
|
||
}
|
||
|
||
/// Разворачивает окно на весь экран или возвращает его прежний размер.
|
||
#[lua(infallible)]
|
||
pub fn toggle_maximize(&self) {
|
||
push_window_command(WindowCommand::ToggleMaximize);
|
||
}
|
||
|
||
/// Разворачивает (`true`) или восстанавливает (`false`) окно.
|
||
#[lua(infallible)]
|
||
pub fn set_maximized(&self, maximized: bool) {
|
||
push_window_command(WindowCommand::SetMaximized(maximized));
|
||
}
|
||
|
||
/// Сворачивает окно.
|
||
#[lua(infallible)]
|
||
pub fn minimize(&self) {
|
||
push_window_command(WindowCommand::Minimize);
|
||
}
|
||
|
||
/// Развёрнуто ли окно прямо сейчас.
|
||
#[lua(infallible)]
|
||
pub fn is_maximized(&self) -> bool {
|
||
WINDOW_MAXIMIZED.with(Cell::get)
|
||
}
|
||
|
||
/// Запускает LSP-сервер по описанию (Rust-версия Lua-метода).
|
||
#[lua(skip)]
|
||
pub fn run_lsp(&self, config: LspConfig) -> Result<(), String> {
|
||
self.lsp.start(config)
|
||
}
|
||
|
||
/// Запускает LSP-сервер.
|
||
///
|
||
/// Пример из Lua:
|
||
///
|
||
/// ```lua
|
||
/// app:run_lsp({
|
||
/// name = "gopls",
|
||
/// cmd = "/usr/local/bin/gopls",
|
||
/// args = { "serve" },
|
||
/// env = { GOFLAGS = "-mod=mod" },
|
||
/// languages = { "go" },
|
||
/// watcher = "**/*.go",
|
||
/// initialization_options = { usePlaceholders = true },
|
||
/// })
|
||
/// ```
|
||
///
|
||
/// Возвращает `true`, если сервер запущен. Ошибка запуска или таймаут
|
||
/// `initialize` превращается в ошибку Lua.
|
||
#[lua(name = "run_lsp")]
|
||
fn lua_run_lsp(&self, config: mlua::Table) -> mlua::Result<bool> {
|
||
let config = parse_lsp_config(&config)?;
|
||
self.run_lsp(config)
|
||
.map(|()| true)
|
||
.map_err(mlua::Error::RuntimeError)
|
||
}
|
||
|
||
/// Имена всех запущенных LSP-серверов.
|
||
#[lua(infallible)]
|
||
pub fn lsp_servers(&self) -> Vec<String> {
|
||
self.lsp.server_names()
|
||
}
|
||
|
||
/// Останавливает LSP-сервер по имени.
|
||
///
|
||
/// Возвращает `false`, если сервера с таким именем не было.
|
||
#[lua(infallible)]
|
||
pub fn stop_lsp(&self, name: String) -> bool {
|
||
self.lsp.stop(&name)
|
||
}
|
||
|
||
/// Перезапускает LSP-сервер с прежними настройками.
|
||
///
|
||
/// Возвращает `false`, если сервера не было или новый процесс не поднялся.
|
||
#[lua(infallible)]
|
||
pub fn restart_lsp(&self, name: String) -> bool {
|
||
self.lsp.restart(&name)
|
||
}
|
||
|
||
/// Регистрирует обработчик диагностики всех LSP-серверов.
|
||
///
|
||
/// `delay_ms` — пауза тишины в миллисекундах: обработчик вызывается после
|
||
/// последнего обновления диагностики. Если серверы присылают сообщения
|
||
/// чаще задержки, промежуточные вызовы пропускаются, а последний вызов
|
||
/// выполняется после того, как обновления прекратились.
|
||
///
|
||
/// Обработчик получает один аргумент — массив записей
|
||
/// `{ server, uri, path, diagnostics = { ... } }`. У сообщения есть
|
||
/// `line`, `character`, `end_line`, `end_character` (с нуля, как в LSP),
|
||
/// `severity` (1–4), `severity_name`, `message`, `source`, `code`, `tags`.
|
||
/// Передача `nil` вместо функции удаляет обработчик.
|
||
///
|
||
/// ```lua
|
||
/// app:on_lsp_diagnostics(1000, function(servers)
|
||
/// for _, entry in ipairs(servers) do
|
||
/// print(entry.server, entry.path, #entry.diagnostics)
|
||
/// end
|
||
/// end)
|
||
/// ```
|
||
#[lua(infallible, name = "on_lsp_diagnostics")]
|
||
pub fn on_lsp_diagnostics(&self, delay_ms: u64, callback: Option<mlua::Function>) {
|
||
let delay = Duration::from_millis(delay_ms);
|
||
*self.diagnostics_handler.borrow_mut() =
|
||
callback.map(|callback| DiagnosticsHandler { delay, callback });
|
||
// Обработчик должен получить уже накопленную диагностику, а не ждать
|
||
// следующего ответа LSP-сервера.
|
||
self.lsp.refresh_diagnostics();
|
||
}
|
||
|
||
/// Задержка зарегистрированного обработчика диагностики, если он есть.
|
||
#[lua(skip)]
|
||
pub fn diagnostics_delay(&self) -> Option<Duration> {
|
||
self.diagnostics_handler
|
||
.borrow()
|
||
.as_ref()
|
||
.map(|handler| handler.delay)
|
||
}
|
||
|
||
/// Вызывает Lua-обработчик диагностики со свежим снимком.
|
||
///
|
||
/// Возвращает `false`, если обработчик не зарегистрирован.
|
||
#[lua(skip)]
|
||
pub fn emit_diagnostics(&self, report: Vec<DocumentDiagnostics>) -> bool {
|
||
let Some(callback) = self
|
||
.diagnostics_handler
|
||
.borrow()
|
||
.as_ref()
|
||
.map(|handler| handler.callback.clone())
|
||
else {
|
||
return false;
|
||
};
|
||
|
||
let Some(lua) = callback.weak_lua().try_upgrade() else {
|
||
return false;
|
||
};
|
||
let value = match report.into_lua(&lua) {
|
||
Ok(value) => value,
|
||
Err(err) => {
|
||
eprintln!("Lua: не удалось передать диагностику: {err}");
|
||
return false;
|
||
}
|
||
};
|
||
if let Err(err) = callback.call::<()>(value) {
|
||
eprintln!("Lua: ошибка в обработчике диагностики: {err}");
|
||
}
|
||
true
|
||
}
|
||
}
|
||
|
||
/// Второй аргумент `app:open`.
|
||
enum OpenFocus {
|
||
/// Фокус не меняется.
|
||
Keep,
|
||
/// Фокус переходит в редактор; `None` сохраняет текущий курсор.
|
||
Editor(Option<(u32, u32)>),
|
||
}
|
||
|
||
/// Разбирает необязательный второй аргумент `app:open`.
|
||
fn parse_open_focus(value: mlua::Value) -> mlua::Result<OpenFocus> {
|
||
let table = match value {
|
||
mlua::Value::Nil => return Ok(OpenFocus::Keep),
|
||
mlua::Value::Table(table) => table,
|
||
_ => {
|
||
return Err(mlua::Error::RuntimeError(
|
||
"во втором аргументе app:open нужна таблица {строка, столбец} или {}".to_owned(),
|
||
));
|
||
}
|
||
};
|
||
|
||
let line = table.get::<Option<i64>>(1)?;
|
||
let column = table.get::<Option<i64>>(2)?;
|
||
match (line, column) {
|
||
(None, None) => Ok(OpenFocus::Editor(None)),
|
||
(Some(line), Some(column)) if line >= 0 && column >= 0 => {
|
||
Ok(OpenFocus::Editor(Some((line as u32, column as u32))))
|
||
}
|
||
_ => Err(mlua::Error::RuntimeError(
|
||
"позиция в app:open — два неотрицательных числа: {строка, столбец}".to_owned(),
|
||
)),
|
||
}
|
||
}
|
||
|
||
/// Разбирает Lua-таблицу, переданную в `app:run_lsp`.
|
||
fn parse_lsp_config(table: &mlua::Table) -> mlua::Result<LspConfig> {
|
||
let name: String = table
|
||
.get("name")
|
||
.map_err(|_| mlua::Error::RuntimeError("в run_lsp нужен строковый name".to_owned()))?;
|
||
|
||
let command: String = table
|
||
.get("cmd")
|
||
.map_err(|_| mlua::Error::RuntimeError("в run_lsp нужен строковый cmd".to_owned()))?;
|
||
|
||
let args = match table.get::<mlua::Table>("args") {
|
||
Ok(args) => args
|
||
.sequence_values::<String>()
|
||
.collect::<mlua::Result<Vec<_>>>()?,
|
||
Err(_) => Vec::new(),
|
||
};
|
||
|
||
let env = match table.get::<mlua::Table>("env") {
|
||
Ok(env) => env
|
||
.pairs::<String, String>()
|
||
.collect::<mlua::Result<Vec<_>>>()?,
|
||
Err(_) => Vec::new(),
|
||
};
|
||
|
||
// Корень нормализуется в `LspManager::start`: относительный путь там
|
||
// отсчитывается от текущего каталога процесса.
|
||
let root = match table.get::<String>("root") {
|
||
Ok(root) => PathBuf::from(root),
|
||
Err(_) => std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
|
||
};
|
||
|
||
let languages = match table.get::<mlua::Value>("languages") {
|
||
Ok(value) => lua_strings(value)?,
|
||
Err(_) => Vec::new(),
|
||
};
|
||
|
||
let initialization_options = match table.get::<mlua::Value>("initialization_options") {
|
||
Ok(mlua::Value::Nil) | Err(_) => None,
|
||
Ok(value) => Some(lua_to_json(value)?),
|
||
};
|
||
|
||
Ok(LspConfig {
|
||
name,
|
||
command,
|
||
args,
|
||
env,
|
||
root,
|
||
languages,
|
||
watcher: parse_watcher(table)?,
|
||
initialization_options,
|
||
})
|
||
}
|
||
|
||
fn parse_watcher(table: &mlua::Table) -> mlua::Result<Option<WatcherConfig>> {
|
||
let value = match table.get::<mlua::Value>("watcher") {
|
||
Ok(mlua::Value::Nil) | Err(_) => return Ok(None),
|
||
Ok(value) => value,
|
||
};
|
||
|
||
match value {
|
||
mlua::Value::String(pattern) => Ok(Some(WatcherConfig {
|
||
patterns: vec![pattern.to_string_lossy()],
|
||
ignore: Vec::new(),
|
||
})),
|
||
mlua::Value::Boolean(true) => Ok(Some(WatcherConfig {
|
||
patterns: vec!["**/*".to_owned()],
|
||
ignore: Vec::new(),
|
||
})),
|
||
mlua::Value::Boolean(false) | mlua::Value::Nil => Ok(None),
|
||
mlua::Value::Table(config) => {
|
||
let patterns = match config.get::<mlua::Value>("pattern") {
|
||
Ok(mlua::Value::Nil) | Err(_) => Vec::new(),
|
||
Ok(value) => lua_strings(value)?,
|
||
};
|
||
let ignore = match config.get::<mlua::Value>("ignore") {
|
||
Ok(mlua::Value::Nil) | Err(_) => Vec::new(),
|
||
Ok(value) => lua_strings(value)?,
|
||
};
|
||
|
||
if patterns.is_empty() {
|
||
Ok(None)
|
||
} else {
|
||
Ok(Some(WatcherConfig { patterns, ignore }))
|
||
}
|
||
}
|
||
_ => Err(mlua::Error::RuntimeError(
|
||
"watcher должен быть строкой, таблицей или boolean".to_owned(),
|
||
)),
|
||
}
|
||
}
|
||
|
||
/// Разбирает обработчик, переданный в `app:map_key` / `app:map_completion_key`.
|
||
///
|
||
/// `nil` означает «удалить привязку».
|
||
fn parse_key_handler(
|
||
context: KeyContext,
|
||
handler: mlua::Value,
|
||
) -> mlua::Result<Option<KeyHandler>> {
|
||
match handler {
|
||
mlua::Value::Nil => Ok(None),
|
||
mlua::Value::Function(callback) => Ok(Some(KeyHandler::Lua(callback))),
|
||
mlua::Value::String(name) => {
|
||
let name = name.to_string_lossy();
|
||
let parsed = match context {
|
||
KeyContext::Global => AppCommand::parse(&name).map(KeyHandler::App),
|
||
KeyContext::Completion => {
|
||
CompletionCommand::parse(&name).map(KeyHandler::Completion)
|
||
}
|
||
};
|
||
parsed.map(Some).ok_or_else(|| {
|
||
let available = match context {
|
||
KeyContext::Global => AppCommand::ALL
|
||
.iter()
|
||
.map(|command| command.name())
|
||
.collect::<Vec<_>>(),
|
||
KeyContext::Completion => CompletionCommand::ALL
|
||
.iter()
|
||
.map(|command| command.name())
|
||
.collect::<Vec<_>>(),
|
||
};
|
||
mlua::Error::RuntimeError(format!(
|
||
"неизвестная команда клавиши: {name}; доступны: {}",
|
||
available.join(", ")
|
||
))
|
||
})
|
||
}
|
||
_ => Err(mlua::Error::RuntimeError(
|
||
"обработчик должен быть функцией, именем команды или nil".to_owned(),
|
||
)),
|
||
}
|
||
}
|
||
|
||
/// Проверяет идентификатор элемента верхней панели.
|
||
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();
|
||
for pair in nodes.sequence_values::<mlua::Table>() {
|
||
let table = pair?;
|
||
let id: String = table.get("id")?;
|
||
let label = table.get::<String>("label").unwrap_or_else(|_| id.clone());
|
||
let icon = table
|
||
.get::<Option<String>>("icon")?
|
||
.and_then(|name| icons::canonical_name(&name).map(str::to_owned));
|
||
let expandable = table.get::<Option<bool>>("expandable")?.unwrap_or(false);
|
||
let expanded = table.get::<Option<bool>>("expanded")?.unwrap_or(false);
|
||
parsed.push(TreeNode {
|
||
id,
|
||
label,
|
||
icon,
|
||
expandable,
|
||
expanded,
|
||
});
|
||
}
|
||
Ok(parsed)
|
||
}
|
||
|
||
fn lua_strings(value: mlua::Value) -> mlua::Result<Vec<String>> {
|
||
match value {
|
||
mlua::Value::String(value) => Ok(vec![value.to_string_lossy()]),
|
||
mlua::Value::Table(table) => table
|
||
.sequence_values::<String>()
|
||
.collect::<mlua::Result<Vec<_>>>(),
|
||
mlua::Value::Nil => Ok(Vec::new()),
|
||
_ => Err(mlua::Error::RuntimeError(
|
||
"ожидалась строка или массив строк".to_owned(),
|
||
)),
|
||
}
|
||
}
|
||
|
||
fn lua_to_json(value: mlua::Value) -> mlua::Result<serde_json::Value> {
|
||
match value {
|
||
mlua::Value::Nil => Ok(serde_json::Value::Null),
|
||
mlua::Value::Boolean(value) => Ok(serde_json::Value::Bool(value)),
|
||
mlua::Value::Integer(value) => Ok(serde_json::Value::Number(value.into())),
|
||
mlua::Value::Number(value) => Ok(Number::from_f64(value)
|
||
.map(serde_json::Value::Number)
|
||
.unwrap_or(serde_json::Value::Null)),
|
||
mlua::Value::String(value) => Ok(serde_json::Value::String(value.to_string_lossy())),
|
||
mlua::Value::Table(table) => {
|
||
let length = table.raw_len();
|
||
if length > 0 {
|
||
let mut items = Vec::with_capacity(length);
|
||
for value in table.sequence_values::<mlua::Value>() {
|
||
items.push(lua_to_json(value?)?);
|
||
}
|
||
Ok(serde_json::Value::Array(items))
|
||
} else {
|
||
let mut map = serde_json::Map::new();
|
||
for pair in table.pairs::<mlua::Value, mlua::Value>() {
|
||
let (key, value) = pair?;
|
||
let key = match key {
|
||
mlua::Value::String(key) => key.to_string_lossy(),
|
||
mlua::Value::Integer(key) => key.to_string(),
|
||
mlua::Value::Number(key) => key.to_string(),
|
||
_ => continue,
|
||
};
|
||
map.insert(key, lua_to_json(value)?);
|
||
}
|
||
Ok(serde_json::Value::Object(map))
|
||
}
|
||
}
|
||
_ => Ok(serde_json::Value::Null),
|
||
}
|
||
}
|