This commit is contained in:
2026-10-29 22:40:40 +03:00
commit dce595fbc0
18 changed files with 7983 additions and 0 deletions
+310
View File
@@ -0,0 +1,310 @@
//! Глобальный API приложения.
//!
//! Единственная точка входа для команд управления окном и редактором: UI-код
//! не работает с `ViewportCommand` и состоянием редактора напрямую, а вызывает
//! методы [`AppApi`].
use std::cell::RefCell;
use std::rc::Rc;
use eframe::egui;
use crate::editor::EditorState;
use crate::keymap::{KeyBinding, KeyMap};
use crate::syntax::Language;
thread_local! {
/// Текущий экземпляр API. `thread_local`, потому что `egui::Context` привязан
/// к потоку с event loop; заодно это делает тесты независимыми друг от друга.
static INSTANCE: RefCell<Option<AppApi>> = const { RefCell::new(None) };
}
/// Краткая информация об открытой вкладке.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct TabInfo {
/// Номер вкладки (с нуля), слева направо.
pub index: usize,
/// Название — то же, что показано в заголовке вкладки.
pub title: String,
}
/// Глобальный объект с функциями управления приложением.
///
/// Использование: `AppApi::global().close()`, `AppApi::global().tabs()`.
/// Экземпляр устанавливается в начале каждого кадра (см. [`AppApi::install`]),
/// поэтому всегда указывает на актуальные `egui::Context` и состояние редактора.
///
/// Методы этого `impl`-блока автоматически экспортируются в Lua через
/// `#[mlua::userdata_impl]`: в сценариях они доступны как `app:method(...)`.
/// Чтобы не экспортировать метод, пометьте его `#[lua(skip)]`.
#[derive(Clone, mlua::UserData)]
pub struct AppApi {
#[lua(skip)]
ctx: egui::Context,
#[lua(skip)]
editor: Rc<RefCell<EditorState>>,
#[lua(skip)]
keymap: Rc<RefCell<KeyMap>>,
}
#[mlua::userdata_impl]
impl AppApi {
/// Делает переданные контекст, состояние редактора и карту клавиш текущими
/// для [`AppApi::global`].
#[lua(skip)]
pub fn install(
ctx: egui::Context,
editor: Rc<RefCell<EditorState>>,
keymap: Rc<RefCell<KeyMap>>,
) -> Self {
let api = Self {
ctx,
editor,
keymap,
};
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")
})
}
/// Контекст egui, к которому привязан API.
#[lua(skip)]
pub fn context(&self) -> &egui::Context {
&self.ctx
}
/// Все открытые вкладки в порядке слева направо.
#[allow(dead_code)]
// Часть публичного API приложения: вызывается внешним кодом и тестами.
#[lua(infallible)]
pub fn tabs(&self) -> Vec<TabInfo> {
self.editor
.borrow()
.documents()
.iter()
.enumerate()
.map(|(index, doc)| TabInfo {
index,
title: doc.name.clone(),
})
.collect()
}
/// Индекс активной вкладки в списке [`AppApi::tabs`].
#[allow(dead_code)]
// Часть публичного API приложения: вызывается внешним кодом и тестами.
#[lua(infallible, name = "active_tab")]
pub fn active_tab_index(&self) -> usize {
self.editor.borrow().active_index()
}
/// Переключает на следующую вкладку (по кругу) и возвращает её индекс.
#[allow(dead_code)]
// Часть публичного API приложения: вызывается внешним кодом и тестами.
#[lua(infallible)]
pub fn next_tab(&self) -> usize {
let mut editor = self.editor.borrow_mut();
let next = (editor.active_index() + 1) % editor.documents().len();
editor.set_active(next);
next
}
/// Переключает на предыдущую вкладку (по кругу) и возвращает её индекс.
#[allow(dead_code)]
// Часть публичного API приложения: вызывается внешним кодом и тестами.
#[lua(infallible)]
pub fn previous_tab(&self) -> usize {
let mut editor = self.editor.borrow_mut();
let count = editor.documents().len();
let previous = (editor.active_index() + count - 1) % count;
editor.set_active(previous);
previous
}
/// Закрывает вкладку по её номеру в списке [`AppApi::tabs`].
///
/// Возвращает `false`, если вкладки с таким номером нет. Если закрывается
/// последняя вкладка, вместо неё открывается пустой документ.
#[lua(infallible)]
pub fn close_tab(&self, index: usize) -> bool {
let mut editor = self.editor.borrow_mut();
if index >= editor.documents().len() {
return false;
}
editor.close(index);
true
}
/// Сохраняет содержимое активной вкладки в файл, названный как сама вкладка.
///
/// Путь считается относительно текущего рабочего каталога процесса.
/// При успешной записи с вкладки снимается маркер несохранённых изменений.
#[lua(skip)]
pub fn save(&self) -> std::io::Result<()> {
let mut editor = self.editor.borrow_mut();
let doc = editor.active_mut();
std::fs::write(&doc.name, &doc.content)?;
doc.mark_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>` — нажатие с Shift, Ctrl или Alt;
/// * `!a` — отпускание клавиши;
/// * `<A-!r>` — отпускание клавиши при зажатом Alt.
///
/// Клавиша определяется по физическому коду, поэтому Caps Lock и раскладка
/// не важны. Повторное назначение той же клавиши заменяет обработчик.
///
/// # Errors
/// Возвращает ошибку Lua, если запись привязки некорректна.
pub fn map_key(&self, keys: String, callback: mlua::Function) -> mlua::Result<()> {
let binding = KeyBinding::parse(&keys).map_err(mlua::Error::RuntimeError)?;
self.keymap.borrow_mut().bind(binding, callback);
Ok(())
}
/// Содержимое активной вкладки.
#[lua(infallible)]
pub fn active_text(&self) -> String {
self.editor.borrow().active().content.clone()
}
/// Заменяет содержимое активной вкладки.
#[lua(skip)]
pub fn set_active_text(&self, text: impl Into<String>) {
self.editor.borrow_mut().active_mut().content = text.into();
}
/// 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 editor = self.editor.borrow_mut();
editor.open_new();
editor.active_index()
}
/// Делает вкладку активной по её номеру в списке [`AppApi::tabs`].
///
/// Возвращает `false`, если вкладки с таким номером нет.
#[lua(infallible)]
pub fn set_active_tab(&self, index: usize) -> bool {
let mut editor = self.editor.borrow_mut();
if index >= editor.documents().len() {
return false;
}
editor.set_active(index);
true
}
/// Включает подсветку синтаксиса для вкладки `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;
};
let mut editor = self.editor.borrow_mut();
let Some(doc) = editor.document_mut(index) else {
return false;
};
doc.language = Some(language);
true
}
/// 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.editor
.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 {
let mut editor = self.editor.borrow_mut();
let Some(doc) = editor.document_mut(index) else {
return false;
};
doc.language = None;
true
}
/// Закрывает окно приложения.
#[lua(infallible)]
pub fn close(&self) {
self.ctx.send_viewport_cmd(egui::ViewportCommand::Close);
}
/// Разворачивает окно на весь экран или возвращает его прежний размер.
#[lua(infallible)]
pub fn toggle_maximize(&self) {
self.set_maximized(!self.is_maximized());
}
/// Разворачивает (`true`) или восстанавливает (`false`) окно.
#[lua(infallible)]
pub fn set_maximized(&self, maximized: bool) {
self.ctx
.send_viewport_cmd(egui::ViewportCommand::Maximized(maximized));
}
/// Сворачивает окно.
#[lua(infallible)]
pub fn minimize(&self) {
self.ctx
.send_viewport_cmd(egui::ViewportCommand::Minimized(true));
}
/// Развёрнуто ли окно прямо сейчас.
#[lua(infallible)]
pub fn is_maximized(&self) -> bool {
self.ctx.input(|i| i.viewport().maximized.unwrap_or(false))
}
}