//! Глобальный 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> = const { RefCell::new(None) }; /// Команды окна, ожидающие ближайшего кадра. static WINDOW_COMMANDS: RefCell> = const { RefCell::new(VecDeque::new()) }; /// Развёрнуто ли окно. Вид обновляет значение в каждом кадре, чтобы /// `app:is_maximized()` из Lua видел актуальное состояние. static WINDOW_MAXIMIZED: Cell = 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 { 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>, #[lua(skip)] keymap: Rc>, #[lua(skip)] left_panel: PanelHandle, #[lua(skip)] right_panel: PanelHandle, #[lua(skip)] bottom_panel: PanelHandle, #[lua(skip)] toolbar: ToolbarHandle, #[lua(skip)] lsp: Arc, #[lua(skip)] diagnostics_handler: Rc>>, } /// 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) -> 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 { 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) -> 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, background: Option, id: String, side: String, ) -> mlua::Result<()> { let id = parse_toolbar_id(id)?; let side = parse_toolbar_side(&side)?; let icon = parse_toolbar_icon(icon)?; let background = parse_toolbar_color(background)?; self.state.borrow_mut().push( side, ToolbarItem::new(id, text, icon, background, ToolbarKind::Label), ); Ok(()) } /// Добавляет кнопку в левую или правую область панели. /// /// Параметры совпадают с [`ToolbarApi::add_label`]; дополнительный /// `callback` вызывается при нажатии без аргументов. /// /// `background = nil` оставляет стандартный фон кнопки. pub fn add_button( &self, text: String, icon: Option, background: Option, id: String, side: String, callback: mlua::Function, ) -> mlua::Result<()> { let id = parse_toolbar_id(id)?; let side = parse_toolbar_side(&side)?; let icon = parse_toolbar_icon(icon)?; let background = parse_toolbar_color(background)?; self.state.borrow_mut().push( side, ToolbarItem::new(id, text, icon, background, ToolbarKind::Button { callback }), ); Ok(()) } /// Скрывает элемент с идентификатором `id`. /// /// Возвращает `false`, если элемента с таким `id` нет. Элемент остаётся /// в модели и снова показывается через [`ToolbarApi::show`]. #[lua(infallible)] pub fn hide(&self, id: String) -> bool { self.state.borrow_mut().hide(&id) } /// Снова показывает скрытый элемент с идентификатором `id`. /// /// Возвращает `false`, если элемента с таким `id` нет. #[lua(infallible)] pub fn show(&self, id: String) -> bool { self.state.borrow_mut().show(&id) } /// Удаляет все элементы верхней панели. #[lua(infallible)] pub fn clear(&self) { self.state.borrow_mut().clear(); } } #[mlua::userdata_impl] impl AppApi { /// Делает переданные документы и карту клавиш текущими для /// [`AppApi::global`]. #[lua(skip)] pub fn install(workspace: Rc>, keymap: Rc>) -> 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 { self.lsp.clone() } /// Все открытые вкладки в порядке слева направо. #[allow(dead_code)] // Часть публичного API приложения: вызывается внешним кодом и тестами. #[lua(infallible)] pub fn tabs(&self) -> Vec { 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` — нажатие клавиши без модификаторов; /// * ``, ``, ``, `` — нажатие с Shift, Ctrl, Alt или /// платформенным модификатором (Cmd/Super); /// * `!a` — отпускание клавиши; /// * `` — отпускание клавиши при зажатом 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) { 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) -> std::io::Result { 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, position: Option<(u32, u32)>, ) -> std::io::Result { self.open_impl(path, position, true) } #[lua(skip)] fn open_impl( &self, path: impl AsRef, position: Option<(u32, u32)>, focus_editor: bool, ) -> std::io::Result { 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 { 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) -> std::io::Result> { 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) -> mlua::Result> { 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 { 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 { 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 { 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 { 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) { 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 { self.diagnostics_handler .borrow() .as_ref() .map(|handler| handler.delay) } /// Вызывает Lua-обработчик диагностики со свежим снимком. /// /// Возвращает `false`, если обработчик не зарегистрирован. #[lua(skip)] pub fn emit_diagnostics(&self, report: Vec) -> 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 { 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::>(1)?; let column = table.get::>(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 { 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::("args") { Ok(args) => args .sequence_values::() .collect::>>()?, Err(_) => Vec::new(), }; let env = match table.get::("env") { Ok(env) => env .pairs::() .collect::>>()?, Err(_) => Vec::new(), }; // Корень нормализуется в `LspManager::start`: относительный путь там // отсчитывается от текущего каталога процесса. let root = match table.get::("root") { Ok(root) => PathBuf::from(root), Err(_) => std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")), }; let languages = match table.get::("languages") { Ok(value) => lua_strings(value)?, Err(_) => Vec::new(), }; let initialization_options = match table.get::("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> { let value = match table.get::("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::("pattern") { Ok(mlua::Value::Nil) | Err(_) => Vec::new(), Ok(value) => lua_strings(value)?, }; let ignore = match config.get::("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> { 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::>(), KeyContext::Completion => CompletionCommand::ALL .iter() .map(|command| command.name()) .collect::>(), }; mlua::Error::RuntimeError(format!( "неизвестная команда клавиши: {name}; доступны: {}", available.join(", ") )) }) } _ => Err(mlua::Error::RuntimeError( "обработчик должен быть функцией, именем команды или nil".to_owned(), )), } } /// Проверяет идентификатор элемента верхней панели. fn parse_toolbar_id(id: String) -> mlua::Result { if id.trim().is_empty() { return Err(mlua::Error::RuntimeError( "id элемента верхней панели не может быть пустым".to_owned(), )); } Ok(id) } /// Разбирает сторону верхней панели: `left` или `right`. fn parse_toolbar_side(side: &str) -> mlua::Result { ToolbarSide::parse(side).ok_or_else(|| { mlua::Error::RuntimeError(format!( "неизвестная сторона панели: {side}; ожидается \"left\" или \"right\"" )) }) } /// Проверяет имя иконки по каталогу Lucide. /// /// `nil` и пустая строка означают «без иконки»; неизвестное имя — ошибка, /// чтобы опечатка в сценарии не пропадала молча. fn parse_toolbar_icon(icon: Option) -> mlua::Result> { let Some(name) = icon else { return Ok(None); }; if name.trim().is_empty() { return Ok(None); } icons::canonical_name(&name) .map(|canonical| Some(canonical.to_owned())) .ok_or_else(|| mlua::Error::RuntimeError(format!("неизвестная иконка: {name}"))) } /// Разбирает цвет фона элемента верхней панели. /// /// Поддерживаются `#RGB`, `#RGBA`, `#RRGGBB` и `#RRGGBBAA`; `nil`, /// пустая строка и `transparent` означают прозрачный фон. fn parse_toolbar_color(color: Option) -> mlua::Result> { let Some(color) = color else { return Ok(None); }; let color = color.trim(); if color.is_empty() || color.eq_ignore_ascii_case("transparent") { return Ok(None); } parse_hex_color(color).map(Some).ok_or_else(|| { mlua::Error::RuntimeError(format!( "некорректный цвет фона: {color}; ожидается #RGB, #RGBA, #RRGGBB или #RRGGBBAA" )) }) } /// Переводит шестнадцатеричный цвет в `Hsla`. fn parse_hex_color(value: &str) -> Option { let hex = value.strip_prefix('#').unwrap_or(value); if !hex.chars().all(|ch| ch.is_ascii_hexdigit()) { return None; } let nibble = |index: usize| { u8::from_str_radix(&hex[index..index + 1], 16) .ok() .map(|value| value * 17) }; let byte = |index: usize| u8::from_str_radix(&hex[index..index + 2], 16).ok(); let (r, g, b, a) = match hex.len() { 3 => (nibble(0)?, nibble(1)?, nibble(2)?, 255), 4 => (nibble(0)?, nibble(1)?, nibble(2)?, nibble(3)?), 6 => (byte(0)?, byte(2)?, byte(4)?, 255), 8 => (byte(0)?, byte(2)?, byte(4)?, byte(6)?), _ => return None, }; Some(Hsla::from(Rgba { r: f32::from(r) / 255.0, g: f32::from(g) / 255.0, b: f32::from(b) / 255.0, a: f32::from(a) / 255.0, })) } /// Разбирает массив узлов дерева, переданный в `TreeApi::append`. fn parse_tree_nodes(nodes: &mlua::Table) -> mlua::Result> { let mut parsed = Vec::new(); for pair in nodes.sequence_values::() { let table = pair?; let id: String = table.get("id")?; let label = table.get::("label").unwrap_or_else(|_| id.clone()); let icon = table .get::>("icon")? .and_then(|name| icons::canonical_name(&name).map(str::to_owned)); let expandable = table.get::>("expandable")?.unwrap_or(false); let expanded = table.get::>("expanded")?.unwrap_or(false); parsed.push(TreeNode { id, label, icon, expandable, expanded, }); } Ok(parsed) } fn lua_strings(value: mlua::Value) -> mlua::Result> { match value { mlua::Value::String(value) => Ok(vec![value.to_string_lossy()]), mlua::Value::Table(table) => table .sequence_values::() .collect::>>(), mlua::Value::Nil => Ok(Vec::new()), _ => Err(mlua::Error::RuntimeError( "ожидалась строка или массив строк".to_owned(), )), } } fn lua_to_json(value: mlua::Value) -> mlua::Result { 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::() { 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::() { 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), } }