This commit is contained in:
2026-10-29 22:40:40 +03:00
commit dce595fbc0
18 changed files with 7983 additions and 0 deletions
Generated
+4693
View File
File diff suppressed because it is too large Load Diff
+20
View File
@@ -0,0 +1,20 @@
[package]
name = "egui-rust-test"
version = "0.1.0"
edition = "2024"
[dependencies]
egui = "0.37"
eframe = { version = "0.37", features = ["wayland", "x11", "default_fonts"] }
mlua = { version = "0.12", features = ["lua54", "vendored", "macros"] }
# tree-sitter и грамматики для подсветки синтаксиса.
tree-sitter = "0.25"
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]
opt-level = 1
+55
View File
@@ -0,0 +1,55 @@
-- Стартовый сценарий приложения.
--
-- Приложение выполняет этот файл при запуске. Каталог поиска задаётся
-- константой SCRIPT_DIR в src/constants.rs (по умолчанию — текущий каталог).
print("[lua] init.lua выполнен")
-- Доступные методы объекта app (номера вкладок — с нуля):
-- app:tabs() -- {{ index = 0, title = "main.rs" }, ...}
-- app:active_tab() -- индекс активной вкладки
-- app:set_active_tab(index) -- true, если вкладка существует
-- app:next_tab() -- переключить на следующую, вернуть индекс
-- app:previous_tab() -- переключить на предыдущую, вернуть индекс
-- app:open_tab() -- открыть пустую вкладку, вернуть индекс
-- app:close_tab(index) -- true, если вкладка закрыта
-- app:active_text() -- текст активной вкладки
-- app:set_active_text(text) -- заменить текст активной вкладки
-- app:set_language(i, "rust") -- включить подсветку (rust|go|js|json|python)
-- -- для вкладки i; false — нет вкладки/языка
-- app:language(i) -- язык подсветки вкладки или nil
-- app:clear_language(i) -- выключить подсветку для вкладки
-- app:save() -- сохранить активную вкладку в её файл
-- app:map_key(keys, fn) -- обработчик клавиши/сочетания
-- -- "a" нажатие A
-- -- "1" нажатие 1
-- -- "!a" отпускание A
-- -- "<S-c>" C при зажатом Shift
-- -- "<C-z>" Z при зажатом Ctrl
-- -- "<A-!r>" отпускание R при зажатом Alt
-- -- клавиша берётся по физическому коду:
-- -- Caps Lock и раскладка не важны
-- app:close() -- закрыть окно
-- app:toggle_maximize()
-- app:set_maximized(true/false)
-- app:minimize()
-- app:is_maximized()
--
-- Методы добавляются в src/api.rs: всё, что есть в impl AppApi, помеченном
-- #[mlua::userdata_impl], автоматически доступно из Lua.
--
-- Другие Lua-файлы можно подключать из каталога сценариев:
-- include("tools.lua") -- выполнить файл сразу
-- require("tools") -- выполнить один раз (как обычный Lua-модуль)
--
-- Подсветка синтаксиса по умолчанию выключена. Включить её можно так:
-- app:set_language(0, "rust")
app:set_language(0, "rust")
app:next_tab()
-- Пример привязки: Ctrl+S сохраняет вкладку, отпускание Esc закрывает окно.
-- app:map_key("<C-s>", function() app:save() end)
-- app:map_key("<!Esc>", function() app:close() end)
app:map_key("<C-q>", function() app:close() end)
+310
View File
@@ -0,0 +1,310 @@
//! Глобальный API приложения.
//!
//! Единственная точка входа для команд управления окном и редактором: UI-код
//! не работает с `ViewportCommand` и состоянием редактора напрямую, а вызывает
//! методы [`AppApi`].
use std::cell::RefCell;
use std::rc::Rc;
use eframe::egui;
use crate::editor::EditorState;
use crate::keymap::{KeyBinding, KeyMap};
use crate::syntax::Language;
thread_local! {
/// Текущий экземпляр API. `thread_local`, потому что `egui::Context` привязан
/// к потоку с event loop; заодно это делает тесты независимыми друг от друга.
static INSTANCE: RefCell<Option<AppApi>> = const { RefCell::new(None) };
}
/// Краткая информация об открытой вкладке.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct TabInfo {
/// Номер вкладки (с нуля), слева направо.
pub index: usize,
/// Название — то же, что показано в заголовке вкладки.
pub title: String,
}
/// Глобальный объект с функциями управления приложением.
///
/// Использование: `AppApi::global().close()`, `AppApi::global().tabs()`.
/// Экземпляр устанавливается в начале каждого кадра (см. [`AppApi::install`]),
/// поэтому всегда указывает на актуальные `egui::Context` и состояние редактора.
///
/// Методы этого `impl`-блока автоматически экспортируются в Lua через
/// `#[mlua::userdata_impl]`: в сценариях они доступны как `app:method(...)`.
/// Чтобы не экспортировать метод, пометьте его `#[lua(skip)]`.
#[derive(Clone, mlua::UserData)]
pub struct AppApi {
#[lua(skip)]
ctx: egui::Context,
#[lua(skip)]
editor: Rc<RefCell<EditorState>>,
#[lua(skip)]
keymap: Rc<RefCell<KeyMap>>,
}
#[mlua::userdata_impl]
impl AppApi {
/// Делает переданные контекст, состояние редактора и карту клавиш текущими
/// для [`AppApi::global`].
#[lua(skip)]
pub fn install(
ctx: egui::Context,
editor: Rc<RefCell<EditorState>>,
keymap: Rc<RefCell<KeyMap>>,
) -> Self {
let api = Self {
ctx,
editor,
keymap,
};
INSTANCE.with(|slot| *slot.borrow_mut() = Some(api.clone()));
api
}
/// Глобальный экземпляр API.
///
/// # Panics
/// Если [`AppApi::install`] ещё не вызывался (ни одного кадра не отрисовано).
#[lua(skip)]
pub fn global() -> Self {
INSTANCE.with(|slot| {
slot.borrow()
.clone()
.expect("AppApi не инициализирован: сначала вызовите AppApi::install")
})
}
/// Контекст egui, к которому привязан API.
#[lua(skip)]
pub fn context(&self) -> &egui::Context {
&self.ctx
}
/// Все открытые вкладки в порядке слева направо.
#[allow(dead_code)]
// Часть публичного API приложения: вызывается внешним кодом и тестами.
#[lua(infallible)]
pub fn tabs(&self) -> Vec<TabInfo> {
self.editor
.borrow()
.documents()
.iter()
.enumerate()
.map(|(index, doc)| TabInfo {
index,
title: doc.name.clone(),
})
.collect()
}
/// Индекс активной вкладки в списке [`AppApi::tabs`].
#[allow(dead_code)]
// Часть публичного API приложения: вызывается внешним кодом и тестами.
#[lua(infallible, name = "active_tab")]
pub fn active_tab_index(&self) -> usize {
self.editor.borrow().active_index()
}
/// Переключает на следующую вкладку (по кругу) и возвращает её индекс.
#[allow(dead_code)]
// Часть публичного API приложения: вызывается внешним кодом и тестами.
#[lua(infallible)]
pub fn next_tab(&self) -> usize {
let mut editor = self.editor.borrow_mut();
let next = (editor.active_index() + 1) % editor.documents().len();
editor.set_active(next);
next
}
/// Переключает на предыдущую вкладку (по кругу) и возвращает её индекс.
#[allow(dead_code)]
// Часть публичного API приложения: вызывается внешним кодом и тестами.
#[lua(infallible)]
pub fn previous_tab(&self) -> usize {
let mut editor = self.editor.borrow_mut();
let count = editor.documents().len();
let previous = (editor.active_index() + count - 1) % count;
editor.set_active(previous);
previous
}
/// Закрывает вкладку по её номеру в списке [`AppApi::tabs`].
///
/// Возвращает `false`, если вкладки с таким номером нет. Если закрывается
/// последняя вкладка, вместо неё открывается пустой документ.
#[lua(infallible)]
pub fn close_tab(&self, index: usize) -> bool {
let mut editor = self.editor.borrow_mut();
if index >= editor.documents().len() {
return false;
}
editor.close(index);
true
}
/// Сохраняет содержимое активной вкладки в файл, названный как сама вкладка.
///
/// Путь считается относительно текущего рабочего каталога процесса.
/// При успешной записи с вкладки снимается маркер несохранённых изменений.
#[lua(skip)]
pub fn save(&self) -> std::io::Result<()> {
let mut editor = self.editor.borrow_mut();
let doc = editor.active_mut();
std::fs::write(&doc.name, &doc.content)?;
doc.mark_saved();
Ok(())
}
/// Lua-версия [`AppApi::save`]: ошибка ввода-вывода становится ошибкой Lua.
#[lua(name = "save")]
fn lua_save(&self) -> mlua::Result<()> {
self.save()
.map_err(|err| mlua::Error::RuntimeError(err.to_string()))
}
/// Назначает обработчик на нажатие или отпускание клавиши.
///
/// `keys` записывается в духе nvim:
///
/// * `a`, `1` — нажатие клавиши без модификаторов;
/// * `<S-c>`, `<C-z>`, `<A-r>` — нажатие с Shift, Ctrl или Alt;
/// * `!a` — отпускание клавиши;
/// * `<A-!r>` — отпускание клавиши при зажатом Alt.
///
/// Клавиша определяется по физическому коду, поэтому Caps Lock и раскладка
/// не важны. Повторное назначение той же клавиши заменяет обработчик.
///
/// # Errors
/// Возвращает ошибку Lua, если запись привязки некорректна.
pub fn map_key(&self, keys: String, callback: mlua::Function) -> mlua::Result<()> {
let binding = KeyBinding::parse(&keys).map_err(mlua::Error::RuntimeError)?;
self.keymap.borrow_mut().bind(binding, callback);
Ok(())
}
/// Содержимое активной вкладки.
#[lua(infallible)]
pub fn active_text(&self) -> String {
self.editor.borrow().active().content.clone()
}
/// Заменяет содержимое активной вкладки.
#[lua(skip)]
pub fn set_active_text(&self, text: impl Into<String>) {
self.editor.borrow_mut().active_mut().content = text.into();
}
/// Lua-версия [`AppApi::set_active_text`] (generic-методы не экспортируются).
#[lua(infallible, name = "set_active_text")]
fn lua_set_active_text(&self, text: String) {
self.set_active_text(text);
}
/// Открывает новую пустую вкладку, делает её активной и возвращает её индекс.
#[lua(infallible)]
pub fn open_tab(&self) -> usize {
let mut editor = self.editor.borrow_mut();
editor.open_new();
editor.active_index()
}
/// Делает вкладку активной по её номеру в списке [`AppApi::tabs`].
///
/// Возвращает `false`, если вкладки с таким номером нет.
#[lua(infallible)]
pub fn set_active_tab(&self, index: usize) -> bool {
let mut editor = self.editor.borrow_mut();
if index >= editor.documents().len() {
return false;
}
editor.set_active(index);
true
}
/// Включает подсветку синтаксиса для вкладки `index`.
///
/// `language` — название языка: `rust`, `go`, `js`, `json` или `python`
/// (принимаются также `rs`, `golang`, `javascript`, `py`; регистр не важен).
/// Подсветка по умолчанию выключена и включается только этим методом.
///
/// Возвращает `false`, если вкладки нет или язык не поддерживается.
#[lua(skip)]
pub fn set_language(&self, index: usize, language: &str) -> bool {
let Some(language) = Language::from_name(language) else {
return false;
};
let mut editor = self.editor.borrow_mut();
let Some(doc) = editor.document_mut(index) else {
return false;
};
doc.language = Some(language);
true
}
/// Lua-версия [`AppApi::set_language`].
#[lua(infallible, name = "set_language")]
fn lua_set_language(&self, index: usize, language: String) -> bool {
self.set_language(index, &language)
}
/// Язык подсветки вкладки: `"rust"`, `"go"`, `"js"`, `"json"`, `"python"`
/// или `nil`, если подсветка выключена / вкладки нет.
#[lua(infallible)]
pub fn language(&self, index: usize) -> Option<String> {
self.editor
.borrow()
.document(index)
.and_then(|doc| doc.language)
.map(|language| language.name().to_owned())
}
/// Выключает подсветку синтаксиса для вкладки.
///
/// Возвращает `false`, если вкладки с таким номером нет.
#[lua(infallible)]
pub fn clear_language(&self, index: usize) -> bool {
let mut editor = self.editor.borrow_mut();
let Some(doc) = editor.document_mut(index) else {
return false;
};
doc.language = None;
true
}
/// Закрывает окно приложения.
#[lua(infallible)]
pub fn close(&self) {
self.ctx.send_viewport_cmd(egui::ViewportCommand::Close);
}
/// Разворачивает окно на весь экран или возвращает его прежний размер.
#[lua(infallible)]
pub fn toggle_maximize(&self) {
self.set_maximized(!self.is_maximized());
}
/// Разворачивает (`true`) или восстанавливает (`false`) окно.
#[lua(infallible)]
pub fn set_maximized(&self, maximized: bool) {
self.ctx
.send_viewport_cmd(egui::ViewportCommand::Maximized(maximized));
}
/// Сворачивает окно.
#[lua(infallible)]
pub fn minimize(&self) {
self.ctx
.send_viewport_cmd(egui::ViewportCommand::Minimized(true));
}
/// Развёрнуто ли окно прямо сейчас.
#[lua(infallible)]
pub fn is_maximized(&self) -> bool {
self.ctx.input(|i| i.viewport().maximized.unwrap_or(false))
}
}
+175
View File
@@ -0,0 +1,175 @@
//! Приложение: окно без системных рамок и его содержимое.
use std::cell::RefCell;
use std::rc::Rc;
use eframe::egui;
use crate::api::AppApi;
use crate::constants::CORNER_RADIUS;
use crate::editor::EditorState;
use crate::keymap::KeyMap;
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 {
/// Открытые документы, вкладки над редактором и активная вкладка.
///
/// Состояние общее с [`AppApi`] (`Rc<RefCell<_>>`), чтобы им можно было
/// управлять через `AppApi::global()`.
editor: Rc<RefCell<EditorState>>,
/// Привязки клавиш и их Lua-обработчики.
///
/// Лежит перед `scripts`, чтобы обработчики (ссылки на Lua-значения)
/// уничтожились раньше самого Lua-движка.
keymap: Rc<RefCell<KeyMap>>,
/// Движок Lua, созданный при старте.
///
/// Хранится, чтобы глобальное окружение и загруженные модули жили, пока
/// работает приложение.
#[allow(dead_code)]
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 {
/// Создаёт приложение и сразу выполняет стартовый Lua-сценарий.
///
/// Ошибки чтения/выполнения сценария печатаются в stderr и не мешают
/// запуску приложения: без `init.lua` редактор просто работает как обычно.
pub fn new(cc: &eframe::CreationContext<'_>) -> Self {
let mut app = Self::default();
// API доступен сценарию уже до первого кадра.
let api = AppApi::install(cc.egui_ctx.clone(), app.editor.clone(), app.keymap.clone());
match ScriptEngine::new(api) {
Ok(engine) => {
if let Err(err) = engine.run_startup() {
eprintln!("Lua: {err}");
}
app.scripts = Some(engine);
}
Err(err) => eprintln!("Lua: не удалось создать движок: {err}"),
}
app
}
/// Состояние редактора (доступ для тестов).
#[cfg(test)]
pub fn editor(&self) -> std::cell::Ref<'_, EditorState> {
self.editor.borrow()
}
/// Всё содержимое окна. Вынесено из `eframe::App::ui`, чтобы можно было
/// вернуть наружу `Response`-ы заголовка (пригодится для проверок/тестов).
pub fn show(&mut self, ui: &mut egui::Ui) -> TitleBarResponses {
let ctx = ui.ctx().clone();
// Публикуем глобальный API на текущий кадр: дальше любой модуль может
// управлять приложением и редактором через `AppApi::global()`.
AppApi::install(ctx.clone(), self.editor.clone(), self.keymap.clone());
// Обработчики клавиш вызываем до отрисовки, чтобы изменения состояния
// успели попасть в текущий кадр.
self.dispatch_keys(&ctx);
// Базовый фрейм для всего окна приложения со скруглением
let style = ui.style().clone();
let panel_frame = egui::Frame::new()
.fill(style.visuals.window_fill()) // Фон из текущей темы
.corner_radius(egui::CornerRadius::same(CORNER_RADIUS)) // Скругление углов самого окна
.stroke(egui::Stroke::new(
1.0,
style.visuals.widgets.noninteractive.bg_stroke.color,
))
.inner_margin(0.0) // Чтобы заголовок плотно прижимался к краям
.outer_margin(0.0);
let mut bar = None;
egui::CentralPanel::default()
.frame(panel_frame)
.show(ui, |ui| {
// 1. Отрисовка интегрированного заголовка (Title Bar)
// с именем активного документа, как в обычном редакторе.
let title = self.editor.borrow().active().name.clone();
bar = Some(title_bar::render(ui, &title));
// 2. Вкладки и редактор занимают всю оставшуюся площадь
self.show_workspace(ui);
});
// Контент прилегает к краям панели, поэтому рамку окна рисуем поверх него.
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) {
// Заём карты заканчивается до вызова колбэков: обработчик может сам
// вызвать `app:map_key` и изменить привязки.
let callbacks = self.keymap.borrow().callbacks_for(ctx);
for callback in callbacks {
if let Err(err) = callback.call::<()>(()) {
eprintln!("Lua: ошибка в обработчике клавиши: {err}");
}
}
}
/// Полоса вкладок и редактор активного документа.
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);
}
}
+22
View File
@@ -0,0 +1,22 @@
//! Константы оформления окна.
/// Насколько сильно скругляются углы окна.
pub const CORNER_RADIUS: u8 = 12;
/// Высота интегрированного заголовка.
pub const TITLE_BAR_HEIGHT: f32 = 32.0;
/// Толщина невидимой зоны по краям окна, за которую можно менять размер.
pub const RESIZE_BORDER: f32 = 6.0;
/// Высота полосы вкладок над редактором.
pub const TAB_BAR_HEIGHT: f32 = 34.0;
/// Каталог, в котором приложение ищет Lua-сценарии.
///
/// По умолчанию это текущий рабочий каталог процесса — именно там приложение
/// ожидает `init.lua`.
pub const SCRIPT_DIR: &str = ".";
/// Имя стартового сценария, который выполняется при запуске приложения.
pub const STARTUP_SCRIPT: &str = "init.lua";
+159
View File
@@ -0,0 +1,159 @@
//! Документы редактора и состояние вкладок.
use std::sync::atomic::{AtomicU64, Ordering};
use crate::syntax::{Language, SyntaxState};
/// Счётчик идентификаторов документов.
static NEXT_ID: AtomicU64 = AtomicU64::new(1);
/// Открытый в редакторе документ.
pub struct Document {
/// Стабильный идентификатор, пока документ открыт.
///
/// По нему `TextEdit` и `ScrollArea` хранят курсор, выделение и прокрутку,
/// поэтому переключение вкладок не сбрасывает состояние редактора.
pub id: u64,
pub name: String,
pub content: String,
/// Язык подсветки синтаксиса.
///
/// `None` — подсветка выключена (поведение по умолчанию). Включается
/// только явно через `AppApi::set_language`, например из Lua-сценария.
pub language: Option<Language>,
/// Разбор tree-sitter и кэш подсветки для этого документа.
pub syntax: SyntaxState,
/// Содержимое на момент последнего «сохранения».
saved_content: String,
}
impl Document {
pub fn new(name: impl Into<String>, content: impl Into<String>) -> Self {
let content = content.into();
Self {
id: NEXT_ID.fetch_add(1, Ordering::Relaxed),
name: name.into(),
saved_content: content.clone(),
content,
language: None,
syntax: SyntaxState::default(),
}
}
/// Есть ли изменения, которые ещё не сохранены.
pub fn is_dirty(&self) -> bool {
self.content != self.saved_content
}
/// Помечает текущее содержимое сохранённым.
pub fn mark_saved(&mut self) {
self.saved_content = self.content.clone();
}
}
/// Открытые документы и активная вкладка.
///
/// Инвариант: `documents` никогда не пуст — редактор всегда показывает хотя бы
/// один документ. Это позволяет `active`/`active_mut` не возвращать `Option`.
pub struct EditorState {
documents: Vec<Document>,
active: usize,
next_untitled: u32,
}
impl Default for EditorState {
fn default() -> Self {
Self {
documents: vec![
Document::new("main.rs", SAMPLE_RUST),
Document::new("notes.md", SAMPLE_NOTES),
],
active: 0,
next_untitled: 0,
}
}
}
impl EditorState {
pub fn documents(&self) -> &[Document] {
&self.documents
}
pub fn active_index(&self) -> usize {
self.active
}
pub fn active(&self) -> &Document {
&self.documents[self.active]
}
pub fn active_mut(&mut self) -> &mut Document {
&mut self.documents[self.active]
}
/// Документ по номеру вкладки (для API).
pub fn document(&self, index: usize) -> Option<&Document> {
self.documents.get(index)
}
/// Изменяемый документ по номеру вкладки (для API).
pub fn document_mut(&mut self, index: usize) -> Option<&mut Document> {
self.documents.get_mut(index)
}
/// Делает вкладку активной, если индекс существует.
pub fn set_active(&mut self, index: usize) {
if index < self.documents.len() {
self.active = index;
}
}
/// Открывает пустую вкладку и делает её активной.
pub fn open_new(&mut self) {
self.next_untitled += 1;
self.documents.push(Document::new(
format!("untitled-{}.txt", self.next_untitled),
"",
));
self.active = self.documents.len() - 1;
}
/// Закрывает вкладку и выбирает соседнюю.
///
/// Если закрывается последняя вкладка, вместо неё открывается пустой документ.
pub fn close(&mut self, index: usize) {
if index >= self.documents.len() {
return;
}
self.documents.remove(index);
if self.documents.is_empty() {
self.open_new();
} else if index < self.active {
self.active -= 1;
} else if index == self.active {
self.active = self.active.min(self.documents.len() - 1);
}
}
}
const SAMPLE_RUST: &str = r#"//! Демонстрационный файл.
// Правьте код прямо в редакторе: вкладки и нумерация строк уже здесь.
fn main() {
let mut numbers = vec![3, 1, 2];
numbers.sort();
for (i, n) in numbers.iter().enumerate() {
println!("{i}: {n}");
}
}
"#;
const SAMPLE_NOTES: &str = r#"# Заметки
- «+» на панели вкладок открывает новый файл
- крестик на вкладке закрывает её
- Ctrl+S сохраняет вкладку в файл с её названием
"#;
+352
View File
@@ -0,0 +1,352 @@
//! Привязки клавиш к Lua-обработчикам.
//!
//! Запись привязки немного напоминает nvim:
//!
//! * `a`, `1` — нажатие клавиши без модификаторов;
//! * `<S-c>`, `<C-z>`, `<A-r>` — нажатие с Shift, Ctrl или Alt;
//! * `!a` — отпускание клавиши;
//! * `<A-!r>` — отпускание клавиши при зажатом Alt.
//!
//! Клавиша всегда определяется по физическому коду (`physical_key`), поэтому
//! Caps Lock и текущая раскладка клавиатуры на совпадение не влияют.
use eframe::egui;
/// Модификаторы, которые учитываются при совпадении привязки.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct Modifiers {
pub shift: bool,
pub ctrl: bool,
pub alt: bool,
}
impl Modifiers {
/// Все три модификатора должны совпасть точно: `<C-z>` не сработает,
/// если вместе с Ctrl зажат ещё и Shift.
fn matches(self, pressed: &egui::Modifiers) -> bool {
self.shift == pressed.shift && self.ctrl == pressed.ctrl && self.alt == pressed.alt
}
}
/// Разобранная привязка клавиши.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct KeyBinding {
/// Физическая клавиша (не зависит от раскладки и Caps Lock).
pub key: egui::Key,
pub modifiers: Modifiers,
/// `true` — обработчик вызывается при отпускании клавиши.
pub on_release: bool,
}
impl KeyBinding {
/// Разбирает строку вида `a`, `!1`, `<C-z>` или `<A-!r>`.
///
/// # Errors
/// Возвращает текст ошибки, если строка пуста, сочетание не закрыто,
/// модификатор или клавиша неизвестны.
pub fn parse(spec: &str) -> Result<Self, String> {
let spec = spec.trim();
if spec.is_empty() {
return Err("пустая привязка клавиши".to_owned());
}
if spec.starts_with('<') || spec.ends_with('>') {
if !(spec.starts_with('<') && spec.ends_with('>')) {
return Err(format!("незакрытое сочетание клавиш: {spec}"));
}
return Self::parse_chord(&spec[1..spec.len() - 1]);
}
let (on_release, key) = split_release(spec);
Ok(Self {
key: parse_key(key)?,
modifiers: Modifiers::default(),
on_release,
})
}
/// Разбирает содержимое угловых скобок: модификаторы через `-`, последний
/// элемент — клавиша. Например, `S-c` или `A-!r`.
fn parse_chord(inner: &str) -> Result<Self, String> {
let tokens: Vec<&str> = inner.split('-').collect();
if tokens.iter().any(|token| token.trim().is_empty()) {
return Err(format!("пустое сочетание клавиш: <{inner}>"));
}
let (key_token, modifier_tokens) = tokens
.split_last()
.expect("split('-') всегда возвращает хотя бы один элемент");
let mut modifiers = Modifiers::default();
for token in modifier_tokens {
match token.trim() {
"S" | "s" => modifiers.shift = true,
"C" | "c" => modifiers.ctrl = true,
"A" | "a" => modifiers.alt = true,
other => return Err(format!("неизвестный модификатор: {other}")),
}
}
let (on_release, key) = split_release(key_token.trim());
Ok(Self {
key: parse_key(key)?,
modifiers,
on_release,
})
}
/// Совпадает ли привязка с конкретным событием клавиатуры.
fn matches(&self, key: egui::Key, pressed: bool, modifiers: &egui::Modifiers) -> bool {
self.key == key && self.on_release == !pressed && self.modifiers.matches(modifiers)
}
}
/// Отделяет `!` (событие отпускания) от имени клавиши.
fn split_release(token: &str) -> (bool, &str) {
match token.strip_prefix('!') {
Some(rest) => (true, rest.trim()),
None => (false, token),
}
}
/// Превращает имя клавиши из привязки в [`egui::Key`].
fn parse_key(token: &str) -> Result<egui::Key, String> {
let token = token.trim();
if token.is_empty() {
return Err("не указана клавиша".to_owned());
}
// Односимвольные клавиши (`a`, `1`, `-`) и точные имена (`F1`, `Space`).
if let Some(key) = egui::Key::from_name(token) {
return Ok(key);
}
// Регистронезависимые имена: `space`, `enter`, `esc`, `pgup` и т. п.
if let Some(key) = alias_key(token) {
return Ok(key);
}
// `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-функцией, которую нужно выполнить.
struct Binding {
binding: KeyBinding,
callback: mlua::Function,
}
/// Все назначенные привязки клавиш.
#[derive(Default)]
pub struct KeyMap {
bindings: Vec<Binding>,
}
impl KeyMap {
/// Добавляет привязку. Повторное назначение той же клавиши заменяет
/// предыдущий обработчик.
pub fn bind(&mut self, binding: KeyBinding, callback: mlua::Function) {
if let Some(existing) = self
.bindings
.iter_mut()
.find(|existing| existing.binding == binding)
{
existing.callback = callback;
} else {
self.bindings.push(Binding { binding, callback });
}
}
/// Собирает обработчики, которые нужно вызвать для событий текущего кадра.
///
/// Заём `KeyMap` здесь заканчивается: вызывать колбэки нужно уже после
/// возврата из метода, потому что обработчик может сам менять привязки
/// через `app:map_key`.
pub fn callbacks_for(&self, ctx: &egui::Context) -> Vec<mlua::Function> {
let key_events: Vec<(egui::Key, bool, egui::Modifiers)> = ctx.input(|input| {
input
.events
.iter()
.filter_map(|event| {
let egui::Event::Key {
key,
physical_key,
pressed,
modifiers,
..
} = event
else {
return None;
};
// Физический код не зависит от раскладки и Caps Lock.
// Логическая клавиша — запасной вариант для платформ,
// где physical_key не заполняется.
Some((physical_key.unwrap_or(*key), *pressed, *modifiers))
})
.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
}
}
#[cfg(test)]
mod tests {
use super::*;
fn parse(spec: &str) -> KeyBinding {
KeyBinding::parse(spec).expect("привязка должна разбираться")
}
#[test]
fn plain_keys_and_release() {
assert_eq!(
parse("a"),
KeyBinding {
key: egui::Key::A,
modifiers: Modifiers::default(),
on_release: false,
}
);
assert_eq!(
parse("1"),
KeyBinding {
key: egui::Key::Num1,
modifiers: Modifiers::default(),
on_release: false,
}
);
assert_eq!(
parse("!a"),
KeyBinding {
key: egui::Key::A,
modifiers: Modifiers::default(),
on_release: true,
}
);
}
#[test]
fn chords_with_modifiers() {
assert_eq!(
parse("<S-c>"),
KeyBinding {
key: egui::Key::C,
modifiers: Modifiers {
shift: true,
..Default::default()
},
on_release: false,
}
);
assert_eq!(
parse("<C-z>"),
KeyBinding {
key: egui::Key::Z,
modifiers: Modifiers {
ctrl: true,
..Default::default()
},
on_release: false,
}
);
assert_eq!(
parse("<A-!r>"),
KeyBinding {
key: egui::Key::R,
modifiers: Modifiers {
alt: true,
..Default::default()
},
on_release: true,
}
);
assert_eq!(
parse("<C-S-A-x>"),
KeyBinding {
key: egui::Key::X,
modifiers: Modifiers {
shift: true,
ctrl: true,
alt: true,
},
on_release: false,
}
);
}
#[test]
fn invalid_bindings_are_rejected() {
for spec in ["", " ", "<", "<C-z", "<Q-z>", "!"] {
assert!(
KeyBinding::parse(spec).is_err(),
"строка {spec:?} должна быть отклонена"
);
}
}
#[test]
fn named_keys_are_case_insensitive() {
assert_eq!(parse("<C-space>").key, egui::Key::Space);
assert_eq!(parse("<S-enter>").key, egui::Key::Enter);
assert_eq!(parse("<A-esc>").key, egui::Key::Escape);
assert_eq!(parse("<C-f1>").key, egui::Key::F1);
assert_eq!(parse("<C-pgdn>").key, egui::Key::PageDown);
}
#[test]
fn matching_requires_exact_modifiers() {
let binding = parse("<S-c>");
let shift = egui::Modifiers {
shift: true,
..Default::default()
};
let shift_ctrl = egui::Modifiers {
shift: true,
ctrl: true,
..Default::default()
};
assert!(binding.matches(egui::Key::C, true, &shift));
assert!(!binding.matches(egui::Key::C, true, &shift_ctrl));
assert!(!binding.matches(egui::Key::C, false, &shift));
assert!(!binding.matches(egui::Key::V, true, &shift));
}
}
+39
View File
@@ -0,0 +1,39 @@
mod api;
mod app;
mod constants;
mod editor;
mod keymap;
mod script;
mod syntax;
mod ui;
#[cfg(test)]
mod tests;
use app::MyApp;
use eframe::egui;
fn main() -> eframe::Result<()> {
let options = eframe::NativeOptions {
viewport: egui::ViewportBuilder::default()
.with_inner_size([900.0, 600.0])
.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(
"Custom Window 0.37",
options,
Box::new(|cc| Ok(Box::new(MyApp::new(cc)))),
)
}
+121
View File
@@ -0,0 +1,121 @@
//! Выполнение Lua-сценариев приложения.
//!
//! При запуске приложение выполняет [`STARTUP_SCRIPT`] из каталога [`SCRIPT_DIR`]
//! (см. [`ScriptEngine::run_startup`]). Скриптам доступны:
//!
//! * объект `app` — управление окном и редактором (методы [`AppApi`], вызываются
//! через двоеточие: `app:next_tab()`);
//! * функция `include(path)` — выполняет другой Lua-файл относительно [`SCRIPT_DIR`]
//! (или по абсолютному пути) в общем глобальном окружении;
//! * стандартный `require`, который также ищет модули в [`SCRIPT_DIR`].
use std::path::{Path, PathBuf};
use mlua::{Lua, Table};
use crate::api::{AppApi, TabInfo};
use crate::constants::{SCRIPT_DIR, STARTUP_SCRIPT};
/// Вкладка превращается в Lua-таблицу `{ index = 0, title = "main.rs" }`.
impl mlua::IntoLua for TabInfo {
fn into_lua(self, lua: &Lua) -> mlua::Result<mlua::Value> {
let tab = lua.create_table()?;
tab.set("index", self.index)?;
tab.set("title", self.title)?;
Ok(mlua::Value::Table(tab))
}
}
/// Движок Lua и состояние его глобального окружения.
pub struct ScriptEngine {
lua: Lua,
}
impl ScriptEngine {
/// Создаёт движок и публикует в нём API приложения.
pub fn new(api: AppApi) -> mlua::Result<Self> {
let engine = Self { lua: Lua::new() };
engine.expose_app_api(api)?;
engine.configure_require()?;
Ok(engine)
}
/// Выполняет стартовый сценарий из [`SCRIPT_DIR`].
///
/// # Errors
/// Возвращает ошибку, если файла нет, его не удалось прочитать или в нём
/// есть синтаксическая/исполняемая ошибка.
pub fn run_startup(&self) -> mlua::Result<()> {
self.run_file(&script_path(STARTUP_SCRIPT))
}
/// Выполняет Lua-файл в глобальном окружении движка.
pub fn run_file(&self, path: &Path) -> mlua::Result<()> {
run_file(&self.lua, path).map(|_| ())
}
/// Публикует объект `app` с функциями управления приложением.
///
/// Все методы [`AppApi`] из `#[mlua::userdata_impl]`-блока доступны как
/// методы userdata: `app:next_tab()`, `app:set_active_text("...")`.
/// Номера вкладок всюду нумеруются с нуля.
fn expose_app_api(&self, api: AppApi) -> mlua::Result<()> {
self.lua
.globals()
.set("app", self.lua.create_userdata(api)?)?;
Ok(())
}
/// Добавляет каталог сценариев в `package.path`, чтобы `require` находил
/// соседние Lua-модули, и объявляет `include`.
fn configure_require(&self) -> mlua::Result<()> {
let globals = self.lua.globals();
if let Ok(package) = globals.get::<Table>("package") {
let current: String = package.get("path").unwrap_or_default();
let dir = SCRIPT_DIR;
let extra = format!("{dir}/?.lua;{dir}/?/init.lua");
let path = if current.is_empty() {
extra
} else {
format!("{extra};{current}")
};
package.set("path", path)?;
}
// include выполняет файл сразу, как dofile, но путь по умолчанию
// отсчитывается от каталога сценариев, а не от cwd.
globals.set(
"include",
self.lua
.create_function(|lua, path: String| run_file(lua, &script_path(&path)))?,
)?;
Ok(())
}
}
/// Путь к файлу сценария: абсолютные пути используются как есть, остальные
/// отсчитываются от [`SCRIPT_DIR`].
fn script_path(path: &str) -> PathBuf {
let path = Path::new(path);
if path.is_absolute() {
path.to_path_buf()
} else {
Path::new(SCRIPT_DIR).join(path)
}
}
/// Читает и выполняет Lua-файл, возвращая значения, которые вернул чанк.
fn run_file(lua: &Lua, path: &Path) -> mlua::Result<mlua::MultiValue> {
let source = std::fs::read_to_string(path).map_err(|err| {
mlua::Error::RuntimeError(format!(
"не удалось прочитать сценарий {}: {err}",
path.display()
))
})?;
lua.load(&source)
.set_name(path.to_string_lossy().into_owned())
.eval()
}
+522
View File
@@ -0,0 +1,522 @@
//! Интеграция 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
"#;
+646
View File
@@ -0,0 +1,646 @@
//! Проверки UI-логики на «сырых» событиях egui (без реального окна).
use std::cell::RefCell;
use std::rc::Rc;
use eframe::egui;
use crate::api::AppApi;
use crate::app::MyApp;
use crate::editor::EditorState;
use crate::keymap::KeyMap;
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 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]
fn title_bar_buttons_are_hoverable_and_clickable() {
let ctx = egui::Context::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.active().name, "main.rs");
// Открытие и закрытие новых вкладок.
state.open_new();
assert_eq!(state.documents().len(), 3);
assert_eq!(state.active_index(), 2);
state.close(2);
assert_eq!(state.active_index(), 1);
// Редактор не должен оставаться пустым.
while state.documents().len() > 1 {
state.close(0);
}
state.close(0);
assert_eq!(state.documents().len(), 1);
assert_eq!(state.active_index(), 0);
}
#[test]
fn closing_tab_before_active_keeps_the_same_document_active() {
let mut state = EditorState::default();
state.open_new();
let name = state.active().name.clone();
state.close(0);
assert_eq!(state.active().name, name);
assert_eq!(state.active_index(), 1);
}
#[test]
fn editing_marks_document_dirty_and_saving_clears_it() {
let mut state = EditorState::default();
assert!(!state.active().is_dirty());
state.active_mut().content.push_str("\n// правка\n");
assert!(state.active().is_dirty());
state.active_mut().mark_saved();
assert!(!state.active().is_dirty());
}
#[test]
fn typing_into_editor_updates_active_document() {
let ctx = egui::Context::default();
let mut app = MyApp::default();
// Прогреваем раскладку и кликаем в область редактора ниже панели вкладок.
step_app(&ctx, &mut app, vec![]);
let editor_pos = egui::pos2(SCREEN.x / 2.0, SCREEN.y - 80.0);
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())]);
let editor = app.editor();
let doc = editor.active();
assert!(
doc.content.contains("ZZZ"),
"введённый текст не попал в документ: {:?}",
doc.content
);
assert!(doc.is_dirty(), "правка должна помечать документ изменённым");
}
#[test]
fn api_lists_tabs_and_closes_them_by_number() {
let ctx = egui::Context::default();
let mut app = MyApp::default();
step_app(&ctx, &mut app, vec![]);
let api = AppApi::global();
let tabs = api.tabs();
assert_eq!(
tabs.iter()
.map(|tab| (tab.index, tab.title.as_str()))
.collect::<Vec<_>>(),
vec![(0, "main.rs"), (1, "notes.md")]
);
// Несуществующий номер ничего не закрывает.
assert!(!api.close_tab(99));
assert_eq!(api.tabs().len(), 2);
assert!(api.close_tab(1));
assert_eq!(api.tabs().len(), 1);
assert_eq!(app.editor().documents().len(), 1);
}
#[test]
fn api_save_writes_active_tab_to_file_named_after_it() {
// Тест пишет во временный файл, а не в каталог проекта.
let mut state = EditorState::default();
let path = std::env::temp_dir().join(format!(
"egui-rust-test-save-{}-{}.rs",
std::process::id(),
state.active().id
));
let _ = std::fs::remove_file(&path);
{
let doc = state.active_mut();
doc.name = path.to_string_lossy().into_owned();
doc.content = "fn main() {}\n".to_owned();
}
assert!(state.active().is_dirty());
let editor = Rc::new(RefCell::new(state));
let api = AppApi::install(
egui::Context::default(),
editor.clone(),
Rc::new(RefCell::new(KeyMap::default())),
);
api.save().expect("сохранение должно удаться");
assert_eq!(
std::fs::read_to_string(&path).expect("файл должен существовать"),
"fn main() {}\n"
);
assert!(!editor.borrow().active().is_dirty());
let _ = std::fs::remove_file(&path);
}
#[test]
fn api_switches_tabs_forward_and_back_in_a_cycle() {
let editor = Rc::new(RefCell::new(EditorState::default()));
let api = AppApi::install(
egui::Context::default(),
editor.clone(),
Rc::new(RefCell::new(KeyMap::default())),
);
assert_eq!(api.active_tab_index(), 0);
assert_eq!(api.next_tab(), 1);
assert_eq!(api.active_tab_index(), 1);
// С последней вкладки следующая — первая.
assert_eq!(api.next_tab(), 0);
// С первой вкладки предыдущая — последняя.
assert_eq!(api.previous_tab(), 1);
assert_eq!(api.previous_tab(), 0);
assert_eq!(editor.borrow().active_index(), 0);
}
#[test]
fn lua_script_can_include_files_and_control_the_app() {
let ctx = egui::Context::default();
let mut app = MyApp::default();
// Первый кадр публикует AppApi, который использует сценарий.
step_app(&ctx, &mut app, vec![]);
let dir = std::env::temp_dir().join(format!(
"egui-rust-test-lua-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("системные часы")
.as_nanos()
));
std::fs::create_dir_all(&dir).expect("временный каталог сценариев");
let helper = dir.join("helper.lua");
let main = dir.join("main.lua");
std::fs::write(
&helper,
"function lua_config() app:set_active_text(\"from lua\") end\n",
)
.expect("helper.lua");
std::fs::write(
&main,
format!(
"include({:?})\nlua_config()\napp:next_tab()\napp:set_language(1, \"python\")\n",
helper.to_string_lossy()
),
)
.expect("main.lua");
let engine = ScriptEngine::new(AppApi::global()).expect("движок Lua");
engine.run_file(&main).expect("сценарий должен выполниться");
let editor = app.editor();
assert_eq!(
editor.documents()[0].content,
"from lua",
"include должен выполнить подключённый файл в общем окружении"
);
assert_eq!(
editor.active_index(),
1,
"app:next_tab() не переключил вкладку"
);
assert_eq!(
editor.documents()[1].language,
Some(Language::Python),
"app:set_language() не включил подсветку из Lua"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn lua_key_bindings_use_physical_keys_and_modifiers() {
let ctx = egui::Context::default();
let mut app = MyApp::default();
// Первый кадр публикует 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 script = dir.join("keys.lua");
std::fs::write(
&script,
r#"
app:map_key("a", function() app:set_active_text("a pressed") end)
app:map_key("!1", function() app:set_active_text("1 released") end)
app:map_key("<S-c>", function() app:set_active_text("shift c") end)
app:map_key("<C-z>", function() app:set_active_text("ctrl z") end)
app:map_key("<A-!r>", function() app:set_active_text("alt r released") end)
"#,
)
.expect("keys.lua");
let engine = ScriptEngine::new(AppApi::global()).expect("движок Lua");
engine
.run_file(&script)
.expect("привязки должны назначиться");
let text = |app: &MyApp| app.editor().active().content.clone();
// Логическая клавиша Z, физическая A: срабатывает именно физическая.
step_app(
&ctx,
&mut app,
vec![key_event(
egui::Key::A,
egui::Key::Z,
true,
egui::Modifiers::default(),
)],
);
assert_eq!(text(&app), "a pressed");
// Shift+A не должна срабатывать как обычная A.
step_app(
&ctx,
&mut app,
vec![key_event(
egui::Key::A,
egui::Key::A,
true,
egui::Modifiers {
shift: true,
..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,
egui::Modifiers {
shift: true,
..Default::default()
},
)],
);
assert_eq!(text(&app), "shift c");
step_app(
&ctx,
&mut app,
vec![key_event(
egui::Key::Z,
egui::Key::Z,
true,
egui::Modifiers {
ctrl: true,
..Default::default()
},
)],
);
assert_eq!(text(&app), "ctrl z");
// Отпускание R при зажатом Alt.
step_app(
&ctx,
&mut app,
vec![key_event(
egui::Key::R,
egui::Key::R,
false,
egui::Modifiers {
alt: true,
..Default::default()
},
)],
);
assert_eq!(text(&app), "alt r released");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn language_names_cover_all_supported_grammars() {
for (name, expected) in [
("rust", Language::Rust),
("go", Language::Go),
("js", Language::JavaScript),
("json", Language::Json),
("python", Language::Python),
] {
assert_eq!(Language::from_name(name), Some(expected), "язык {name}");
assert_eq!(Language::from_name(&name.to_uppercase()), Some(expected));
}
// Распространённые короткие имена.
assert_eq!(Language::from_name("rs"), Some(Language::Rust));
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);
}
#[test]
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]
fn highlighting_is_off_by_default_and_enabled_through_api() {
let ctx = egui::Context::default();
let mut app = MyApp::default();
step_app(&ctx, &mut app, vec![]);
assert!(
app.editor()
.documents()
.iter()
.all(|doc| doc.language.is_none()),
"подсветка должна быть выключена по умолчанию"
);
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(99, "rust"));
assert_eq!(api.language(0).as_deref(), Some("rust"));
// Кадр с включённой подсветкой должен отрисовываться без паники.
step_app(&ctx, &mut app, vec![]);
assert!(api.clear_language(0));
assert_eq!(api.language(0), None);
assert!(!api.clear_language(99));
}
+298
View File
@@ -0,0 +1,298 @@
//! Область редактирования кода: нумерация строк и многострочное поле ввода.
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;
}
}
+93
View File
@@ -0,0 +1,93 @@
//! Векторные иконки кнопок заголовка (без шрифтов — рисуются через `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
}
+8
View File
@@ -0,0 +1,8 @@
//! Виджеты окна: интегрированный заголовок, вкладки, редактор, иконки
//! и зоны изменения размера.
pub mod code_editor;
pub mod icons;
pub mod resize_handles;
pub mod tabs;
pub mod title_bar;
+121
View File
@@ -0,0 +1,121 @@
//! Невидимые зоны по периметру окна для изменения размера.
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
View File
@@ -0,0 +1,228 @@
//! Полоса вкладок над редактором.
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
}
+121
View File
@@ -0,0 +1,121 @@
//! Интегрированный в окно заголовок: перетаскивание и кнопки управления.
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
}