use gpui
This commit is contained in:
Generated
+5362
-1022
File diff suppressed because it is too large
Load Diff
+10
-10
@@ -1,20 +1,20 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "egui-rust-test"
|
name = "gpui-editor-test"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
egui = "0.37"
|
gpui-kit = { version = "0.8.1", features = [
|
||||||
eframe = { version = "0.37", features = ["wayland", "x11", "default_fonts"] }
|
"tree-sitter",
|
||||||
|
"tree-sitter-rust",
|
||||||
|
"tree-sitter-go",
|
||||||
|
"tree-sitter-javascript",
|
||||||
|
"tree-sitter-python",
|
||||||
|
] }
|
||||||
mlua = { version = "0.12", features = ["lua54", "vendored", "macros"] }
|
mlua = { version = "0.12", features = ["lua54", "vendored", "macros"] }
|
||||||
|
|
||||||
# tree-sitter и грамматики для подсветки синтаксиса.
|
[dev-dependencies]
|
||||||
tree-sitter = "0.25"
|
gpui-kit = { version = "0.8.1", features = ["test-support"] }
|
||||||
tree-sitter-rust = "0.24"
|
|
||||||
tree-sitter-go = "0.23"
|
|
||||||
tree-sitter-javascript = "0.23"
|
|
||||||
tree-sitter-json = "0.24"
|
|
||||||
tree-sitter-python = "0.23"
|
|
||||||
|
|
||||||
[profile.dev]
|
[profile.dev]
|
||||||
opt-level = 1
|
opt-level = 1
|
||||||
|
|||||||
@@ -27,8 +27,10 @@ print("[lua] init.lua выполнен")
|
|||||||
-- -- "<S-c>" C при зажатом Shift
|
-- -- "<S-c>" C при зажатом Shift
|
||||||
-- -- "<C-z>" Z при зажатом Ctrl
|
-- -- "<C-z>" Z при зажатом Ctrl
|
||||||
-- -- "<A-!r>" отпускание R при зажатом Alt
|
-- -- "<A-!r>" отпускание R при зажатом Alt
|
||||||
-- -- клавиша берётся по физическому коду:
|
-- -- клавиша сравнивается с именем клавиши от GPUI:
|
||||||
-- -- Caps Lock и раскладка не важны
|
-- -- Caps Lock не важен; на Linux и Windows буквы
|
||||||
|
-- -- берутся по аппаратному keycode, поэтому
|
||||||
|
-- -- <C-q> работает и на русской раскладке
|
||||||
-- app:close() -- закрыть окно
|
-- app:close() -- закрыть окно
|
||||||
-- app:toggle_maximize()
|
-- app:toggle_maximize()
|
||||||
-- app:set_maximized(true/false)
|
-- app:set_maximized(true/false)
|
||||||
|
|||||||
+87
-78
@@ -1,22 +1,57 @@
|
|||||||
//! Глобальный API приложения.
|
//! Глобальный API приложения.
|
||||||
//!
|
//!
|
||||||
//! Единственная точка входа для команд управления окном и редактором: UI-код
|
//! Единственная точка входа для команд управления окном и редактором: UI-код
|
||||||
//! не работает с `ViewportCommand` и состоянием редактора напрямую, а вызывает
|
//! и Lua-сценарии не трогают `Window` и состояние вида напрямую, а вызывают
|
||||||
//! методы [`AppApi`].
|
//! методы [`AppApi`].
|
||||||
|
//!
|
||||||
|
//! Команды окна не выполняются в момент вызова: Lua-обработчик выполняется вне
|
||||||
|
//! контекста `App`/`Window`, поэтому [`AppApi`] складывает их в очередь, а вид
|
||||||
|
//! вычитывает её в начале кадра.
|
||||||
|
|
||||||
use std::cell::RefCell;
|
use std::cell::{Cell, RefCell};
|
||||||
|
use std::collections::VecDeque;
|
||||||
use std::rc::Rc;
|
use std::rc::Rc;
|
||||||
|
|
||||||
use eframe::egui;
|
use crate::editor::{Language, Workspace};
|
||||||
|
|
||||||
use crate::editor::EditorState;
|
|
||||||
use crate::keymap::{KeyBinding, KeyMap};
|
use crate::keymap::{KeyBinding, KeyMap};
|
||||||
use crate::syntax::Language;
|
|
||||||
|
|
||||||
thread_local! {
|
thread_local! {
|
||||||
/// Текущий экземпляр API. `thread_local`, потому что `egui::Context` привязан
|
/// Текущий экземпляр API. `thread_local`, потому что UI GPUI привязан к
|
||||||
/// к потоку с event loop; заодно это делает тесты независимыми друг от друга.
|
/// потоку с event loop; заодно это делает тесты независимыми друг от друга.
|
||||||
static INSTANCE: RefCell<Option<AppApi>> = const { RefCell::new(None) };
|
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));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Краткая информация об открытой вкладке.
|
/// Краткая информация об открытой вкладке.
|
||||||
@@ -31,8 +66,8 @@ pub struct TabInfo {
|
|||||||
/// Глобальный объект с функциями управления приложением.
|
/// Глобальный объект с функциями управления приложением.
|
||||||
///
|
///
|
||||||
/// Использование: `AppApi::global().close()`, `AppApi::global().tabs()`.
|
/// Использование: `AppApi::global().close()`, `AppApi::global().tabs()`.
|
||||||
/// Экземпляр устанавливается в начале каждого кадра (см. [`AppApi::install`]),
|
/// Экземпляр устанавливается один раз при создании приложения (см.
|
||||||
/// поэтому всегда указывает на актуальные `egui::Context` и состояние редактора.
|
/// [`AppApi::install`]) и указывает на общие с видом документы и карту клавиш.
|
||||||
///
|
///
|
||||||
/// Методы этого `impl`-блока автоматически экспортируются в Lua через
|
/// Методы этого `impl`-блока автоматически экспортируются в Lua через
|
||||||
/// `#[mlua::userdata_impl]`: в сценариях они доступны как `app:method(...)`.
|
/// `#[mlua::userdata_impl]`: в сценариях они доступны как `app:method(...)`.
|
||||||
@@ -40,28 +75,18 @@ pub struct TabInfo {
|
|||||||
#[derive(Clone, mlua::UserData)]
|
#[derive(Clone, mlua::UserData)]
|
||||||
pub struct AppApi {
|
pub struct AppApi {
|
||||||
#[lua(skip)]
|
#[lua(skip)]
|
||||||
ctx: egui::Context,
|
workspace: Rc<RefCell<Workspace>>,
|
||||||
#[lua(skip)]
|
|
||||||
editor: Rc<RefCell<EditorState>>,
|
|
||||||
#[lua(skip)]
|
#[lua(skip)]
|
||||||
keymap: Rc<RefCell<KeyMap>>,
|
keymap: Rc<RefCell<KeyMap>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[mlua::userdata_impl]
|
#[mlua::userdata_impl]
|
||||||
impl AppApi {
|
impl AppApi {
|
||||||
/// Делает переданные контекст, состояние редактора и карту клавиш текущими
|
/// Делает переданные документы и карту клавиш текущими для
|
||||||
/// для [`AppApi::global`].
|
/// [`AppApi::global`].
|
||||||
#[lua(skip)]
|
#[lua(skip)]
|
||||||
pub fn install(
|
pub fn install(workspace: Rc<RefCell<Workspace>>, keymap: Rc<RefCell<KeyMap>>) -> Self {
|
||||||
ctx: egui::Context,
|
let api = Self { workspace, keymap };
|
||||||
editor: Rc<RefCell<EditorState>>,
|
|
||||||
keymap: Rc<RefCell<KeyMap>>,
|
|
||||||
) -> Self {
|
|
||||||
let api = Self {
|
|
||||||
ctx,
|
|
||||||
editor,
|
|
||||||
keymap,
|
|
||||||
};
|
|
||||||
INSTANCE.with(|slot| *slot.borrow_mut() = Some(api.clone()));
|
INSTANCE.with(|slot| *slot.borrow_mut() = Some(api.clone()));
|
||||||
api
|
api
|
||||||
}
|
}
|
||||||
@@ -69,7 +94,7 @@ impl AppApi {
|
|||||||
/// Глобальный экземпляр API.
|
/// Глобальный экземпляр API.
|
||||||
///
|
///
|
||||||
/// # Panics
|
/// # Panics
|
||||||
/// Если [`AppApi::install`] ещё не вызывался (ни одного кадра не отрисовано).
|
/// Если [`AppApi::install`] ещё не вызывался.
|
||||||
#[lua(skip)]
|
#[lua(skip)]
|
||||||
pub fn global() -> Self {
|
pub fn global() -> Self {
|
||||||
INSTANCE.with(|slot| {
|
INSTANCE.with(|slot| {
|
||||||
@@ -79,18 +104,12 @@ impl AppApi {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Контекст egui, к которому привязан API.
|
|
||||||
#[lua(skip)]
|
|
||||||
pub fn context(&self) -> &egui::Context {
|
|
||||||
&self.ctx
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Все открытые вкладки в порядке слева направо.
|
/// Все открытые вкладки в порядке слева направо.
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
// Часть публичного API приложения: вызывается внешним кодом и тестами.
|
// Часть публичного API приложения: вызывается внешним кодом и тестами.
|
||||||
#[lua(infallible)]
|
#[lua(infallible)]
|
||||||
pub fn tabs(&self) -> Vec<TabInfo> {
|
pub fn tabs(&self) -> Vec<TabInfo> {
|
||||||
self.editor
|
self.workspace
|
||||||
.borrow()
|
.borrow()
|
||||||
.documents()
|
.documents()
|
||||||
.iter()
|
.iter()
|
||||||
@@ -107,7 +126,7 @@ impl AppApi {
|
|||||||
// Часть публичного API приложения: вызывается внешним кодом и тестами.
|
// Часть публичного API приложения: вызывается внешним кодом и тестами.
|
||||||
#[lua(infallible, name = "active_tab")]
|
#[lua(infallible, name = "active_tab")]
|
||||||
pub fn active_tab_index(&self) -> usize {
|
pub fn active_tab_index(&self) -> usize {
|
||||||
self.editor.borrow().active_index()
|
self.workspace.borrow().active_index()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Переключает на следующую вкладку (по кругу) и возвращает её индекс.
|
/// Переключает на следующую вкладку (по кругу) и возвращает её индекс.
|
||||||
@@ -115,9 +134,9 @@ impl AppApi {
|
|||||||
// Часть публичного API приложения: вызывается внешним кодом и тестами.
|
// Часть публичного API приложения: вызывается внешним кодом и тестами.
|
||||||
#[lua(infallible)]
|
#[lua(infallible)]
|
||||||
pub fn next_tab(&self) -> usize {
|
pub fn next_tab(&self) -> usize {
|
||||||
let mut editor = self.editor.borrow_mut();
|
let mut workspace = self.workspace.borrow_mut();
|
||||||
let next = (editor.active_index() + 1) % editor.documents().len();
|
let next = (workspace.active_index() + 1) % workspace.documents().len();
|
||||||
editor.set_active(next);
|
workspace.set_active(next);
|
||||||
next
|
next
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -126,10 +145,10 @@ impl AppApi {
|
|||||||
// Часть публичного API приложения: вызывается внешним кодом и тестами.
|
// Часть публичного API приложения: вызывается внешним кодом и тестами.
|
||||||
#[lua(infallible)]
|
#[lua(infallible)]
|
||||||
pub fn previous_tab(&self) -> usize {
|
pub fn previous_tab(&self) -> usize {
|
||||||
let mut editor = self.editor.borrow_mut();
|
let mut workspace = self.workspace.borrow_mut();
|
||||||
let count = editor.documents().len();
|
let count = workspace.documents().len();
|
||||||
let previous = (editor.active_index() + count - 1) % count;
|
let previous = (workspace.active_index() + count - 1) % count;
|
||||||
editor.set_active(previous);
|
workspace.set_active(previous);
|
||||||
previous
|
previous
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -139,11 +158,11 @@ impl AppApi {
|
|||||||
/// последняя вкладка, вместо неё открывается пустой документ.
|
/// последняя вкладка, вместо неё открывается пустой документ.
|
||||||
#[lua(infallible)]
|
#[lua(infallible)]
|
||||||
pub fn close_tab(&self, index: usize) -> bool {
|
pub fn close_tab(&self, index: usize) -> bool {
|
||||||
let mut editor = self.editor.borrow_mut();
|
let mut workspace = self.workspace.borrow_mut();
|
||||||
if index >= editor.documents().len() {
|
if index >= workspace.documents().len() {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
editor.close(index);
|
workspace.close(index);
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -153,10 +172,13 @@ impl AppApi {
|
|||||||
/// При успешной записи с вкладки снимается маркер несохранённых изменений.
|
/// При успешной записи с вкладки снимается маркер несохранённых изменений.
|
||||||
#[lua(skip)]
|
#[lua(skip)]
|
||||||
pub fn save(&self) -> std::io::Result<()> {
|
pub fn save(&self) -> std::io::Result<()> {
|
||||||
let mut editor = self.editor.borrow_mut();
|
let (path, content) = {
|
||||||
let doc = editor.active_mut();
|
let workspace = self.workspace.borrow();
|
||||||
std::fs::write(&doc.name, &doc.content)?;
|
let doc = workspace.active();
|
||||||
doc.mark_saved();
|
(doc.name.clone(), doc.content.clone())
|
||||||
|
};
|
||||||
|
std::fs::write(&path, content)?;
|
||||||
|
self.workspace.borrow_mut().mark_active_saved();
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -176,8 +198,7 @@ impl AppApi {
|
|||||||
/// * `!a` — отпускание клавиши;
|
/// * `!a` — отпускание клавиши;
|
||||||
/// * `<A-!r>` — отпускание клавиши при зажатом Alt.
|
/// * `<A-!r>` — отпускание клавиши при зажатом Alt.
|
||||||
///
|
///
|
||||||
/// Клавиша определяется по физическому коду, поэтому Caps Lock и раскладка
|
/// Повторное назначение той же клавиши заменяет обработчик.
|
||||||
/// не важны. Повторное назначение той же клавиши заменяет обработчик.
|
|
||||||
///
|
///
|
||||||
/// # Errors
|
/// # Errors
|
||||||
/// Возвращает ошибку Lua, если запись привязки некорректна.
|
/// Возвращает ошибку Lua, если запись привязки некорректна.
|
||||||
@@ -190,13 +211,13 @@ impl AppApi {
|
|||||||
/// Содержимое активной вкладки.
|
/// Содержимое активной вкладки.
|
||||||
#[lua(infallible)]
|
#[lua(infallible)]
|
||||||
pub fn active_text(&self) -> String {
|
pub fn active_text(&self) -> String {
|
||||||
self.editor.borrow().active().content.clone()
|
self.workspace.borrow().active().content.clone()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Заменяет содержимое активной вкладки.
|
/// Заменяет содержимое активной вкладки.
|
||||||
#[lua(skip)]
|
#[lua(skip)]
|
||||||
pub fn set_active_text(&self, text: impl Into<String>) {
|
pub fn set_active_text(&self, text: impl Into<String>) {
|
||||||
self.editor.borrow_mut().active_mut().content = text.into();
|
self.workspace.borrow_mut().set_active_text(text);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Lua-версия [`AppApi::set_active_text`] (generic-методы не экспортируются).
|
/// Lua-версия [`AppApi::set_active_text`] (generic-методы не экспортируются).
|
||||||
@@ -208,9 +229,9 @@ impl AppApi {
|
|||||||
/// Открывает новую пустую вкладку, делает её активной и возвращает её индекс.
|
/// Открывает новую пустую вкладку, делает её активной и возвращает её индекс.
|
||||||
#[lua(infallible)]
|
#[lua(infallible)]
|
||||||
pub fn open_tab(&self) -> usize {
|
pub fn open_tab(&self) -> usize {
|
||||||
let mut editor = self.editor.borrow_mut();
|
let mut workspace = self.workspace.borrow_mut();
|
||||||
editor.open_new();
|
workspace.open_new();
|
||||||
editor.active_index()
|
workspace.active_index()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Делает вкладку активной по её номеру в списке [`AppApi::tabs`].
|
/// Делает вкладку активной по её номеру в списке [`AppApi::tabs`].
|
||||||
@@ -218,11 +239,11 @@ impl AppApi {
|
|||||||
/// Возвращает `false`, если вкладки с таким номером нет.
|
/// Возвращает `false`, если вкладки с таким номером нет.
|
||||||
#[lua(infallible)]
|
#[lua(infallible)]
|
||||||
pub fn set_active_tab(&self, index: usize) -> bool {
|
pub fn set_active_tab(&self, index: usize) -> bool {
|
||||||
let mut editor = self.editor.borrow_mut();
|
let mut workspace = self.workspace.borrow_mut();
|
||||||
if index >= editor.documents().len() {
|
if index >= workspace.documents().len() {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
editor.set_active(index);
|
workspace.set_active(index);
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -238,12 +259,7 @@ impl AppApi {
|
|||||||
let Some(language) = Language::from_name(language) else {
|
let Some(language) = Language::from_name(language) else {
|
||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
let mut editor = self.editor.borrow_mut();
|
self.workspace.borrow_mut().set_language(index, language)
|
||||||
let Some(doc) = editor.document_mut(index) else {
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
doc.language = Some(language);
|
|
||||||
true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Lua-версия [`AppApi::set_language`].
|
/// Lua-версия [`AppApi::set_language`].
|
||||||
@@ -256,7 +272,7 @@ impl AppApi {
|
|||||||
/// или `nil`, если подсветка выключена / вкладки нет.
|
/// или `nil`, если подсветка выключена / вкладки нет.
|
||||||
#[lua(infallible)]
|
#[lua(infallible)]
|
||||||
pub fn language(&self, index: usize) -> Option<String> {
|
pub fn language(&self, index: usize) -> Option<String> {
|
||||||
self.editor
|
self.workspace
|
||||||
.borrow()
|
.borrow()
|
||||||
.document(index)
|
.document(index)
|
||||||
.and_then(|doc| doc.language)
|
.and_then(|doc| doc.language)
|
||||||
@@ -268,43 +284,36 @@ impl AppApi {
|
|||||||
/// Возвращает `false`, если вкладки с таким номером нет.
|
/// Возвращает `false`, если вкладки с таким номером нет.
|
||||||
#[lua(infallible)]
|
#[lua(infallible)]
|
||||||
pub fn clear_language(&self, index: usize) -> bool {
|
pub fn clear_language(&self, index: usize) -> bool {
|
||||||
let mut editor = self.editor.borrow_mut();
|
self.workspace.borrow_mut().clear_language(index)
|
||||||
let Some(doc) = editor.document_mut(index) else {
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
doc.language = None;
|
|
||||||
true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Закрывает окно приложения.
|
/// Закрывает окно приложения.
|
||||||
#[lua(infallible)]
|
#[lua(infallible)]
|
||||||
pub fn close(&self) {
|
pub fn close(&self) {
|
||||||
self.ctx.send_viewport_cmd(egui::ViewportCommand::Close);
|
push_window_command(WindowCommand::Close);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Разворачивает окно на весь экран или возвращает его прежний размер.
|
/// Разворачивает окно на весь экран или возвращает его прежний размер.
|
||||||
#[lua(infallible)]
|
#[lua(infallible)]
|
||||||
pub fn toggle_maximize(&self) {
|
pub fn toggle_maximize(&self) {
|
||||||
self.set_maximized(!self.is_maximized());
|
push_window_command(WindowCommand::ToggleMaximize);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Разворачивает (`true`) или восстанавливает (`false`) окно.
|
/// Разворачивает (`true`) или восстанавливает (`false`) окно.
|
||||||
#[lua(infallible)]
|
#[lua(infallible)]
|
||||||
pub fn set_maximized(&self, maximized: bool) {
|
pub fn set_maximized(&self, maximized: bool) {
|
||||||
self.ctx
|
push_window_command(WindowCommand::SetMaximized(maximized));
|
||||||
.send_viewport_cmd(egui::ViewportCommand::Maximized(maximized));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Сворачивает окно.
|
/// Сворачивает окно.
|
||||||
#[lua(infallible)]
|
#[lua(infallible)]
|
||||||
pub fn minimize(&self) {
|
pub fn minimize(&self) {
|
||||||
self.ctx
|
push_window_command(WindowCommand::Minimize);
|
||||||
.send_viewport_cmd(egui::ViewportCommand::Minimized(true));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Развёрнуто ли окно прямо сейчас.
|
/// Развёрнуто ли окно прямо сейчас.
|
||||||
#[lua(infallible)]
|
#[lua(infallible)]
|
||||||
pub fn is_maximized(&self) -> bool {
|
pub fn is_maximized(&self) -> bool {
|
||||||
self.ctx.input(|i| i.viewport().maximized.unwrap_or(false))
|
WINDOW_MAXIMIZED.with(Cell::get)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+347
-124
@@ -1,175 +1,398 @@
|
|||||||
//! Приложение: окно без системных рамок и его содержимое.
|
//! Приложение: окно, вкладки и редактор кода.
|
||||||
|
//!
|
||||||
|
//! Вид [`MyApp`] — единственное место, где доменная модель [`Workspace`]
|
||||||
|
//! встречается с виджетами gpui-component. Текст вкладки живёт в модели,
|
||||||
|
//! а `Editor` показывает активный документ; синхронизация выполняется в
|
||||||
|
//! `render`, когда меняется [`Workspace::revision`].
|
||||||
|
|
||||||
use std::cell::RefCell;
|
use std::cell::RefCell;
|
||||||
use std::rc::Rc;
|
use std::rc::Rc;
|
||||||
|
|
||||||
use eframe::egui;
|
use gpui_kit::component::button::{Button, ButtonVariants};
|
||||||
|
use gpui_kit::component::input::{Editor, EditorState, InputEvent, TabSize};
|
||||||
|
use gpui_kit::component::tab::{Tab, TabBar};
|
||||||
|
use gpui_kit::component::{ActiveTheme, IconName, Sizable, StyledExt, TitleBar, h_flex};
|
||||||
|
use gpui_kit::*;
|
||||||
|
|
||||||
use crate::api::AppApi;
|
use crate::api::{self, AppApi, WindowCommand};
|
||||||
use crate::constants::CORNER_RADIUS;
|
use crate::constants::{TAB_SIZE, WINDOW_TITLE};
|
||||||
use crate::editor::EditorState;
|
use crate::editor::Workspace;
|
||||||
use crate::keymap::KeyMap;
|
use crate::keymap::KeyMap;
|
||||||
use crate::script::ScriptEngine;
|
use crate::script::ScriptEngine;
|
||||||
use crate::ui::title_bar::TitleBarResponses;
|
|
||||||
use crate::ui::{code_editor, resize_handles, tabs, title_bar};
|
|
||||||
|
|
||||||
#[derive(Default)]
|
/// Приложение текстового редактора.
|
||||||
pub struct MyApp {
|
pub struct MyApp {
|
||||||
/// Открытые документы, вкладки над редактором и активная вкладка.
|
/// Открытые документы, вкладки и активная вкладка.
|
||||||
///
|
///
|
||||||
/// Состояние общее с [`AppApi`] (`Rc<RefCell<_>>`), чтобы им можно было
|
/// Состояние общее с [`AppApi`] (`Rc<RefCell<_>>`), чтобы им можно было
|
||||||
/// управлять через `AppApi::global()`.
|
/// управлять через `AppApi::global()` и Lua.
|
||||||
editor: Rc<RefCell<EditorState>>,
|
workspace: Rc<RefCell<Workspace>>,
|
||||||
/// Привязки клавиш и их Lua-обработчики.
|
/// Привязки клавиш и их Lua-обработчики.
|
||||||
///
|
///
|
||||||
/// Лежит перед `scripts`, чтобы обработчики (ссылки на Lua-значения)
|
/// Лежит перед `scripts`, чтобы обработчики (ссылки на Lua-значения)
|
||||||
/// уничтожились раньше самого Lua-движка.
|
/// уничтожились раньше самого Lua-движка.
|
||||||
keymap: Rc<RefCell<KeyMap>>,
|
keymap: Rc<RefCell<KeyMap>>,
|
||||||
|
/// Виджет редактора, показывающий активный документ.
|
||||||
|
editor: Entity<EditorState>,
|
||||||
|
/// Ревизия [`Workspace`], уже перенесённая в `editor`.
|
||||||
|
synced_revision: u64,
|
||||||
|
/// Идентификатор документа, который сейчас показан в редакторе.
|
||||||
|
///
|
||||||
|
/// Нужен, чтобы отличить переключение вкладки от изменения её текста и
|
||||||
|
/// сохранить/восстановить курсор при переходе.
|
||||||
|
displayed_id: u64,
|
||||||
|
/// Курсор редактора ещё ни разу не получал фокус.
|
||||||
|
focused_once: bool,
|
||||||
|
/// Подписка на изменения редактора; держим её, пока жив вид.
|
||||||
|
_editor_subscription: Subscription,
|
||||||
/// Движок Lua, созданный при старте.
|
/// Движок Lua, созданный при старте.
|
||||||
///
|
///
|
||||||
/// Хранится, чтобы глобальное окружение и загруженные модули жили, пока
|
/// Хранится, чтобы глобальное окружение и загруженные модули жили, пока
|
||||||
/// работает приложение.
|
/// работает приложение.
|
||||||
#[allow(dead_code)]
|
|
||||||
scripts: Option<ScriptEngine>,
|
scripts: Option<ScriptEngine>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl eframe::App for MyApp {
|
|
||||||
/// Фон вне скруглённого окна должен быть полностью прозрачным,
|
|
||||||
/// иначе eframe зальёт его цветом по умолчанию и скругления не будет видно.
|
|
||||||
fn clear_color(&self, _visuals: &egui::Visuals) -> [f32; 4] {
|
|
||||||
egui::Color32::TRANSPARENT.to_normalized_gamma_f32()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
|
|
||||||
self.show(ui);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl MyApp {
|
impl MyApp {
|
||||||
/// Создаёт приложение и сразу выполняет стартовый Lua-сценарий.
|
/// Создаёт приложение, но не выполняет стартовый сценарий.
|
||||||
///
|
///
|
||||||
/// Ошибки чтения/выполнения сценария печатаются в stderr и не мешают
|
/// Такой конструктор удобен тестам: они не зависят от `init.lua` в
|
||||||
/// запуску приложения: без `init.lua` редактор просто работает как обычно.
|
/// рабочем каталоге. Боевой запуск использует [`Self::with_startup_script`].
|
||||||
pub fn new(cc: &eframe::CreationContext<'_>) -> Self {
|
pub fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||||
let mut app = Self::default();
|
let workspace = Rc::new(RefCell::new(Workspace::default()));
|
||||||
|
let keymap = Rc::new(RefCell::new(KeyMap::default()));
|
||||||
|
|
||||||
// API доступен сценарию уже до первого кадра.
|
let initial_text = workspace.borrow().active().content.clone();
|
||||||
let api = AppApi::install(cc.egui_ctx.clone(), app.editor.clone(), app.keymap.clone());
|
let editor = cx.new(|cx| {
|
||||||
match ScriptEngine::new(api) {
|
EditorState::new(window, cx)
|
||||||
|
.line_number(true)
|
||||||
|
.folding(true)
|
||||||
|
.searchable(true)
|
||||||
|
.tab_size(TabSize {
|
||||||
|
tab_size: TAB_SIZE,
|
||||||
|
hard_tabs: false,
|
||||||
|
})
|
||||||
|
.default_value(initial_text)
|
||||||
|
});
|
||||||
|
|
||||||
|
// Любая правка в виджете сразу попадает в модель. Ревизию при этом не
|
||||||
|
// трогаем: редактор уже показывает этот текст, и обратная
|
||||||
|
// синхронизация сбросила бы курсор после каждого символа.
|
||||||
|
let _editor_subscription = cx.subscribe_in(
|
||||||
|
&editor,
|
||||||
|
window,
|
||||||
|
|this, _editor, event: &InputEvent, _window, cx| {
|
||||||
|
if matches!(event, InputEvent::Change) {
|
||||||
|
let text = this.editor.read(cx).value().to_string();
|
||||||
|
let mut workspace = this.workspace.borrow_mut();
|
||||||
|
let was_dirty = workspace.active().is_dirty();
|
||||||
|
workspace.replace_active_content(text);
|
||||||
|
// Индикатор на вкладке меняется только при переходе
|
||||||
|
// «чисто → грязно» (или обратно после отмены правки).
|
||||||
|
if was_dirty != workspace.active().is_dirty() {
|
||||||
|
drop(workspace);
|
||||||
|
cx.notify();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
let displayed_id = workspace.borrow().active().id;
|
||||||
|
AppApi::install(workspace.clone(), keymap.clone());
|
||||||
|
|
||||||
|
Self {
|
||||||
|
workspace,
|
||||||
|
keymap,
|
||||||
|
editor,
|
||||||
|
synced_revision: 0,
|
||||||
|
displayed_id,
|
||||||
|
focused_once: false,
|
||||||
|
_editor_subscription,
|
||||||
|
scripts: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Выполняет `init.lua` из каталога сценариев.
|
||||||
|
///
|
||||||
|
/// Ошибки печатаются в stderr и не мешают запуску: без `init.lua`
|
||||||
|
/// редактор работает как обычно.
|
||||||
|
pub fn with_startup_script(mut self) -> Self {
|
||||||
|
match ScriptEngine::new(AppApi::global()) {
|
||||||
Ok(engine) => {
|
Ok(engine) => {
|
||||||
if let Err(err) = engine.run_startup() {
|
if let Err(err) = engine.run_startup() {
|
||||||
eprintln!("Lua: {err}");
|
eprintln!("Lua: {err}");
|
||||||
}
|
}
|
||||||
app.scripts = Some(engine);
|
self.scripts = Some(engine);
|
||||||
}
|
}
|
||||||
Err(err) => eprintln!("Lua: не удалось создать движок: {err}"),
|
Err(err) => eprintln!("Lua: не удалось создать движок: {err}"),
|
||||||
}
|
}
|
||||||
|
self
|
||||||
app
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Состояние редактора (доступ для тестов).
|
/// Общие документы (доступ для тестов).
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
pub fn editor(&self) -> std::cell::Ref<'_, EditorState> {
|
pub fn workspace_handle(&self) -> Rc<RefCell<Workspace>> {
|
||||||
self.editor.borrow()
|
self.workspace.clone()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Всё содержимое окна. Вынесено из `eframe::App::ui`, чтобы можно было
|
/// Редактор активной вкладки (доступ для тестов).
|
||||||
/// вернуть наружу `Response`-ы заголовка (пригодится для проверок/тестов).
|
#[cfg(test)]
|
||||||
pub fn show(&mut self, ui: &mut egui::Ui) -> TitleBarResponses {
|
pub fn editor_handle(&self) -> Entity<EditorState> {
|
||||||
let ctx = ui.ctx().clone();
|
self.editor.clone()
|
||||||
|
}
|
||||||
|
|
||||||
// Публикуем глобальный API на текущий кадр: дальше любой модуль может
|
/// Переносит изменения модели в виджет редактора.
|
||||||
// управлять приложением и редактором через `AppApi::global()`.
|
///
|
||||||
AppApi::install(ctx.clone(), self.editor.clone(), self.keymap.clone());
|
/// `set_value` сбрасывает выделение и прокрутку, поэтому вызывается только
|
||||||
|
/// когда текст действительно изменился извне (смена вкладки, Lua, `+`/`×`).
|
||||||
|
fn sync_editor(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
|
let revision = self.workspace.borrow().revision();
|
||||||
|
if revision == self.synced_revision {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.synced_revision = revision;
|
||||||
|
|
||||||
// Обработчики клавиш вызываем до отрисовки, чтобы изменения состояния
|
let (active_id, content, language, cursor) = {
|
||||||
// успели попасть в текущий кадр.
|
let workspace = self.workspace.borrow();
|
||||||
self.dispatch_keys(&ctx);
|
let doc = workspace.active();
|
||||||
|
(doc.id, doc.content.clone(), doc.language, doc.cursor)
|
||||||
|
};
|
||||||
|
|
||||||
// Базовый фрейм для всего окна приложения со скруглением
|
let switching = active_id != self.displayed_id;
|
||||||
let style = ui.style().clone();
|
if switching {
|
||||||
let panel_frame = egui::Frame::new()
|
// Запоминаем курсор покидаемой вкладки до того, как индекс
|
||||||
.fill(style.visuals.window_fill()) // Фон из текущей темы
|
// документа перестанет быть актуальным.
|
||||||
.corner_radius(egui::CornerRadius::same(CORNER_RADIUS)) // Скругление углов самого окна
|
let cursor = self.editor.read(cx).cursor();
|
||||||
.stroke(egui::Stroke::new(
|
if let Some(doc) = self
|
||||||
1.0,
|
.workspace
|
||||||
style.visuals.widgets.noninteractive.bg_stroke.color,
|
.borrow_mut()
|
||||||
))
|
.document_mut_by_id(self.displayed_id)
|
||||||
.inner_margin(0.0) // Чтобы заголовок плотно прижимался к краям
|
{
|
||||||
.outer_margin(0.0);
|
doc.cursor = cursor;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.displayed_id = active_id;
|
||||||
|
|
||||||
let mut bar = None;
|
let highlight = language
|
||||||
|
.map(|language| language.highlight_name())
|
||||||
egui::CentralPanel::default()
|
.unwrap_or("");
|
||||||
.frame(panel_frame)
|
self.editor.update(cx, |state, cx| {
|
||||||
.show(ui, |ui| {
|
if state.value().as_ref() != content.as_str() {
|
||||||
// 1. Отрисовка интегрированного заголовка (Title Bar)
|
state.set_value(content, window, cx);
|
||||||
// с именем активного документа, как в обычном редакторе.
|
}
|
||||||
let title = self.editor.borrow().active().name.clone();
|
if state.language_name().as_ref() != highlight {
|
||||||
bar = Some(title_bar::render(ui, &title));
|
state.set_highlighter(highlight, cx);
|
||||||
|
}
|
||||||
// 2. Вкладки и редактор занимают всю оставшуюся площадь
|
if switching {
|
||||||
self.show_workspace(ui);
|
let cursor = cursor.min(state.value().len());
|
||||||
|
state.set_selected_range(cursor..cursor, cx);
|
||||||
|
}
|
||||||
|
state.focus(window, cx);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Контент прилегает к краям панели, поэтому рамку окна рисуем поверх него.
|
|
||||||
ui.painter().rect_stroke(
|
|
||||||
ctx.content_rect(),
|
|
||||||
egui::CornerRadius::same(CORNER_RADIUS),
|
|
||||||
egui::Stroke::new(1.0, style.visuals.widgets.noninteractive.bg_stroke.color),
|
|
||||||
egui::StrokeKind::Inside,
|
|
||||||
);
|
|
||||||
|
|
||||||
// 3. Невидимые зоны изменения размера по краям (после контента — значит, поверх него).
|
|
||||||
resize_handles::render(ui);
|
|
||||||
|
|
||||||
bar.expect("CentralPanel всегда отрисовывает заголовок")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Вызывает Lua-обработчики, совпавшие с событиями клавиатуры этого кадра.
|
/// Полоса вкладок: активная вкладка, маркер несохранённых изменений и «+».
|
||||||
fn dispatch_keys(&self, ctx: &egui::Context) {
|
fn render_tabs(&self, cx: &mut Context<Self>) -> impl IntoElement {
|
||||||
// Заём карты заканчивается до вызова колбэков: обработчик может сам
|
let (tabs, active) = {
|
||||||
// вызвать `app:map_key` и изменить привязки.
|
let workspace = self.workspace.borrow();
|
||||||
let callbacks = self.keymap.borrow().callbacks_for(ctx);
|
(
|
||||||
|
workspace
|
||||||
|
.documents()
|
||||||
|
.iter()
|
||||||
|
.map(|doc| (doc.name.clone(), doc.is_dirty()))
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
workspace.active_index(),
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
let entity = cx.entity();
|
||||||
|
let mut bar = TabBar::new("tabs").selected_index(active);
|
||||||
|
|
||||||
|
for (index, (name, dirty)) in tabs.into_iter().enumerate() {
|
||||||
|
// У несохранённого документа вместо крестика горит точка — как в
|
||||||
|
// исходном приложении.
|
||||||
|
let suffix: AnyElement = if dirty {
|
||||||
|
div()
|
||||||
|
.size(px(6.))
|
||||||
|
.rounded_full()
|
||||||
|
.bg(cx.theme().primary)
|
||||||
|
.into_any_element()
|
||||||
|
} else {
|
||||||
|
let entity = entity.clone();
|
||||||
|
Button::new(format!("close-tab-{index}"))
|
||||||
|
.xsmall()
|
||||||
|
.ghost()
|
||||||
|
.icon(IconName::Close)
|
||||||
|
.tooltip("Закрыть вкладку")
|
||||||
|
.on_click(move |_, _, cx| {
|
||||||
|
// Иначе клик закроет вкладку и тут же переключит на неё
|
||||||
|
// соседнюю по уже устаревшему индексу.
|
||||||
|
cx.stop_propagation();
|
||||||
|
entity.update(cx, |this, cx| {
|
||||||
|
this.workspace.borrow_mut().close(index);
|
||||||
|
cx.notify();
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.into_any_element()
|
||||||
|
};
|
||||||
|
|
||||||
|
bar = bar.child(Tab::new().label(name).suffix(suffix));
|
||||||
|
}
|
||||||
|
|
||||||
|
bar.suffix(
|
||||||
|
Button::new("new-tab")
|
||||||
|
.xsmall()
|
||||||
|
.ghost()
|
||||||
|
.icon(IconName::Plus)
|
||||||
|
.tooltip("Новая вкладка")
|
||||||
|
.on_click({
|
||||||
|
let entity = entity.clone();
|
||||||
|
move |_, _, cx| {
|
||||||
|
entity.update(cx, |this, cx| {
|
||||||
|
this.workspace.borrow_mut().open_new();
|
||||||
|
cx.notify();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.on_click({
|
||||||
|
let entity = entity.clone();
|
||||||
|
move |index, _, cx| {
|
||||||
|
entity.update(cx, |this, cx| {
|
||||||
|
this.workspace.borrow_mut().set_active(*index);
|
||||||
|
cx.notify();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Render for MyApp {
|
||||||
|
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||||
|
// Состояние окна нужно `app:is_maximized()` из Lua.
|
||||||
|
api::set_window_maximized(window.is_maximized());
|
||||||
|
// Команды могли накопиться в Lua-обработчике или в стартовом сценарии.
|
||||||
|
apply_window_commands(window);
|
||||||
|
|
||||||
|
self.sync_editor(window, cx);
|
||||||
|
if !self.focused_once {
|
||||||
|
self.focused_once = true;
|
||||||
|
self.editor.update(cx, |state, cx| state.focus(window, cx));
|
||||||
|
}
|
||||||
|
|
||||||
|
let title = {
|
||||||
|
let workspace = self.workspace.borrow();
|
||||||
|
format!("{} — {WINDOW_TITLE}", workspace.active().name)
|
||||||
|
};
|
||||||
|
|
||||||
|
let keymap = self.keymap.clone();
|
||||||
|
let entity = cx.entity();
|
||||||
|
|
||||||
|
div()
|
||||||
|
.id("app-root")
|
||||||
|
.size_full()
|
||||||
|
.flex()
|
||||||
|
.flex_col()
|
||||||
|
.bg(cx.theme().background)
|
||||||
|
.text_color(cx.theme().foreground)
|
||||||
|
.on_key_down({
|
||||||
|
let keymap = keymap.clone();
|
||||||
|
let entity = entity.clone();
|
||||||
|
move |event, window, cx| {
|
||||||
|
let mut handled = false;
|
||||||
|
if is_save_shortcut(&event.keystroke) {
|
||||||
|
save_active_tab();
|
||||||
|
handled = true;
|
||||||
|
}
|
||||||
|
handled |= dispatch_keymap(&keymap, &event.keystroke, true);
|
||||||
|
// Обработчик мог поставить команду окна или изменить документы.
|
||||||
|
apply_window_commands(window);
|
||||||
|
if handled {
|
||||||
|
entity.update(cx, |_, cx| cx.notify());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.on_key_up({
|
||||||
|
let keymap = keymap.clone();
|
||||||
|
let entity = entity.clone();
|
||||||
|
move |event, window, cx| {
|
||||||
|
let handled = dispatch_keymap(&keymap, &event.keystroke, false);
|
||||||
|
apply_window_commands(window);
|
||||||
|
if handled {
|
||||||
|
entity.update(cx, |_, cx| cx.notify());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.child(
|
||||||
|
TitleBar::new().child(
|
||||||
|
h_flex()
|
||||||
|
.w_full()
|
||||||
|
.pr_2()
|
||||||
|
.justify_between()
|
||||||
|
.child(div().text_sm().font_medium().child(title))
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.text_xs()
|
||||||
|
.text_color(cx.theme().muted_foreground)
|
||||||
|
.child("gpui-kit · tree-sitter"),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.child(self.render_tabs(cx))
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.flex_1()
|
||||||
|
.w_full()
|
||||||
|
.min_h_0()
|
||||||
|
.child(Editor::new(&self.editor).bordered(false).size_full()),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Выполняет накопленные команды окна.
|
||||||
|
fn apply_window_commands(window: &mut Window) {
|
||||||
|
for command in api::drain_window_commands() {
|
||||||
|
match command {
|
||||||
|
WindowCommand::Close => {
|
||||||
|
window.remove_window();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
WindowCommand::Minimize => window.minimize_window(),
|
||||||
|
WindowCommand::ToggleMaximize => window.zoom_window(),
|
||||||
|
WindowCommand::SetMaximized(maximized) => {
|
||||||
|
if window.is_maximized() != maximized {
|
||||||
|
window.zoom_window();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ctrl+S (Cmd+S на macOS) сохраняет активную вкладку.
|
||||||
|
fn is_save_shortcut(keystroke: &Keystroke) -> bool {
|
||||||
|
keystroke.key.eq_ignore_ascii_case("s")
|
||||||
|
&& (keystroke.modifiers.control || keystroke.modifiers.platform)
|
||||||
|
&& !keystroke.modifiers.alt
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Сохраняет активную вкладку, печатая ошибку в stderr.
|
||||||
|
fn save_active_tab() {
|
||||||
|
if let Err(err) = AppApi::global().save() {
|
||||||
|
eprintln!("Не удалось сохранить вкладку: {err}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Вызывает Lua-обработчики, совпавшие с событием клавиатуры.
|
||||||
|
///
|
||||||
|
/// Возвращает `true`, если хотя бы один обработчик вызван. Заём карты
|
||||||
|
/// заканчивается до вызова колбэков: обработчик может сам вызвать
|
||||||
|
/// `app:map_key` и изменить привязки.
|
||||||
|
fn dispatch_keymap(keymap: &Rc<RefCell<KeyMap>>, keystroke: &Keystroke, pressed: bool) -> bool {
|
||||||
|
let callbacks = keymap.borrow().callbacks_for(keystroke, pressed);
|
||||||
|
let handled = !callbacks.is_empty();
|
||||||
for callback in callbacks {
|
for callback in callbacks {
|
||||||
if let Err(err) = callback.call::<()>(()) {
|
if let Err(err) = callback.call::<()>(()) {
|
||||||
eprintln!("Lua: ошибка в обработчике клавиши: {err}");
|
eprintln!("Lua: ошибка в обработчике клавиши: {err}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
handled
|
||||||
|
|
||||||
/// Полоса вкладок и редактор активного документа.
|
|
||||||
fn show_workspace(&mut self, ui: &mut egui::Ui) {
|
|
||||||
let mut focus_editor = false;
|
|
||||||
|
|
||||||
// Ctrl+S пишет активную вкладку в файл с её названием.
|
|
||||||
if ui.input_mut(|input| input.consume_key(egui::Modifiers::COMMAND, egui::Key::S))
|
|
||||||
&& let Err(err) = AppApi::global().save()
|
|
||||||
{
|
|
||||||
eprintln!("Не удалось сохранить вкладку: {err}");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Заём держим только на время отрисовки, чтобы действия из API
|
|
||||||
// (они берут тот же `RefCell`) не конфликтовали с ним.
|
|
||||||
let actions = {
|
|
||||||
let editor = self.editor.borrow();
|
|
||||||
tabs::render(ui, editor.documents(), editor.active_index())
|
|
||||||
};
|
|
||||||
|
|
||||||
if let Some(index) = actions.clicked_tab {
|
|
||||||
self.editor.borrow_mut().set_active(index);
|
|
||||||
focus_editor = true;
|
|
||||||
}
|
|
||||||
if let Some(index) = actions.closed_tab {
|
|
||||||
AppApi::global().close_tab(index);
|
|
||||||
}
|
|
||||||
if actions.add_clicked {
|
|
||||||
self.editor.borrow_mut().open_new();
|
|
||||||
focus_editor = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut editor = self.editor.borrow_mut();
|
|
||||||
code_editor::render(ui, editor.active_mut(), focus_editor);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
+15
-9
@@ -1,16 +1,22 @@
|
|||||||
//! Константы оформления окна.
|
//! Константы оформления окна и сценариев.
|
||||||
|
|
||||||
/// Насколько сильно скругляются углы окна.
|
/// Ширина окна при запуске.
|
||||||
pub const CORNER_RADIUS: u8 = 12;
|
pub const WINDOW_WIDTH: f32 = 900.0;
|
||||||
|
|
||||||
/// Высота интегрированного заголовка.
|
/// Высота окна при запуске.
|
||||||
pub const TITLE_BAR_HEIGHT: f32 = 32.0;
|
pub const WINDOW_HEIGHT: f32 = 600.0;
|
||||||
|
|
||||||
/// Толщина невидимой зоны по краям окна, за которую можно менять размер.
|
/// Минимальная ширина окна.
|
||||||
pub const RESIZE_BORDER: f32 = 6.0;
|
pub const MIN_WINDOW_WIDTH: f32 = 420.0;
|
||||||
|
|
||||||
/// Высота полосы вкладок над редактором.
|
/// Минимальная высота окна.
|
||||||
pub const TAB_BAR_HEIGHT: f32 = 34.0;
|
pub const MIN_WINDOW_HEIGHT: f32 = 280.0;
|
||||||
|
|
||||||
|
/// Подпись окна в панели задач и в заголовке.
|
||||||
|
pub const WINDOW_TITLE: &str = "Custom Window App";
|
||||||
|
|
||||||
|
/// Ширина одного уровня отступа в редакторе.
|
||||||
|
pub const TAB_SIZE: usize = 4;
|
||||||
|
|
||||||
/// Каталог, в котором приложение ищет Lua-сценарии.
|
/// Каталог, в котором приложение ищет Lua-сценарии.
|
||||||
///
|
///
|
||||||
|
|||||||
+150
-14
@@ -1,18 +1,88 @@
|
|||||||
//! Документы редактора и состояние вкладок.
|
//! Документы редактора и состояние вкладок.
|
||||||
|
//!
|
||||||
|
//! Это доменная модель приложения: текст, имена и языки вкладок живут здесь,
|
||||||
|
//! а виджет `Editor` из gpui-component показывает активный документ. Такое
|
||||||
|
//! разделение позволяет Lua-сценариям менять документы без доступа к `Window`
|
||||||
|
//! и `App`: вид обновляет редактор, когда меняется [`Workspace::revision`].
|
||||||
|
|
||||||
use std::sync::atomic::{AtomicU64, Ordering};
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
|
|
||||||
use crate::syntax::{Language, SyntaxState};
|
|
||||||
|
|
||||||
/// Счётчик идентификаторов документов.
|
/// Счётчик идентификаторов документов.
|
||||||
static NEXT_ID: AtomicU64 = AtomicU64::new(1);
|
static NEXT_ID: AtomicU64 = AtomicU64::new(1);
|
||||||
|
|
||||||
|
/// Языки, для которых в редакторе включена подсветка tree-sitter.
|
||||||
|
///
|
||||||
|
/// Названия намеренно короткие (`js`, а не `javascript`): именно их возвращает
|
||||||
|
/// [`Language::name`] и показывает Lua-API. gpui-component принимает такие
|
||||||
|
/// псевдонимы и сам приводит их к имени грамматики.
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
pub enum Language {
|
||||||
|
Rust,
|
||||||
|
Go,
|
||||||
|
JavaScript,
|
||||||
|
Json,
|
||||||
|
Python,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Language {
|
||||||
|
/// Все поддерживаемые языки.
|
||||||
|
#[allow(dead_code)]
|
||||||
|
// Часть публичного интерфейса модуля: удобно для перебора языков снаружи.
|
||||||
|
pub const ALL: [Self; 5] = [
|
||||||
|
Self::Rust,
|
||||||
|
Self::Go,
|
||||||
|
Self::JavaScript,
|
||||||
|
Self::Json,
|
||||||
|
Self::Python,
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Разбирает название языка из API/сценария.
|
||||||
|
///
|
||||||
|
/// Основные имена — `rust`, `go`, `js`, `json`, `python`; короткие
|
||||||
|
/// (`rs`, `golang`, `javascript`, `py`) и регистр тоже принимаются.
|
||||||
|
pub fn from_name(name: &str) -> Option<Self> {
|
||||||
|
match name.trim().to_ascii_lowercase().as_str() {
|
||||||
|
"rust" | "rs" => Some(Self::Rust),
|
||||||
|
"go" | "golang" => Some(Self::Go),
|
||||||
|
"js" | "javascript" | "node" => Some(Self::JavaScript),
|
||||||
|
"json" => Some(Self::Json),
|
||||||
|
"python" | "py" => Some(Self::Python),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Каноническое имя языка (то, что возвращает `AppApi::language`).
|
||||||
|
pub fn name(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Rust => "rust",
|
||||||
|
Self::Go => "go",
|
||||||
|
Self::JavaScript => "js",
|
||||||
|
Self::Json => "json",
|
||||||
|
Self::Python => "python",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Имя языка для `EditorState::set_highlighter`.
|
||||||
|
///
|
||||||
|
/// Здесь `js` разворачивается в `javascript`: так грамматика находится
|
||||||
|
/// без дополнительных псевдонимов на нашей стороне.
|
||||||
|
pub fn highlight_name(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Rust => "rust",
|
||||||
|
Self::Go => "go",
|
||||||
|
Self::JavaScript => "javascript",
|
||||||
|
Self::Json => "json",
|
||||||
|
Self::Python => "python",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Открытый в редакторе документ.
|
/// Открытый в редакторе документ.
|
||||||
pub struct Document {
|
pub struct Document {
|
||||||
/// Стабильный идентификатор, пока документ открыт.
|
/// Стабильный идентификатор, пока документ открыт.
|
||||||
///
|
///
|
||||||
/// По нему `TextEdit` и `ScrollArea` хранят курсор, выделение и прокрутку,
|
/// По нему вид отличает «ту же вкладку» от «другой вкладки с тем же
|
||||||
/// поэтому переключение вкладок не сбрасывает состояние редактора.
|
/// индексом» после закрытия соседей.
|
||||||
pub id: u64,
|
pub id: u64,
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub content: String,
|
pub content: String,
|
||||||
@@ -21,8 +91,8 @@ pub struct Document {
|
|||||||
/// `None` — подсветка выключена (поведение по умолчанию). Включается
|
/// `None` — подсветка выключена (поведение по умолчанию). Включается
|
||||||
/// только явно через `AppApi::set_language`, например из Lua-сценария.
|
/// только явно через `AppApi::set_language`, например из Lua-сценария.
|
||||||
pub language: Option<Language>,
|
pub language: Option<Language>,
|
||||||
/// Разбор tree-sitter и кэш подсветки для этого документа.
|
/// Позиция курсора в байтах на момент ухода с вкладки.
|
||||||
pub syntax: SyntaxState,
|
pub cursor: usize,
|
||||||
/// Содержимое на момент последнего «сохранения».
|
/// Содержимое на момент последнего «сохранения».
|
||||||
saved_content: String,
|
saved_content: String,
|
||||||
}
|
}
|
||||||
@@ -36,7 +106,7 @@ impl Document {
|
|||||||
saved_content: content.clone(),
|
saved_content: content.clone(),
|
||||||
content,
|
content,
|
||||||
language: None,
|
language: None,
|
||||||
syntax: SyntaxState::default(),
|
cursor: 0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,13 +125,19 @@ impl Document {
|
|||||||
///
|
///
|
||||||
/// Инвариант: `documents` никогда не пуст — редактор всегда показывает хотя бы
|
/// Инвариант: `documents` никогда не пуст — редактор всегда показывает хотя бы
|
||||||
/// один документ. Это позволяет `active`/`active_mut` не возвращать `Option`.
|
/// один документ. Это позволяет `active`/`active_mut` не возвращать `Option`.
|
||||||
pub struct EditorState {
|
pub struct Workspace {
|
||||||
documents: Vec<Document>,
|
documents: Vec<Document>,
|
||||||
active: usize,
|
active: usize,
|
||||||
next_untitled: u32,
|
next_untitled: u32,
|
||||||
|
/// Номер «поколения» состояния.
|
||||||
|
///
|
||||||
|
/// Вид перечитывает активный документ в редактор только когда ревизия
|
||||||
|
/// изменилась. Набор текста сам обновляет документ и ревизию не трогает,
|
||||||
|
/// иначе редактор переставлял бы курсор после каждого символа.
|
||||||
|
revision: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for EditorState {
|
impl Default for Workspace {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
documents: vec![
|
documents: vec![
|
||||||
@@ -70,11 +146,12 @@ impl Default for EditorState {
|
|||||||
],
|
],
|
||||||
active: 0,
|
active: 0,
|
||||||
next_untitled: 0,
|
next_untitled: 0,
|
||||||
|
revision: 0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl EditorState {
|
impl Workspace {
|
||||||
pub fn documents(&self) -> &[Document] {
|
pub fn documents(&self) -> &[Document] {
|
||||||
&self.documents
|
&self.documents
|
||||||
}
|
}
|
||||||
@@ -96,15 +173,26 @@ impl EditorState {
|
|||||||
self.documents.get(index)
|
self.documents.get(index)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Изменяемый документ по номеру вкладки (для API).
|
/// Изменяемый документ по стабильному идентификатору.
|
||||||
pub fn document_mut(&mut self, index: usize) -> Option<&mut Document> {
|
pub fn document_mut_by_id(&mut self, id: u64) -> Option<&mut Document> {
|
||||||
self.documents.get_mut(index)
|
self.documents.iter_mut().find(|doc| doc.id == id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Текущая ревизия состояния.
|
||||||
|
pub fn revision(&self) -> u64 {
|
||||||
|
self.revision
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Сообщает виду, что документы нужно перечитать.
|
||||||
|
fn bump(&mut self) {
|
||||||
|
self.revision = self.revision.wrapping_add(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Делает вкладку активной, если индекс существует.
|
/// Делает вкладку активной, если индекс существует.
|
||||||
pub fn set_active(&mut self, index: usize) {
|
pub fn set_active(&mut self, index: usize) {
|
||||||
if index < self.documents.len() {
|
if index < self.documents.len() && index != self.active {
|
||||||
self.active = index;
|
self.active = index;
|
||||||
|
self.bump();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -116,6 +204,7 @@ impl EditorState {
|
|||||||
"",
|
"",
|
||||||
));
|
));
|
||||||
self.active = self.documents.len() - 1;
|
self.active = self.documents.len() - 1;
|
||||||
|
self.bump();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Закрывает вкладку и выбирает соседнюю.
|
/// Закрывает вкладку и выбирает соседнюю.
|
||||||
@@ -135,6 +224,53 @@ impl EditorState {
|
|||||||
} else if index == self.active {
|
} else if index == self.active {
|
||||||
self.active = self.active.min(self.documents.len() - 1);
|
self.active = self.active.min(self.documents.len() - 1);
|
||||||
}
|
}
|
||||||
|
self.bump();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Обновляет текст активного документа без изменения ревизии.
|
||||||
|
///
|
||||||
|
/// Используется видом после ввода в редакторе: сам виджет уже показывает
|
||||||
|
/// этот текст, поэтому повторная синхронизация только сбросила бы курсор.
|
||||||
|
pub fn replace_active_content(&mut self, content: String) {
|
||||||
|
self.active_mut().content = content;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Заменяет текст активной вкладки извне (Lua, меню, тесты).
|
||||||
|
pub fn set_active_text(&mut self, text: impl Into<String>) {
|
||||||
|
self.active_mut().content = text.into();
|
||||||
|
self.bump();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Включает подсветку для вкладки; возвращает `false`, если вкладки нет.
|
||||||
|
pub fn set_language(&mut self, index: usize, language: Language) -> bool {
|
||||||
|
let Some(doc) = self.documents.get_mut(index) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
if doc.language != Some(language) {
|
||||||
|
doc.language = Some(language);
|
||||||
|
self.bump();
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Выключает подсветку для вкладки; возвращает `false`, если вкладки нет.
|
||||||
|
pub fn clear_language(&mut self, index: usize) -> bool {
|
||||||
|
let Some(doc) = self.documents.get_mut(index) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
if doc.language.take().is_some() {
|
||||||
|
self.bump();
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Помечает активный документ сохранённым.
|
||||||
|
///
|
||||||
|
/// Ревизия меняется, чтобы индикатор несохранённых изменений на вкладке
|
||||||
|
/// перерисовался даже без отдельного уведомления вида.
|
||||||
|
pub fn mark_active_saved(&mut self) {
|
||||||
|
self.active_mut().mark_saved();
|
||||||
|
self.bump();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+192
-102
@@ -7,10 +7,21 @@
|
|||||||
//! * `!a` — отпускание клавиши;
|
//! * `!a` — отпускание клавиши;
|
||||||
//! * `<A-!r>` — отпускание клавиши при зажатом Alt.
|
//! * `<A-!r>` — отпускание клавиши при зажатом Alt.
|
||||||
//!
|
//!
|
||||||
//! Клавиша всегда определяется по физическому коду (`physical_key`), поэтому
|
//! Клавиша сравнивается с `Keystroke.key` из GPUI. Это не результат ввода, а
|
||||||
//! Caps Lock и текущая раскладка клавиатуры на совпадение не влияют.
|
//! имя клавиши, которое платформа уже нормализует:
|
||||||
|
//!
|
||||||
|
//! * Caps Lock не превращает `a` в `A`;
|
||||||
|
//! * на Linux и Windows буквы берутся по аппаратному keycode, поэтому на
|
||||||
|
//! русской раскладке физическая `q` всё равно приходит как `"q"`, а
|
||||||
|
//! напечатанная `й` — только в `key_char`;
|
||||||
|
//! * на macOS GPUI отдаёт символ раскладки, поэтому там привязки следуют
|
||||||
|
//! раскладке (ограничение самого GPUI, не этого модуля).
|
||||||
|
//!
|
||||||
|
//! Отдельного поля с физическим кодом в публичном `Keystroke`/`KeyDownEvent`
|
||||||
|
//! у gpui-kit 0.8.1 нет, но для Linux/Windows он и не нужен: ASCII-имя клавиши
|
||||||
|
//! уже восстановлено по keycode.
|
||||||
|
|
||||||
use eframe::egui;
|
use gpui_kit::Keystroke;
|
||||||
|
|
||||||
/// Модификаторы, которые учитываются при совпадении привязки.
|
/// Модификаторы, которые учитываются при совпадении привязки.
|
||||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||||
@@ -23,16 +34,23 @@ pub struct Modifiers {
|
|||||||
impl Modifiers {
|
impl Modifiers {
|
||||||
/// Все три модификатора должны совпасть точно: `<C-z>` не сработает,
|
/// Все три модификатора должны совпасть точно: `<C-z>` не сработает,
|
||||||
/// если вместе с Ctrl зажат ещё и Shift.
|
/// если вместе с Ctrl зажат ещё и Shift.
|
||||||
fn matches(self, pressed: &egui::Modifiers) -> bool {
|
///
|
||||||
self.shift == pressed.shift && self.ctrl == pressed.ctrl && self.alt == pressed.alt
|
/// `platform` (Cmd/Super) и `function` в записи привязки не выражаются,
|
||||||
|
/// поэтому события с ними не совпадают ни с одной привязкой.
|
||||||
|
fn matches(self, pressed: &gpui_kit::Modifiers) -> bool {
|
||||||
|
self.shift == pressed.shift
|
||||||
|
&& self.ctrl == pressed.control
|
||||||
|
&& self.alt == pressed.alt
|
||||||
|
&& !pressed.platform
|
||||||
|
&& !pressed.function
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Разобранная привязка клавиши.
|
/// Разобранная привязка клавиши.
|
||||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
pub struct KeyBinding {
|
pub struct KeyBinding {
|
||||||
/// Физическая клавиша (не зависит от раскладки и Caps Lock).
|
/// Каноническое имя клавиши в нижнем регистре (`a`, `1`, `pageup`, `f5`).
|
||||||
pub key: egui::Key,
|
pub key: String,
|
||||||
pub modifiers: Modifiers,
|
pub modifiers: Modifiers,
|
||||||
/// `true` — обработчик вызывается при отпускании клавиши.
|
/// `true` — обработчик вызывается при отпускании клавиши.
|
||||||
pub on_release: bool,
|
pub on_release: bool,
|
||||||
@@ -95,9 +113,25 @@ impl KeyBinding {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Совпадает ли привязка с конкретным событием клавиатуры.
|
/// Совпадает ли привязка с событием клавиатуры.
|
||||||
fn matches(&self, key: egui::Key, pressed: bool, modifiers: &egui::Modifiers) -> bool {
|
///
|
||||||
self.key == key && self.on_release == !pressed && self.modifiers.matches(modifiers)
|
/// `pressed` — `true` для нажатия и `false` для отпускания.
|
||||||
|
pub fn matches(&self, keystroke: &Keystroke, pressed: bool) -> bool {
|
||||||
|
self.on_release == !pressed
|
||||||
|
&& self.modifiers.matches(&keystroke.modifiers)
|
||||||
|
&& self.key_matches(keystroke)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Клавиша события совпадает с привязкой.
|
||||||
|
///
|
||||||
|
/// Помимо `key` проверяется `key_char`: на части раскладок GPUI кладёт
|
||||||
|
/// в `key` не-ASCII символ, а печатаемый символ — в `key_char`.
|
||||||
|
fn key_matches(&self, keystroke: &Keystroke) -> bool {
|
||||||
|
canonical_key(&keystroke.key) == self.key
|
||||||
|
|| keystroke
|
||||||
|
.key_char
|
||||||
|
.as_deref()
|
||||||
|
.is_some_and(|key_char| canonical_key(key_char) == self.key)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -109,52 +143,67 @@ fn split_release(token: &str) -> (bool, &str) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Превращает имя клавиши из привязки в [`egui::Key`].
|
/// Приводит имя клавиши из привязки к каноническому виду.
|
||||||
fn parse_key(token: &str) -> Result<egui::Key, String> {
|
fn parse_key(token: &str) -> Result<String, String> {
|
||||||
let token = token.trim();
|
let token = token.trim();
|
||||||
if token.is_empty() {
|
if token.is_empty() {
|
||||||
return Err("не указана клавиша".to_owned());
|
return Err("не указана клавиша".to_owned());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Односимвольные клавиши (`a`, `1`, `-`) и точные имена (`F1`, `Space`).
|
match canonical_key(token).as_str() {
|
||||||
if let Some(key) = egui::Key::from_name(token) {
|
// Одиночный символ уже канонизирован (`A` → `a`).
|
||||||
return Ok(key);
|
canonical if canonical.chars().count() == 1 => Ok(canonical.to_owned()),
|
||||||
|
canonical if canonical.starts_with('f') && canonical[1..].parse::<u8>().is_ok() => {
|
||||||
|
Ok(canonical.to_owned())
|
||||||
|
}
|
||||||
|
canonical
|
||||||
|
if matches!(
|
||||||
|
canonical,
|
||||||
|
"space"
|
||||||
|
| "tab"
|
||||||
|
| "enter"
|
||||||
|
| "escape"
|
||||||
|
| "backspace"
|
||||||
|
| "delete"
|
||||||
|
| "insert"
|
||||||
|
| "home"
|
||||||
|
| "end"
|
||||||
|
| "pageup"
|
||||||
|
| "pagedown"
|
||||||
|
| "up"
|
||||||
|
| "down"
|
||||||
|
| "left"
|
||||||
|
| "right"
|
||||||
|
) =>
|
||||||
|
{
|
||||||
|
Ok(canonical.to_owned())
|
||||||
|
}
|
||||||
|
_ => Err(format!("неизвестная клавиша: {token}")),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Регистронезависимые имена: `space`, `enter`, `esc`, `pgup` и т. п.
|
/// Каноническое имя клавиши: нижний регистр, псевдонимы приведены к одному виду.
|
||||||
if let Some(key) = alias_key(token) {
|
///
|
||||||
return Ok(key);
|
/// Функция используется и при разборе записи привязки, и при сравнении с
|
||||||
|
/// событием GPUI, поэтому обе стороны всегда говорят на одном языке.
|
||||||
|
fn canonical_key(key: &str) -> String {
|
||||||
|
let lower = key.trim().to_lowercase();
|
||||||
|
match lower.as_str() {
|
||||||
|
" " => return "space".to_owned(),
|
||||||
|
"return" | "cr" => return "enter".to_owned(),
|
||||||
|
"esc" => return "escape".to_owned(),
|
||||||
|
"bs" => return "backspace".to_owned(),
|
||||||
|
"del" => return "delete".to_owned(),
|
||||||
|
"ins" => return "insert".to_owned(),
|
||||||
|
"pgup" | "page_up" => return "pageup".to_owned(),
|
||||||
|
"pgdn" | "page_down" => return "pagedown".to_owned(),
|
||||||
|
"arrowup" => return "up".to_owned(),
|
||||||
|
"arrowdown" => return "down".to_owned(),
|
||||||
|
"arrowleft" => return "left".to_owned(),
|
||||||
|
"arrowright" => return "right".to_owned(),
|
||||||
|
_ => {}
|
||||||
}
|
}
|
||||||
|
lower
|
||||||
// `f1` → `F1`.
|
|
||||||
if let Some(key) = egui::Key::from_name(&token.to_ascii_uppercase()) {
|
|
||||||
return Ok(key);
|
|
||||||
}
|
|
||||||
|
|
||||||
Err(format!("неизвестная клавиша: {token}"))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Имена клавиш в нижнем регистре, которых нет в [`egui::Key::from_name`].
|
|
||||||
fn alias_key(token: &str) -> Option<egui::Key> {
|
|
||||||
let name = match token.to_ascii_lowercase().as_str() {
|
|
||||||
"space" => "Space",
|
|
||||||
"tab" => "Tab",
|
|
||||||
"enter" | "return" | "cr" => "Enter",
|
|
||||||
"esc" | "escape" => "Escape",
|
|
||||||
"backspace" | "bs" => "Backspace",
|
|
||||||
"delete" | "del" => "Delete",
|
|
||||||
"insert" => "Insert",
|
|
||||||
"home" => "Home",
|
|
||||||
"end" => "End",
|
|
||||||
"pageup" | "pgup" => "PageUp",
|
|
||||||
"pagedown" | "pgdn" => "PageDown",
|
|
||||||
"up" | "arrowup" => "ArrowUp",
|
|
||||||
"down" | "arrowdown" => "ArrowDown",
|
|
||||||
"left" | "arrowleft" => "ArrowLeft",
|
|
||||||
"right" | "arrowright" => "ArrowRight",
|
|
||||||
_ => return None,
|
|
||||||
};
|
|
||||||
egui::Key::from_name(name)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Привязка вместе с Lua-функцией, которую нужно выполнить.
|
/// Привязка вместе с Lua-функцией, которую нужно выполнить.
|
||||||
@@ -184,46 +233,17 @@ impl KeyMap {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Собирает обработчики, которые нужно вызвать для событий текущего кадра.
|
/// Собирает обработчики, которые нужно вызвать для одного события.
|
||||||
///
|
///
|
||||||
/// Заём `KeyMap` здесь заканчивается: вызывать колбэки нужно уже после
|
/// Заём `KeyMap` здесь заканчивается: вызывать колбэки нужно уже после
|
||||||
/// возврата из метода, потому что обработчик может сам менять привязки
|
/// возврата из метода, потому что обработчик может сам менять привязки
|
||||||
/// через `app:map_key`.
|
/// через `app:map_key`.
|
||||||
pub fn callbacks_for(&self, ctx: &egui::Context) -> Vec<mlua::Function> {
|
pub fn callbacks_for(&self, keystroke: &Keystroke, pressed: bool) -> Vec<mlua::Function> {
|
||||||
let key_events: Vec<(egui::Key, bool, egui::Modifiers)> = ctx.input(|input| {
|
self.bindings
|
||||||
input
|
|
||||||
.events
|
|
||||||
.iter()
|
.iter()
|
||||||
.filter_map(|event| {
|
.filter(|binding| binding.binding.matches(keystroke, pressed))
|
||||||
let egui::Event::Key {
|
.map(|binding| binding.callback.clone())
|
||||||
key,
|
|
||||||
physical_key,
|
|
||||||
pressed,
|
|
||||||
modifiers,
|
|
||||||
..
|
|
||||||
} = event
|
|
||||||
else {
|
|
||||||
return None;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Физический код не зависит от раскладки и Caps Lock.
|
|
||||||
// Логическая клавиша — запасной вариант для платформ,
|
|
||||||
// где physical_key не заполняется.
|
|
||||||
Some((physical_key.unwrap_or(*key), *pressed, *modifiers))
|
|
||||||
})
|
|
||||||
.collect()
|
.collect()
|
||||||
});
|
|
||||||
|
|
||||||
let mut callbacks = Vec::new();
|
|
||||||
for (key, pressed, modifiers) in key_events {
|
|
||||||
for binding in &self.bindings {
|
|
||||||
if binding.binding.matches(key, pressed, &modifiers) {
|
|
||||||
callbacks.push(binding.callback.clone());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
callbacks
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -231,6 +251,15 @@ impl KeyMap {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
/// Событие без символа ввода: для проверки совпадения этого достаточно.
|
||||||
|
fn stroke(key: &str, modifiers: gpui_kit::Modifiers) -> Keystroke {
|
||||||
|
Keystroke {
|
||||||
|
modifiers,
|
||||||
|
key: key.to_owned(),
|
||||||
|
key_char: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn parse(spec: &str) -> KeyBinding {
|
fn parse(spec: &str) -> KeyBinding {
|
||||||
KeyBinding::parse(spec).expect("привязка должна разбираться")
|
KeyBinding::parse(spec).expect("привязка должна разбираться")
|
||||||
}
|
}
|
||||||
@@ -240,7 +269,7 @@ mod tests {
|
|||||||
assert_eq!(
|
assert_eq!(
|
||||||
parse("a"),
|
parse("a"),
|
||||||
KeyBinding {
|
KeyBinding {
|
||||||
key: egui::Key::A,
|
key: "a".to_owned(),
|
||||||
modifiers: Modifiers::default(),
|
modifiers: Modifiers::default(),
|
||||||
on_release: false,
|
on_release: false,
|
||||||
}
|
}
|
||||||
@@ -248,7 +277,7 @@ mod tests {
|
|||||||
assert_eq!(
|
assert_eq!(
|
||||||
parse("1"),
|
parse("1"),
|
||||||
KeyBinding {
|
KeyBinding {
|
||||||
key: egui::Key::Num1,
|
key: "1".to_owned(),
|
||||||
modifiers: Modifiers::default(),
|
modifiers: Modifiers::default(),
|
||||||
on_release: false,
|
on_release: false,
|
||||||
}
|
}
|
||||||
@@ -256,7 +285,7 @@ mod tests {
|
|||||||
assert_eq!(
|
assert_eq!(
|
||||||
parse("!a"),
|
parse("!a"),
|
||||||
KeyBinding {
|
KeyBinding {
|
||||||
key: egui::Key::A,
|
key: "a".to_owned(),
|
||||||
modifiers: Modifiers::default(),
|
modifiers: Modifiers::default(),
|
||||||
on_release: true,
|
on_release: true,
|
||||||
}
|
}
|
||||||
@@ -268,7 +297,7 @@ mod tests {
|
|||||||
assert_eq!(
|
assert_eq!(
|
||||||
parse("<S-c>"),
|
parse("<S-c>"),
|
||||||
KeyBinding {
|
KeyBinding {
|
||||||
key: egui::Key::C,
|
key: "c".to_owned(),
|
||||||
modifiers: Modifiers {
|
modifiers: Modifiers {
|
||||||
shift: true,
|
shift: true,
|
||||||
..Default::default()
|
..Default::default()
|
||||||
@@ -279,7 +308,7 @@ mod tests {
|
|||||||
assert_eq!(
|
assert_eq!(
|
||||||
parse("<C-z>"),
|
parse("<C-z>"),
|
||||||
KeyBinding {
|
KeyBinding {
|
||||||
key: egui::Key::Z,
|
key: "z".to_owned(),
|
||||||
modifiers: Modifiers {
|
modifiers: Modifiers {
|
||||||
ctrl: true,
|
ctrl: true,
|
||||||
..Default::default()
|
..Default::default()
|
||||||
@@ -290,7 +319,7 @@ mod tests {
|
|||||||
assert_eq!(
|
assert_eq!(
|
||||||
parse("<A-!r>"),
|
parse("<A-!r>"),
|
||||||
KeyBinding {
|
KeyBinding {
|
||||||
key: egui::Key::R,
|
key: "r".to_owned(),
|
||||||
modifiers: Modifiers {
|
modifiers: Modifiers {
|
||||||
alt: true,
|
alt: true,
|
||||||
..Default::default()
|
..Default::default()
|
||||||
@@ -301,7 +330,7 @@ mod tests {
|
|||||||
assert_eq!(
|
assert_eq!(
|
||||||
parse("<C-S-A-x>"),
|
parse("<C-S-A-x>"),
|
||||||
KeyBinding {
|
KeyBinding {
|
||||||
key: egui::Key::X,
|
key: "x".to_owned(),
|
||||||
modifiers: Modifiers {
|
modifiers: Modifiers {
|
||||||
shift: true,
|
shift: true,
|
||||||
ctrl: true,
|
ctrl: true,
|
||||||
@@ -314,7 +343,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn invalid_bindings_are_rejected() {
|
fn invalid_bindings_are_rejected() {
|
||||||
for spec in ["", " ", "<", "<C-z", "<Q-z>", "!"] {
|
for spec in ["", " ", "<", "<C-z", "<Q-z>", "!", "<S-unknown>"] {
|
||||||
assert!(
|
assert!(
|
||||||
KeyBinding::parse(spec).is_err(),
|
KeyBinding::parse(spec).is_err(),
|
||||||
"строка {spec:?} должна быть отклонена"
|
"строка {spec:?} должна быть отклонена"
|
||||||
@@ -324,29 +353,90 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn named_keys_are_case_insensitive() {
|
fn named_keys_are_case_insensitive() {
|
||||||
assert_eq!(parse("<C-space>").key, egui::Key::Space);
|
assert_eq!(parse("<C-space>").key, "space");
|
||||||
assert_eq!(parse("<S-enter>").key, egui::Key::Enter);
|
assert_eq!(parse("<S-enter>").key, "enter");
|
||||||
assert_eq!(parse("<A-esc>").key, egui::Key::Escape);
|
assert_eq!(parse("<A-esc>").key, "escape");
|
||||||
assert_eq!(parse("<C-f1>").key, egui::Key::F1);
|
assert_eq!(parse("<C-f1>").key, "f1");
|
||||||
assert_eq!(parse("<C-pgdn>").key, egui::Key::PageDown);
|
assert_eq!(parse("<C-pgdn>").key, "pagedown");
|
||||||
|
assert_eq!(parse("<C-F5>").key, "f5");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn matching_requires_exact_modifiers() {
|
fn matching_requires_exact_modifiers() {
|
||||||
let binding = parse("<S-c>");
|
let binding = parse("<S-c>");
|
||||||
let shift = egui::Modifiers {
|
let shift = gpui_kit::Modifiers {
|
||||||
shift: true,
|
shift: true,
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
let shift_ctrl = egui::Modifiers {
|
let shift_ctrl = gpui_kit::Modifiers {
|
||||||
shift: true,
|
shift: true,
|
||||||
ctrl: true,
|
control: true,
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
|
|
||||||
assert!(binding.matches(egui::Key::C, true, &shift));
|
assert!(binding.matches(&stroke("c", shift), true));
|
||||||
assert!(!binding.matches(egui::Key::C, true, &shift_ctrl));
|
assert!(!binding.matches(&stroke("c", shift_ctrl), true));
|
||||||
assert!(!binding.matches(egui::Key::C, false, &shift));
|
assert!(!binding.matches(&stroke("c", shift), false));
|
||||||
assert!(!binding.matches(egui::Key::V, true, &shift));
|
assert!(!binding.matches(&stroke("v", shift), true));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn caps_lock_and_key_char_do_not_break_plain_keys() {
|
||||||
|
let binding = parse("a");
|
||||||
|
|
||||||
|
// Caps Lock: GPUI сообщает напечатанный символ `A`, привязка — `a`.
|
||||||
|
assert!(binding.matches(&stroke("A", gpui_kit::Modifiers::default()), true));
|
||||||
|
|
||||||
|
// Символ, который напечатала бы клавиша, может отличаться от имени
|
||||||
|
// клавиши (композиция, Option на macOS): GPUI кладёт его в key_char.
|
||||||
|
let mut event = stroke("x", gpui_kit::Modifiers::default());
|
||||||
|
event.key_char = Some("a".to_owned());
|
||||||
|
assert!(binding.matches(&event, true));
|
||||||
|
|
||||||
|
// А вот Shift+A — уже другое событие.
|
||||||
|
let shift = gpui_kit::Modifiers {
|
||||||
|
shift: true,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
assert!(!binding.matches(&stroke("a", shift), true));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cyrillic_layout_uses_the_key_name_gpui_reports() {
|
||||||
|
let binding = parse("<C-q>");
|
||||||
|
let ctrl = gpui_kit::Modifiers {
|
||||||
|
control: true,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
// Linux/Windows: GPUI восстанавливает ASCII-клавишу по аппаратному
|
||||||
|
// keycode, поэтому при русской раскладке key уже "q", а напечатанная
|
||||||
|
// "й" лежит только в key_char. Таблица соответствий не нужна.
|
||||||
|
let mut event = stroke("q", ctrl);
|
||||||
|
event.key_char = Some("й".to_owned());
|
||||||
|
assert!(binding.matches(&event, true));
|
||||||
|
|
||||||
|
// Соседняя физическая клавиша по-прежнему не подходит.
|
||||||
|
event.key = "w".to_owned();
|
||||||
|
event.key_char = Some("ц".to_owned());
|
||||||
|
assert!(!binding.matches(&event, true));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn release_bindings_match_only_key_up() {
|
||||||
|
let binding = parse("<!A>");
|
||||||
|
let plain = gpui_kit::Modifiers::default();
|
||||||
|
assert!(!binding.matches(&stroke("a", plain), true));
|
||||||
|
assert!(binding.matches(&stroke("a", plain), false));
|
||||||
|
|
||||||
|
// Alt зажат: обычная привязка отпускания больше не совпадает.
|
||||||
|
let alt = gpui_kit::Modifiers {
|
||||||
|
alt: true,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
assert!(!binding.matches(&stroke("a", alt), false));
|
||||||
|
|
||||||
|
// А привязка вида `<A-!a>` — совпадает.
|
||||||
|
assert!(parse("<A-!a>").matches(&stroke("a", alt), false));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+41
-25
@@ -1,39 +1,55 @@
|
|||||||
|
//! Точка входа приложения.
|
||||||
|
//!
|
||||||
|
//! Окно создаётся средствами GPUI (gpui-kit). Заголовок рисует компонент
|
||||||
|
//! `TitleBar`: он же отвечает за перетаскивание окна, двойной клик и кнопки
|
||||||
|
//! свернуть/развернуть/закрыть, поэтому отдельные зоны изменения размера
|
||||||
|
//! больше не нужны.
|
||||||
|
|
||||||
mod api;
|
mod api;
|
||||||
mod app;
|
mod app;
|
||||||
mod constants;
|
mod constants;
|
||||||
mod editor;
|
mod editor;
|
||||||
mod keymap;
|
mod keymap;
|
||||||
mod script;
|
mod script;
|
||||||
mod syntax;
|
|
||||||
mod ui;
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests;
|
mod tests;
|
||||||
|
|
||||||
|
use gpui_kit::component::TitleBar;
|
||||||
|
use gpui_kit::*;
|
||||||
|
|
||||||
use app::MyApp;
|
use app::MyApp;
|
||||||
use eframe::egui;
|
use constants::{MIN_WINDOW_HEIGHT, MIN_WINDOW_WIDTH, WINDOW_HEIGHT, WINDOW_TITLE, WINDOW_WIDTH};
|
||||||
|
|
||||||
fn main() -> eframe::Result<()> {
|
fn main() {
|
||||||
let options = eframe::NativeOptions {
|
application().with_assets(assets::Assets).run(|cx| {
|
||||||
viewport: egui::ViewportBuilder::default()
|
// Инициализация темы, шрифтов и компонентов обязательна до создания окон.
|
||||||
.with_inner_size([900.0, 600.0])
|
init(cx);
|
||||||
.with_min_inner_size([420.0, 280.0])
|
|
||||||
.with_decorations(false) // Отключаем стандартные рамки ОС
|
|
||||||
.with_transparent(true), // Прозрачность для скругления углов
|
|
||||||
// Аппаратный MSAA (сглаживание на уровне GPU): 0 — выключен,
|
|
||||||
// дальше степень двойки. Для плоского 2D-интерфейса egui разница
|
|
||||||
// почти незаметна, но если внутри появится 3D/сложные меши,
|
|
||||||
// это единственный «настоящий» переключатель сглаживания.
|
|
||||||
multisampling: 4,
|
|
||||||
// Дизеринг убирает полосы (banding) на градиентах и полупрозрачных
|
|
||||||
// заливках. Он включён по умолчанию, здесь оставлен явно.
|
|
||||||
dithering: true,
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
|
|
||||||
eframe::run_native(
|
open_window(window_options(), cx, |window, cx| {
|
||||||
"Custom Window 0.37",
|
cx.new(|cx| MyApp::new(window, cx).with_startup_script())
|
||||||
options,
|
})
|
||||||
Box::new(|cc| Ok(Box::new(MyApp::new(cc)))),
|
.expect("не удалось открыть окно");
|
||||||
)
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Параметры окна в стиле исходного приложения: размер 900×600, минимум
|
||||||
|
/// 420×280 и собственный заголовок вместо системного.
|
||||||
|
fn window_options() -> WindowOptions {
|
||||||
|
WindowOptions {
|
||||||
|
window_bounds: Some(WindowBounds::Windowed(Bounds::new(
|
||||||
|
point(px(100.), px(100.)),
|
||||||
|
size(px(WINDOW_WIDTH), px(WINDOW_HEIGHT)),
|
||||||
|
))),
|
||||||
|
window_min_size: Some(size(px(MIN_WINDOW_WIDTH), px(MIN_WINDOW_HEIGHT))),
|
||||||
|
// На Linux системный заголовок включается на уровне оконного менеджера.
|
||||||
|
// `Client` просит отдать оформление приложению — это аналог
|
||||||
|
// `with_decorations(false)` из прошлой версии.
|
||||||
|
window_decorations: Some(WindowDecorations::Client),
|
||||||
|
titlebar: Some(TitlebarOptions {
|
||||||
|
title: Some(WINDOW_TITLE.into()),
|
||||||
|
..TitleBar::title_bar_options()
|
||||||
|
}),
|
||||||
|
..TitleBar::window_options()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
-522
@@ -1,522 +0,0 @@
|
|||||||
//! Интеграция tree-sitter: разбор текста и подсветка синтаксиса.
|
|
||||||
//!
|
|
||||||
//! Модуль не зависит от UI-кода: [`SyntaxState::parse`] возвращает обычное
|
|
||||||
//! дерево tree-sitter, поэтому его можно использовать не только для цветов,
|
|
||||||
//! но и для навигации по структуре кода, поиска узлов и проверки ошибок.
|
|
||||||
//!
|
|
||||||
//! Подсветка — это список байтовых диапазонов с [`HighlightStyle`]. Как именно
|
|
||||||
//! их раскрашивать, решает редактор (см. `ui::code_editor`).
|
|
||||||
//!
|
|
||||||
//! Поддерживаемые языки: Rust, Go, JavaScript, JSON, Python. Подсветка
|
|
||||||
//! выключена, пока для документа явно не выбран язык (см. `AppApi::set_language`).
|
|
||||||
|
|
||||||
use std::collections::hash_map::DefaultHasher;
|
|
||||||
use std::hash::{Hash, Hasher};
|
|
||||||
use std::sync::OnceLock;
|
|
||||||
|
|
||||||
use tree_sitter::{Parser, Query, QueryCursor, StreamingIterator, Tree};
|
|
||||||
|
|
||||||
/// Язык, для которого подключена грамматика tree-sitter.
|
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
||||||
pub enum Language {
|
|
||||||
Rust,
|
|
||||||
Go,
|
|
||||||
JavaScript,
|
|
||||||
Json,
|
|
||||||
Python,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Language {
|
|
||||||
/// Все поддерживаемые языки.
|
|
||||||
#[allow(dead_code)]
|
|
||||||
// Часть публичного интерфейса модуля: удобно для перебора языков снаружи.
|
|
||||||
pub const ALL: [Self; 5] = [
|
|
||||||
Self::Rust,
|
|
||||||
Self::Go,
|
|
||||||
Self::JavaScript,
|
|
||||||
Self::Json,
|
|
||||||
Self::Python,
|
|
||||||
];
|
|
||||||
|
|
||||||
/// Разбирает название языка из API/сценария.
|
|
||||||
///
|
|
||||||
/// Основные имена — `rust`, `go`, `js`, `json`, `python`; короткие
|
|
||||||
/// (`rs`, `golang`, `javascript`, `py`) и регистр тоже принимаются.
|
|
||||||
pub fn from_name(name: &str) -> Option<Self> {
|
|
||||||
match name.trim().to_ascii_lowercase().as_str() {
|
|
||||||
"rust" | "rs" => Some(Self::Rust),
|
|
||||||
"go" | "golang" => Some(Self::Go),
|
|
||||||
"js" | "javascript" | "node" => Some(Self::JavaScript),
|
|
||||||
"json" => Some(Self::Json),
|
|
||||||
"python" | "py" => Some(Self::Python),
|
|
||||||
_ => None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Каноническое имя языка (то, что возвращает `AppApi::language`).
|
|
||||||
pub fn name(self) -> &'static str {
|
|
||||||
match self {
|
|
||||||
Self::Rust => "rust",
|
|
||||||
Self::Go => "go",
|
|
||||||
Self::JavaScript => "js",
|
|
||||||
Self::Json => "json",
|
|
||||||
Self::Python => "python",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Грамматика tree-sitter для языка.
|
|
||||||
fn grammar(self) -> tree_sitter::Language {
|
|
||||||
match self {
|
|
||||||
Self::Rust => tree_sitter_rust::LANGUAGE.into(),
|
|
||||||
Self::Go => tree_sitter_go::LANGUAGE.into(),
|
|
||||||
Self::JavaScript => tree_sitter_javascript::LANGUAGE.into(),
|
|
||||||
Self::Json => tree_sitter_json::LANGUAGE.into(),
|
|
||||||
Self::Python => tree_sitter_python::LANGUAGE.into(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Запрос подсветки для языка.
|
|
||||||
fn query_source(self) -> &'static str {
|
|
||||||
match self {
|
|
||||||
Self::Rust => RUST_QUERY,
|
|
||||||
Self::Go => GO_QUERY,
|
|
||||||
Self::JavaScript => JS_QUERY,
|
|
||||||
Self::Json => JSON_QUERY,
|
|
||||||
Self::Python => PYTHON_QUERY,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Семантическая категория участка кода.
|
|
||||||
///
|
|
||||||
/// Редактор сам выбирает цвет для каждой категории, поэтому тема оформления
|
|
||||||
/// не зависит от грамматик.
|
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
||||||
pub enum HighlightStyle {
|
|
||||||
Keyword,
|
|
||||||
String,
|
|
||||||
Escape,
|
|
||||||
Number,
|
|
||||||
Comment,
|
|
||||||
Function,
|
|
||||||
Type,
|
|
||||||
Property,
|
|
||||||
Constant,
|
|
||||||
Operator,
|
|
||||||
Punctuation,
|
|
||||||
Variable,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl HighlightStyle {
|
|
||||||
/// Числовая метка для компактного буфера при разборе пересечений.
|
|
||||||
fn tag(self) -> u8 {
|
|
||||||
match self {
|
|
||||||
Self::Keyword => 1,
|
|
||||||
Self::String => 2,
|
|
||||||
Self::Escape => 3,
|
|
||||||
Self::Number => 4,
|
|
||||||
Self::Comment => 5,
|
|
||||||
Self::Function => 6,
|
|
||||||
Self::Type => 7,
|
|
||||||
Self::Property => 8,
|
|
||||||
Self::Constant => 9,
|
|
||||||
Self::Operator => 10,
|
|
||||||
Self::Punctuation => 11,
|
|
||||||
Self::Variable => 12,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn from_tag(tag: u8) -> Option<Self> {
|
|
||||||
Some(match tag {
|
|
||||||
1 => Self::Keyword,
|
|
||||||
2 => Self::String,
|
|
||||||
3 => Self::Escape,
|
|
||||||
4 => Self::Number,
|
|
||||||
5 => Self::Comment,
|
|
||||||
6 => Self::Function,
|
|
||||||
7 => Self::Type,
|
|
||||||
8 => Self::Property,
|
|
||||||
9 => Self::Constant,
|
|
||||||
10 => Self::Operator,
|
|
||||||
11 => Self::Punctuation,
|
|
||||||
12 => Self::Variable,
|
|
||||||
_ => return None,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Приоритет при совпадении диапазонов: более специфичная категория
|
|
||||||
/// должна перекрывать более общую (например, имя функции — свойство).
|
|
||||||
fn priority(self) -> u8 {
|
|
||||||
match self {
|
|
||||||
Self::Variable | Self::Punctuation | Self::Operator | Self::String => 1,
|
|
||||||
Self::Number | Self::Constant | Self::Property => 2,
|
|
||||||
Self::Escape | Self::Type => 3,
|
|
||||||
Self::Function => 4,
|
|
||||||
Self::Keyword | Self::Comment => 5,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Участок текста с назначенным стилем (байтовые смещения, `end` не включён).
|
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
||||||
pub struct HighlightSpan {
|
|
||||||
pub start: usize,
|
|
||||||
pub end: usize,
|
|
||||||
pub style: HighlightStyle,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Состояние разбора одного документа: переиспользуемый парсер и кэш подсветки.
|
|
||||||
///
|
|
||||||
/// Кэш сравнивает язык и хеш текста, поэтому при наборе текста подсветка
|
|
||||||
/// пересчитывается, а в остальные кадры берётся готовый результат.
|
|
||||||
#[derive(Default)]
|
|
||||||
pub struct SyntaxState {
|
|
||||||
parser: Option<Parser>,
|
|
||||||
cache: Option<HighlightCache>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl SyntaxState {
|
|
||||||
/// Разбирает текст в дерево tree-sitter.
|
|
||||||
///
|
|
||||||
/// Это универсальная точка входа: дерево можно обходить для поиска
|
|
||||||
/// определений, проверки ошибок и любых других задач, не связанных с
|
|
||||||
/// подсветкой. Возвращает `None`, если грамматика не установилась.
|
|
||||||
pub fn parse(&mut self, language: Language, text: &str) -> Option<Tree> {
|
|
||||||
let parser = self.parser.get_or_insert_with(Parser::new);
|
|
||||||
if let Err(err) = parser.set_language(&language.grammar()) {
|
|
||||||
eprintln!("tree-sitter: не удалось установить язык {language:?}: {err}");
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
parser.parse(text, None)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Подсвеченные участки текста для языка.
|
|
||||||
///
|
|
||||||
/// Результат кэшируется по языку и содержимому, поэтому вызывать метод
|
|
||||||
/// каждый кадр дёшево, пока текст не изменился.
|
|
||||||
pub fn spans(&mut self, language: Language, text: &str) -> &[HighlightSpan] {
|
|
||||||
let signature = Signature {
|
|
||||||
language,
|
|
||||||
len: text.len(),
|
|
||||||
hash: hash_text(text),
|
|
||||||
};
|
|
||||||
|
|
||||||
let fresh = self
|
|
||||||
.cache
|
|
||||||
.as_ref()
|
|
||||||
.is_some_and(|cache| cache.signature == signature);
|
|
||||||
|
|
||||||
if !fresh {
|
|
||||||
// Подсветка строится поверх того же дерева, которое отдаёт `parse`,
|
|
||||||
// поэтому разбор всегда один.
|
|
||||||
let spans = self
|
|
||||||
.parse(language, text)
|
|
||||||
.map(|tree| highlight_tree(language, text, &tree))
|
|
||||||
.unwrap_or_default();
|
|
||||||
self.cache = Some(HighlightCache { signature, spans });
|
|
||||||
}
|
|
||||||
|
|
||||||
&self
|
|
||||||
.cache
|
|
||||||
.as_ref()
|
|
||||||
.expect("кэш заполняется выше при промахе")
|
|
||||||
.spans
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Ключ кэша подсветки.
|
|
||||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
|
||||||
struct Signature {
|
|
||||||
language: Language,
|
|
||||||
len: usize,
|
|
||||||
hash: u64,
|
|
||||||
}
|
|
||||||
|
|
||||||
struct HighlightCache {
|
|
||||||
signature: Signature,
|
|
||||||
spans: Vec<HighlightSpan>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Сырой захват запроса до разрешения пересечений.
|
|
||||||
struct RawSpan {
|
|
||||||
start: usize,
|
|
||||||
end: usize,
|
|
||||||
style: HighlightStyle,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl RawSpan {
|
|
||||||
fn priority(&self) -> u8 {
|
|
||||||
self.style.priority()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Считает участки подсветки по готовому дереву tree-sitter.
|
|
||||||
fn highlight_tree(language: Language, text: &str, tree: &Tree) -> Vec<HighlightSpan> {
|
|
||||||
let Some(query) = query(language) else {
|
|
||||||
return Vec::new();
|
|
||||||
};
|
|
||||||
|
|
||||||
let capture_names = query.capture_names();
|
|
||||||
let mut cursor = QueryCursor::new();
|
|
||||||
let mut matches = cursor.matches(query, tree.root_node(), text.as_bytes());
|
|
||||||
|
|
||||||
let mut raw = Vec::new();
|
|
||||||
while let Some(matched) = matches.next() {
|
|
||||||
for capture in matched.captures {
|
|
||||||
let Some(style) = style_for_capture(capture_names[capture.index as usize]) else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
let range = capture.node.byte_range();
|
|
||||||
if range.start < range.end && range.end <= text.len() {
|
|
||||||
raw.push(RawSpan {
|
|
||||||
start: range.start,
|
|
||||||
end: range.end,
|
|
||||||
style,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
drop(matches);
|
|
||||||
|
|
||||||
resolve_overlaps(text, raw)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Раскрашивает байты «сверху вниз»: сначала крупные участки, затем более
|
|
||||||
/// мелкие и приоритетные. Так вложенные escape-последовательности попадают
|
|
||||||
/// внутрь строк, а имя функции перекрывает свойство с тем же диапазоном.
|
|
||||||
fn resolve_overlaps(text: &str, mut raw: Vec<RawSpan>) -> Vec<HighlightSpan> {
|
|
||||||
raw.sort_by(|a, b| {
|
|
||||||
(b.end - b.start)
|
|
||||||
.cmp(&(a.end - a.start))
|
|
||||||
.then(a.priority().cmp(&b.priority()))
|
|
||||||
});
|
|
||||||
|
|
||||||
let mut cells = vec![0u8; text.len()];
|
|
||||||
for span in raw {
|
|
||||||
cells[span.start..span.end].fill(span.style.tag());
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut spans = Vec::new();
|
|
||||||
let mut index = 0;
|
|
||||||
while index < cells.len() {
|
|
||||||
let tag = cells[index];
|
|
||||||
let start = index;
|
|
||||||
while index < cells.len() && cells[index] == tag {
|
|
||||||
index += 1;
|
|
||||||
}
|
|
||||||
if let Some(style) = HighlightStyle::from_tag(tag) {
|
|
||||||
spans.push(HighlightSpan {
|
|
||||||
start,
|
|
||||||
end: index,
|
|
||||||
style,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
spans
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Сопоставляет имя захвата запроса (`@function.method`, `@string`, ...)
|
|
||||||
/// с категорией стиля.
|
|
||||||
fn style_for_capture(name: &str) -> Option<HighlightStyle> {
|
|
||||||
// Ключи JSON-объекта грамматика помечает как строки — рисуем их как свойства.
|
|
||||||
if name == "string.special.key" {
|
|
||||||
return Some(HighlightStyle::Property);
|
|
||||||
}
|
|
||||||
|
|
||||||
let head = name.split('.').next().unwrap_or(name);
|
|
||||||
Some(match head {
|
|
||||||
"keyword" => HighlightStyle::Keyword,
|
|
||||||
"string" | "text" => HighlightStyle::String,
|
|
||||||
"escape" => HighlightStyle::Escape,
|
|
||||||
"number" => HighlightStyle::Number,
|
|
||||||
"comment" => HighlightStyle::Comment,
|
|
||||||
"function" | "method" | "attribute" | "decorator" => HighlightStyle::Function,
|
|
||||||
"type" | "constructor" => HighlightStyle::Type,
|
|
||||||
"property" => HighlightStyle::Property,
|
|
||||||
"constant" | "boolean" => HighlightStyle::Constant,
|
|
||||||
"operator" => HighlightStyle::Operator,
|
|
||||||
"punctuation" => HighlightStyle::Punctuation,
|
|
||||||
"variable" | "parameter" => HighlightStyle::Variable,
|
|
||||||
_ => return None,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn hash_text(text: &str) -> u64 {
|
|
||||||
let mut hasher = DefaultHasher::new();
|
|
||||||
text.hash(&mut hasher);
|
|
||||||
hasher.finish()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Скомпилированный запрос подсветки (один на язык, создаётся при первом обращении).
|
|
||||||
fn query(language: Language) -> Option<&'static Query> {
|
|
||||||
static RUST: OnceLock<Option<Query>> = OnceLock::new();
|
|
||||||
static GO: OnceLock<Option<Query>> = OnceLock::new();
|
|
||||||
static JS: OnceLock<Option<Query>> = OnceLock::new();
|
|
||||||
static JSON: OnceLock<Option<Query>> = OnceLock::new();
|
|
||||||
static PYTHON: OnceLock<Option<Query>> = OnceLock::new();
|
|
||||||
|
|
||||||
let slot = match language {
|
|
||||||
Language::Rust => &RUST,
|
|
||||||
Language::Go => &GO,
|
|
||||||
Language::JavaScript => &JS,
|
|
||||||
Language::Json => &JSON,
|
|
||||||
Language::Python => &PYTHON,
|
|
||||||
};
|
|
||||||
|
|
||||||
slot.get_or_init(
|
|
||||||
|| match Query::new(&language.grammar(), language.query_source()) {
|
|
||||||
Ok(query) => Some(query),
|
|
||||||
Err(err) => {
|
|
||||||
eprintln!("tree-sitter: запрос подсветки для {language:?}: {err}");
|
|
||||||
None
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.as_ref()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Запросы намеренно не используют предикаты `#match?`: они не нужны для
|
|
||||||
// базовой подсветки и не должны зависеть от конкретной реализации tree-sitter.
|
|
||||||
|
|
||||||
const RUST_QUERY: &str = r#"
|
|
||||||
(line_comment) @comment
|
|
||||||
(block_comment) @comment
|
|
||||||
(doc_comment) @comment
|
|
||||||
|
|
||||||
(string_literal) @string
|
|
||||||
(raw_string_literal) @string
|
|
||||||
(char_literal) @string
|
|
||||||
(escape_sequence) @escape
|
|
||||||
|
|
||||||
(integer_literal) @number
|
|
||||||
(float_literal) @number
|
|
||||||
(boolean_literal) @constant
|
|
||||||
|
|
||||||
(type_identifier) @type
|
|
||||||
(primitive_type) @type
|
|
||||||
(field_identifier) @property
|
|
||||||
(lifetime) @type
|
|
||||||
|
|
||||||
(function_item name: (identifier) @function)
|
|
||||||
(function_signature_item name: (identifier) @function)
|
|
||||||
(macro_invocation macro: (identifier) @function)
|
|
||||||
(call_expression function: (identifier) @function)
|
|
||||||
(call_expression function: (field_expression field: (field_identifier) @function))
|
|
||||||
(metavariable) @variable
|
|
||||||
|
|
||||||
[
|
|
||||||
"as" "async" "await" "break" "const" "continue" "default" "dyn" "else"
|
|
||||||
"enum" "extern" "fn" "for" "gen" "if" "impl" "in" "let" "loop" "match"
|
|
||||||
"mod" "move" "pub" "raw" "ref" "return" "static" "struct" "trait" "type"
|
|
||||||
"union" "unsafe" "use" "where" "while" "yield"
|
|
||||||
] @keyword
|
|
||||||
(crate) @keyword
|
|
||||||
(mutable_specifier) @keyword
|
|
||||||
(self) @keyword
|
|
||||||
(super) @keyword
|
|
||||||
"#;
|
|
||||||
|
|
||||||
const GO_QUERY: &str = r#"
|
|
||||||
(comment) @comment
|
|
||||||
|
|
||||||
(interpreted_string_literal) @string
|
|
||||||
(raw_string_literal) @string
|
|
||||||
(rune_literal) @string
|
|
||||||
(escape_sequence) @escape
|
|
||||||
|
|
||||||
(int_literal) @number
|
|
||||||
(float_literal) @number
|
|
||||||
(imaginary_literal) @number
|
|
||||||
(true) @constant
|
|
||||||
(false) @constant
|
|
||||||
(nil) @constant
|
|
||||||
(iota) @constant
|
|
||||||
|
|
||||||
(type_identifier) @type
|
|
||||||
(field_identifier) @property
|
|
||||||
|
|
||||||
(function_declaration name: (identifier) @function)
|
|
||||||
(method_declaration name: (field_identifier) @function)
|
|
||||||
(call_expression function: (identifier) @function)
|
|
||||||
(call_expression function: (selector_expression field: (field_identifier) @function))
|
|
||||||
|
|
||||||
[
|
|
||||||
"break" "case" "chan" "const" "continue" "default" "defer" "else"
|
|
||||||
"fallthrough" "for" "func" "go" "goto" "if" "import" "interface" "map"
|
|
||||||
"package" "range" "return" "select" "struct" "switch" "type" "var"
|
|
||||||
] @keyword
|
|
||||||
"#;
|
|
||||||
|
|
||||||
const JS_QUERY: &str = r#"
|
|
||||||
(comment) @comment
|
|
||||||
|
|
||||||
(string) @string
|
|
||||||
(template_string) @string
|
|
||||||
(regex) @string
|
|
||||||
(escape_sequence) @escape
|
|
||||||
(number) @number
|
|
||||||
(true) @constant
|
|
||||||
(false) @constant
|
|
||||||
(null) @constant
|
|
||||||
(undefined) @constant
|
|
||||||
|
|
||||||
(property_identifier) @property
|
|
||||||
|
|
||||||
(function_declaration name: (identifier) @function)
|
|
||||||
(generator_function_declaration name: (identifier) @function)
|
|
||||||
(method_definition name: (property_identifier) @function)
|
|
||||||
(class_declaration name: (identifier) @type)
|
|
||||||
(call_expression function: (identifier) @function)
|
|
||||||
(call_expression function: (member_expression property: (property_identifier) @function))
|
|
||||||
|
|
||||||
[
|
|
||||||
"as" "async" "await" "break" "case" "catch" "class" "const" "continue"
|
|
||||||
"debugger" "default" "delete" "do" "else" "export" "extends" "finally"
|
|
||||||
"for" "from" "function" "get" "if" "import" "in" "instanceof" "let"
|
|
||||||
"new" "of" "return" "set" "static" "switch" "target" "throw" "try"
|
|
||||||
"typeof" "var" "void" "while" "with" "yield"
|
|
||||||
] @keyword
|
|
||||||
(this) @keyword
|
|
||||||
(super) @keyword
|
|
||||||
"#;
|
|
||||||
|
|
||||||
const JSON_QUERY: &str = r#"
|
|
||||||
(comment) @comment
|
|
||||||
(escape_sequence) @escape
|
|
||||||
|
|
||||||
(pair key: (string) @string.special.key)
|
|
||||||
(string) @string
|
|
||||||
(number) @number
|
|
||||||
(true) @constant
|
|
||||||
(false) @constant
|
|
||||||
(null) @constant
|
|
||||||
"#;
|
|
||||||
|
|
||||||
const PYTHON_QUERY: &str = r#"
|
|
||||||
(comment) @comment
|
|
||||||
|
|
||||||
(string) @string
|
|
||||||
(concatenated_string) @string
|
|
||||||
(escape_sequence) @escape
|
|
||||||
(integer) @number
|
|
||||||
(float) @number
|
|
||||||
(none) @constant
|
|
||||||
(true) @constant
|
|
||||||
(false) @constant
|
|
||||||
|
|
||||||
(attribute attribute: (identifier) @property)
|
|
||||||
(type (identifier) @type)
|
|
||||||
|
|
||||||
(function_definition name: (identifier) @function)
|
|
||||||
(class_definition name: (identifier) @type)
|
|
||||||
(decorator) @function
|
|
||||||
(call function: (identifier) @function)
|
|
||||||
(call function: (attribute attribute: (identifier) @function))
|
|
||||||
|
|
||||||
[
|
|
||||||
"and" "as" "assert" "async" "await" "break" "class" "continue" "def"
|
|
||||||
"del" "elif" "else" "except" "finally" "for" "from" "global" "if"
|
|
||||||
"import" "in" "is" "lambda" "match" "nonlocal" "not" "or" "pass"
|
|
||||||
"raise" "return" "try" "while" "with" "yield"
|
|
||||||
] @keyword
|
|
||||||
"#;
|
|
||||||
+268
-420
@@ -1,196 +1,29 @@
|
|||||||
//! Проверки UI-логики на «сырых» событиях egui (без реального окна).
|
//! Тесты доменной модели, Lua-интеграции и клавиатурных привязок.
|
||||||
|
//!
|
||||||
|
//! UI-часть (заголовок, перетаскивание, изменение размера) теперь принадлежит
|
||||||
|
//! gpui-kit, поэтому её отдельно не проверяем. Остаются тесты, которые
|
||||||
|
//! фиксируют поведение самого приложения: документы, API, Lua и KeyMap.
|
||||||
|
|
||||||
use std::cell::RefCell;
|
use std::cell::RefCell;
|
||||||
use std::rc::Rc;
|
use std::rc::Rc;
|
||||||
|
|
||||||
use eframe::egui;
|
use gpui_kit::{Keystroke, Modifiers, TestAppContext};
|
||||||
|
|
||||||
use crate::api::AppApi;
|
use crate::api::{AppApi, WindowCommand, drain_window_commands};
|
||||||
use crate::app::MyApp;
|
use crate::app::MyApp;
|
||||||
use crate::editor::EditorState;
|
use crate::editor::{Language, Workspace};
|
||||||
use crate::keymap::KeyMap;
|
use crate::keymap::{KeyBinding, KeyMap, Modifiers as KeyModifiers};
|
||||||
use crate::script::ScriptEngine;
|
use crate::script::ScriptEngine;
|
||||||
use crate::syntax::{HighlightStyle, Language, SyntaxState};
|
|
||||||
use crate::ui::title_bar::TitleBarResponses;
|
|
||||||
|
|
||||||
const SCREEN: egui::Vec2 = egui::vec2(600.0, 400.0);
|
fn install_api(workspace: Rc<RefCell<Workspace>>) -> (AppApi, Rc<RefCell<KeyMap>>) {
|
||||||
|
let keymap = Rc::new(RefCell::new(KeyMap::default()));
|
||||||
/// Один проход кадра для данного экземпляра приложения:
|
let api = AppApi::install(workspace, keymap.clone());
|
||||||
/// возвращает ответы заголовка и команды окна.
|
(api, keymap)
|
||||||
fn step_app(
|
|
||||||
ctx: &egui::Context,
|
|
||||||
app: &mut MyApp,
|
|
||||||
events: Vec<egui::Event>,
|
|
||||||
) -> (TitleBarResponses, Vec<egui::ViewportCommand>) {
|
|
||||||
let raw = egui::RawInput {
|
|
||||||
screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, SCREEN)),
|
|
||||||
events,
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
|
|
||||||
let mut bar = None;
|
|
||||||
let mut out = ctx.run_ui(raw, |ui| {
|
|
||||||
bar = Some(app.show(ui));
|
|
||||||
});
|
|
||||||
|
|
||||||
let commands = out
|
|
||||||
.viewport_output
|
|
||||||
.get(&egui::ViewportId::ROOT)
|
|
||||||
.map(|output| output.commands.clone())
|
|
||||||
.unwrap_or_default();
|
|
||||||
out.textures_delta.clear();
|
|
||||||
|
|
||||||
(bar.expect("заголовок отрисован"), commands)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Один проход кадра на свежем приложении.
|
|
||||||
fn step(
|
|
||||||
ctx: &egui::Context,
|
|
||||||
events: Vec<egui::Event>,
|
|
||||||
) -> (TitleBarResponses, Vec<egui::ViewportCommand>) {
|
|
||||||
let mut app = MyApp::default();
|
|
||||||
step_app(ctx, &mut app, events)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn button_event(pos: egui::Pos2, pressed: bool) -> egui::Event {
|
|
||||||
egui::Event::PointerButton {
|
|
||||||
pos,
|
|
||||||
button: egui::PointerButton::Primary,
|
|
||||||
pressed,
|
|
||||||
modifiers: egui::Modifiers::default(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn click_at(ctx: &egui::Context, pos: egui::Pos2) -> Vec<egui::ViewportCommand> {
|
|
||||||
let mut commands = Vec::new();
|
|
||||||
commands.extend(step(ctx, vec![egui::Event::PointerMoved(pos)]).1);
|
|
||||||
commands.extend(step(ctx, vec![button_event(pos, true)]).1);
|
|
||||||
commands.extend(step(ctx, vec![]).1);
|
|
||||||
commands.extend(step(ctx, vec![button_event(pos, false)]).1);
|
|
||||||
commands.extend(step(ctx, vec![]).1);
|
|
||||||
commands
|
|
||||||
}
|
|
||||||
|
|
||||||
fn press_at(ctx: &egui::Context, pos: egui::Pos2) -> Vec<egui::ViewportCommand> {
|
|
||||||
let mut commands = step(ctx, vec![egui::Event::PointerMoved(pos)]).1;
|
|
||||||
commands.extend(step(ctx, vec![button_event(pos, true)]).1);
|
|
||||||
commands
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Событие клавиатуры, у которого логическая клавиша может отличаться от
|
|
||||||
/// физической (как при смене раскладки).
|
|
||||||
fn key_event(
|
|
||||||
physical: egui::Key,
|
|
||||||
logical: egui::Key,
|
|
||||||
pressed: bool,
|
|
||||||
modifiers: egui::Modifiers,
|
|
||||||
) -> egui::Event {
|
|
||||||
egui::Event::Key {
|
|
||||||
key: logical,
|
|
||||||
physical_key: Some(physical),
|
|
||||||
pressed,
|
|
||||||
repeat: false,
|
|
||||||
modifiers,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn title_bar_buttons_are_hoverable_and_clickable() {
|
fn workspace_keeps_at_least_one_tab() {
|
||||||
let ctx = egui::Context::default();
|
let mut state = Workspace::default();
|
||||||
// прогреваем: egui считает раскладку по предыдущему кадру
|
|
||||||
step(&ctx, vec![]);
|
|
||||||
let (bar, _) = step(&ctx, vec![]);
|
|
||||||
|
|
||||||
let close_pos = bar.close.rect.center();
|
|
||||||
let maximize_pos = bar.maximize.rect.center();
|
|
||||||
let minimize_pos = bar.minimize.rect.center();
|
|
||||||
|
|
||||||
// Кнопки должны находиться в заголовке, а не где-то ещё
|
|
||||||
assert!(bar.drag.rect.contains(close_pos));
|
|
||||||
|
|
||||||
// Наведение даёт подсветку
|
|
||||||
step(&ctx, vec![egui::Event::PointerMoved(close_pos)]);
|
|
||||||
let (hovered, _) = step(&ctx, vec![]);
|
|
||||||
assert!(
|
|
||||||
hovered.close.hovered(),
|
|
||||||
"кнопка «закрыть» не подсвечивается"
|
|
||||||
);
|
|
||||||
|
|
||||||
let close_cmds = click_at(&ctx, close_pos);
|
|
||||||
assert!(
|
|
||||||
close_cmds.contains(&egui::ViewportCommand::Close),
|
|
||||||
"клик по «закрыть» не отправил Close: {close_cmds:?}"
|
|
||||||
);
|
|
||||||
|
|
||||||
let max_cmds = click_at(&ctx, maximize_pos);
|
|
||||||
assert!(
|
|
||||||
max_cmds.contains(&egui::ViewportCommand::Maximized(true)),
|
|
||||||
"клик по «развернуть» не отправил Maximized: {max_cmds:?}"
|
|
||||||
);
|
|
||||||
|
|
||||||
let min_cmds = click_at(&ctx, minimize_pos);
|
|
||||||
assert!(
|
|
||||||
min_cmds.contains(&egui::ViewportCommand::Minimized(true)),
|
|
||||||
"клик по «свернуть» не отправил Minimized: {min_cmds:?}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn dragging_title_bar_starts_window_drag_even_over_the_text() {
|
|
||||||
for (name, x_offset) in [("по тексту", 40.0), ("по пустому месту", 300.0)]
|
|
||||||
{
|
|
||||||
let ctx = egui::Context::default();
|
|
||||||
step(&ctx, vec![]);
|
|
||||||
let (bar, _) = step(&ctx, vec![]);
|
|
||||||
|
|
||||||
let start = egui::pos2(bar.drag.rect.left() + x_offset, bar.drag.rect.center().y);
|
|
||||||
let moved = start + egui::vec2(24.0, 12.0);
|
|
||||||
|
|
||||||
step(&ctx, vec![egui::Event::PointerMoved(start)]);
|
|
||||||
let mut commands = step(&ctx, vec![button_event(start, true)]).1;
|
|
||||||
commands.extend(step(&ctx, vec![egui::Event::PointerMoved(moved)]).1);
|
|
||||||
|
|
||||||
assert!(
|
|
||||||
commands.contains(&egui::ViewportCommand::StartDrag),
|
|
||||||
"драг заголовка {name} не инициировал перемещение окна: {commands:?}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn edges_and_corners_start_resize() {
|
|
||||||
use egui::viewport::ResizeDirection as Dir;
|
|
||||||
|
|
||||||
let cases = [
|
|
||||||
(egui::pos2(300.0, 2.0), Dir::North),
|
|
||||||
(egui::pos2(300.0, SCREEN.y - 2.0), Dir::South),
|
|
||||||
(egui::pos2(2.0, 200.0), Dir::West),
|
|
||||||
(egui::pos2(SCREEN.x - 2.0, 200.0), Dir::East),
|
|
||||||
(egui::pos2(2.0, 2.0), Dir::NorthWest),
|
|
||||||
(egui::pos2(SCREEN.x - 2.0, 2.0), Dir::NorthEast),
|
|
||||||
(egui::pos2(SCREEN.x - 2.0, SCREEN.y - 2.0), Dir::SouthEast),
|
|
||||||
(egui::pos2(2.0, SCREEN.y - 2.0), Dir::SouthWest),
|
|
||||||
];
|
|
||||||
|
|
||||||
for (pos, expected) in cases {
|
|
||||||
let ctx = egui::Context::default();
|
|
||||||
step(&ctx, vec![]);
|
|
||||||
step(&ctx, vec![]);
|
|
||||||
let commands = press_at(&ctx, pos);
|
|
||||||
assert!(
|
|
||||||
commands.contains(&egui::ViewportCommand::BeginResize(expected)),
|
|
||||||
"в точке {pos:?} ожидался BeginResize({expected:?}), получено: {commands:?}"
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
!commands.contains(&egui::ViewportCommand::StartDrag),
|
|
||||||
"в точке {pos:?} вместо ресайза началось перемещение окна"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn editor_state_keeps_at_least_one_tab() {
|
|
||||||
let mut state = EditorState::default();
|
|
||||||
assert_eq!(state.documents().len(), 2);
|
assert_eq!(state.documents().len(), 2);
|
||||||
assert_eq!(state.active().name, "main.rs");
|
assert_eq!(state.active().name, "main.rs");
|
||||||
|
|
||||||
@@ -213,58 +46,51 @@ fn editor_state_keeps_at_least_one_tab() {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn closing_tab_before_active_keeps_the_same_document_active() {
|
fn closing_tab_before_active_keeps_the_same_document_active() {
|
||||||
let mut state = EditorState::default();
|
let mut state = Workspace::default();
|
||||||
state.open_new();
|
state.open_new();
|
||||||
let name = state.active().name.clone();
|
let id = state.active().id;
|
||||||
|
|
||||||
state.close(0);
|
state.close(0);
|
||||||
assert_eq!(state.active().name, name);
|
assert_eq!(state.active().id, id);
|
||||||
assert_eq!(state.active_index(), 1);
|
assert_eq!(state.active_index(), 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn editing_marks_document_dirty_and_saving_clears_it() {
|
fn editing_marks_document_dirty_and_saving_clears_it() {
|
||||||
let mut state = EditorState::default();
|
let mut state = Workspace::default();
|
||||||
assert!(!state.active().is_dirty());
|
assert!(!state.active().is_dirty());
|
||||||
|
|
||||||
state.active_mut().content.push_str("\n// правка\n");
|
state.set_active_text("// правка\n");
|
||||||
assert!(state.active().is_dirty());
|
assert!(state.active().is_dirty());
|
||||||
|
|
||||||
state.active_mut().mark_saved();
|
state.mark_active_saved();
|
||||||
assert!(!state.active().is_dirty());
|
assert!(!state.active().is_dirty());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn typing_into_editor_updates_active_document() {
|
fn revision_changes_only_for_external_edits() {
|
||||||
let ctx = egui::Context::default();
|
let mut state = Workspace::default();
|
||||||
let mut app = MyApp::default();
|
let revision = state.revision();
|
||||||
|
|
||||||
// Прогреваем раскладку и кликаем в область редактора ниже панели вкладок.
|
// Набор текста в виджете ревизию не двигает: редактор уже показывает текст.
|
||||||
step_app(&ctx, &mut app, vec![]);
|
state.replace_active_content("typed".to_owned());
|
||||||
let editor_pos = egui::pos2(SCREEN.x / 2.0, SCREEN.y - 80.0);
|
assert_eq!(state.revision(), revision);
|
||||||
step_app(&ctx, &mut app, vec![egui::Event::PointerMoved(editor_pos)]);
|
|
||||||
step_app(&ctx, &mut app, vec![button_event(editor_pos, true)]);
|
|
||||||
step_app(&ctx, &mut app, vec![button_event(editor_pos, false)]);
|
|
||||||
|
|
||||||
step_app(&ctx, &mut app, vec![egui::Event::Text("ZZZ".to_owned())]);
|
// Внешняя замена (Lua) — двигает.
|
||||||
|
state.set_active_text("from lua");
|
||||||
|
assert_ne!(state.revision(), revision);
|
||||||
|
|
||||||
let editor = app.editor();
|
// Переключение вкладки — тоже.
|
||||||
let doc = editor.active();
|
let before_switch = state.revision();
|
||||||
assert!(
|
state.set_active(1);
|
||||||
doc.content.contains("ZZZ"),
|
assert_ne!(state.revision(), before_switch);
|
||||||
"введённый текст не попал в документ: {:?}",
|
|
||||||
doc.content
|
|
||||||
);
|
|
||||||
assert!(doc.is_dirty(), "правка должна помечать документ изменённым");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn api_lists_tabs_and_closes_them_by_number() {
|
fn api_lists_tabs_and_closes_them_by_number() {
|
||||||
let ctx = egui::Context::default();
|
let workspace = Rc::new(RefCell::new(Workspace::default()));
|
||||||
let mut app = MyApp::default();
|
let (api, _keymap) = install_api(workspace.clone());
|
||||||
step_app(&ctx, &mut app, vec![]);
|
|
||||||
|
|
||||||
let api = AppApi::global();
|
|
||||||
let tabs = api.tabs();
|
let tabs = api.tabs();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
tabs.iter()
|
tabs.iter()
|
||||||
@@ -279,52 +105,44 @@ fn api_lists_tabs_and_closes_them_by_number() {
|
|||||||
|
|
||||||
assert!(api.close_tab(1));
|
assert!(api.close_tab(1));
|
||||||
assert_eq!(api.tabs().len(), 1);
|
assert_eq!(api.tabs().len(), 1);
|
||||||
assert_eq!(app.editor().documents().len(), 1);
|
assert_eq!(workspace.borrow().documents().len(), 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn api_save_writes_active_tab_to_file_named_after_it() {
|
fn api_save_writes_active_tab_to_file_named_after_it() {
|
||||||
// Тест пишет во временный файл, а не в каталог проекта.
|
// Тест пишет во временный файл, а не в каталог проекта.
|
||||||
let mut state = EditorState::default();
|
let workspace = Rc::new(RefCell::new(Workspace::default()));
|
||||||
let path = std::env::temp_dir().join(format!(
|
let path = std::env::temp_dir().join(format!(
|
||||||
"egui-rust-test-save-{}-{}.rs",
|
"gpui-editor-test-save-{}-{}.rs",
|
||||||
std::process::id(),
|
std::process::id(),
|
||||||
state.active().id
|
workspace.borrow().active().id
|
||||||
));
|
));
|
||||||
let _ = std::fs::remove_file(&path);
|
let _ = std::fs::remove_file(&path);
|
||||||
|
|
||||||
{
|
{
|
||||||
|
let mut state = workspace.borrow_mut();
|
||||||
let doc = state.active_mut();
|
let doc = state.active_mut();
|
||||||
doc.name = path.to_string_lossy().into_owned();
|
doc.name = path.to_string_lossy().into_owned();
|
||||||
doc.content = "fn main() {}\n".to_owned();
|
doc.content = "fn main() {}\n".to_owned();
|
||||||
}
|
}
|
||||||
assert!(state.active().is_dirty());
|
assert!(workspace.borrow().active().is_dirty());
|
||||||
|
|
||||||
let editor = Rc::new(RefCell::new(state));
|
let (api, _keymap) = install_api(workspace.clone());
|
||||||
let api = AppApi::install(
|
|
||||||
egui::Context::default(),
|
|
||||||
editor.clone(),
|
|
||||||
Rc::new(RefCell::new(KeyMap::default())),
|
|
||||||
);
|
|
||||||
api.save().expect("сохранение должно удаться");
|
api.save().expect("сохранение должно удаться");
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
std::fs::read_to_string(&path).expect("файл должен существовать"),
|
std::fs::read_to_string(&path).expect("файл должен существовать"),
|
||||||
"fn main() {}\n"
|
"fn main() {}\n"
|
||||||
);
|
);
|
||||||
assert!(!editor.borrow().active().is_dirty());
|
assert!(!workspace.borrow().active().is_dirty());
|
||||||
|
|
||||||
let _ = std::fs::remove_file(&path);
|
let _ = std::fs::remove_file(&path);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn api_switches_tabs_forward_and_back_in_a_cycle() {
|
fn api_switches_tabs_forward_and_back_in_a_cycle() {
|
||||||
let editor = Rc::new(RefCell::new(EditorState::default()));
|
let workspace = Rc::new(RefCell::new(Workspace::default()));
|
||||||
let api = AppApi::install(
|
let (api, _keymap) = install_api(workspace.clone());
|
||||||
egui::Context::default(),
|
|
||||||
editor.clone(),
|
|
||||||
Rc::new(RefCell::new(KeyMap::default())),
|
|
||||||
);
|
|
||||||
|
|
||||||
assert_eq!(api.active_tab_index(), 0);
|
assert_eq!(api.active_tab_index(), 0);
|
||||||
|
|
||||||
@@ -337,18 +155,39 @@ fn api_switches_tabs_forward_and_back_in_a_cycle() {
|
|||||||
// С первой вкладки предыдущая — последняя.
|
// С первой вкладки предыдущая — последняя.
|
||||||
assert_eq!(api.previous_tab(), 1);
|
assert_eq!(api.previous_tab(), 1);
|
||||||
assert_eq!(api.previous_tab(), 0);
|
assert_eq!(api.previous_tab(), 0);
|
||||||
assert_eq!(editor.borrow().active_index(), 0);
|
assert_eq!(workspace.borrow().active_index(), 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn lua_script_can_include_files_and_control_the_app() {
|
fn window_commands_are_queued_for_the_view() {
|
||||||
let ctx = egui::Context::default();
|
let workspace = Rc::new(RefCell::new(Workspace::default()));
|
||||||
let mut app = MyApp::default();
|
let (api, _keymap) = install_api(workspace);
|
||||||
// Первый кадр публикует AppApi, который использует сценарий.
|
|
||||||
step_app(&ctx, &mut app, vec![]);
|
|
||||||
|
|
||||||
|
// Очередь могла остаться от другого теста на этом потоке — очищаем.
|
||||||
|
let _ = drain_window_commands();
|
||||||
|
|
||||||
|
api.minimize();
|
||||||
|
api.toggle_maximize();
|
||||||
|
api.set_maximized(false);
|
||||||
|
api.close();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
drain_window_commands(),
|
||||||
|
vec![
|
||||||
|
WindowCommand::Minimize,
|
||||||
|
WindowCommand::ToggleMaximize,
|
||||||
|
WindowCommand::SetMaximized(false),
|
||||||
|
WindowCommand::Close,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
// Повторное чтение пустой очереди ничего не возвращает.
|
||||||
|
assert!(drain_window_commands().is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Временный каталог с уникальным именем для Lua-сценариев.
|
||||||
|
fn temp_script_dir(prefix: &str) -> std::path::PathBuf {
|
||||||
let dir = std::env::temp_dir().join(format!(
|
let dir = std::env::temp_dir().join(format!(
|
||||||
"egui-rust-test-lua-{}-{}",
|
"gpui-editor-test-{prefix}-{}-{}",
|
||||||
std::process::id(),
|
std::process::id(),
|
||||||
std::time::SystemTime::now()
|
std::time::SystemTime::now()
|
||||||
.duration_since(std::time::UNIX_EPOCH)
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
@@ -356,7 +195,15 @@ fn lua_script_can_include_files_and_control_the_app() {
|
|||||||
.as_nanos()
|
.as_nanos()
|
||||||
));
|
));
|
||||||
std::fs::create_dir_all(&dir).expect("временный каталог сценариев");
|
std::fs::create_dir_all(&dir).expect("временный каталог сценариев");
|
||||||
|
dir
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn lua_script_can_include_files_and_control_the_app() {
|
||||||
|
let workspace = Rc::new(RefCell::new(Workspace::default()));
|
||||||
|
let (api, _keymap) = install_api(workspace.clone());
|
||||||
|
|
||||||
|
let dir = temp_script_dir("lua");
|
||||||
let helper = dir.join("helper.lua");
|
let helper = dir.join("helper.lua");
|
||||||
let main = dir.join("main.lua");
|
let main = dir.join("main.lua");
|
||||||
std::fs::write(
|
std::fs::write(
|
||||||
@@ -373,22 +220,22 @@ fn lua_script_can_include_files_and_control_the_app() {
|
|||||||
)
|
)
|
||||||
.expect("main.lua");
|
.expect("main.lua");
|
||||||
|
|
||||||
let engine = ScriptEngine::new(AppApi::global()).expect("движок Lua");
|
let engine = ScriptEngine::new(api).expect("движок Lua");
|
||||||
engine.run_file(&main).expect("сценарий должен выполниться");
|
engine.run_file(&main).expect("сценарий должен выполниться");
|
||||||
|
|
||||||
let editor = app.editor();
|
let state = workspace.borrow();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
editor.documents()[0].content,
|
state.documents()[0].content,
|
||||||
"from lua",
|
"from lua",
|
||||||
"include должен выполнить подключённый файл в общем окружении"
|
"include должен выполнить подключённый файл в общем окружении"
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
editor.active_index(),
|
state.active_index(),
|
||||||
1,
|
1,
|
||||||
"app:next_tab() не переключил вкладку"
|
"app:next_tab() не переключил вкладку"
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
editor.documents()[1].language,
|
state.documents()[1].language,
|
||||||
Some(Language::Python),
|
Some(Language::Python),
|
||||||
"app:set_language() не включил подсветку из Lua"
|
"app:set_language() не включил подсветку из Lua"
|
||||||
);
|
);
|
||||||
@@ -397,22 +244,11 @@ fn lua_script_can_include_files_and_control_the_app() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn lua_key_bindings_use_physical_keys_and_modifiers() {
|
fn lua_key_bindings_match_press_release_and_modifiers() {
|
||||||
let ctx = egui::Context::default();
|
let workspace = Rc::new(RefCell::new(Workspace::default()));
|
||||||
let mut app = MyApp::default();
|
let (api, keymap) = install_api(workspace.clone());
|
||||||
// Первый кадр публикует AppApi с картой клавиш приложения.
|
|
||||||
step_app(&ctx, &mut app, vec![]);
|
|
||||||
|
|
||||||
let dir = std::env::temp_dir().join(format!(
|
|
||||||
"egui-rust-test-keys-{}-{}",
|
|
||||||
std::process::id(),
|
|
||||||
std::time::SystemTime::now()
|
|
||||||
.duration_since(std::time::UNIX_EPOCH)
|
|
||||||
.expect("системные часы")
|
|
||||||
.as_nanos()
|
|
||||||
));
|
|
||||||
std::fs::create_dir_all(&dir).expect("временный каталог сценариев");
|
|
||||||
|
|
||||||
|
let dir = temp_script_dir("keys");
|
||||||
let script = dir.join("keys.lua");
|
let script = dir.join("keys.lua");
|
||||||
std::fs::write(
|
std::fs::write(
|
||||||
&script,
|
&script,
|
||||||
@@ -426,100 +262,80 @@ fn lua_key_bindings_use_physical_keys_and_modifiers() {
|
|||||||
)
|
)
|
||||||
.expect("keys.lua");
|
.expect("keys.lua");
|
||||||
|
|
||||||
let engine = ScriptEngine::new(AppApi::global()).expect("движок Lua");
|
let engine = ScriptEngine::new(api).expect("движок Lua");
|
||||||
engine
|
engine
|
||||||
.run_file(&script)
|
.run_file(&script)
|
||||||
.expect("привязки должны назначиться");
|
.expect("привязки должны назначиться");
|
||||||
|
|
||||||
let text = |app: &MyApp| app.editor().active().content.clone();
|
let text = || workspace.borrow().active().content.clone();
|
||||||
|
let stroke = |key: &str, modifiers: gpui_kit::Modifiers| Keystroke {
|
||||||
|
modifiers,
|
||||||
|
key: key.to_owned(),
|
||||||
|
key_char: None,
|
||||||
|
};
|
||||||
|
let fire = |keystroke: &Keystroke, pressed: bool| {
|
||||||
|
for callback in keymap.borrow().callbacks_for(keystroke, pressed) {
|
||||||
|
callback.call::<()>(()).expect("Lua-обработчик");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Логическая клавиша Z, физическая A: срабатывает именно физическая.
|
// Нажатие A.
|
||||||
step_app(
|
fire(&stroke("a", Modifiers::default()), true);
|
||||||
&ctx,
|
assert_eq!(text(), "a pressed");
|
||||||
&mut app,
|
|
||||||
vec![key_event(
|
|
||||||
egui::Key::A,
|
|
||||||
egui::Key::Z,
|
|
||||||
true,
|
|
||||||
egui::Modifiers::default(),
|
|
||||||
)],
|
|
||||||
);
|
|
||||||
assert_eq!(text(&app), "a pressed");
|
|
||||||
|
|
||||||
// Shift+A не должна срабатывать как обычная A.
|
// Shift+A не должна срабатывать как обычная A.
|
||||||
step_app(
|
fire(
|
||||||
&ctx,
|
&stroke(
|
||||||
&mut app,
|
"a",
|
||||||
vec![key_event(
|
Modifiers {
|
||||||
egui::Key::A,
|
|
||||||
egui::Key::A,
|
|
||||||
true,
|
|
||||||
egui::Modifiers {
|
|
||||||
shift: true,
|
shift: true,
|
||||||
..Default::default()
|
..Default::default()
|
||||||
},
|
},
|
||||||
)],
|
),
|
||||||
);
|
|
||||||
assert_eq!(text(&app), "a pressed");
|
|
||||||
|
|
||||||
// Отпускание цифры 1, логическая клавиша при этом другая.
|
|
||||||
step_app(
|
|
||||||
&ctx,
|
|
||||||
&mut app,
|
|
||||||
vec![key_event(
|
|
||||||
egui::Key::Num1,
|
|
||||||
egui::Key::Num2,
|
|
||||||
false,
|
|
||||||
egui::Modifiers::default(),
|
|
||||||
)],
|
|
||||||
);
|
|
||||||
assert_eq!(text(&app), "1 released");
|
|
||||||
|
|
||||||
step_app(
|
|
||||||
&ctx,
|
|
||||||
&mut app,
|
|
||||||
vec![key_event(
|
|
||||||
egui::Key::C,
|
|
||||||
egui::Key::C,
|
|
||||||
true,
|
true,
|
||||||
egui::Modifiers {
|
);
|
||||||
|
assert_eq!(text(), "a pressed");
|
||||||
|
|
||||||
|
// Отпускание цифры 1.
|
||||||
|
fire(&stroke("1", Modifiers::default()), false);
|
||||||
|
assert_eq!(text(), "1 released");
|
||||||
|
|
||||||
|
fire(
|
||||||
|
&stroke(
|
||||||
|
"c",
|
||||||
|
Modifiers {
|
||||||
shift: true,
|
shift: true,
|
||||||
..Default::default()
|
..Default::default()
|
||||||
},
|
},
|
||||||
)],
|
),
|
||||||
);
|
|
||||||
assert_eq!(text(&app), "shift c");
|
|
||||||
|
|
||||||
step_app(
|
|
||||||
&ctx,
|
|
||||||
&mut app,
|
|
||||||
vec![key_event(
|
|
||||||
egui::Key::Z,
|
|
||||||
egui::Key::Z,
|
|
||||||
true,
|
true,
|
||||||
egui::Modifiers {
|
);
|
||||||
ctrl: true,
|
assert_eq!(text(), "shift c");
|
||||||
|
|
||||||
|
fire(
|
||||||
|
&stroke(
|
||||||
|
"z",
|
||||||
|
Modifiers {
|
||||||
|
control: true,
|
||||||
..Default::default()
|
..Default::default()
|
||||||
},
|
},
|
||||||
)],
|
),
|
||||||
|
true,
|
||||||
);
|
);
|
||||||
assert_eq!(text(&app), "ctrl z");
|
assert_eq!(text(), "ctrl z");
|
||||||
|
|
||||||
// Отпускание R при зажатом Alt.
|
// Отпускание R при зажатом Alt.
|
||||||
step_app(
|
fire(
|
||||||
&ctx,
|
&stroke(
|
||||||
&mut app,
|
"r",
|
||||||
vec![key_event(
|
Modifiers {
|
||||||
egui::Key::R,
|
|
||||||
egui::Key::R,
|
|
||||||
false,
|
|
||||||
egui::Modifiers {
|
|
||||||
alt: true,
|
alt: true,
|
||||||
..Default::default()
|
..Default::default()
|
||||||
},
|
},
|
||||||
)],
|
),
|
||||||
|
false,
|
||||||
);
|
);
|
||||||
assert_eq!(text(&app), "alt r released");
|
assert_eq!(text(), "alt r released");
|
||||||
|
|
||||||
let _ = std::fs::remove_dir_all(&dir);
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
}
|
}
|
||||||
@@ -528,119 +344,151 @@ fn lua_key_bindings_use_physical_keys_and_modifiers() {
|
|||||||
fn language_names_cover_all_supported_grammars() {
|
fn language_names_cover_all_supported_grammars() {
|
||||||
for (name, expected) in [
|
for (name, expected) in [
|
||||||
("rust", Language::Rust),
|
("rust", Language::Rust),
|
||||||
|
("rs", Language::Rust),
|
||||||
|
("Rust", Language::Rust),
|
||||||
("go", Language::Go),
|
("go", Language::Go),
|
||||||
|
("golang", Language::Go),
|
||||||
("js", Language::JavaScript),
|
("js", Language::JavaScript),
|
||||||
|
("javascript", Language::JavaScript),
|
||||||
("json", Language::Json),
|
("json", Language::Json),
|
||||||
("python", Language::Python),
|
("python", Language::Python),
|
||||||
|
("py", Language::Python),
|
||||||
] {
|
] {
|
||||||
assert_eq!(Language::from_name(name), Some(expected), "язык {name}");
|
assert_eq!(Language::from_name(name), Some(expected), "язык {name:?}");
|
||||||
assert_eq!(Language::from_name(&name.to_uppercase()), Some(expected));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Распространённые короткие имена.
|
assert_eq!(Language::from_name("unknown"), None);
|
||||||
assert_eq!(Language::from_name("rs"), Some(Language::Rust));
|
assert_eq!(Language::from_name(""), None);
|
||||||
assert_eq!(Language::from_name("golang"), Some(Language::Go));
|
|
||||||
assert_eq!(
|
|
||||||
Language::from_name("javascript"),
|
|
||||||
Some(Language::JavaScript)
|
|
||||||
);
|
|
||||||
assert_eq!(Language::from_name("py"), Some(Language::Python));
|
|
||||||
|
|
||||||
assert_eq!(Language::from_name("cobol"), None);
|
// Канонические имена, которые возвращает API и Lua.
|
||||||
}
|
assert_eq!(Language::Rust.name(), "rust");
|
||||||
|
assert_eq!(Language::JavaScript.name(), "js");
|
||||||
#[test]
|
assert_eq!(Language::Python.name(), "python");
|
||||||
fn tree_sitter_highlights_all_supported_languages() {
|
|
||||||
let samples = [
|
|
||||||
(
|
|
||||||
Language::Rust,
|
|
||||||
"fn main() {\n let x = 42; // комментарий\n println!(\"hi\");\n}\n",
|
|
||||||
HighlightStyle::Keyword,
|
|
||||||
),
|
|
||||||
(
|
|
||||||
Language::Go,
|
|
||||||
"package main\n\nfunc main() {\n\tx := 42 // комментарий\n\t_ = x\n}\n",
|
|
||||||
HighlightStyle::Keyword,
|
|
||||||
),
|
|
||||||
(
|
|
||||||
Language::JavaScript,
|
|
||||||
"function main() {\n const x = 42; // comment\n console.log(\"hi\");\n}\n",
|
|
||||||
HighlightStyle::Keyword,
|
|
||||||
),
|
|
||||||
(
|
|
||||||
Language::Json,
|
|
||||||
"{\"name\": \"value\", \"count\": 42, \"ok\": true}\n",
|
|
||||||
HighlightStyle::Property,
|
|
||||||
),
|
|
||||||
(
|
|
||||||
Language::Python,
|
|
||||||
"def main():\n x = 42 # comment\n print(\"hi\")\n",
|
|
||||||
HighlightStyle::Keyword,
|
|
||||||
),
|
|
||||||
];
|
|
||||||
|
|
||||||
for (language, source, expected) in samples {
|
|
||||||
let mut syntax = SyntaxState::default();
|
|
||||||
let styles: Vec<_> = syntax
|
|
||||||
.spans(language, source)
|
|
||||||
.iter()
|
|
||||||
.map(|span| span.style)
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
assert!(!styles.is_empty(), "{language:?}: подсветка пуста");
|
|
||||||
assert!(
|
|
||||||
styles.contains(&expected),
|
|
||||||
"{language:?}: нет стиля {expected:?} в {styles:?}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn syntax_tree_is_available_for_tasks_beyond_highlighting() {
|
|
||||||
let mut syntax = SyntaxState::default();
|
|
||||||
let tree = syntax
|
|
||||||
.parse(Language::Rust, "fn main() {}\n")
|
|
||||||
.expect("дерево должно разобраться");
|
|
||||||
|
|
||||||
let root = tree.root_node();
|
|
||||||
assert_eq!(root.kind(), "source_file");
|
|
||||||
|
|
||||||
let mut cursor = root.walk();
|
|
||||||
assert!(
|
|
||||||
root.children(&mut cursor)
|
|
||||||
.any(|node| node.kind() == "function_item"),
|
|
||||||
"в дереве должна находиться функция — его можно обходить, а не только красить"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn highlighting_is_off_by_default_and_enabled_through_api() {
|
fn highlighting_is_off_by_default_and_enabled_through_api() {
|
||||||
let ctx = egui::Context::default();
|
let workspace = Rc::new(RefCell::new(Workspace::default()));
|
||||||
let mut app = MyApp::default();
|
let (api, _keymap) = install_api(workspace.clone());
|
||||||
step_app(&ctx, &mut app, vec![]);
|
|
||||||
|
|
||||||
assert!(
|
assert_eq!(api.language(0), None);
|
||||||
app.editor()
|
assert_eq!(workspace.borrow().documents()[0].language, None);
|
||||||
.documents()
|
|
||||||
.iter()
|
assert!(api.set_language(0, "rs"));
|
||||||
.all(|doc| doc.language.is_none()),
|
assert_eq!(api.language(0), Some("rust".to_owned()));
|
||||||
"подсветка должна быть выключена по умолчанию"
|
assert_eq!(
|
||||||
|
workspace.borrow().documents()[0].language,
|
||||||
|
Some(Language::Rust)
|
||||||
);
|
);
|
||||||
|
|
||||||
let api = AppApi::global();
|
// Неизвестный язык и несуществующая вкладка.
|
||||||
assert!(api.set_language(0, "rust"));
|
|
||||||
assert_eq!(api.language(0).as_deref(), Some("rust"));
|
|
||||||
assert_eq!(app.editor().documents()[0].language, Some(Language::Rust));
|
|
||||||
|
|
||||||
// Неизвестный язык и несуществующая вкладка не меняют состояние.
|
|
||||||
assert!(!api.set_language(0, "cobol"));
|
assert!(!api.set_language(0, "cobol"));
|
||||||
assert!(!api.set_language(99, "rust"));
|
assert!(!api.set_language(99, "rust"));
|
||||||
assert_eq!(api.language(0).as_deref(), Some("rust"));
|
|
||||||
|
|
||||||
// Кадр с включённой подсветкой должен отрисовываться без паники.
|
|
||||||
step_app(&ctx, &mut app, vec![]);
|
|
||||||
|
|
||||||
assert!(api.clear_language(0));
|
assert!(api.clear_language(0));
|
||||||
assert_eq!(api.language(0), None);
|
assert_eq!(api.language(0), None);
|
||||||
assert!(!api.clear_language(99));
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn keymap_bindings_are_parsed_and_replaced() {
|
||||||
|
let plain = KeyBinding::parse("a").expect("a");
|
||||||
|
let ctrl = KeyBinding::parse("<C-s>").expect("ctrl-s");
|
||||||
|
assert_eq!(plain.modifiers, KeyModifiers::default());
|
||||||
|
assert!(ctrl.modifiers.ctrl);
|
||||||
|
assert!(!ctrl.on_release);
|
||||||
|
|
||||||
|
// Повторное назначение той же клавиши не увеличивает число привязок:
|
||||||
|
// проверить это можно только через KeyMap, поэтому создаём пару функций.
|
||||||
|
let lua = mlua::Lua::new();
|
||||||
|
let mut keymap = KeyMap::default();
|
||||||
|
let first = lua
|
||||||
|
.load("return function() end")
|
||||||
|
.eval::<mlua::Function>()
|
||||||
|
.unwrap();
|
||||||
|
let second = lua
|
||||||
|
.load("return function() end")
|
||||||
|
.eval::<mlua::Function>()
|
||||||
|
.unwrap();
|
||||||
|
keymap.bind(plain.clone(), first);
|
||||||
|
keymap.bind(plain, second);
|
||||||
|
|
||||||
|
let stroke = Keystroke {
|
||||||
|
modifiers: Modifiers::default(),
|
||||||
|
key: "a".to_owned(),
|
||||||
|
key_char: None,
|
||||||
|
};
|
||||||
|
assert_eq!(keymap.callbacks_for(&stroke, true).len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[gpui_kit::test]
|
||||||
|
fn lua_key_bindings_receive_keys_while_editor_is_focused(cx: &mut TestAppContext) {
|
||||||
|
cx.update(gpui_kit::init);
|
||||||
|
|
||||||
|
let mut workspace = None;
|
||||||
|
let (app, visual) = cx.add_window_view(|window, cx| {
|
||||||
|
let app = MyApp::new(window, cx);
|
||||||
|
workspace = Some(app.workspace_handle());
|
||||||
|
app
|
||||||
|
});
|
||||||
|
let workspace = workspace.expect("вид создан");
|
||||||
|
|
||||||
|
// Регистрируем привязку через Lua — так же, как это делает init.lua.
|
||||||
|
let dir = temp_script_dir("ui-keys");
|
||||||
|
let script = dir.join("keys.lua");
|
||||||
|
std::fs::write(
|
||||||
|
&script,
|
||||||
|
r#"app:map_key("<A-r>", function() app:set_active_text("alt r pressed") end)"#,
|
||||||
|
)
|
||||||
|
.expect("keys.lua");
|
||||||
|
let engine = ScriptEngine::new(AppApi::global()).expect("движок Lua");
|
||||||
|
engine.run_file(&script).expect("привязка");
|
||||||
|
|
||||||
|
visual.update(|window, cx| {
|
||||||
|
app.update(cx, |this, cx| {
|
||||||
|
this.editor_handle()
|
||||||
|
.update(cx, |state, cx| state.focus(window, cx));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Событие клавиатуры идёт через фокус редактора вверх до корня вида,
|
||||||
|
// где его видит KeyMap.
|
||||||
|
visual.simulate_keystrokes("alt-r");
|
||||||
|
visual.run_until_parked();
|
||||||
|
|
||||||
|
assert_eq!(workspace.borrow().active().content, "alt r pressed");
|
||||||
|
|
||||||
|
drop(engine);
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[gpui_kit::test]
|
||||||
|
fn typing_into_editor_updates_active_document(cx: &mut TestAppContext) {
|
||||||
|
cx.update(gpui_kit::init);
|
||||||
|
|
||||||
|
let mut workspace = None;
|
||||||
|
let (app, visual) = cx.add_window_view(|window, cx| {
|
||||||
|
let app = MyApp::new(window, cx);
|
||||||
|
workspace = Some(app.workspace_handle());
|
||||||
|
app
|
||||||
|
});
|
||||||
|
let workspace = workspace.expect("вид создан");
|
||||||
|
|
||||||
|
// Фокус нужен до первого ввода: simulate_input отправляет текст в
|
||||||
|
// сфокусированный ввод.
|
||||||
|
visual.update(|window, cx| {
|
||||||
|
app.update(cx, |this, cx| {
|
||||||
|
this.editor_handle()
|
||||||
|
.update(cx, |state, cx| state.focus(window, cx));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
visual.simulate_input("ZZZ");
|
||||||
|
|
||||||
|
let state = workspace.borrow();
|
||||||
|
let doc = state.active();
|
||||||
|
assert!(
|
||||||
|
doc.content.contains("ZZZ"),
|
||||||
|
"введённый текст не попал в документ: {:?}",
|
||||||
|
doc.content
|
||||||
|
);
|
||||||
|
assert!(doc.is_dirty(), "правка должна помечать документ изменённым");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,298 +0,0 @@
|
|||||||
//! Область редактирования кода: нумерация строк и многострочное поле ввода.
|
|
||||||
|
|
||||||
use eframe::egui;
|
|
||||||
|
|
||||||
use crate::constants::CORNER_RADIUS;
|
|
||||||
use crate::editor::Document;
|
|
||||||
use crate::syntax::{HighlightStyle, Language, SyntaxState};
|
|
||||||
|
|
||||||
const FONT_SIZE: f32 = 13.0;
|
|
||||||
/// Отступы текста от краёв области (как у обычного `TextEdit`).
|
|
||||||
const MARGIN_X: i8 = 10;
|
|
||||||
const MARGIN_Y: i8 = 8;
|
|
||||||
/// Отступ номера строки от текста справа и минимальная ширина колонки.
|
|
||||||
const GUTTER_PADDING: f32 = 12.0;
|
|
||||||
|
|
||||||
/// Рисует редактор активного документа.
|
|
||||||
///
|
|
||||||
/// `focus` — запросить фокус у поля ввода (например, сразу после переключения вкладки).
|
|
||||||
pub fn render(ui: &mut egui::Ui, doc: &mut Document, focus: bool) {
|
|
||||||
let area = ui.available_rect_before_wrap();
|
|
||||||
|
|
||||||
// Фон на всю оставшуюся область; нижние углы повторяют скругление окна.
|
|
||||||
ui.painter().rect_filled(
|
|
||||||
area,
|
|
||||||
egui::CornerRadius {
|
|
||||||
nw: 0,
|
|
||||||
ne: 0,
|
|
||||||
sw: CORNER_RADIUS,
|
|
||||||
se: CORNER_RADIUS,
|
|
||||||
},
|
|
||||||
ui.visuals().extreme_bg_color,
|
|
||||||
);
|
|
||||||
|
|
||||||
let font_id = egui::FontId::monospace(FONT_SIZE);
|
|
||||||
let char_width = ui.fonts_mut(|fonts| fonts.glyph_width(&font_id, '0'));
|
|
||||||
let line_height =
|
|
||||||
ui.fonts_mut(|fonts| fonts.row_height(&font_id)) + ui.spacing().extra_text_line_spacing;
|
|
||||||
|
|
||||||
let line_count = doc.content.split('\n').count();
|
|
||||||
let gutter_width =
|
|
||||||
(line_count.to_string().len() as f32 * char_width + GUTTER_PADDING * 2.0).max(30.0);
|
|
||||||
|
|
||||||
// Сколько строк нужно, чтобы поле ввода заполнило пустую область целиком:
|
|
||||||
// клик ниже последней строки должен ставить курсор, а не попадать в пустоту.
|
|
||||||
let rows_to_fill =
|
|
||||||
(((area.height() - 2.0 * f32::from(MARGIN_Y)) / line_height).floor() as usize).max(1);
|
|
||||||
|
|
||||||
let doc_id = doc.id;
|
|
||||||
let language = doc.language;
|
|
||||||
// Поля берём по отдельности: `content` нужен `TextEdit`, а `syntax` —
|
|
||||||
// подсветке, и это разные заимствования одного документа.
|
|
||||||
let content = &mut doc.content;
|
|
||||||
let syntax = &mut doc.syntax;
|
|
||||||
|
|
||||||
egui::ScrollArea::vertical()
|
|
||||||
.id_salt(("code_scroll", doc_id))
|
|
||||||
.auto_shrink([false, false])
|
|
||||||
.show(ui, |ui| {
|
|
||||||
// Без отступов между гуттером и текстом: строка должна быть единой.
|
|
||||||
ui.spacing_mut().item_spacing = egui::Vec2::ZERO;
|
|
||||||
|
|
||||||
let gutter_left = ui.cursor().left();
|
|
||||||
|
|
||||||
let output = ui
|
|
||||||
.horizontal_top(|ui| {
|
|
||||||
ui.add_space(gutter_width);
|
|
||||||
|
|
||||||
let text_edit = egui::TextEdit::multiline(&mut *content)
|
|
||||||
.id_salt(("code_edit", doc_id))
|
|
||||||
.font(font_id.clone())
|
|
||||||
.frame(egui::Frame::NONE.inner_margin(egui::Margin {
|
|
||||||
left: MARGIN_X,
|
|
||||||
right: MARGIN_X,
|
|
||||||
top: MARGIN_Y,
|
|
||||||
bottom: MARGIN_Y,
|
|
||||||
}))
|
|
||||||
.desired_width(f32::INFINITY)
|
|
||||||
.desired_rows(rows_to_fill.max(line_count));
|
|
||||||
|
|
||||||
// Подсветка включается только для документов с выбранным
|
|
||||||
// языком; без него используется стандартный layouter.
|
|
||||||
if let Some(language) = language {
|
|
||||||
let theme = SyntaxTheme::from_visuals(ui.visuals());
|
|
||||||
let mut layouter =
|
|
||||||
|ui: &egui::Ui, text: &dyn egui::TextBuffer, wrap_width: f32| {
|
|
||||||
let job = highlighted_layout_job(
|
|
||||||
text.as_str(),
|
|
||||||
language,
|
|
||||||
syntax,
|
|
||||||
&theme,
|
|
||||||
&font_id,
|
|
||||||
line_height,
|
|
||||||
wrap_width,
|
|
||||||
);
|
|
||||||
ui.fonts_mut(|fonts| fonts.layout_job(job))
|
|
||||||
};
|
|
||||||
text_edit.layouter(&mut layouter).show(ui)
|
|
||||||
} else {
|
|
||||||
text_edit.show(ui)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.inner;
|
|
||||||
|
|
||||||
if focus {
|
|
||||||
output.response.response.request_focus();
|
|
||||||
}
|
|
||||||
|
|
||||||
paint_line_numbers(
|
|
||||||
ui,
|
|
||||||
&output,
|
|
||||||
content.as_str(),
|
|
||||||
&font_id,
|
|
||||||
gutter_left,
|
|
||||||
gutter_width,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Собирает `LayoutJob` для подсвеченного текста.
|
|
||||||
///
|
|
||||||
/// Форматирование повторяет стандартный layouter `TextEdit`, чтобы курсор,
|
|
||||||
/// перенос строк и номера строк работали так же, как без подсветки.
|
|
||||||
fn highlighted_layout_job(
|
|
||||||
text: &str,
|
|
||||||
language: Language,
|
|
||||||
syntax: &mut SyntaxState,
|
|
||||||
theme: &SyntaxTheme,
|
|
||||||
font_id: &egui::FontId,
|
|
||||||
line_height: f32,
|
|
||||||
wrap_width: f32,
|
|
||||||
) -> egui::text::LayoutJob {
|
|
||||||
let mut job = egui::text::LayoutJob::default();
|
|
||||||
job.wrap.max_width = wrap_width;
|
|
||||||
job.keep_trailing_whitespace = true;
|
|
||||||
|
|
||||||
let format = |color: egui::Color32| egui::TextFormat {
|
|
||||||
font_id: font_id.clone(),
|
|
||||||
color,
|
|
||||||
line_height: Some(line_height),
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
|
|
||||||
let mut cursor = 0;
|
|
||||||
for span in syntax.spans(language, text) {
|
|
||||||
if span.start > cursor {
|
|
||||||
job.append(
|
|
||||||
text.get(cursor..span.start).unwrap_or_default(),
|
|
||||||
0.0,
|
|
||||||
format(theme.default),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
job.append(
|
|
||||||
text.get(span.start..span.end).unwrap_or_default(),
|
|
||||||
0.0,
|
|
||||||
format(theme.color(span.style)),
|
|
||||||
);
|
|
||||||
cursor = span.end;
|
|
||||||
}
|
|
||||||
if cursor < text.len() {
|
|
||||||
job.append(
|
|
||||||
text.get(cursor..).unwrap_or_default(),
|
|
||||||
0.0,
|
|
||||||
format(theme.default),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if text.is_empty() {
|
|
||||||
// Пустой абзац нужен, чтобы у поля была строка для курсора.
|
|
||||||
job.append("", 0.0, format(theme.default));
|
|
||||||
}
|
|
||||||
|
|
||||||
job
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Цвета подсветки, подобранные под текущую тему egui.
|
|
||||||
#[derive(Clone, Copy)]
|
|
||||||
struct SyntaxTheme {
|
|
||||||
default: egui::Color32,
|
|
||||||
keyword: egui::Color32,
|
|
||||||
string: egui::Color32,
|
|
||||||
escape: egui::Color32,
|
|
||||||
number: egui::Color32,
|
|
||||||
comment: egui::Color32,
|
|
||||||
function: egui::Color32,
|
|
||||||
type_: egui::Color32,
|
|
||||||
property: egui::Color32,
|
|
||||||
constant: egui::Color32,
|
|
||||||
operator: egui::Color32,
|
|
||||||
punctuation: egui::Color32,
|
|
||||||
variable: egui::Color32,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl SyntaxTheme {
|
|
||||||
fn from_visuals(visuals: &egui::Visuals) -> Self {
|
|
||||||
let default = visuals.text_color();
|
|
||||||
|
|
||||||
if visuals.dark_mode {
|
|
||||||
Self {
|
|
||||||
default,
|
|
||||||
keyword: egui::Color32::from_rgb(0xC6, 0x78, 0xDD),
|
|
||||||
string: egui::Color32::from_rgb(0x98, 0xC3, 0x79),
|
|
||||||
escape: egui::Color32::from_rgb(0x56, 0xB6, 0xC2),
|
|
||||||
number: egui::Color32::from_rgb(0xD1, 0x9A, 0x66),
|
|
||||||
comment: egui::Color32::from_rgb(0x7F, 0x84, 0x8E),
|
|
||||||
function: egui::Color32::from_rgb(0x61, 0xAF, 0xEF),
|
|
||||||
type_: egui::Color32::from_rgb(0xE5, 0xC0, 0x7B),
|
|
||||||
property: egui::Color32::from_rgb(0xE0, 0x6C, 0x75),
|
|
||||||
constant: egui::Color32::from_rgb(0xD1, 0x9A, 0x66),
|
|
||||||
operator: egui::Color32::from_rgb(0x56, 0xB6, 0xC2),
|
|
||||||
punctuation: egui::Color32::from_rgb(0xAB, 0xB2, 0xBF),
|
|
||||||
variable: default,
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
Self {
|
|
||||||
default,
|
|
||||||
keyword: egui::Color32::from_rgb(0xA6, 0x26, 0xA4),
|
|
||||||
string: egui::Color32::from_rgb(0x50, 0xA1, 0x4F),
|
|
||||||
escape: egui::Color32::from_rgb(0x01, 0x84, 0xBC),
|
|
||||||
number: egui::Color32::from_rgb(0x98, 0x68, 0x01),
|
|
||||||
comment: egui::Color32::from_rgb(0xA0, 0xA1, 0xA7),
|
|
||||||
function: egui::Color32::from_rgb(0x40, 0x78, 0xF2),
|
|
||||||
type_: egui::Color32::from_rgb(0xC1, 0x84, 0x01),
|
|
||||||
property: egui::Color32::from_rgb(0xE4, 0x56, 0x49),
|
|
||||||
constant: egui::Color32::from_rgb(0x98, 0x68, 0x01),
|
|
||||||
operator: egui::Color32::from_rgb(0x01, 0x84, 0xBC),
|
|
||||||
punctuation: egui::Color32::from_rgb(0x38, 0x3A, 0x42),
|
|
||||||
variable: default,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn color(&self, style: HighlightStyle) -> egui::Color32 {
|
|
||||||
match style {
|
|
||||||
HighlightStyle::Keyword => self.keyword,
|
|
||||||
HighlightStyle::String => self.string,
|
|
||||||
HighlightStyle::Escape => self.escape,
|
|
||||||
HighlightStyle::Number => self.number,
|
|
||||||
HighlightStyle::Comment => self.comment,
|
|
||||||
HighlightStyle::Function => self.function,
|
|
||||||
HighlightStyle::Type => self.type_,
|
|
||||||
HighlightStyle::Property => self.property,
|
|
||||||
HighlightStyle::Constant => self.constant,
|
|
||||||
HighlightStyle::Operator => self.operator,
|
|
||||||
HighlightStyle::Punctuation => self.punctuation,
|
|
||||||
HighlightStyle::Variable => self.variable,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Рисует номера строк вдоль гуттера.
|
|
||||||
///
|
|
||||||
/// Строки берутся из галереи `TextEdit`, поэтому номера совпадают с текстом даже
|
|
||||||
/// при переносе длинных строк: у перенесённой строки номер не повторяется.
|
|
||||||
fn paint_line_numbers(
|
|
||||||
ui: &egui::Ui,
|
|
||||||
output: &egui::widgets::text_edit::TextEditOutput,
|
|
||||||
content: &str,
|
|
||||||
font_id: &egui::FontId,
|
|
||||||
gutter_left: f32,
|
|
||||||
gutter_width: f32,
|
|
||||||
) {
|
|
||||||
let visuals = ui.visuals();
|
|
||||||
let number_color = visuals.weak_text_color();
|
|
||||||
let current_line_color = visuals.strong_text_color();
|
|
||||||
|
|
||||||
// Индекс курсора — в символах, а не в байтах.
|
|
||||||
let current_line = output.cursor_range.map(|range| {
|
|
||||||
content
|
|
||||||
.chars()
|
|
||||||
.take(range.primary.index.into())
|
|
||||||
.filter(|&c| c == '\n')
|
|
||||||
.count()
|
|
||||||
});
|
|
||||||
let has_focus = output.response.response.has_focus();
|
|
||||||
|
|
||||||
let right = gutter_left + gutter_width - GUTTER_PADDING;
|
|
||||||
let painter = ui.painter();
|
|
||||||
|
|
||||||
let mut line_number = 1;
|
|
||||||
let mut starts_new_line = true;
|
|
||||||
for row in &output.galley.rows {
|
|
||||||
if starts_new_line {
|
|
||||||
let is_current = has_focus && current_line == Some(line_number - 1);
|
|
||||||
painter.text(
|
|
||||||
egui::pos2(right, output.galley_pos.y + row.rect().center().y),
|
|
||||||
egui::Align2::RIGHT_CENTER,
|
|
||||||
line_number,
|
|
||||||
font_id.clone(),
|
|
||||||
if is_current {
|
|
||||||
current_line_color
|
|
||||||
} else {
|
|
||||||
number_color
|
|
||||||
},
|
|
||||||
);
|
|
||||||
line_number += 1;
|
|
||||||
}
|
|
||||||
starts_new_line = row.ends_with_newline;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,93 +0,0 @@
|
|||||||
//! Векторные иконки кнопок заголовка (без шрифтов — рисуются через `Painter`).
|
|
||||||
|
|
||||||
use eframe::egui;
|
|
||||||
|
|
||||||
/// Тип иконки, отображаемой на кнопке заголовка.
|
|
||||||
pub enum IconType {
|
|
||||||
Minimize,
|
|
||||||
Maximize,
|
|
||||||
Restore,
|
|
||||||
Close,
|
|
||||||
Plus,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Кнопка с векторной иконкой внутри.
|
|
||||||
pub fn custom_icon_button(ui: &mut egui::Ui, size: egui::Vec2, icon: IconType) -> egui::Response {
|
|
||||||
let (rect, response) = ui.allocate_exact_size(size, egui::Sense::click());
|
|
||||||
|
|
||||||
if response.hovered() {
|
|
||||||
ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand);
|
|
||||||
}
|
|
||||||
|
|
||||||
let visuals = ui.style().interact(&response);
|
|
||||||
|
|
||||||
// Подсветка кнопки при наведении мыши
|
|
||||||
if response.hovered() || response.is_pointer_button_down_on() {
|
|
||||||
ui.painter().rect_filled(rect, 4.0, visuals.bg_fill);
|
|
||||||
}
|
|
||||||
|
|
||||||
let stroke = egui::Stroke::new(1.5, visuals.fg_stroke.color);
|
|
||||||
let center = rect.center();
|
|
||||||
let r = 5.0; // Размер иконки относительно центра
|
|
||||||
|
|
||||||
match icon {
|
|
||||||
IconType::Minimize => {
|
|
||||||
ui.painter().line_segment(
|
|
||||||
[center - egui::vec2(r, 0.0), center + egui::vec2(r, 0.0)],
|
|
||||||
stroke,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
IconType::Maximize => {
|
|
||||||
ui.painter().rect_stroke(
|
|
||||||
egui::Rect::from_center_size(center, egui::vec2(r * 2.0, r * 2.0)),
|
|
||||||
0.0,
|
|
||||||
stroke,
|
|
||||||
egui::StrokeKind::Middle,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
IconType::Restore => {
|
|
||||||
// Два перекрывающихся квадрата — символ «восстановить»
|
|
||||||
let back = egui::Rect::from_center_size(
|
|
||||||
center + egui::vec2(2.0, -2.0),
|
|
||||||
egui::vec2(r * 2.0, r * 2.0),
|
|
||||||
);
|
|
||||||
let front = egui::Rect::from_center_size(
|
|
||||||
center + egui::vec2(-2.0, 2.0),
|
|
||||||
egui::vec2(r * 2.0, r * 2.0),
|
|
||||||
);
|
|
||||||
// Заливаем «передний» квадрат фоном, чтобы задний не просвечивал сквозь него
|
|
||||||
let fill = if response.hovered() || response.is_pointer_button_down_on() {
|
|
||||||
visuals.bg_fill
|
|
||||||
} else {
|
|
||||||
ui.visuals().window_fill()
|
|
||||||
};
|
|
||||||
ui.painter().rect_filled(front, 0.0, fill);
|
|
||||||
ui.painter()
|
|
||||||
.rect_stroke(back, 0.0, stroke, egui::StrokeKind::Middle);
|
|
||||||
ui.painter()
|
|
||||||
.rect_stroke(front, 0.0, stroke, egui::StrokeKind::Middle);
|
|
||||||
}
|
|
||||||
IconType::Close => {
|
|
||||||
ui.painter().line_segment(
|
|
||||||
[center - egui::vec2(r, r), center + egui::vec2(r, r)],
|
|
||||||
stroke,
|
|
||||||
);
|
|
||||||
ui.painter().line_segment(
|
|
||||||
[center - egui::vec2(-r, r), center + egui::vec2(-r, r)],
|
|
||||||
stroke,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
IconType::Plus => {
|
|
||||||
ui.painter().line_segment(
|
|
||||||
[center - egui::vec2(4.0, 0.0), center + egui::vec2(4.0, 0.0)],
|
|
||||||
stroke,
|
|
||||||
);
|
|
||||||
ui.painter().line_segment(
|
|
||||||
[center - egui::vec2(0.0, 4.0), center + egui::vec2(0.0, 4.0)],
|
|
||||||
stroke,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
response
|
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
//! Виджеты окна: интегрированный заголовок, вкладки, редактор, иконки
|
|
||||||
//! и зоны изменения размера.
|
|
||||||
|
|
||||||
pub mod code_editor;
|
|
||||||
pub mod icons;
|
|
||||||
pub mod resize_handles;
|
|
||||||
pub mod tabs;
|
|
||||||
pub mod title_bar;
|
|
||||||
@@ -1,121 +0,0 @@
|
|||||||
//! Невидимые зоны по периметру окна для изменения размера.
|
|
||||||
|
|
||||||
use eframe::egui;
|
|
||||||
|
|
||||||
use crate::api::AppApi;
|
|
||||||
use crate::constants::RESIZE_BORDER;
|
|
||||||
|
|
||||||
/// Невидимые полосы и уголки по периметру окна: тянут за них — меняется размер окна.
|
|
||||||
///
|
|
||||||
/// Регистрируются последними (в том же слое), поэтому выигрывают hit-test у содержимого.
|
|
||||||
/// Само изменение размера выполняет оконная система через `ViewportCommand::BeginResize`.
|
|
||||||
pub fn render(ui: &mut egui::Ui) {
|
|
||||||
use egui::viewport::ResizeDirection as Dir;
|
|
||||||
|
|
||||||
let api = AppApi::global();
|
|
||||||
let ctx = api.context();
|
|
||||||
|
|
||||||
// Развёрнутое окно уже занимает весь экран — тянуть нечего.
|
|
||||||
if api.is_maximized() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let b = RESIZE_BORDER;
|
|
||||||
let rect = ctx.content_rect();
|
|
||||||
if rect.width() <= 2.0 * b || rect.height() <= 2.0 * b {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let north = egui::Rect::from_min_max(rect.left_top(), egui::pos2(rect.right(), rect.top() + b));
|
|
||||||
let south = egui::Rect::from_min_max(
|
|
||||||
egui::pos2(rect.left(), rect.bottom() - b),
|
|
||||||
rect.right_bottom(),
|
|
||||||
);
|
|
||||||
let west =
|
|
||||||
egui::Rect::from_min_max(rect.left_top(), egui::pos2(rect.left() + b, rect.bottom()));
|
|
||||||
let east = egui::Rect::from_min_max(
|
|
||||||
egui::pos2(rect.right() - b, rect.top()),
|
|
||||||
rect.right_bottom(),
|
|
||||||
);
|
|
||||||
|
|
||||||
let edges = [
|
|
||||||
(
|
|
||||||
Dir::North,
|
|
||||||
egui::CursorIcon::ResizeVertical,
|
|
||||||
north,
|
|
||||||
"resize_n",
|
|
||||||
),
|
|
||||||
(
|
|
||||||
Dir::South,
|
|
||||||
egui::CursorIcon::ResizeVertical,
|
|
||||||
south,
|
|
||||||
"resize_s",
|
|
||||||
),
|
|
||||||
(
|
|
||||||
Dir::West,
|
|
||||||
egui::CursorIcon::ResizeHorizontal,
|
|
||||||
west,
|
|
||||||
"resize_w",
|
|
||||||
),
|
|
||||||
(
|
|
||||||
Dir::East,
|
|
||||||
egui::CursorIcon::ResizeHorizontal,
|
|
||||||
east,
|
|
||||||
"resize_e",
|
|
||||||
),
|
|
||||||
];
|
|
||||||
|
|
||||||
// Углы идут после рёбер: в них должен побеждать диагональный ресайз.
|
|
||||||
let corners = [
|
|
||||||
(
|
|
||||||
Dir::NorthWest,
|
|
||||||
egui::CursorIcon::ResizeNwSe,
|
|
||||||
egui::Rect::from_min_size(rect.left_top(), egui::Vec2::splat(b)),
|
|
||||||
"resize_nw",
|
|
||||||
),
|
|
||||||
(
|
|
||||||
Dir::NorthEast,
|
|
||||||
egui::CursorIcon::ResizeNeSw,
|
|
||||||
egui::Rect::from_min_size(
|
|
||||||
egui::pos2(rect.right() - b, rect.top()),
|
|
||||||
egui::Vec2::splat(b),
|
|
||||||
),
|
|
||||||
"resize_ne",
|
|
||||||
),
|
|
||||||
(
|
|
||||||
Dir::SouthEast,
|
|
||||||
egui::CursorIcon::ResizeNwSe,
|
|
||||||
egui::Rect::from_min_size(
|
|
||||||
egui::pos2(rect.right() - b, rect.bottom() - b),
|
|
||||||
egui::Vec2::splat(b),
|
|
||||||
),
|
|
||||||
"resize_se",
|
|
||||||
),
|
|
||||||
(
|
|
||||||
Dir::SouthWest,
|
|
||||||
egui::CursorIcon::ResizeNeSw,
|
|
||||||
egui::Rect::from_min_size(
|
|
||||||
egui::pos2(rect.left(), rect.bottom() - b),
|
|
||||||
egui::Vec2::splat(b),
|
|
||||||
),
|
|
||||||
"resize_sw",
|
|
||||||
),
|
|
||||||
];
|
|
||||||
|
|
||||||
for (direction, cursor, handle_rect, id_salt) in edges.into_iter().chain(corners) {
|
|
||||||
// `Sense::drag()` (без click) — перетаскивание начинается сразу по нажатию,
|
|
||||||
// а именно этого требует `BeginResize`.
|
|
||||||
let response = ui.interact(
|
|
||||||
handle_rect,
|
|
||||||
ui.scope_id().with(id_salt),
|
|
||||||
egui::Sense::drag(),
|
|
||||||
);
|
|
||||||
|
|
||||||
if response.hovered() {
|
|
||||||
ctx.set_cursor_icon(cursor);
|
|
||||||
}
|
|
||||||
if response.drag_started() {
|
|
||||||
ctx.send_viewport_cmd(egui::ViewportCommand::BeginResize(direction));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-228
@@ -1,228 +0,0 @@
|
|||||||
//! Полоса вкладок над редактором.
|
|
||||||
|
|
||||||
use eframe::egui;
|
|
||||||
|
|
||||||
use crate::constants::TAB_BAR_HEIGHT;
|
|
||||||
use crate::editor::Document;
|
|
||||||
use crate::ui::icons::{IconType, custom_icon_button};
|
|
||||||
|
|
||||||
/// Действия пользователя с вкладками за текущий кадр.
|
|
||||||
#[derive(Default)]
|
|
||||||
pub struct TabBarActions {
|
|
||||||
/// Клик по вкладке: её нужно сделать активной.
|
|
||||||
pub clicked_tab: Option<usize>,
|
|
||||||
/// Клик по крестику: вкладку нужно закрыть.
|
|
||||||
pub closed_tab: Option<usize>,
|
|
||||||
/// Клик по «+»: нужно открыть новый документ.
|
|
||||||
pub add_clicked: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
const TAB_MIN_WIDTH: f32 = 96.0;
|
|
||||||
const TAB_MAX_WIDTH: f32 = 220.0;
|
|
||||||
const TAB_PADDING_LEFT: f32 = 12.0;
|
|
||||||
const TAB_PADDING_RIGHT: f32 = 6.0;
|
|
||||||
const TAB_LABEL_GAP: f32 = 6.0;
|
|
||||||
const TAB_CLOSE_SIZE: f32 = 18.0;
|
|
||||||
const TAB_CORNER_RADIUS: u8 = 6;
|
|
||||||
|
|
||||||
/// Рисует полосу вкладок и возвращает действия пользователя.
|
|
||||||
pub fn render(ui: &mut egui::Ui, documents: &[Document], active: usize) -> TabBarActions {
|
|
||||||
let mut actions = TabBarActions::default();
|
|
||||||
|
|
||||||
let bar_rect = egui::Rect::from_min_size(
|
|
||||||
ui.cursor().min,
|
|
||||||
egui::vec2(ui.available_width(), TAB_BAR_HEIGHT),
|
|
||||||
);
|
|
||||||
|
|
||||||
let visuals = ui.visuals().clone();
|
|
||||||
|
|
||||||
// Фон полосы совпадает с фоном заголовка, поэтому она выглядит его продолжением.
|
|
||||||
ui.painter()
|
|
||||||
.rect_filled(bar_rect, 0.0, visuals.window_fill());
|
|
||||||
|
|
||||||
// Тонкая линия-разделитель под вкладками. Активная вкладка перекрывает её
|
|
||||||
// и сливается с фоном редактора.
|
|
||||||
let separator = egui::Rect::from_min_max(
|
|
||||||
egui::pos2(bar_rect.left(), bar_rect.bottom() - 1.0),
|
|
||||||
bar_rect.right_bottom(),
|
|
||||||
);
|
|
||||||
ui.painter().rect_filled(
|
|
||||||
separator,
|
|
||||||
0.0,
|
|
||||||
visuals.widgets.noninteractive.bg_stroke.color,
|
|
||||||
);
|
|
||||||
|
|
||||||
ui.allocate_ui_with_layout(
|
|
||||||
egui::vec2(bar_rect.width(), TAB_BAR_HEIGHT),
|
|
||||||
egui::Layout::left_to_right(egui::Align::Center),
|
|
||||||
|ui| {
|
|
||||||
ui.spacing_mut().item_spacing.x = 2.0;
|
|
||||||
ui.add_space(6.0);
|
|
||||||
|
|
||||||
for (index, doc) in documents.iter().enumerate() {
|
|
||||||
let tab = tab(ui, doc, index == active);
|
|
||||||
if tab.close_clicked {
|
|
||||||
actions.closed_tab = Some(index);
|
|
||||||
} else if tab.clicked {
|
|
||||||
actions.clicked_tab = Some(index);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ui.add_space(4.0);
|
|
||||||
let add = custom_icon_button(ui, egui::vec2(20.0, 20.0), IconType::Plus);
|
|
||||||
if add.on_hover_text("Новая вкладка").clicked() {
|
|
||||||
actions.add_clicked = true;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
actions
|
|
||||||
}
|
|
||||||
|
|
||||||
struct TabResponse {
|
|
||||||
clicked: bool,
|
|
||||||
close_clicked: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Одна вкладка: фон, название и кнопка закрытия.
|
|
||||||
fn tab(ui: &mut egui::Ui, doc: &Document, is_active: bool) -> TabResponse {
|
|
||||||
let font_id = egui::FontId::proportional(12.5);
|
|
||||||
let label_width = ui.fonts_mut(|fonts| {
|
|
||||||
fonts
|
|
||||||
.layout_no_wrap(doc.name.clone(), font_id.clone(), egui::Color32::WHITE)
|
|
||||||
.size()
|
|
||||||
.x
|
|
||||||
});
|
|
||||||
let width =
|
|
||||||
(label_width + TAB_PADDING_LEFT + TAB_LABEL_GAP + TAB_CLOSE_SIZE + TAB_PADDING_RIGHT)
|
|
||||||
.clamp(TAB_MIN_WIDTH, TAB_MAX_WIDTH);
|
|
||||||
|
|
||||||
let (rect, response) =
|
|
||||||
ui.allocate_exact_size(egui::vec2(width, TAB_BAR_HEIGHT), egui::Sense::click());
|
|
||||||
if response.hovered() {
|
|
||||||
ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand);
|
|
||||||
}
|
|
||||||
|
|
||||||
let visuals = ui.visuals().clone();
|
|
||||||
let corner = egui::CornerRadius {
|
|
||||||
nw: TAB_CORNER_RADIUS,
|
|
||||||
ne: TAB_CORNER_RADIUS,
|
|
||||||
sw: 0,
|
|
||||||
se: 0,
|
|
||||||
};
|
|
||||||
|
|
||||||
let fill = if is_active {
|
|
||||||
visuals.extreme_bg_color
|
|
||||||
} else if response.hovered() {
|
|
||||||
// Полтона от цвета hover-виджетов: вкладка должна подсвечиваться мягко.
|
|
||||||
visuals
|
|
||||||
.window_fill()
|
|
||||||
.lerp_to_gamma(visuals.widgets.hovered.weak_bg_fill, 0.5)
|
|
||||||
} else {
|
|
||||||
egui::Color32::TRANSPARENT
|
|
||||||
};
|
|
||||||
ui.painter().rect_filled(rect, corner, fill);
|
|
||||||
|
|
||||||
// Акцентная полоска у активной вкладки.
|
|
||||||
if is_active {
|
|
||||||
let stripe = egui::Rect::from_min_size(rect.left_top(), egui::vec2(rect.width(), 2.0));
|
|
||||||
ui.painter()
|
|
||||||
.rect_filled(stripe, corner, visuals.selection.bg_fill);
|
|
||||||
}
|
|
||||||
|
|
||||||
let close_rect = egui::Rect::from_center_size(
|
|
||||||
egui::pos2(
|
|
||||||
rect.right() - TAB_PADDING_RIGHT - TAB_CLOSE_SIZE / 2.0,
|
|
||||||
rect.center().y,
|
|
||||||
),
|
|
||||||
egui::Vec2::splat(TAB_CLOSE_SIZE),
|
|
||||||
);
|
|
||||||
|
|
||||||
let text_color = if is_active {
|
|
||||||
visuals.strong_text_color()
|
|
||||||
} else if response.hovered() {
|
|
||||||
visuals.text_color()
|
|
||||||
} else {
|
|
||||||
visuals.weak_text_color()
|
|
||||||
};
|
|
||||||
|
|
||||||
// Название рисуем через `LayoutJob`, чтобы длинные имена обрезались многоточием.
|
|
||||||
let mut job = egui::text::LayoutJob::single_section(
|
|
||||||
doc.name.clone(),
|
|
||||||
egui::TextFormat::simple(font_id, text_color),
|
|
||||||
);
|
|
||||||
job.wrap = egui::text::TextWrapping {
|
|
||||||
max_width: (close_rect.left() - TAB_LABEL_GAP - (rect.left() + TAB_PADDING_LEFT)).max(0.0),
|
|
||||||
max_rows: 1,
|
|
||||||
break_anywhere: true,
|
|
||||||
overflow_character: Some('…'),
|
|
||||||
};
|
|
||||||
let galley = ui.fonts_mut(|fonts| fonts.layout_job(job));
|
|
||||||
ui.painter().galley(
|
|
||||||
egui::pos2(
|
|
||||||
rect.left() + TAB_PADDING_LEFT,
|
|
||||||
rect.center().y - galley.size().y / 2.0,
|
|
||||||
),
|
|
||||||
galley,
|
|
||||||
text_color,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Кнопка закрытия регистрируется после вкладки, поэтому выигрывает у неё hit-test
|
|
||||||
// в своей области (так же, как кнопки заголовка регистрируются после drag-зоны).
|
|
||||||
let close = close_button(
|
|
||||||
ui,
|
|
||||||
close_rect,
|
|
||||||
ui.scope_id().with(("tab_close", doc.id)),
|
|
||||||
is_active || response.hovered(),
|
|
||||||
doc.is_dirty(),
|
|
||||||
);
|
|
||||||
|
|
||||||
TabResponse {
|
|
||||||
clicked: response.clicked(),
|
|
||||||
close_clicked: close.clicked(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Маленький крестик вкладки; у изменённого документа вместо него — точка.
|
|
||||||
fn close_button(
|
|
||||||
ui: &mut egui::Ui,
|
|
||||||
rect: egui::Rect,
|
|
||||||
id: egui::Id,
|
|
||||||
show_cross: bool,
|
|
||||||
dirty: bool,
|
|
||||||
) -> egui::Response {
|
|
||||||
// Невидимый крестик не должен ловить клики: иначе клик по пустому месту
|
|
||||||
// закрывал бы вкладку.
|
|
||||||
let sense = if show_cross {
|
|
||||||
egui::Sense::click()
|
|
||||||
} else {
|
|
||||||
egui::Sense::hover()
|
|
||||||
};
|
|
||||||
let response = ui.interact(rect, id, sense);
|
|
||||||
|
|
||||||
let visuals = ui.visuals();
|
|
||||||
let painter = ui.painter();
|
|
||||||
|
|
||||||
if response.hovered() {
|
|
||||||
ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand);
|
|
||||||
painter.rect_filled(rect, 4.0, visuals.widgets.hovered.bg_fill);
|
|
||||||
}
|
|
||||||
|
|
||||||
let center = rect.center();
|
|
||||||
if dirty && !response.hovered() {
|
|
||||||
painter.circle_filled(center, 3.0, visuals.selection.bg_fill);
|
|
||||||
} else if show_cross {
|
|
||||||
let stroke = egui::Stroke::new(1.2, visuals.weak_text_color());
|
|
||||||
let r = 3.5;
|
|
||||||
painter.line_segment(
|
|
||||||
[center + egui::vec2(-r, r), center + egui::vec2(r, -r)],
|
|
||||||
stroke,
|
|
||||||
);
|
|
||||||
painter.line_segment(
|
|
||||||
[center + egui::vec2(-r, -r), center + egui::vec2(r, r)],
|
|
||||||
stroke,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
response
|
|
||||||
}
|
|
||||||
@@ -1,121 +0,0 @@
|
|||||||
//! Интегрированный в окно заголовок: перетаскивание и кнопки управления.
|
|
||||||
|
|
||||||
use eframe::egui;
|
|
||||||
|
|
||||||
use crate::api::AppApi;
|
|
||||||
use crate::constants::TITLE_BAR_HEIGHT;
|
|
||||||
use crate::ui::icons::{IconType, custom_icon_button};
|
|
||||||
|
|
||||||
/// Ответы виджетов заголовка: нужны, чтобы отличать клик по кнопке от драга окна.
|
|
||||||
pub struct TitleBarResponses {
|
|
||||||
pub close: egui::Response,
|
|
||||||
pub maximize: egui::Response,
|
|
||||||
pub minimize: egui::Response,
|
|
||||||
pub drag: egui::Response,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl TitleBarResponses {
|
|
||||||
fn any_button_hovered(&self) -> bool {
|
|
||||||
self.close.hovered() || self.maximize.hovered() || self.minimize.hovered()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Рисует заголовок окна с именем активного документа и применяет действия
|
|
||||||
/// кнопок через [`AppApi`].
|
|
||||||
pub fn render(ui: &mut egui::Ui, document_name: &str) -> TitleBarResponses {
|
|
||||||
let api = AppApi::global();
|
|
||||||
let ctx = api.context();
|
|
||||||
let is_maximized = api.is_maximized();
|
|
||||||
|
|
||||||
let bar_rect = egui::Rect::from_min_size(
|
|
||||||
ui.cursor().min,
|
|
||||||
egui::vec2(ui.available_width(), TITLE_BAR_HEIGHT),
|
|
||||||
);
|
|
||||||
|
|
||||||
// ВАЖНО: зона перетаскивания регистрируется ПЕРВОЙ.
|
|
||||||
// В egui hit-test побеждает виджет, добавленный последним, а `Response::hovered()`
|
|
||||||
// получают только виджеты из `hits.click` / `hits.drag`. Если зарегистрировать
|
|
||||||
// drag-зону после кнопок, она станет и клик-, и drag-целью — кнопки перестанут
|
|
||||||
// получать и клики, и подсветку при наведении.
|
|
||||||
let drag = ui.interact(
|
|
||||||
bar_rect,
|
|
||||||
ui.scope_id().with("title_bar_drag"),
|
|
||||||
egui::Sense::click_and_drag(),
|
|
||||||
);
|
|
||||||
|
|
||||||
let mut close = None;
|
|
||||||
let mut maximize = None;
|
|
||||||
let mut minimize = None;
|
|
||||||
|
|
||||||
ui.allocate_ui_with_layout(
|
|
||||||
egui::vec2(ui.available_width(), TITLE_BAR_HEIGHT),
|
|
||||||
egui::Layout::left_to_right(egui::Align::Center),
|
|
||||||
|ui| {
|
|
||||||
ui.spacing_mut().item_spacing = egui::vec2(4.0, 0.0);
|
|
||||||
|
|
||||||
// Текст заголовка (слева).
|
|
||||||
// `selectable(false)` обязателен: иначе лейбл получает Sense::click_and_drag,
|
|
||||||
// регистрируется поверх drag-зоны и перехватывает перетаскивание окна,
|
|
||||||
// а текст начинает выделяться мышкой.
|
|
||||||
ui.add_space(12.0);
|
|
||||||
ui.add(
|
|
||||||
egui::Label::new(
|
|
||||||
egui::RichText::new(format!("{document_name} — Custom Window App"))
|
|
||||||
.size(13.0)
|
|
||||||
.strong(),
|
|
||||||
)
|
|
||||||
.selectable(false),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Блок кнопок управления окном (смещаем в правый край через right_to_left)
|
|
||||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
|
||||||
ui.add_space(8.0); // Отступ справа от крайней кнопки
|
|
||||||
|
|
||||||
let button_size = egui::vec2(24.0, 24.0);
|
|
||||||
|
|
||||||
// Порядок важен: в right_to_left первая добавленная кнопка — крайняя правая.
|
|
||||||
close = Some(custom_icon_button(ui, button_size, IconType::Close));
|
|
||||||
let max_icon = if is_maximized {
|
|
||||||
IconType::Restore
|
|
||||||
} else {
|
|
||||||
IconType::Maximize
|
|
||||||
};
|
|
||||||
maximize = Some(custom_icon_button(ui, button_size, max_icon));
|
|
||||||
minimize = Some(custom_icon_button(ui, button_size, IconType::Minimize));
|
|
||||||
});
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
let responses = TitleBarResponses {
|
|
||||||
close: close.expect("кнопка закрытия создаётся всегда"),
|
|
||||||
maximize: maximize.expect("кнопка разворота создаётся всегда"),
|
|
||||||
minimize: minimize.expect("кнопка сворачивания создаётся всегда"),
|
|
||||||
drag,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Действия кнопок — только через глобальный API.
|
|
||||||
if responses.close.clicked() {
|
|
||||||
api.close();
|
|
||||||
}
|
|
||||||
if responses.maximize.clicked() {
|
|
||||||
api.toggle_maximize();
|
|
||||||
}
|
|
||||||
if responses.minimize.clicked() {
|
|
||||||
api.minimize();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Перетаскивать окно можно за любую свободную часть заголовка (включая текст).
|
|
||||||
if !is_maximized
|
|
||||||
&& !responses.any_button_hovered()
|
|
||||||
&& responses.drag.drag_started_by(egui::PointerButton::Primary)
|
|
||||||
{
|
|
||||||
ctx.send_viewport_cmd(egui::ViewportCommand::StartDrag);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Двойной клик по заголовку — стандартное разворачивание/восстановление
|
|
||||||
if responses.drag.double_clicked() && !responses.any_button_hovered() {
|
|
||||||
api.toggle_maximize();
|
|
||||||
}
|
|
||||||
|
|
||||||
responses
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user