refactoring

This commit is contained in:
2026-10-31 01:06:21 +03:00
parent 27385a3edb
commit 19e05e7ed0
17 changed files with 2673 additions and 671 deletions
+292 -90
View File
@@ -13,14 +13,18 @@ use std::collections::VecDeque;
use std::path::{Path, PathBuf};
use std::rc::Rc;
use std::sync::Arc;
use std::time::Duration;
use mlua::IntoLua;
use serde_json::Number;
use crate::constants::{BOTTOM_PANEL_HEIGHT, LEFT_PANEL_WIDTH, RIGHT_PANEL_WIDTH};
use crate::editor::{Language, Workspace};
use crate::editor::{FocusAfterSync, Language, Workspace};
use crate::icons;
use crate::keymap::{KeyBinding, KeyMap};
use crate::lsp::{LspConfig, LspManager, WatcherConfig, document_uri};
use crate::keymap::{AppCommand, CompletionCommand, KeyBinding, KeyContext, KeyHandler, KeyMap};
use crate::lsp::{
DocumentDiagnostics, LspConfig, LspManager, WatcherConfig, document_uri, position_to_offset,
};
use crate::panel::{PanelHandle, PanelState};
use crate::tree::{TreeHandle, TreeNode};
@@ -88,6 +92,16 @@ pub struct DirEntryInfo {
pub is_dir: bool,
}
/// Зарегистрированный Lua-обработчик диагностики.
///
/// `delay` — пауза тишины, после которой обработчик вызывается. Если сервер
/// присылает сообщения чаще, вызов откладывается до последнего изменения.
#[derive(Clone)]
struct DiagnosticsHandler {
delay: Duration,
callback: mlua::Function,
}
/// Глобальный объект с функциями управления приложением.
///
/// Использование: `AppApi::global().close()`, `AppApi::global().tabs()`.
@@ -111,6 +125,8 @@ pub struct AppApi {
bottom_panel: PanelHandle,
#[lua(skip)]
lsp: Arc<LspManager>,
#[lua(skip)]
diagnostics_handler: Rc<RefCell<Option<DiagnosticsHandler>>>,
}
/// API одной панели окна.
@@ -183,6 +199,16 @@ impl TreeApi {
self.state.borrow_mut().remove(&id)
}
/// Удаляет все узлы дерева и сбрасывает выбор.
///
/// Обработчики `on_toggle` и `on_click` продолжают действовать: дерево
/// можно наполнить заново. Удобно для списков, которые перерисовываются
/// целиком, например для диагностики.
#[lua(infallible)]
pub fn clear(&self) {
self.state.borrow_mut().clear();
}
/// Назначает обработчик раскрытия/сворачивания узла.
///
/// Обработчик получает `(expanded, id, loaded)`, где `expanded` — новая
@@ -246,13 +272,18 @@ impl PanelApi {
/// Добавляет в панель блок дерева и возвращает его API.
///
/// `title` — заголовок вкладки нижней панели. Боковые панели заголовок
/// сохраняют, но не рисуют.
///
/// ```lua
/// local tree = app.left_panel:add_tree()
/// tree:append({ { id = "a", label = "a.txt" } }, "")
///
/// local diagnostics = app.bottom_panel:add_tree("Диагностика")
/// ```
#[lua(infallible, name = "add_tree")]
pub fn add_tree(&self) -> TreeApi {
TreeApi::new(self.state.borrow_mut().add_tree())
pub fn add_tree(&self, title: Option<String>) -> TreeApi {
TreeApi::new(self.state.borrow_mut().add_tree(title))
}
}
@@ -269,6 +300,7 @@ impl AppApi {
right_panel: Rc::new(RefCell::new(PanelState::new(RIGHT_PANEL_WIDTH))),
bottom_panel: Rc::new(RefCell::new(PanelState::new(BOTTOM_PANEL_HEIGHT))),
lsp: Arc::new(LspManager::new()),
diagnostics_handler: Rc::new(RefCell::new(None)),
};
INSTANCE.with(|slot| *slot.borrow_mut() = Some(api.clone()));
api
@@ -324,10 +356,7 @@ impl AppApi {
.map(|(index, doc)| TabInfo {
index,
title: doc.name.clone(),
icon: doc
.icon
.clone()
.unwrap_or_else(|| icons::DEFAULT_TAB_ICON.to_owned()),
icon: doc.icon_name().to_owned(),
})
.collect()
}
@@ -345,10 +374,7 @@ impl AppApi {
// Часть публичного API приложения: вызывается внешним кодом и тестами.
#[lua(infallible)]
pub fn next_tab(&self) -> usize {
let mut workspace = self.workspace.borrow_mut();
let next = (workspace.active_index() + 1) % workspace.documents().len();
workspace.set_active(next);
next
self.workspace.borrow_mut().cycle_active(1)
}
/// Переключает на предыдущую вкладку (по кругу) и возвращает её индекс.
@@ -356,11 +382,7 @@ impl AppApi {
// Часть публичного API приложения: вызывается внешним кодом и тестами.
#[lua(infallible)]
pub fn previous_tab(&self) -> usize {
let mut workspace = self.workspace.borrow_mut();
let count = workspace.documents().len();
let previous = (workspace.active_index() + count - 1) % count;
workspace.set_active(previous);
previous
self.workspace.borrow_mut().cycle_active(-1)
}
/// Закрывает вкладку по её номеру в списке [`AppApi::tabs`].
@@ -414,17 +436,41 @@ impl AppApi {
/// `keys` записывается в духе nvim:
///
/// * `a`, `1` — нажатие клавиши без модификаторов;
/// * `<S-c>`, `<C-z>`, `<A-r>` — нажатие с Shift, Ctrl или Alt;
/// * `<S-c>`, `<C-z>`, `<A-r>`, `<D-s>` — нажатие с Shift, Ctrl, Alt или
/// платформенным модификатором (Cmd/Super);
/// * `!a` — отпускание клавиши;
/// * `<A-!r>` — отпускание клавиши при зажатом Alt.
///
/// Повторное назначение той же клавиши заменяет обработчик.
/// Второй аргумент — Lua-функция, имя встроенной команды (`save`,
/// `next_tab`, `toggle_left_panel` и т. п.) или `nil`, чтобы удалить
/// привязку. Повторное назначение той же клавиши заменяет обработчик.
///
/// # Errors
/// Возвращает ошибку Lua, если запись привязки некорректна.
pub fn map_key(&self, keys: String, callback: mlua::Function) -> mlua::Result<()> {
let binding = KeyBinding::parse(&keys).map_err(mlua::Error::RuntimeError)?;
self.keymap.borrow_mut().bind(binding, callback);
/// Возвращает ошибку Lua, если запись привязки или имя команды некорректны.
pub fn map_key(&self, keys: String, handler: mlua::Value) -> mlua::Result<()> {
self.bind_key(KeyContext::Global, &keys, handler)
}
/// Назначает обработчик клавиши, пока открыт список автодополнения.
///
/// Синтаксис совпадает с [`AppApi::map_key`]. Встроенные команды этого
/// слоя: `completion_trigger`, `completion_next`, `completion_previous`,
/// `completion_page_up`, `completion_page_down`, `completion_accept`,
/// `completion_cancel`, `completion_dismiss`.
#[lua(name = "map_completion_key")]
pub fn map_completion_key(&self, keys: String, handler: mlua::Value) -> mlua::Result<()> {
self.bind_key(KeyContext::Completion, &keys, handler)
}
#[lua(skip)]
fn bind_key(&self, context: KeyContext, keys: &str, handler: mlua::Value) -> mlua::Result<()> {
let binding = KeyBinding::parse(keys).map_err(mlua::Error::RuntimeError)?;
match parse_key_handler(context, handler)? {
Some(handler) => self.keymap.borrow_mut().set(context, binding, handler),
None => {
self.keymap.borrow_mut().unbind(context, &binding);
}
}
Ok(())
}
@@ -458,8 +504,34 @@ impl AppApi {
///
/// Если файла нет, создаётся пустая вкладка с таким именем — как в vim.
/// Язык подсветки определяется по расширению, если для него есть грамматика.
/// Фокус редактора не меняется: для этого есть [`AppApi::open_focused`]
/// и Lua-форма `app:open(path, {...})`.
#[lua(skip)]
pub fn open(&self, path: impl AsRef<Path>) -> std::io::Result<usize> {
self.open_impl(path, None, false)
}
/// Открывает файл и переводит фокус в редактор (Rust-версия
/// `app:open(path, position)`).
///
/// `position` — строка и столбец с нуля, как в диагностике LSP; столбец
/// считается в UTF-16. `None` оставляет курсор там, где он был.
#[lua(skip)]
pub fn open_focused(
&self,
path: impl AsRef<Path>,
position: Option<(u32, u32)>,
) -> std::io::Result<usize> {
self.open_impl(path, position, true)
}
#[lua(skip)]
fn open_impl(
&self,
path: impl AsRef<Path>,
position: Option<(u32, u32)>,
focus_editor: bool,
) -> std::io::Result<usize> {
let path = path.as_ref();
let content = match std::fs::read_to_string(path) {
Ok(content) => content,
@@ -467,19 +539,39 @@ impl AppApi {
Err(err) => return Err(err),
};
let cursor = position.map(|(line, column)| {
position_to_offset(&content, lsp_types::Position::new(line, column))
});
let focus = if focus_editor {
FocusAfterSync::Editor(cursor)
} else {
FocusAfterSync::Keep
};
let name = path.to_string_lossy().into_owned();
let language = Language::from_path(&name);
let mut workspace = self.workspace.borrow_mut();
Ok(workspace.open_file(name, content, language))
let index = workspace.open_file(name, content, language);
workspace.set_focus_after_sync(focus);
Ok(index)
}
/// Lua-версия [`AppApi::open`]: `app:open("main.go")`.
///
/// Второй аргумент необязателен:
///
/// * `nil`/нет аргумента — открыть вкладку, не трогая фокус;
/// * `{}` — перевести фокус в редактор, курсор оставить на месте;
/// * `{line, column}` — перевести фокус и поставить курсор (с нуля).
///
/// Возвращает номер вкладки (с нуля). Ошибка чтения становится ошибкой Lua.
#[lua(name = "open")]
fn lua_open(&self, path: String) -> mlua::Result<usize> {
self.open(&path)
.map_err(|err| mlua::Error::RuntimeError(format!("не удалось открыть {path}: {err}")))
fn lua_open(&self, path: String, position: mlua::Value) -> mlua::Result<usize> {
match parse_open_focus(position)? {
OpenFocus::Keep => self.open(&path),
OpenFocus::Editor(position) => self.open_focused(&path, position),
}
.map_err(|err| mlua::Error::RuntimeError(format!("не удалось открыть {path}: {err}")))
}
/// Читает содержимое каталога (Rust-версия `app:list_dir`).
@@ -490,7 +582,7 @@ impl AppApi {
#[lua(skip)]
pub fn list_dir(&self, path: impl AsRef<Path>) -> std::io::Result<Vec<DirEntryInfo>> {
let raw = path.as_ref().as_os_str().to_string_lossy().into_owned();
let root = raw.is_empty() || raw.as_str() == ".";
let relative_to_cwd = raw.is_empty() || raw == ".";
let directory = if raw.is_empty() {
Path::new(".")
} else {
@@ -502,7 +594,7 @@ impl AppApi {
let entry = entry?;
let name = entry.file_name().to_string_lossy().into_owned();
let is_dir = entry.file_type().map(|kind| kind.is_dir()).unwrap_or(false);
let id = if root {
let id = if relative_to_cwd {
name.clone()
} else {
Path::new(&raw)
@@ -583,14 +675,10 @@ impl AppApi {
/// `file` по умолчанию. `nil`, если вкладки нет.
#[lua(infallible)]
pub fn tab_icon(&self, index: usize) -> Option<String> {
let workspace = self.workspace.borrow();
workspace.document(index)?;
Some(
workspace
.tab_icon(index)
.unwrap_or(icons::DEFAULT_TAB_ICON)
.to_owned(),
)
self.workspace
.borrow()
.document(index)
.map(|doc| doc.icon_name().to_owned())
}
/// Включает подсветку синтаксиса для вкладки `index`.
@@ -746,6 +834,108 @@ impl AppApi {
pub fn restart_lsp(&self, name: String) -> bool {
self.lsp.restart(&name)
}
/// Регистрирует обработчик диагностики всех LSP-серверов.
///
/// `delay_ms` — пауза тишины в миллисекундах: обработчик вызывается после
/// последнего обновления диагностики. Если серверы присылают сообщения
/// чаще задержки, промежуточные вызовы пропускаются, а последний вызов
/// выполняется после того, как обновления прекратились.
///
/// Обработчик получает один аргумент — массив записей
/// `{ server, uri, path, diagnostics = { ... } }`. У сообщения есть
/// `line`, `character`, `end_line`, `end_character` (с нуля, как в LSP),
/// `severity` (1–4), `severity_name`, `message`, `source`, `code`, `tags`.
/// Передача `nil` вместо функции удаляет обработчик.
///
/// ```lua
/// app:on_lsp_diagnostics(1000, function(servers)
/// for _, entry in ipairs(servers) do
/// print(entry.server, entry.path, #entry.diagnostics)
/// end
/// end)
/// ```
#[lua(infallible, name = "on_lsp_diagnostics")]
pub fn on_lsp_diagnostics(&self, delay_ms: u64, callback: Option<mlua::Function>) {
let delay = Duration::from_millis(delay_ms);
*self.diagnostics_handler.borrow_mut() =
callback.map(|callback| DiagnosticsHandler { delay, callback });
// Обработчик должен получить уже накопленную диагностику, а не ждать
// следующего ответа LSP-сервера.
self.lsp.refresh_diagnostics();
}
/// Задержка зарегистрированного обработчика диагностики, если он есть.
#[lua(skip)]
pub fn diagnostics_delay(&self) -> Option<Duration> {
self.diagnostics_handler
.borrow()
.as_ref()
.map(|handler| handler.delay)
}
/// Вызывает Lua-обработчик диагностики со свежим снимком.
///
/// Возвращает `false`, если обработчик не зарегистрирован.
#[lua(skip)]
pub fn emit_diagnostics(&self, report: Vec<DocumentDiagnostics>) -> bool {
let Some(callback) = self
.diagnostics_handler
.borrow()
.as_ref()
.map(|handler| handler.callback.clone())
else {
return false;
};
let Some(lua) = callback.weak_lua().try_upgrade() else {
return false;
};
let value = match report.into_lua(&lua) {
Ok(value) => value,
Err(err) => {
eprintln!("Lua: не удалось передать диагностику: {err}");
return false;
}
};
if let Err(err) = callback.call::<()>(value) {
eprintln!("Lua: ошибка в обработчике диагностики: {err}");
}
true
}
}
/// Второй аргумент `app:open`.
enum OpenFocus {
/// Фокус не меняется.
Keep,
/// Фокус переходит в редактор; `None` сохраняет текущий курсор.
Editor(Option<(u32, u32)>),
}
/// Разбирает необязательный второй аргумент `app:open`.
fn parse_open_focus(value: mlua::Value) -> mlua::Result<OpenFocus> {
let table = match value {
mlua::Value::Nil => return Ok(OpenFocus::Keep),
mlua::Value::Table(table) => table,
_ => {
return Err(mlua::Error::RuntimeError(
"во втором аргументе app:open нужна таблица {строка, столбец} или {}".to_owned(),
));
}
};
let line = table.get::<Option<i64>>(1)?;
let column = table.get::<Option<i64>>(2)?;
match (line, column) {
(None, None) => Ok(OpenFocus::Editor(None)),
(Some(line), Some(column)) if line >= 0 && column >= 0 => {
Ok(OpenFocus::Editor(Some((line as u32, column as u32))))
}
_ => Err(mlua::Error::RuntimeError(
"позиция в app:open — два неотрицательных числа: {строка, столбец}".to_owned(),
)),
}
}
/// Разбирает Lua-таблицу, переданную в `app:run_lsp`.
@@ -754,14 +944,9 @@ fn parse_lsp_config(table: &mlua::Table) -> mlua::Result<LspConfig> {
.get("name")
.map_err(|_| mlua::Error::RuntimeError("в run_lsp нужен строковый name".to_owned()))?;
let command = ["cmd", "command", "path", "binary"]
.iter()
.find_map(|key| table.get::<String>(*key).ok())
.ok_or_else(|| {
mlua::Error::RuntimeError(
"в run_lsp нужен путь к серверу: cmd, command, path или binary".to_owned(),
)
})?;
let command: String = table
.get("cmd")
.map_err(|_| mlua::Error::RuntimeError("в run_lsp нужен строковый cmd".to_owned()))?;
let args = match table.get::<mlua::Table>("args") {
Ok(args) => args
@@ -777,32 +962,22 @@ fn parse_lsp_config(table: &mlua::Table) -> mlua::Result<LspConfig> {
Err(_) => Vec::new(),
};
let root = table
.get::<String>("root")
.ok()
.map(PathBuf::from)
.unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
let root = if root.is_absolute() {
root
} else {
std::env::current_dir()
.unwrap_or_else(|_| PathBuf::from("."))
.join(root)
// Корень нормализуется в `LspManager::start`: относительный путь там
// отсчитывается от текущего каталога процесса.
let root = match table.get::<String>("root") {
Ok(root) => PathBuf::from(root),
Err(_) => std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
};
let root = std::fs::canonicalize(&root).unwrap_or(root);
let languages = match table.get::<mlua::Value>("languages") {
Ok(value) => lua_strings(value)?,
Err(_) => Vec::new(),
};
let initialization_options = ["initialization_options", "init_options"]
.iter()
.find_map(|key| match table.get::<mlua::Value>(*key) {
Ok(mlua::Value::Nil) | Err(_) => None,
Ok(value) => Some(lua_to_json(value)),
})
.transpose()?;
let initialization_options = match table.get::<mlua::Value>("initialization_options") {
Ok(mlua::Value::Nil) | Err(_) => None,
Ok(value) => Some(lua_to_json(value)?),
};
Ok(LspConfig {
name,
@@ -817,15 +992,9 @@ fn parse_lsp_config(table: &mlua::Table) -> mlua::Result<LspConfig> {
}
fn parse_watcher(table: &mlua::Table) -> mlua::Result<Option<WatcherConfig>> {
let value = ["watcher", "file_watcher", "watch"].iter().find_map(|key| {
match table.get::<mlua::Value>(*key) {
Ok(mlua::Value::Nil) | Err(_) => None,
Ok(value) => Some(value),
}
});
let Some(value) = value else {
return Ok(None);
let value = match table.get::<mlua::Value>("watcher") {
Ok(mlua::Value::Nil) | Err(_) => return Ok(None),
Ok(value) => value,
};
match value {
@@ -839,22 +1008,14 @@ fn parse_watcher(table: &mlua::Table) -> mlua::Result<Option<WatcherConfig>> {
})),
mlua::Value::Boolean(false) | mlua::Value::Nil => Ok(None),
mlua::Value::Table(config) => {
let patterns = ["pattern", "patterns"]
.iter()
.find_map(|key| match config.get::<mlua::Value>(*key) {
Ok(mlua::Value::Nil) | Err(_) => None,
Ok(value) => Some(lua_strings(value)),
})
.transpose()?
.unwrap_or_default();
let ignore = ["ignore", "ignored"]
.iter()
.find_map(|key| match config.get::<mlua::Value>(*key) {
Ok(mlua::Value::Nil) | Err(_) => None,
Ok(value) => Some(lua_strings(value)),
})
.transpose()?
.unwrap_or_default();
let patterns = match config.get::<mlua::Value>("pattern") {
Ok(mlua::Value::Nil) | Err(_) => Vec::new(),
Ok(value) => lua_strings(value)?,
};
let ignore = match config.get::<mlua::Value>("ignore") {
Ok(mlua::Value::Nil) | Err(_) => Vec::new(),
Ok(value) => lua_strings(value)?,
};
if patterns.is_empty() {
Ok(None)
@@ -868,6 +1029,47 @@ fn parse_watcher(table: &mlua::Table) -> mlua::Result<Option<WatcherConfig>> {
}
}
/// Разбирает обработчик, переданный в `app:map_key` / `app:map_completion_key`.
///
/// `nil` означает «удалить привязку».
fn parse_key_handler(
context: KeyContext,
handler: mlua::Value,
) -> mlua::Result<Option<KeyHandler>> {
match handler {
mlua::Value::Nil => Ok(None),
mlua::Value::Function(callback) => Ok(Some(KeyHandler::Lua(callback))),
mlua::Value::String(name) => {
let name = name.to_string_lossy();
let parsed = match context {
KeyContext::Global => AppCommand::parse(&name).map(KeyHandler::App),
KeyContext::Completion => {
CompletionCommand::parse(&name).map(KeyHandler::Completion)
}
};
parsed.map(Some).ok_or_else(|| {
let available = match context {
KeyContext::Global => AppCommand::ALL
.iter()
.map(|command| command.name())
.collect::<Vec<_>>(),
KeyContext::Completion => CompletionCommand::ALL
.iter()
.map(|command| command.name())
.collect::<Vec<_>>(),
};
mlua::Error::RuntimeError(format!(
"неизвестная команда клавиши: {name}; доступны: {}",
available.join(", ")
))
})
}
_ => Err(mlua::Error::RuntimeError(
"обработчик должен быть функцией, именем команды или nil".to_owned(),
)),
}
}
/// Разбирает массив узлов дерева, переданный в `TreeApi::append`.
fn parse_tree_nodes(nodes: &mlua::Table) -> mlua::Result<Vec<TreeNode>> {
let mut parsed = Vec::new();
+506 -173
View File
@@ -7,13 +7,19 @@
use std::cell::{Cell, RefCell};
use std::collections::HashMap;
use std::future::Future;
use std::ops::Range;
use std::pin::Pin;
use std::rc::Rc;
use std::task::{Context as TaskContext, Poll};
use std::time::Duration;
use gpui_kit::assets::IconName as AssetIcon;
use gpui_kit::component::button::{Button, ButtonVariants};
use gpui_kit::component::input::{Editor, EditorState, HoverProvider, InputEvent, Rope, TabSize};
use gpui_kit::component::input::{
Editor, EditorState, HoverProvider, InputEvent, Rope, TabSize, TextDecoration,
TextDecorationCollection,
};
use gpui_kit::component::resizable::HandleEdge;
use gpui_kit::component::tab::{Tab, TabBar};
use gpui_kit::component::text::TextView;
@@ -31,9 +37,9 @@ use crate::completion::{
parse_completion_response, range_for_lsp, word_start,
};
use crate::constants::{MIN_LEFT_PANEL_WIDTH, MIN_MAIN_AREA_WIDTH, TAB_SIZE, WINDOW_TITLE};
use crate::editor::Workspace;
use crate::editor::{FocusAfterSync, Workspace};
use crate::icons;
use crate::keymap::KeyMap;
use crate::keymap::{AppCommand, CompletionCommand, KeyContext, KeyHandler, KeyMap};
use crate::lsp::{LspManager, document_uri, position_to_offset};
use crate::panel::{PanelBlock, PanelHandle};
use crate::script::ScriptEngine;
@@ -78,6 +84,13 @@ struct CompletionUi {
scroll: ScrollHandle,
}
impl CompletionUi {
/// Сколько строк списка реально рисуется и доступно для выбора.
fn visible_len(&self) -> usize {
self.items.len().min(MAX_COMPLETION_ROWS)
}
}
/// Документация выбранного элемента для второй всплывающей панели.
#[derive(Default)]
struct DocsUi {
@@ -124,6 +137,35 @@ impl HoverProvider for LspHoverProvider {
}
}
/// Ожидание следующего обновления диагностики LSP.
///
/// Не блокирует поток: сервер будит `Waker` из своего потока чтения,
/// а задача вида просыпается в обычном цикле GPUI.
struct DiagnosticsChanged {
manager: std::sync::Arc<LspManager>,
revision: u64,
}
impl Future for DiagnosticsChanged {
type Output = u64;
fn poll(self: Pin<&mut Self>, cx: &mut TaskContext<'_>) -> Poll<u64> {
let current = self.manager.diagnostics_revision();
if current != self.revision {
return Poll::Ready(current);
}
self.manager.register_diagnostics_waker(cx.waker().clone());
// Повторная проверка закрывает гонку между проверкой и регистрацией:
// сервер мог обновить диагностику уже после первой проверки.
let current = self.manager.diagnostics_revision();
if current != self.revision {
Poll::Ready(current)
} else {
Poll::Pending
}
}
}
/// Приложение текстового редактора.
pub struct MyApp {
/// Открытые документы, вкладки и активная вкладка.
@@ -156,10 +198,15 @@ pub struct MyApp {
displayed_id: u64,
/// Курсор редактора ещё ни разу не получал фокус.
focused_once: bool,
/// Явный запрос `app:open(path, ...)` на фокус в редакторе.
///
/// Применяется повторно после отрисовки панелей: дерево могло отложить
/// собственный запрос фокуса и перехватить его в этом же кадре.
focus_editor_after_render: bool,
/// Подписка на изменения редактора; держим её, пока жив вид.
_editor_subscription: Subscription,
/// Глобальный перехватчик клавиш: он срабатывает раньше действий
/// редактора и закрывает автодополнение на стрелках, не мешая курсору.
/// Глобальный перехватчик клавиш: слой автодополнения срабатывает
/// раньше действий редактора и не мешает движению курсора.
_key_interceptor: Subscription,
/// Движок Lua, созданный при старте.
///
@@ -181,6 +228,11 @@ pub struct MyApp {
/// (их может быть несколько из-за `additionalTextEdits`) не должны
/// переоткрывать список. Очищается после финального текста или в `defer`.
suppress_completion_for_text: Option<String>,
/// Коллекция подчёркиваний диагностики в редакторе.
///
/// Рисуется независимо от подсветки синтаксиса, поэтому ошибки
/// видны и в файлах без tree-sitter грамматики.
diagnostics_decorations: TextDecorationCollection,
}
impl MyApp {
@@ -191,6 +243,7 @@ impl MyApp {
pub fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
let workspace = Rc::new(RefCell::new(Workspace::default()));
let keymap = Rc::new(RefCell::new(KeyMap::default()));
keymap.borrow_mut().install_defaults();
let api = AppApi::install(workspace.clone(), keymap.clone());
let lsp = api.lsp_handle();
@@ -253,13 +306,82 @@ impl MyApp {
);
let displayed_id = workspace.borrow().active().id;
let diagnostics_decorations = editor.update(cx, |state, cx| {
state.create_decorations_collection(Vec::new(), cx)
});
// Диагностика приходит уведомлениями сервера, а не ответом на запрос.
// Одна задача применяет уже известную диагностику к редактору и вызывает
// Lua-обработчик; у хранилища один `Waker`, поэтому подписчик один.
let diagnostics_api = api.clone();
let diagnostics_manager = lsp.clone();
cx.spawn(async move |this, cx| {
let mut revision = diagnostics_manager.diagnostics_revision();
loop {
// Подчёркивания в активной вкладке обновляются сразу.
let alive = this
.update(cx, |this, cx| this.apply_diagnostics(cx))
.is_ok();
if !alive {
break;
}
// Lua-обработчик вызывается только после паузы тишины:
// каждое новое обновление перезапускает ожидание, поэтому
// промежуточные снимки пропускаются.
if diagnostics_api.diagnostics_delay().is_some() {
loop {
let Some(delay) = diagnostics_api.diagnostics_delay() else {
break;
};
if !delay.is_zero() {
cx.background_executor().timer(delay).await;
}
let current = diagnostics_manager.diagnostics_revision();
if current == revision {
break;
}
revision = current;
let alive = this
.update(cx, |this, cx| this.apply_diagnostics(cx))
.is_ok();
if !alive {
return;
}
}
if diagnostics_api.diagnostics_delay().is_some() {
let report = diagnostics_manager.diagnostics_snapshot();
let alive = this
.update(cx, |_this, cx| {
diagnostics_api.emit_diagnostics(report);
cx.notify();
})
.is_ok();
if !alive {
break;
}
}
}
revision = diagnostics_manager.diagnostics_revision();
revision = DiagnosticsChanged {
manager: diagnostics_manager.clone(),
revision,
}
.await;
}
})
.detach();
let left_panel = api.left_panel_handle();
let right_panel = api.right_panel_handle();
let bottom_panel = api.bottom_panel_handle();
// `capture_key_down` на корне вызывается уже после обработки action,
// поэтому для стрелок он не успевал. Interceptor срабатывает до
// key bindings: можно закрыть список и не мешать движению курсора.
// key bindings: слой автодополнения может закрыть список и не мешать
// движению курсора.
let view = cx.weak_entity();
let _key_interceptor = cx.intercept_keystrokes(move |event, window, cx| {
let handled = view
@@ -283,6 +405,7 @@ impl MyApp {
synced_revision: 0,
displayed_id,
focused_once: false,
focus_editor_after_render: false,
_editor_subscription,
_key_interceptor,
scripts: None,
@@ -292,6 +415,7 @@ impl MyApp {
docs_cache: HashMap::new(),
docs_generation: 0,
suppress_completion_for_text: None,
diagnostics_decorations,
}
}
@@ -343,20 +467,14 @@ impl MyApp {
#[cfg(test)]
pub fn open_test_completion_with_count(&mut self, count: usize, cx: &mut Context<Self>) {
let cursor = self.editor.read(cx).cursor();
self.completion.open = true;
self.completion.items = (0..count)
let items = (0..count)
.map(|index| CompletionItem {
label: format!("test_{index}"),
detail: Some("test".to_owned()),
..Default::default()
})
.collect();
self.completion.selected = 0;
self.completion.start_offset = cursor;
self.completion.query.clear();
self.completion.scroll.scroll_to_item(0);
self.update_docs_for_selection(cx);
cx.notify();
self.show_completion(items, cursor, String::new(), 0, cx);
}
/// Прокрутка списка автодополнения (доступ для тестов).
@@ -377,6 +495,18 @@ impl MyApp {
self.completion.selected
}
/// Применяет известную LSP-диагностику к редактору (доступ для тестов).
#[cfg(test)]
pub fn refresh_diagnostics(&mut self, cx: &mut Context<Self>) {
self.apply_diagnostics(cx);
}
/// Байтовые диапазоны подчёркиваний диагностики (доступ для тестов).
#[cfg(test)]
pub fn diagnostic_ranges(&self, cx: &App) -> Vec<Range<usize>> {
self.diagnostics_decorations.get_ranges(cx)
}
/// Отдаёт активный документ LSP-серверам, которые его обслуживают.
fn sync_lsp_document(&self) {
let workspace = self.workspace.borrow();
@@ -387,6 +517,51 @@ impl MyApp {
}
}
/// Переносит диагностику LSP активного документа в редактор.
///
/// Ошибки подчёркиваются красным, предупреждения и остальные сообщения —
/// жёлтым. Подчёркивания — отдельные декорации, а не `DiagnosticSet`:
/// тот рисуется только вместе с tree-sitter подсветкой, которой может
/// не быть у файла.
fn apply_diagnostics(&mut self, cx: &mut Context<Self>) {
let uri = {
let workspace = self.workspace.borrow();
document_uri(&workspace.active().name)
};
let diagnostics = uri
.as_ref()
.map(|uri| self.lsp.diagnostics(uri.as_str()))
.unwrap_or_default();
let text = self.editor.read(cx).value().to_string();
let error = cx.theme().highlight_theme.style.status.error(cx);
let warning = cx.theme().highlight_theme.style.status.warning(cx);
let decorations = diagnostics
.into_iter()
.filter_map(|diagnostic| {
let range = diagnostic_range(&text, diagnostic.range)?;
let color = if diagnostic.severity == Some(lsp_types::DiagnosticSeverity::ERROR) {
error
} else {
warning
};
Some(TextDecoration::new(
range,
HighlightStyle {
underline: Some(UnderlineStyle {
color: Some(color),
thickness: px(1.),
wavy: true,
}),
..Default::default()
},
))
})
.collect::<Vec<_>>();
self.diagnostics_decorations.set(decorations, cx);
}
/// Решает, нужно ли после правки запросить автодополнение.
fn on_editor_changed(
&mut self,
@@ -490,12 +665,26 @@ impl MyApp {
.unwrap_or(0)
.min(max_selected);
self.show_completion(items, start, query, selected, cx);
}
/// Показывает список с выбранным элементом и обновляет документацию.
fn show_completion(
&mut self,
items: Vec<CompletionItem>,
start: usize,
query: String,
selected: usize,
cx: &mut Context<Self>,
) {
self.completion.open = true;
self.completion.items = items;
self.completion.selected = selected;
self.completion.selected = selected.min(self.completion.visible_len().saturating_sub(1));
self.completion.start_offset = start;
self.completion.query = query;
self.completion.scroll.scroll_to_item(selected);
self.completion
.scroll
.scroll_to_item(self.completion.selected);
self.update_docs_for_selection(cx);
cx.notify();
@@ -517,8 +706,7 @@ impl MyApp {
}
fn select_completion(&mut self, selected: usize, cx: &mut Context<Self>) {
let visible = self.completion.items.len().min(MAX_COMPLETION_ROWS);
if selected >= visible || selected == self.completion.selected {
if selected >= self.completion.visible_len() || selected == self.completion.selected {
return;
}
self.completion.selected = selected;
@@ -529,7 +717,7 @@ impl MyApp {
/// Сдвигает выделение на `delta` строк, не выходя за пределы списка.
fn move_completion_selection(&mut self, delta: isize, cx: &mut Context<Self>) {
let visible = self.completion.items.len().min(MAX_COMPLETION_ROWS);
let visible = self.completion.visible_len();
if visible == 0 {
return;
}
@@ -610,70 +798,86 @@ impl MyApp {
.detach();
}
/// Обрабатывает клавиши, пока открыт список автодополнения.
/// Обрабатывает клавиши слоя автодополнения.
///
/// Вызывается из перехватчика клавиш, который срабатывает раньше
/// действий редактора. Пока список открыт, `↑`/`↓` и `PageUp`/`PageDown`
/// двигают выделение, а не курсор; `Enter`/`Tab` вставляют элемент,
/// `Esc` закрывает список. Горизонтальные стрелки, `Home` и `End`
/// закрывают список и работают с курсором как обычно.
///
/// Все привязки этого слоя приходят из [`KeyMap`], поэтому их можно
/// заменить через `app:map_completion_key`.
fn handle_completion_key(
&mut self,
keystroke: &Keystroke,
window: &mut Window,
cx: &mut Context<Self>,
) -> bool {
if is_completion_shortcut(keystroke) {
self.request_completions(window, cx);
return true;
let handlers = self
.keymap
.borrow()
.handlers_for(KeyContext::Completion, keystroke, true);
let mut handled = false;
for handler in handlers {
match handler {
KeyHandler::Completion(command) => {
handled |= self.run_completion_command(command, window, cx);
}
KeyHandler::Lua(callback) => {
if self.completion.open {
if let Err(err) = callback.call::<()>(()) {
eprintln!("Lua: ошибка в обработчике клавиши: {err}");
}
handled = true;
}
}
// Привязки другого слоя здесь не выполняются.
KeyHandler::App(_) => {}
}
}
if !self.completion.open {
// Сочетания с модификаторами и горизонтальная навигация закрывают
// список, даже если сценарий не назначил для них привязку.
if !handled && self.completion.open && dismisses_completion(keystroke) {
self.close_completion(cx);
}
handled
}
/// Выполняет встроенную команду списка автодополнения.
///
/// Возвращает `true`, если клавиша обработана и не должна доходить до
/// редактора.
fn run_completion_command(
&mut self,
command: CompletionCommand,
window: &mut Window,
cx: &mut Context<Self>,
) -> bool {
if command != CompletionCommand::Trigger && !self.completion.open {
return false;
}
// Сочетания с модификаторами — это не навигация по списку: закрываем
// его и отдаём клавишу редактору (Shift+↑ выделяет текст, Shift+Enter
// переносит строку и т. п.).
let plain = !(keystroke.modifiers.control
|| keystroke.modifiers.platform
|| keystroke.modifiers.alt
|| keystroke.modifiers.shift);
match keystroke.key.as_str() {
_ if !plain => {
self.close_completion(cx);
false
}
"enter" | "tab" => {
self.accept_completion(window, cx);
true
}
"escape" => {
self.close_completion(cx);
true
}
"up" => {
self.move_completion_selection(-1, cx);
true
}
"down" => {
self.move_completion_selection(1, cx);
true
}
"pageup" => {
self.move_completion_selection(-COMPLETION_PAGE_ROWS, cx);
true
}
"pagedown" => {
match command {
CompletionCommand::Trigger => self.request_completions(window, cx),
CompletionCommand::Next => self.move_completion_selection(1, cx),
CompletionCommand::Previous => self.move_completion_selection(-1, cx),
CompletionCommand::PageDown => {
self.move_completion_selection(COMPLETION_PAGE_ROWS, cx);
true
}
"left" | "right" | "home" | "end" => {
CompletionCommand::PageUp => {
self.move_completion_selection(-COMPLETION_PAGE_ROWS, cx);
}
CompletionCommand::Accept => self.accept_completion(window, cx),
CompletionCommand::Cancel => self.close_completion(cx),
CompletionCommand::Dismiss => {
self.close_completion(cx);
false
return false;
}
_ => false,
}
true
}
/// Вставляет выбранный элемент в редактор.
@@ -1022,7 +1226,7 @@ impl MyApp {
.text_xs()
.text_color(cx.theme().muted_foreground)
.child(
"Серверы LSP не запущены. Запустите их из init.lua через app:run_lsp{...}.",
"Серверы LSP не запущены. Запустите их из init.lua через app:run_lsp(...).",
),
);
} else {
@@ -1065,16 +1269,24 @@ impl MyApp {
/// Содержимое панели: блоки в порядке добавления.
///
/// Боковые панели рисуют все блоки друг под другом и не показывают их
/// заголовки. `tabs = true` (нижняя панель) рисует вкладки блоков и
/// показывает только активный блок.
///
/// Если блоков нет, правая панель показывает список LSP-серверов, как и
/// раньше, а остальные панели остаются пустыми.
fn render_panel_content(
&self,
panel: &PanelHandle,
lsp_fallback: bool,
tabs: bool,
window: &mut Window,
cx: &mut Context<Self>,
) -> AnyElement {
let blocks = panel.borrow().blocks().to_vec();
let (blocks, active) = {
let state = panel.borrow();
(state.blocks().to_vec(), state.active())
};
if blocks.is_empty() {
return if lsp_fallback {
self.render_right_panel(cx)
@@ -1084,15 +1296,73 @@ impl MyApp {
}
let mut content = div().size_full().flex().flex_col().min_h_0();
for block in blocks {
let element = match block {
PanelBlock::Tree(tree) => self.render_tree_block(&tree, window, cx),
};
content = content.child(div().w_full().flex_1().min_h_0().child(element));
if tabs {
content = content.child(
div()
.w_full()
.flex_shrink_0()
.child(self.render_panel_tabs(panel, &blocks, active, cx)),
);
if let Some(block) = blocks.get(active).or_else(|| blocks.first()) {
let PanelBlock::Tree { tree, .. } = block;
content = content.child(
div()
.w_full()
.flex_1()
.min_h_0()
.child(self.render_tree_block(tree, window, cx)),
);
}
} else {
for block in blocks {
let PanelBlock::Tree { tree, .. } = block;
content = content.child(
div()
.w_full()
.flex_1()
.min_h_0()
.child(self.render_tree_block(&tree, window, cx)),
);
}
}
content.into_any_element()
}
/// Полоса вкладок блоков нижней панели.
///
/// Выглядит как вкладки редактора, но без крестика: блоки не удаляются.
fn render_panel_tabs(
&self,
panel: &PanelHandle,
blocks: &[PanelBlock],
active: usize,
cx: &mut Context<Self>,
) -> impl IntoElement {
let entity = cx.entity();
let panel = panel.clone();
let mut bar = TabBar::new("bottom-panel-tabs")
.selected_index(active)
.folder();
for block in blocks {
bar = bar.child(
Tab::new()
.prefix(
Icon::new(icons::tab_icon(Some("list-tree")))
.small()
.flex_shrink_0()
.text_color(cx.theme().tab_foreground),
)
.label(block.title().to_owned()),
);
}
bar.on_click(move |index, _, cx| {
panel.borrow_mut().set_active(*index);
entity.update(cx, |_, cx| cx.notify());
})
}
/// Рисует один блок абстрактного дерева.
///
/// Узлы и их иерархию целиком задаёт Lua; вид только сортирует видимые
@@ -1248,25 +1518,34 @@ impl MyApp {
div().size_full().min_h_0().child(list).into_any_element()
}
/// Возвращает фокус в дерево, когда его панель открывается.
/// Возвращает фокус в активное дерево, когда его панель открывается.
///
/// Фокус получает только панель, которая была закрыта в прошлом кадре и
/// открылась сейчас; внутри неё выбирается первое дерево с выбранной
/// строкой. Если строку ещё ни разу не выбирали, фокус не забирается.
/// открылась сейчас; внутри неё выбирается дерево с выбранной строкой.
/// В нижней панели это дерево активной вкладки, в боковых — первый блок.
fn sync_panel_focus(&self, open: [bool; 3], window: &mut Window, cx: &mut Context<Self>) {
let previous = self.panel_open_state.get();
let panels = [&self.left_panel, &self.right_panel, &self.bottom_panel];
for (index, panel) in panels.into_iter().enumerate() {
let panels = [
(&self.left_panel, false),
(&self.right_panel, false),
(&self.bottom_panel, true),
];
for (index, (panel, tabs)) in panels.into_iter().enumerate() {
if !open[index] || previous[index] {
continue;
}
let tree = panel
.borrow()
.blocks()
.iter()
.find_map(|block| match block {
PanelBlock::Tree(tree) => Some(tree.clone()),
});
let tree = {
let state = panel.borrow();
let block = if tabs {
state.blocks().get(state.active())
} else {
state.blocks().first()
};
block.map(|block| {
let PanelBlock::Tree { tree, .. } = block;
tree.clone()
})
};
let Some(tree) = tree else {
continue;
};
@@ -1293,12 +1572,30 @@ impl MyApp {
}
self.synced_revision = revision;
let (active_id, content, language, cursor) = {
let workspace = self.workspace.borrow();
let (active_id, content, language, cursor, focus_after_sync) = {
let mut workspace = self.workspace.borrow_mut();
let focus_after_sync = workspace.take_focus_after_sync();
let doc = workspace.active();
(doc.id, doc.content.clone(), doc.language, doc.cursor)
(
doc.id,
doc.content.clone(),
doc.language,
doc.cursor,
focus_after_sync,
)
};
// `None` сохраняет прежнее поведение: любое внешнее изменение само
// возвращает фокус редактору. `app:open` без позиции это отменяет.
let (focus_editor, focus_cursor) = match focus_after_sync {
Some(FocusAfterSync::Keep) => (false, None),
Some(FocusAfterSync::Editor(cursor)) => (true, cursor),
None => (true, None),
};
// Только явный запрос должен победить отложенный фокус дерева.
self.focus_editor_after_render =
matches!(focus_after_sync, Some(FocusAfterSync::Editor(_)));
let switching = active_id != self.displayed_id;
if switching {
// Запоминаем курсор покидаемой вкладки до того, как индекс
@@ -1315,7 +1612,7 @@ impl MyApp {
self.displayed_id = active_id;
let highlight = language
.map(|language| language.highlight_name())
.map(|language| language.syntax_name())
.unwrap_or("");
self.editor.update(cx, |state, cx| {
if state.value().as_ref() != content.as_str() {
@@ -1324,16 +1621,20 @@ impl MyApp {
if state.language_name().as_ref() != highlight {
state.set_highlighter(highlight, cx);
}
if switching {
let cursor = cursor.min(state.value().len());
if switching || focus_cursor.is_some() {
let cursor = focus_cursor.unwrap_or(cursor).min(state.value().len());
state.set_selected_range(cursor..cursor, cx);
}
state.focus(window, cx);
if focus_editor {
state.focus(window, cx);
}
});
if switching {
self.close_completion(cx);
self.sync_lsp_document();
// У другой вкладки своя диагностика, уже пришедшая от сервера.
self.apply_diagnostics(cx);
}
}
@@ -1371,19 +1672,16 @@ impl MyApp {
// Нижняя панель занимает ширину редактора, а правая тянется на всю
// высоту — как в большинстве редакторов кода.
let editor_area =
v_resizable("editor-area")
.child(
resizable_panel().child(Editor::new(&self.editor).bordered(false).size_full()),
)
.child(
resizable_panel()
.size(px(bottom_size))
.visible(bottom_open)
.child(div().size_full().bg(cx.theme().sidebar).child(
self.render_panel_content(&self.bottom_panel, false, window, cx),
)),
);
let editor_area = v_resizable("editor-area")
.child(resizable_panel().child(Editor::new(&self.editor).bordered(false).size_full()))
.child(
resizable_panel()
.size(px(bottom_size))
.visible(bottom_open)
.child(div().size_full().bg(cx.theme().sidebar).child(
self.render_panel_content(&self.bottom_panel, false, true, window, cx),
)),
);
let area = h_resizable("main-area")
.child(resizable_panel().child(editor_area))
@@ -1391,12 +1689,9 @@ impl MyApp {
resizable_panel()
.size(px(right_size))
.visible(right_open)
.child(
div()
.size_full()
.bg(cx.theme().sidebar)
.child(self.render_panel_content(&self.right_panel, true, window, cx)),
),
.child(div().size_full().bg(cx.theme().sidebar).child(
self.render_panel_content(&self.right_panel, true, false, window, cx),
)),
);
let mut row = div().size_full().flex().flex_row();
@@ -1408,7 +1703,7 @@ impl MyApp {
.flex_shrink_0()
.relative()
.bg(cx.theme().sidebar)
.child(self.render_panel_content(&self.left_panel, false, window, cx))
.child(self.render_panel_content(&self.left_panel, false, false, window, cx))
.child(
resize_handle("left-panel-handle", Axis::Horizontal)
.inside(HandleEdge::Trailing)
@@ -1445,7 +1740,7 @@ impl MyApp {
(
doc.name.clone(),
doc.is_dirty(),
icons::tab_icon(doc.icon.as_deref()),
icons::tab_icon(Some(doc.icon_name())),
)
})
.collect::<Vec<_>>(),
@@ -1547,6 +1842,14 @@ impl Render for MyApp {
self.editor.update(cx, |state, cx| state.focus(window, cx));
}
// Панели могут отложить возврат фокуса своему дереву — явный запрос
// `app:open(path, ...)` применяем ещё раз уже после их отрисовки.
let workspace = self.render_workspace(window, cx).into_any_element();
if self.focus_editor_after_render {
self.focus_editor_after_render = false;
self.editor.update(cx, |state, cx| state.focus(window, cx));
}
let title = {
let workspace = self.workspace.borrow();
format!("{} — {WINDOW_TITLE}", workspace.active().name)
@@ -1566,12 +1869,7 @@ impl Render for MyApp {
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);
let handled = run_keymap(&keymap, KeyContext::Global, &event.keystroke, true);
// Обработчик мог поставить команду окна или изменить документы.
apply_window_commands(window);
if handled {
@@ -1583,7 +1881,7 @@ impl Render for MyApp {
let keymap = keymap.clone();
let entity = entity.clone();
move |event, window, cx| {
let handled = dispatch_keymap(&keymap, &event.keystroke, false);
let handled = run_keymap(&keymap, KeyContext::Global, &event.keystroke, false);
apply_window_commands(window);
if handled {
entity.update(cx, |_, cx| cx.notify());
@@ -1591,32 +1889,30 @@ impl Render for MyApp {
}
})
.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"),
),
),
TitleBar::new().child(div().w_full().pr_2().text_sm().font_medium().child(title)),
)
.child(self.render_tabs(cx))
.child(
div()
.flex_1()
.w_full()
.min_h_0()
.child(self.render_workspace(window, cx)),
)
.child(div().flex_1().w_full().min_h_0().child(workspace))
.child(self.render_completion_popup(cx))
}
}
/// Переводит диапазон LSP в байтовый диапазон текста.
///
/// Пустой диапазон (например, «ожидалось `;`») расширяется на следующий
/// символ: иначе подчёркивать было бы нечего.
fn diagnostic_range(text: &str, range: lsp_types::Range) -> Option<Range<usize>> {
let start = position_to_offset(text, range.start).min(text.len());
let mut end = position_to_offset(text, range.end).min(text.len());
if end <= start {
end = text[start..]
.chars()
.next()
.map_or(start, |ch| start + ch.len_utf8());
}
(start < end).then_some(start..end)
}
/// Выполняет накопленные команды окна.
fn apply_window_commands(window: &mut Window) {
for command in api::drain_window_commands() {
@@ -1636,20 +1932,6 @@ fn apply_window_commands(window: &mut 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
}
/// Ctrl+Space открывает автодополнение вручную.
fn is_completion_shortcut(keystroke: &Keystroke) -> bool {
keystroke.key.eq_ignore_ascii_case("space")
&& (keystroke.modifiers.control || keystroke.modifiers.platform)
&& !keystroke.modifiers.alt
}
/// Вызывает Lua-обработчик, назначенный блоку дерева.
///
/// Ошибка Lua не должна ломать отрисовку: она печатается в stderr, как и
@@ -1660,21 +1942,19 @@ fn run_tree_action(action: TreeAction) {
id,
expanded,
loaded,
callback,
} => {
if let Some(callback) = callback
&& let Err(err) = callback.call::<()>((expanded, id, loaded))
{
eprintln!("Lua: ошибка в обработчике дерева: {err}");
}
}
TreeAction::Click { id, callback } => {
if let Some(callback) = callback
&& let Err(err) = callback.call::<()>(id)
{
eprintln!("Lua: ошибка в обработчике дерева: {err}");
}
}
callback: Some(callback),
} => report_tree_error(callback.call::<()>((expanded, id, loaded))),
TreeAction::Click {
id,
callback: Some(callback),
} => report_tree_error(callback.call::<()>(id)),
TreeAction::Toggle { callback: None, .. } | TreeAction::Click { callback: None, .. } => {}
}
}
fn report_tree_error(result: mlua::Result<()>) {
if let Err(err) = result {
eprintln!("Lua: ошибка в обработчике дерева: {err}");
}
}
@@ -1685,17 +1965,70 @@ fn save_active_tab() {
}
}
/// Вызывает Lua-обработчики, совпавшие с событием клавиатуры.
/// Выполняет встроенную команду приложения.
fn run_app_command(command: AppCommand) {
let app = AppApi::global();
match command {
AppCommand::Save => save_active_tab(),
AppCommand::NextTab => {
app.next_tab();
}
AppCommand::PreviousTab => {
app.previous_tab();
}
AppCommand::OpenTab => {
app.open_tab();
}
AppCommand::CloseTab => {
app.close_tab(app.active_tab_index());
}
AppCommand::ToggleLeftPanel => {
app.left_panel().toggle();
}
AppCommand::ToggleRightPanel => {
app.right_panel().toggle();
}
AppCommand::ToggleBottomPanel => {
app.bottom_panel().toggle();
}
}
}
/// Клавиша закрывает список автодополнения, даже если привязки не назначено.
///
/// Возвращает `true`, если хотя бы один обработчик вызван. Заём карты
/// Это сохраняет привычное поведение: Shift+↑ выделяет текст, а не листает
/// список, а стрелки влево/вправо и Home/End уводят курсор из-под списка.
fn dismisses_completion(keystroke: &Keystroke) -> bool {
let modified = keystroke.modifiers.control
|| keystroke.modifiers.platform
|| keystroke.modifiers.alt
|| keystroke.modifiers.shift;
modified || matches!(keystroke.key.as_str(), "left" | "right" | "home" | "end")
}
/// Вызывает обработчики одного слоя `KeyMap` для события клавиатуры.
///
/// Возвращает `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 {
if let Err(err) = callback.call::<()>(()) {
eprintln!("Lua: ошибка в обработчике клавиши: {err}");
fn run_keymap(
keymap: &Rc<RefCell<KeyMap>>,
context: KeyContext,
keystroke: &Keystroke,
pressed: bool,
) -> bool {
let handlers = keymap.borrow().handlers_for(context, keystroke, pressed);
let handled = !handlers.is_empty();
for handler in handlers {
match handler {
KeyHandler::Lua(callback) => {
if let Err(err) = callback.call::<()>(()) {
eprintln!("Lua: ошибка в обработчике клавиши: {err}");
}
}
KeyHandler::App(command) => run_app_command(command),
// Другой слой обрабатывается отдельно.
KeyHandler::Completion(_) => {}
}
}
handled
+70 -43
View File
@@ -281,6 +281,56 @@ pub fn completion_kind_name(kind: Option<CompletionItemKind>) -> &'static str {
}
}
/// Группа элемента автодополнения: по ней выбираются иконка и цвет.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum CompletionKindGroup {
Function,
Type,
Variable,
Keyword,
Module,
File,
Folder,
Snippet,
Event,
TypeParameter,
Operator,
Other,
Unknown,
}
fn kind_group(kind: Option<CompletionItemKind>) -> CompletionKindGroup {
match kind {
Some(CompletionItemKind::FUNCTION | CompletionItemKind::METHOD) => {
CompletionKindGroup::Function
}
Some(
CompletionItemKind::STRUCT
| CompletionItemKind::CLASS
| CompletionItemKind::INTERFACE
| CompletionItemKind::ENUM
| CompletionItemKind::CONSTRUCTOR,
) => CompletionKindGroup::Type,
Some(
CompletionItemKind::VARIABLE
| CompletionItemKind::FIELD
| CompletionItemKind::PROPERTY
| CompletionItemKind::CONSTANT
| CompletionItemKind::ENUM_MEMBER,
) => CompletionKindGroup::Variable,
Some(CompletionItemKind::KEYWORD) => CompletionKindGroup::Keyword,
Some(CompletionItemKind::MODULE) => CompletionKindGroup::Module,
Some(CompletionItemKind::FILE) => CompletionKindGroup::File,
Some(CompletionItemKind::FOLDER) => CompletionKindGroup::Folder,
Some(CompletionItemKind::SNIPPET) => CompletionKindGroup::Snippet,
Some(CompletionItemKind::EVENT) => CompletionKindGroup::Event,
Some(CompletionItemKind::TYPE_PARAMETER) => CompletionKindGroup::TypeParameter,
Some(CompletionItemKind::OPERATOR) => CompletionKindGroup::Operator,
Some(_) => CompletionKindGroup::Other,
None => CompletionKindGroup::Unknown,
}
}
/// Иконка для типа элемента автодополнения.
///
/// Используется полный каталог иконок gpui-kit-assets, а не компактный набор
@@ -289,32 +339,20 @@ pub fn completion_kind_name(kind: Option<CompletionItemKind>) -> &'static str {
pub fn completion_icon(kind: Option<CompletionItemKind>) -> gpui_kit::assets::IconName {
use gpui_kit::assets::IconName as Icon;
match kind {
Some(CompletionItemKind::FUNCTION | CompletionItemKind::METHOD) => Icon::SquareFunction,
Some(
CompletionItemKind::STRUCT
| CompletionItemKind::CLASS
| CompletionItemKind::INTERFACE
| CompletionItemKind::ENUM
| CompletionItemKind::CONSTRUCTOR,
) => Icon::Box,
Some(
CompletionItemKind::VARIABLE
| CompletionItemKind::FIELD
| CompletionItemKind::PROPERTY
| CompletionItemKind::CONSTANT
| CompletionItemKind::ENUM_MEMBER,
) => Icon::Variable,
Some(CompletionItemKind::KEYWORD) => Icon::Type,
Some(CompletionItemKind::MODULE) => Icon::Braces,
Some(CompletionItemKind::FILE) => Icon::FileText,
Some(CompletionItemKind::FOLDER) => Icon::Folder,
Some(CompletionItemKind::SNIPPET) => Icon::Component,
Some(CompletionItemKind::EVENT) => Icon::Activity,
Some(CompletionItemKind::TYPE_PARAMETER) => Icon::TypeOutline,
Some(CompletionItemKind::OPERATOR) => Icon::CaseSensitive,
Some(_) => Icon::Circle,
None => Icon::Sparkles,
match kind_group(kind) {
CompletionKindGroup::Function => Icon::SquareFunction,
CompletionKindGroup::Type => Icon::Box,
CompletionKindGroup::Variable => Icon::Variable,
CompletionKindGroup::Keyword => Icon::Type,
CompletionKindGroup::Module => Icon::Braces,
CompletionKindGroup::File => Icon::FileText,
CompletionKindGroup::Folder => Icon::Folder,
CompletionKindGroup::Snippet => Icon::Component,
CompletionKindGroup::Event => Icon::Activity,
CompletionKindGroup::TypeParameter => Icon::TypeOutline,
CompletionKindGroup::Operator => Icon::CaseSensitive,
CompletionKindGroup::Other => Icon::Circle,
CompletionKindGroup::Unknown => Icon::Sparkles,
}
}
@@ -323,23 +361,12 @@ pub fn completion_icon_color(
kind: Option<CompletionItemKind>,
theme: &gpui_kit::component::Theme,
) -> Hsla {
match kind {
Some(CompletionItemKind::FUNCTION | CompletionItemKind::METHOD) => theme.blue,
Some(
CompletionItemKind::STRUCT
| CompletionItemKind::CLASS
| CompletionItemKind::INTERFACE
| CompletionItemKind::ENUM,
) => theme.yellow,
Some(
CompletionItemKind::VARIABLE
| CompletionItemKind::FIELD
| CompletionItemKind::PROPERTY
| CompletionItemKind::CONSTANT
| CompletionItemKind::ENUM_MEMBER,
) => theme.green,
Some(CompletionItemKind::KEYWORD) => theme.magenta,
Some(CompletionItemKind::MODULE) => theme.cyan,
match kind_group(kind) {
CompletionKindGroup::Function => theme.blue,
CompletionKindGroup::Type => theme.yellow,
CompletionKindGroup::Variable => theme.green,
CompletionKindGroup::Keyword => theme.magenta,
CompletionKindGroup::Module => theme.cyan,
_ => theme.muted_foreground,
}
}
+1 -1
View File
@@ -17,7 +17,7 @@ pub const MIN_WINDOW_WIDTH: f32 = 420.0;
pub const MIN_WINDOW_HEIGHT: f32 = 280.0;
/// Подпись окна в панели задач и в заголовке.
pub const WINDOW_TITLE: &str = "Custom Window App";
pub const WINDOW_TITLE: &str = "qode";
/// Ширина одного уровня отступа в редакторе.
pub const TAB_SIZE: usize = 4;
+53 -62
View File
@@ -25,17 +25,6 @@ pub enum Language {
}
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`; короткие
@@ -62,25 +51,12 @@ impl Language {
}
}
/// Имя языка для `EditorState::set_highlighter`.
/// Полное имя языка для tree-sitter и LSP.
///
/// Здесь `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",
}
}
/// Идентификатор языка для LSP (`languageId` в `textDocument/didOpen`).
///
/// Отличается от [`Self::name`] тем же, чем `highlight_name`: LSP ждёт
/// `javascript`, а не `js`.
pub fn lsp_name(self) -> &'static str {
/// Отличается от [`Self::name`] только для JavaScript: грамматика и
/// `languageId` используют `javascript`, а короткое `js` остаётся именем
/// в Lua-API.
pub fn syntax_name(self) -> &'static str {
match self {
Self::Rust => "rust",
Self::Go => "go",
@@ -188,17 +164,35 @@ impl Document {
/// по расширению имени вкладки — так LSP работает и без `set_language`.
pub fn lsp_language_id(&self) -> String {
if let Some(language) = self.language {
return language.lsp_name().to_owned();
return language.syntax_name().to_owned();
}
language_id_from_path(&self.name).to_owned()
}
/// Имя иконки вкладки с учётом иконки по умолчанию.
pub fn icon_name(&self) -> &str {
self.icon
.as_deref()
.unwrap_or(crate::icons::DEFAULT_TAB_ICON)
}
/// Помечает текущее содержимое сохранённым.
pub fn mark_saved(&mut self) {
self.saved_content = self.content.clone();
}
}
/// Что сделать с фокусом редактора после синхронизации модели с видом.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum FocusAfterSync {
/// Оставить фокус там, где он был (`app:open` без позиции).
Keep,
/// Перевести фокус в редактор и, если задано, поставить курсор.
///
/// Смещение задаётся в байтах; `None` — оставить текущую позицию.
Editor(Option<usize>),
}
/// Открытые документы и активная вкладка.
///
/// Инвариант: `documents` никогда не пуст — редактор всегда показывает хотя бы
@@ -207,6 +201,8 @@ pub struct Workspace {
documents: Vec<Document>,
active: usize,
next_untitled: u32,
/// Действие с фокусом, ожидающее ближайшей синхронизации вида.
focus_after_sync: Option<FocusAfterSync>,
/// Номер «поколения» состояния.
///
/// Вид перечитывает активный документ в редактор только когда ревизия
@@ -218,12 +214,10 @@ pub struct Workspace {
impl Default for Workspace {
fn default() -> Self {
Self {
documents: vec![
Document::new("main.rs", SAMPLE_RUST),
Document::new("notes.md", SAMPLE_NOTES),
],
documents: vec![Document::new("untitled-1.txt", "")],
active: 0,
next_untitled: 0,
next_untitled: 1,
focus_after_sync: None,
revision: 0,
}
}
@@ -261,6 +255,16 @@ impl Workspace {
self.revision
}
/// Откладывает действие с фокусом до ближайшей синхронизации вида.
pub fn set_focus_after_sync(&mut self, focus: FocusAfterSync) {
self.focus_after_sync = Some(focus);
}
/// Забирает отложенное действие с фокусом.
pub fn take_focus_after_sync(&mut self) -> Option<FocusAfterSync> {
self.focus_after_sync.take()
}
/// Сообщает виду, что документы нужно перечитать.
fn bump(&mut self) {
self.revision = self.revision.wrapping_add(1);
@@ -274,6 +278,17 @@ impl Workspace {
}
}
/// Переключает активную вкладку на `delta` позиций по кругу.
///
/// Возвращает новый индекс. Инвариант «хотя бы один документ» делает
/// деление по модулю безопасным.
pub fn cycle_active(&mut self, delta: isize) -> usize {
let count = self.documents.len() as isize;
let next = (self.active as isize + delta).rem_euclid(count) as usize;
self.set_active(next);
next
}
/// Открывает пустую вкладку и делает её активной.
pub fn open_new(&mut self) {
self.next_untitled += 1;
@@ -333,7 +348,9 @@ impl Workspace {
if self.documents.is_empty() {
self.open_new();
} else if index < self.active {
return;
}
if index < self.active {
self.active -= 1;
} else if index == self.active {
self.active = self.active.min(self.documents.len() - 1);
@@ -417,30 +434,4 @@ impl Workspace {
}
true
}
/// Имя иконки вкладки, если оно назначено.
pub fn tab_icon(&self, index: usize) -> Option<&str> {
self.document(index)
.and_then(|document| document.icon.as_deref())
}
}
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 сохраняет вкладку в файл с её названием
"#;
+299 -20
View File
@@ -1,12 +1,17 @@
//! Привязки клавиш к Lua-обработчикам.
//! Привязки клавиш к Lua-обработчикам и встроенным командам.
//!
//! Запись привязки немного напоминает nvim:
//!
//! * `a`, `1` — нажатие клавиши без модификаторов;
//! * `<S-c>`, `<C-z>`, `<A-r>` — нажатие с Shift, Ctrl или Alt;
//! * `<S-c>`, `<C-z>`, `<A-r>`, `<D-s>` — нажатие с Shift, Ctrl, Alt или
//! платформенным модификатором (Cmd на macOS, Super на Linux);
//! * `!a` — отпускание клавиши;
//! * `<A-!r>` — отпускание клавиши при зажатом Alt.
//!
//! Привязка действует в одном из двух слоёв: глобальном или в списке
//! автодополнения. Оба слоя проходят через одну [`KeyMap`], поэтому сценарий
//! может переопределить и удалить любую встроенную привязку.
//!
//! Клавиша сравнивается с `Keystroke.key` из GPUI. Это не результат ввода, а
//! имя клавиши, которое платформа уже нормализует:
//!
@@ -29,19 +34,21 @@ pub struct Modifiers {
pub shift: bool,
pub ctrl: bool,
pub alt: bool,
/// Cmd на macOS, Super на Linux и Windows.
pub platform: bool,
}
impl Modifiers {
/// Все три модификатора должны совпасть точно: `<C-z>` не сработает,
/// если вместе с Ctrl зажат ещё и Shift.
/// Все модификаторы должны совпасть точно: `<C-z>` не сработает, если
/// вместе с Ctrl зажат ещё и Shift.
///
/// `platform` (Cmd/Super) и `function` в записи привязки не выражаются,
/// поэтому события с ними не совпадают ни с одной привязкой.
/// `function` в записи привязки не выражается, поэтому события с ним не
/// совпадают ни с одной привязкой.
fn matches(self, pressed: &gpui_kit::Modifiers) -> bool {
self.shift == pressed.shift
&& self.ctrl == pressed.control
&& self.alt == pressed.alt
&& !pressed.platform
&& self.platform == pressed.platform
&& !pressed.function
}
}
@@ -101,6 +108,7 @@ impl KeyBinding {
"S" | "s" => modifiers.shift = true,
"C" | "c" => modifiers.ctrl = true,
"A" | "a" => modifiers.alt = true,
"D" | "d" | "Cmd" | "cmd" | "Super" | "super" => modifiers.platform = true,
other => return Err(format!("неизвестный модификатор: {other}")),
}
}
@@ -206,10 +214,146 @@ fn canonical_key(key: &str) -> String {
lower
}
/// Привязка вместе с Lua-функцией, которую нужно выполнить.
/// Слой, в котором действует привязка.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum KeyContext {
/// Обычные команды приложения и редактора.
#[default]
Global,
/// Навигация, пока открыт список автодополнения.
Completion,
}
/// Встроенная команда приложения, которую можно назначить на клавишу.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum AppCommand {
Save,
NextTab,
PreviousTab,
OpenTab,
CloseTab,
ToggleLeftPanel,
ToggleRightPanel,
ToggleBottomPanel,
}
impl AppCommand {
/// Все встроенные команды приложения.
pub const ALL: [Self; 8] = [
Self::Save,
Self::NextTab,
Self::PreviousTab,
Self::OpenTab,
Self::CloseTab,
Self::ToggleLeftPanel,
Self::ToggleRightPanel,
Self::ToggleBottomPanel,
];
/// Имя команды в Lua-API.
pub fn name(self) -> &'static str {
match self {
Self::Save => "save",
Self::NextTab => "next_tab",
Self::PreviousTab => "previous_tab",
Self::OpenTab => "open_tab",
Self::CloseTab => "close_tab",
Self::ToggleLeftPanel => "toggle_left_panel",
Self::ToggleRightPanel => "toggle_right_panel",
Self::ToggleBottomPanel => "toggle_bottom_panel",
}
}
/// Разбирает имя команды из Lua-обработчика.
pub fn parse(name: &str) -> Option<Self> {
match name {
"save" => Some(Self::Save),
"next_tab" => Some(Self::NextTab),
"previous_tab" => Some(Self::PreviousTab),
"open_tab" => Some(Self::OpenTab),
"close_tab" => Some(Self::CloseTab),
"toggle_left_panel" => Some(Self::ToggleLeftPanel),
"toggle_right_panel" => Some(Self::ToggleRightPanel),
"toggle_bottom_panel" => Some(Self::ToggleBottomPanel),
_ => None,
}
}
}
/// Встроенная команда списка автодополнения.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum CompletionCommand {
Trigger,
Next,
Previous,
PageUp,
PageDown,
Accept,
Cancel,
/// Закрыть список и отдать клавишу редактору.
Dismiss,
}
impl CompletionCommand {
/// Все встроенные команды слоя автодополнения.
pub const ALL: [Self; 8] = [
Self::Trigger,
Self::Next,
Self::Previous,
Self::PageUp,
Self::PageDown,
Self::Accept,
Self::Cancel,
Self::Dismiss,
];
/// Имя команды в Lua-API.
pub fn name(self) -> &'static str {
match self {
Self::Trigger => "completion_trigger",
Self::Next => "completion_next",
Self::Previous => "completion_previous",
Self::PageUp => "completion_page_up",
Self::PageDown => "completion_page_down",
Self::Accept => "completion_accept",
Self::Cancel => "completion_cancel",
Self::Dismiss => "completion_dismiss",
}
}
/// Разбирает имя команды из Lua-обработчика.
pub fn parse(name: &str) -> Option<Self> {
match name {
"completion_trigger" => Some(Self::Trigger),
"completion_next" => Some(Self::Next),
"completion_previous" => Some(Self::Previous),
"completion_page_up" => Some(Self::PageUp),
"completion_page_down" => Some(Self::PageDown),
"completion_accept" => Some(Self::Accept),
"completion_cancel" => Some(Self::Cancel),
"completion_dismiss" => Some(Self::Dismiss),
_ => None,
}
}
}
/// Что нужно выполнить для совпавшей привязки.
#[derive(Clone)]
pub enum KeyHandler {
/// Функция из Lua-сценария.
Lua(mlua::Function),
/// Встроенная команда приложения.
App(AppCommand),
/// Встроенная команда автодополнения.
Completion(CompletionCommand),
}
/// Привязка вместе с обработчиком.
#[derive(Clone)]
struct Binding {
context: KeyContext,
binding: KeyBinding,
callback: mlua::Function,
handler: KeyHandler,
}
/// Все назначенные привязки клавиш.
@@ -219,32 +363,98 @@ pub struct KeyMap {
}
impl KeyMap {
/// Добавляет привязку. Повторное назначение той же клавиши заменяет
/// предыдущий обработчик.
pub fn bind(&mut self, binding: KeyBinding, callback: mlua::Function) {
/// Назначает или заменяет обработчик в указанном слое.
pub fn set(&mut self, context: KeyContext, binding: KeyBinding, handler: KeyHandler) {
if let Some(existing) = self
.bindings
.iter_mut()
.find(|existing| existing.binding == binding)
.find(|existing| existing.context == context && existing.binding == binding)
{
existing.callback = callback;
existing.handler = handler;
} else {
self.bindings.push(Binding { binding, callback });
self.bindings.push(Binding {
context,
binding,
handler,
});
}
}
/// Собирает обработчики, которые нужно вызвать для одного события.
/// Удаляет привязку из указанного слоя.
///
/// Заём `KeyMap` здесь заканчивается: вызывать колбэки нужно уже после
/// Возвращает `true`, если привязка была.
pub fn unbind(&mut self, context: KeyContext, binding: &KeyBinding) -> bool {
let before = self.bindings.len();
self.bindings
.retain(|existing| !(existing.context == context && &existing.binding == binding));
before != self.bindings.len()
}
/// Собирает обработчики, которые нужно выполнить для одного события.
///
/// Заём `KeyMap` здесь заканчивается: вызывать Lua нужно уже после
/// возврата из метода, потому что обработчик может сам менять привязки
/// через `app:map_key`.
pub fn callbacks_for(&self, keystroke: &Keystroke, pressed: bool) -> Vec<mlua::Function> {
pub fn handlers_for(
&self,
context: KeyContext,
keystroke: &Keystroke,
pressed: bool,
) -> Vec<KeyHandler> {
self.bindings
.iter()
.filter(|binding| binding.binding.matches(keystroke, pressed))
.map(|binding| binding.callback.clone())
.filter(|binding| {
binding.context == context && binding.binding.matches(keystroke, pressed)
})
.map(|binding| binding.handler.clone())
.collect()
}
/// Назначает встроенные привязки приложения.
///
/// Они проходят через тот же `KeyMap`, поэтому `app:map_key` и
/// `app:map_completion_key` могут их заменить или удалить.
pub fn install_defaults(&mut self) {
for spec in ["<C-s>", "<D-s>"] {
self.set(
KeyContext::Global,
parse_default(spec),
KeyHandler::App(AppCommand::Save),
);
}
for spec in ["<C-space>", "<D-space>"] {
self.set(
KeyContext::Completion,
parse_default(spec),
KeyHandler::Completion(CompletionCommand::Trigger),
);
}
for (spec, command) in [
("enter", CompletionCommand::Accept),
("tab", CompletionCommand::Accept),
("escape", CompletionCommand::Cancel),
("up", CompletionCommand::Previous),
("down", CompletionCommand::Next),
("pageup", CompletionCommand::PageUp),
("pagedown", CompletionCommand::PageDown),
("left", CompletionCommand::Dismiss),
("right", CompletionCommand::Dismiss),
("home", CompletionCommand::Dismiss),
("end", CompletionCommand::Dismiss),
] {
self.set(
KeyContext::Completion,
parse_default(spec),
KeyHandler::Completion(command),
);
}
}
}
/// Привязка из встроенного списка: ошибка здесь была бы опечаткой в коде.
fn parse_default(spec: &str) -> KeyBinding {
KeyBinding::parse(spec).expect("встроенная привязка клавиши должна разбираться")
}
#[cfg(test)]
@@ -335,6 +545,18 @@ mod tests {
shift: true,
ctrl: true,
alt: true,
platform: false,
},
on_release: false,
}
);
assert_eq!(
parse("<D-s>"),
KeyBinding {
key: "s".to_owned(),
modifiers: Modifiers {
platform: true,
..Default::default()
},
on_release: false,
}
@@ -373,9 +595,15 @@ mod tests {
control: true,
..Default::default()
};
let shift_platform = gpui_kit::Modifiers {
shift: true,
platform: true,
..Default::default()
};
assert!(binding.matches(&stroke("c", shift), true));
assert!(!binding.matches(&stroke("c", shift_ctrl), true));
assert!(!binding.matches(&stroke("c", shift_platform), true));
assert!(!binding.matches(&stroke("c", shift), false));
assert!(!binding.matches(&stroke("v", shift), true));
}
@@ -439,4 +667,55 @@ mod tests {
// А привязка вида `<A-!a>` — совпадает.
assert!(parse("<A-!a>").matches(&stroke("a", alt), false));
}
#[test]
fn default_bindings_are_scoped_and_can_be_removed() {
let mut keymap = KeyMap::default();
keymap.install_defaults();
let ctrl = gpui_kit::Modifiers {
control: true,
..Default::default()
};
let save = keymap.handlers_for(KeyContext::Global, &stroke("s", ctrl), true);
assert!(matches!(
save.as_slice(),
[KeyHandler::App(AppCommand::Save)]
));
let trigger = keymap.handlers_for(KeyContext::Completion, &stroke("space", ctrl), true);
assert!(matches!(
trigger.as_slice(),
[KeyHandler::Completion(CompletionCommand::Trigger)]
));
let down = keymap.handlers_for(
KeyContext::Completion,
&stroke("down", gpui_kit::Modifiers::default()),
true,
);
assert!(matches!(
down.as_slice(),
[KeyHandler::Completion(CompletionCommand::Next)]
));
// Стрелка вниз в обычном режиме принадлежит редактору.
assert!(
keymap
.handlers_for(
KeyContext::Global,
&stroke("down", gpui_kit::Modifiers::default()),
true,
)
.is_empty()
);
let binding = parse("<C-s>");
assert!(keymap.unbind(KeyContext::Global, &binding));
assert!(
keymap
.handlers_for(KeyContext::Global, &stroke("s", ctrl), true)
.is_empty()
);
}
}
+404 -51
View File
@@ -6,6 +6,7 @@
//! * JSON-RPC 2.0 с кадрами `Content-Length`;
//! * синхронизацию открытого документа (`didOpen`/`didChange`/`didSave`);
//! * запросы автодополнения, подсказок и `completionItem/resolve`;
//! * хранение диагностики `textDocument/publishDiagnostics` по документам;
//! * файловый вотчер, который пересылает серверу `workspace/didChangeWatchedFiles`.
//!
//! Всё остальное — какие серверы запускать, с какими аргументами и когда —
@@ -26,11 +27,108 @@ use std::sync::{Arc, Weak};
use std::time::Duration;
use globset::{Glob, GlobSet, GlobSetBuilder};
use lsp_types::{CompletionItem, Position, Uri};
use lsp_types::{
CompletionItem, Diagnostic, DiagnosticSeverity, DiagnosticTag, NumberOrString, Position, Uri,
};
use notify::{EventKind, RecommendedWatcher, RecursiveMode, Watcher};
use parking_lot::Mutex;
use serde_json::{Value, json};
/// Диагностика одного сервера для одного документа.
///
/// Эту структуру Lua получает в обработчике `app:on_lsp_diagnostics(...)`:
/// она содержит имя приславшего сервера, URI и путь файла, а также список
/// сообщений.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct DocumentDiagnostics {
/// Имя LSP-сервера, который прислал диагностику.
pub server: String,
/// URI документа в виде, в котором его прислал сервер.
pub uri: String,
/// Абсолютный путь файла для `file://`-URI; `None` для остальных схем.
pub path: Option<String>,
/// Сообщения диагностики в порядке, присланном сервером.
pub diagnostics: Vec<DiagnosticInfo>,
}
/// Одно сообщение диагностики.
///
/// Позиции, как и в LSP, нумеруются с нуля; `line` — строка, `character` —
/// столбец в UTF-16. Для человекочитаемого номера строки прибавьте единицу.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct DiagnosticInfo {
/// Начальная строка (с нуля).
pub line: u32,
/// Начальный столбец в UTF-16 (с нуля).
pub character: u32,
/// Конечная строка (с нуля).
pub end_line: u32,
/// Конечный столбец в UTF-16 (с нуля).
pub end_character: u32,
/// Числовой уровень: 1 — ошибка, 2 — предупреждение, 3 — информация,
/// 4 — подсказка.
pub severity: Option<i32>,
/// Имя уровня: `error`, `warning`, `information` или `hint`.
pub severity_name: Option<String>,
/// Текст сообщения.
pub message: String,
/// Источник сообщения (обычно имя сервера).
pub source: Option<String>,
/// Код ошибки, если сервер его прислал.
pub code: Option<String>,
/// Теги: `unnecessary`, `deprecated`.
pub tags: Vec<String>,
}
impl DiagnosticInfo {
/// Переводит сообщение протокола в структуру для Lua.
pub fn from_lsp(diagnostic: &Diagnostic) -> Self {
let (severity, severity_name) = match diagnostic.severity {
Some(value) if value == DiagnosticSeverity::ERROR => (Some(1), Some("error")),
Some(value) if value == DiagnosticSeverity::WARNING => (Some(2), Some("warning")),
Some(value) if value == DiagnosticSeverity::INFORMATION => {
(Some(3), Some("information"))
}
Some(value) if value == DiagnosticSeverity::HINT => (Some(4), Some("hint")),
_ => (None, None),
};
let code = diagnostic.code.as_ref().map(|code| match code {
NumberOrString::Number(code) => code.to_string(),
NumberOrString::String(code) => code.clone(),
});
let tags = diagnostic
.tags
.as_deref()
.unwrap_or_default()
.iter()
.filter_map(|tag| {
if tag == &DiagnosticTag::UNNECESSARY {
Some("unnecessary".to_owned())
} else if tag == &DiagnosticTag::DEPRECATED {
Some("deprecated".to_owned())
} else {
None
}
})
.collect();
Self {
line: diagnostic.range.start.line,
character: diagnostic.range.start.character,
end_line: diagnostic.range.end.line,
end_character: diagnostic.range.end.character,
severity,
severity_name: severity_name.map(str::to_owned),
message: diagnostic.message.clone(),
source: diagnostic.source.clone(),
code,
tags,
}
}
}
/// Настройки файлового вотчера для одного LSP-сервера.
#[derive(Clone, Debug, Default)]
pub struct WatcherConfig {
@@ -75,6 +173,135 @@ struct ActiveDocument {
type PendingMap = Mutex<HashMap<u64, mpsc::Sender<Result<Value, String>>>>;
type LogBuffer = Arc<Mutex<VecDeque<String>>>;
/// Диагностика, опубликованная серверами, сгруппированная по документу.
///
/// Внутренняя карта хранит сообщения по имени сервера: так остановка одного
/// сервера убирает только его сообщения, а не всю диагностику документа.
struct DiagnosticsStore {
by_uri: Mutex<HashMap<String, HashMap<String, Vec<lsp_types::Diagnostic>>>>,
/// Номер поколения: растёт при каждом изменении.
revision: AtomicU64,
/// Задача вида, которую надо разбудить после изменения.
///
/// Диагностика приходит в фоновом потоке, поэтому вместо блокирующего
/// ожидания канала UI подписывается на `Waker`.
waker: Mutex<Option<std::task::Waker>>,
}
impl DiagnosticsStore {
fn new() -> Self {
Self {
by_uri: Mutex::new(HashMap::new()),
revision: AtomicU64::new(0),
waker: Mutex::new(None),
}
}
/// Заменяет набор диагностики сервера для документа.
///
/// Пустой список (сервер прислал `[]`) удаляет сообщения сервера.
fn replace(&self, server: &str, uri: &str, diagnostics: Vec<lsp_types::Diagnostic>) {
{
let mut by_uri = self.by_uri.lock();
if diagnostics.is_empty() {
let Some(by_server) = by_uri.get_mut(uri) else {
return;
};
if by_server.remove(server).is_none() {
return;
}
if by_server.is_empty() {
by_uri.remove(uri);
}
} else {
by_uri
.entry(uri.to_owned())
.or_default()
.insert(server.to_owned(), diagnostics);
}
}
self.notify();
}
/// Удаляет диагностику документа целиком (например, при закрытии вкладки).
fn clear_uri(&self, uri: &str) {
if self.by_uri.lock().remove(uri).is_some() {
self.notify();
}
}
/// Удаляет диагностику остановленного сервера во всех документах.
fn clear_server(&self, server: &str) {
let changed = {
let mut by_uri = self.by_uri.lock();
let mut changed = false;
by_uri.retain(|_, by_server| {
if by_server.remove(server).is_some() {
changed = true;
}
!by_server.is_empty()
});
changed
};
if changed {
self.notify();
}
}
/// Все известные сообщения по документу, от всех серверов.
fn diagnostics(&self, uri: &str) -> Vec<lsp_types::Diagnostic> {
self.by_uri
.lock()
.get(uri)
.map(|by_server| by_server.values().flatten().cloned().collect::<Vec<_>>())
.unwrap_or_default()
}
/// Снимок всей диагностики: по одной записи на пару «документ, сервер».
///
/// Записи сортируются по пути и имени сервера, чтобы Lua-сценарий получал
/// стабильный порядок независимо от порядка `HashMap`.
fn snapshot(&self) -> Vec<DocumentDiagnostics> {
let by_uri = self.by_uri.lock();
let mut entries = Vec::new();
for (uri, by_server) in by_uri.iter() {
for (server, diagnostics) in by_server.iter() {
if diagnostics.is_empty() {
continue;
}
entries.push(DocumentDiagnostics {
server: server.clone(),
uri: uri.clone(),
path: uri_to_path(uri).map(|path| path.to_string_lossy().into_owned()),
diagnostics: diagnostics.iter().map(DiagnosticInfo::from_lsp).collect(),
});
}
}
entries.sort_by(|a, b| {
a.path
.cmp(&b.path)
.then_with(|| a.uri.cmp(&b.uri))
.then_with(|| a.server.cmp(&b.server))
});
entries
}
fn notify(&self) {
self.revision.fetch_add(1, Ordering::SeqCst);
if let Some(waker) = self.waker.lock().take() {
waker.wake();
}
}
fn revision(&self) -> u64 {
self.revision.load(Ordering::SeqCst)
}
fn register_waker(&self, waker: std::task::Waker) {
*self.waker.lock() = Some(waker);
}
}
/// Менеджер всех запущенных LSP-серверов.
///
/// Живёт в `Arc`, потому что его одновременно читают UI, Lua-API и фоновые
@@ -83,6 +310,7 @@ pub struct LspManager {
servers: Mutex<BTreeMap<String, Arc<LspServer>>>,
active: Mutex<Option<ActiveDocument>>,
log: LogBuffer,
diagnostics: Arc<DiagnosticsStore>,
}
impl Default for LspManager {
@@ -97,6 +325,7 @@ impl LspManager {
servers: Mutex::new(BTreeMap::new()),
active: Mutex::new(None),
log: Arc::new(Mutex::new(VecDeque::new())),
diagnostics: Arc::new(DiagnosticsStore::new()),
}
}
@@ -119,7 +348,7 @@ impl LspManager {
}
config.root = std::fs::canonicalize(&config.root).unwrap_or(config.root);
let server = LspServer::spawn(config.clone())?;
let server = LspServer::spawn(config.clone(), self.diagnostics.clone())?;
// `initialize` — единственный запрос, который ждём прямо здесь:
// без ответа сервер нельзя считать рабочим.
@@ -170,6 +399,7 @@ impl LspManager {
return false;
};
server.shutdown();
self.diagnostics.clear_server(name);
self.log(format!("остановлен LSP-сервер «{name}»"));
true
}
@@ -209,33 +439,33 @@ impl LspManager {
/// Первый вызов для документа отправляет `didOpen`, последующие с
/// изменившимся текстом — `didChange` с новой версией.
pub fn sync_document(&self, uri: Uri, language_id: String, text: &str) {
let version;
{
let version = {
let mut active = self.active.lock();
if let Some(document) = active.as_mut()
&& document.uri == uri
&& document.text == text
{
if document.language_id != language_id {
document.language_id = language_id;
match active.as_mut() {
Some(document) if document.uri == uri => {
if document.text == text {
if document.language_id != language_id {
document.language_id = language_id;
}
return;
}
document.version = document.version.wrapping_add(1);
document.text = text.to_owned();
document.language_id = language_id.clone();
document.version
}
_ => {
*active = Some(ActiveDocument {
uri: uri.clone(),
language_id: language_id.clone(),
version: 1,
text: text.to_owned(),
});
1
}
return;
}
let same_uri = active.as_ref().is_some_and(|document| document.uri == uri);
version = if same_uri {
active.as_ref().map_or(1, |document| document.version + 1)
} else {
1
};
*active = Some(ActiveDocument {
uri: uri.clone(),
language_id: language_id.clone(),
version,
text: text.to_owned(),
});
}
};
for server in self.servers.lock().values() {
if !server.serves(&language_id) {
@@ -263,6 +493,9 @@ impl LspManager {
for server in self.servers.lock().values() {
server.did_close(uri);
}
// Сервер обычно присылает пустой список сам, но закрытая вкладка
// не должна оставлять подчёркивания при повторном открытии файла.
self.diagnostics.clear_uri(uri.as_str());
}
/// Отправляет `textDocument/completion` в подходящий сервер.
@@ -327,6 +560,46 @@ impl LspManager {
server.request("completionItem/resolve", params)
}
/// Номер поколения диагностики: меняется при каждом обновлении.
///
/// Вид запоминает его и повторно проверяет после регистрации `Waker`,
/// чтобы не потерять обновление между проверкой и ожиданием.
pub fn diagnostics_revision(&self) -> u64 {
self.diagnostics.revision()
}
/// Запоминает задачу, которую надо разбудить при обновлении диагностики.
pub fn register_diagnostics_waker(&self, waker: std::task::Waker) {
self.diagnostics.register_waker(waker);
}
/// Диагностика, известная для документа с указанным URI.
pub fn diagnostics(&self, uri: &str) -> Vec<lsp_types::Diagnostic> {
self.diagnostics.diagnostics(uri)
}
/// Снимок всей диагностики всех серверов.
///
/// Используется обработчиком `app:on_lsp_diagnostics(...)`: он получает
/// файлы, серверы и сообщения целиком, а не только для активной вкладки.
pub fn diagnostics_snapshot(&self) -> Vec<DocumentDiagnostics> {
self.diagnostics.snapshot()
}
/// Будит подписчиков диагностики, даже если новых сообщений не было.
///
/// Нужен при регистрации Lua-обработчика: он должен получить уже
/// накопленную диагностику, не дожидаясь следующего ответа сервера.
pub fn refresh_diagnostics(&self) {
self.diagnostics.notify();
}
/// Кладёт диагностику в хранилище от имени тестового сервера.
#[cfg(test)]
pub fn set_diagnostics_for_test(&self, uri: &str, diagnostics: Vec<lsp_types::Diagnostic>) {
self.diagnostics.replace("test", uri, diagnostics);
}
/// Последние строки журнала серверов.
pub fn log_lines(&self) -> Vec<String> {
self.log.lock().iter().cloned().collect()
@@ -340,17 +613,11 @@ impl LspManager {
let servers = self.servers.lock();
let mut fallback = None;
for server in servers.values() {
if server
.config
.languages
.iter()
.any(|language| language.eq_ignore_ascii_case(language_id))
{
if server.config.languages.is_empty() {
fallback.get_or_insert_with(|| server.clone());
} else if server.serves(language_id) {
return Some(server.clone());
}
if server.config.languages.is_empty() && fallback.is_none() {
fallback = Some(server.clone());
}
}
fallback
}
@@ -369,10 +636,11 @@ struct LspServer {
watcher: Mutex<Option<RecommendedWatcher>>,
alive: AtomicBool,
log: LogBuffer,
diagnostics: Arc<DiagnosticsStore>,
}
impl LspServer {
fn spawn(config: LspConfig) -> Result<Arc<Self>, String> {
fn spawn(config: LspConfig, diagnostics: Arc<DiagnosticsStore>) -> Result<Arc<Self>, String> {
let mut command = Command::new(&config.command);
command
.args(&config.args)
@@ -418,6 +686,7 @@ impl LspServer {
watcher: Mutex::new(None),
alive: AtomicBool::new(true),
log: log.clone(),
diagnostics,
});
spawn_stdout_reader(Arc::downgrade(&server), stdout);
@@ -583,24 +852,25 @@ impl LspServer {
fn handle_notification(&self, method: &str, message: &Value) {
match method {
"textDocument/publishDiagnostics" => {
let uri = message
.pointer("/params/uri")
.and_then(Value::as_str)
.unwrap_or("?");
let count = message
let Some(uri) = message.pointer("/params/uri").and_then(Value::as_str) else {
return;
};
let diagnostics = message
.pointer("/params/diagnostics")
.and_then(Value::as_array)
.map_or(0, Vec::len);
if count > 0 {
self.log(format!("диагностика {uri}: {count}"));
.map(|items| {
items
.iter()
.filter_map(|item| serde_json::from_value(item.clone()).ok())
.collect::<Vec<lsp_types::Diagnostic>>()
})
.unwrap_or_default();
if !diagnostics.is_empty() {
self.log(format!("диагностика {uri}: {}", diagnostics.len()));
}
self.diagnostics.replace(&self.name, uri, diagnostics);
}
"window/logMessage" | "window/showMessage" => {
if let Some(text) = message.pointer("/params/message").and_then(Value::as_str) {
self.log(text.to_owned());
}
}
"window/logTrace" => {
"window/logMessage" | "window/showMessage" | "window/logTrace" => {
if let Some(text) = message.pointer("/params/message").and_then(Value::as_str) {
self.log(text.to_owned());
}
@@ -894,7 +1164,7 @@ fn initialize_params(config: &LspConfig) -> Value {
params.insert(
"clientInfo".into(),
json!({
"name": "gpui-editor",
"name": "qode",
"version": env!("CARGO_PKG_VERSION"),
}),
);
@@ -979,6 +1249,59 @@ pub fn path_to_uri(path: &Path) -> Uri {
Uri::from_str(&encoded).expect("путь превращается в корректный file:// URI")
}
/// Абсолютный путь из `file://`-URI с раскодированием процентов.
///
/// Для URI других схем возвращает `None`: диагностика может относиться к
/// виртуальному документу, у которого нет файла на диске.
fn uri_to_path(uri: &str) -> Option<PathBuf> {
let rest = uri.strip_prefix("file://")?;
let mut path = percent_decode(rest);
// На Windows file:///C:/dir после снятия схемы выглядит как /C:/dir.
if cfg!(windows) {
let bytes = path.as_bytes();
if bytes.len() >= 3
&& bytes[0] == b'/'
&& bytes[1].is_ascii_alphabetic()
&& bytes[2] == b':'
{
path.remove(0);
}
}
Some(PathBuf::from(path))
}
/// Раскодирует `%XX` в строке URI.
fn percent_decode(input: &str) -> String {
fn hex(byte: u8) -> Option<u8> {
match byte {
b'0'..=b'9' => Some(byte - b'0'),
b'a'..=b'f' => Some(byte - b'a' + 10),
b'A'..=b'F' => Some(byte - b'A' + 10),
_ => None,
}
}
let bytes = input.as_bytes();
let mut decoded = Vec::with_capacity(bytes.len());
let mut index = 0;
while index < bytes.len() {
if bytes[index] == b'%' {
let high = bytes.get(index + 1).copied().and_then(hex);
let low = bytes.get(index + 2).copied().and_then(hex);
if let (Some(high), Some(low)) = (high, low) {
decoded.push(high * 16 + low);
index += 3;
continue;
}
}
decoded.push(bytes[index]);
index += 1;
}
String::from_utf8_lossy(&decoded).into_owned()
}
/// URI документа по его имени во вкладке редактора.
///
/// Относительные имена считаются от текущего рабочего каталога — именно
@@ -1068,6 +1391,36 @@ mod tests {
assert!(read_message(&mut empty).unwrap().is_none());
}
#[test]
fn diagnostics_store_keeps_servers_separate_and_signals() {
let store = DiagnosticsStore::new();
let diagnostic = |message: &str| lsp_types::Diagnostic {
range: lsp_types::Range::new(Position::new(0, 0), Position::new(0, 1)),
message: message.to_owned(),
..Default::default()
};
let revision = store.revision();
assert_eq!(revision, 0);
store.replace("rust-analyzer", "file:///a.rs", vec![diagnostic("error")]);
assert!(store.revision() > revision);
assert_eq!(store.diagnostics("file:///a.rs").len(), 1);
// Второй сервер добавляет своё, не затирая чужое.
store.replace("other", "file:///a.rs", vec![diagnostic("warning")]);
assert_eq!(store.diagnostics("file:///a.rs").len(), 2);
// Пустой список удаляет только сообщения этого сервера.
store.replace("rust-analyzer", "file:///a.rs", Vec::new());
let left = store.diagnostics("file:///a.rs");
assert_eq!(left.len(), 1);
assert_eq!(left[0].message, "warning");
store.clear_server("other");
assert!(store.diagnostics("file:///a.rs").is_empty());
}
#[test]
fn positions_use_utf16_columns() {
let text = "a\nβγ\n🙂x";
+2 -2
View File
@@ -62,8 +62,8 @@ fn apply_command_line() -> Result<(), String> {
Ok(())
}
/// Параметры окна в стиле исходного приложения: размер 900×600, минимум
/// 420×280 и собственный заголовок вместо системного.
/// Параметры окна: размер 900×600, минимум 420×280 и собственный
/// заголовок вместо системного.
fn window_options() -> WindowOptions {
WindowOptions {
window_bounds: Some(WindowBounds::Windowed(Bounds::new(
+53 -3
View File
@@ -7,6 +7,10 @@
//!
//! Панель состоит из блоков. Блок — это независимый виджет, который сценарий
//! создаёт через API панели (сейчас поддерживается [`PanelBlock::Tree`]).
//!
//! У блока есть заголовок. Боковые панели показывают все блоки подряд и
//! заголовки не рисуют, а нижняя панель выводит блоки вкладками и показывает
//! только активный блок.
use std::cell::RefCell;
use std::rc::Rc;
@@ -20,7 +24,21 @@ pub type PanelHandle = Rc<RefCell<PanelState>>;
#[derive(Clone)]
pub enum PanelBlock {
/// Абстрактное дерево, наполняемое из Lua.
Tree(TreeHandle),
Tree {
/// Дерево блока.
tree: TreeHandle,
/// Заголовок вкладки; боковые панели его не показывают.
title: String,
},
}
impl PanelBlock {
/// Заголовок блока — он же заголовок вкладки нижней панели.
pub fn title(&self) -> &str {
match self {
Self::Tree { title, .. } => title,
}
}
}
/// Открыта ли панель, каким размером она раскрывается и какие блоки содержит.
@@ -32,6 +50,9 @@ pub struct PanelState {
visible: bool,
size: f32,
blocks: Vec<PanelBlock>,
/// Активный блок — вкладка нижней панели. Боковые панели рисуют все блоки
/// подряд и это поле не используют.
active: usize,
}
impl PanelState {
@@ -41,6 +62,7 @@ impl PanelState {
visible: false,
size: size.max(0.0),
blocks: Vec::new(),
active: 0,
}
}
@@ -80,10 +102,38 @@ impl PanelState {
&self.blocks
}
/// Индекс активного блока (вкладки нижней панели).
pub fn active(&self) -> usize {
self.active
}
/// Делает блок активным.
///
/// Возвращает `false`, если блока с таким индексом нет.
pub fn set_active(&mut self, index: usize) -> bool {
if index < self.blocks.len() {
self.active = index;
true
} else {
false
}
}
/// Добавляет блок дерева и возвращает доступ к нему.
pub fn add_tree(&mut self) -> TreeHandle {
///
/// `title` — заголовок вкладки нижней панели; для боковых панелей он
/// сохраняется, но не рисуется. Пустое название заменяется на «Дерево».
/// Новый блок сразу становится активным.
pub fn add_tree(&mut self, title: Option<String>) -> TreeHandle {
let tree = new_tree();
self.blocks.push(PanelBlock::Tree(tree.clone()));
let title = title
.filter(|title| !title.trim().is_empty())
.unwrap_or_else(|| "Дерево".to_owned());
self.blocks.push(PanelBlock::Tree {
tree: tree.clone(),
title,
});
self.active = self.blocks.len() - 1;
tree
}
}
+33
View File
@@ -19,6 +19,7 @@ use mlua::{Lua, Table};
use crate::api::{AppApi, DirEntryInfo, TabInfo};
use crate::constants::{STARTUP_SCRIPT, script_dir};
use crate::lsp::{DiagnosticInfo, DocumentDiagnostics};
/// Вкладка превращается в Lua-таблицу `{ index = 0, title = "main.rs", icon = "file" }`.
impl mlua::IntoLua for TabInfo {
@@ -43,6 +44,38 @@ impl mlua::IntoLua for DirEntryInfo {
}
}
/// Диагностика сервера для документа превращается в Lua-таблицу
/// `{ server = "gopls", uri = "file:///...", path = "/...", diagnostics = {...} }`.
impl mlua::IntoLua for DocumentDiagnostics {
fn into_lua(self, lua: &Lua) -> mlua::Result<mlua::Value> {
let entry = lua.create_table()?;
entry.set("server", self.server)?;
entry.set("uri", self.uri)?;
entry.set("path", self.path)?;
entry.set("diagnostics", self.diagnostics)?;
Ok(mlua::Value::Table(entry))
}
}
/// Сообщение диагностики превращается в Lua-таблицу с позицией (с нуля),
/// уровнем, текстом, источником, кодом и тегами.
impl mlua::IntoLua for DiagnosticInfo {
fn into_lua(self, lua: &Lua) -> mlua::Result<mlua::Value> {
let entry = lua.create_table()?;
entry.set("line", self.line)?;
entry.set("character", self.character)?;
entry.set("end_line", self.end_line)?;
entry.set("end_character", self.end_character)?;
entry.set("severity", self.severity)?;
entry.set("severity_name", self.severity_name)?;
entry.set("message", self.message)?;
entry.set("source", self.source)?;
entry.set("code", self.code)?;
entry.set("tags", self.tags)?;
Ok(mlua::Value::Table(entry))
}
}
/// Движок Lua и состояние его глобального окружения.
pub struct ScriptEngine {
lua: Lua,
+400 -28
View File
@@ -5,14 +5,16 @@
//! фиксируют поведение самого приложения: документы, API, Lua и KeyMap.
use std::cell::RefCell;
use std::path::Path;
use std::rc::Rc;
use gpui_kit::{Keystroke, Modifiers, TestAppContext, px};
use lsp_types::{Diagnostic, DiagnosticSeverity, Position, Range};
use crate::api::{AppApi, WindowCommand, drain_window_commands};
use crate::app::MyApp;
use crate::editor::{Language, Workspace};
use crate::keymap::{KeyBinding, KeyMap, Modifiers as KeyModifiers};
use crate::editor::{FocusAfterSync, Language, Workspace};
use crate::keymap::{KeyBinding, KeyContext, KeyHandler, KeyMap, Modifiers as KeyModifiers};
use crate::panel::PanelBlock;
use crate::script::ScriptEngine;
use crate::tree::TreeNode;
@@ -55,17 +57,17 @@ fn lua_lsp_api_lists_no_servers_and_validates_config() {
#[test]
fn workspace_keeps_at_least_one_tab() {
let mut state = Workspace::default();
assert_eq!(state.documents().len(), 2);
assert_eq!(state.active().name, "main.rs");
assert_eq!(state.documents().len(), 1);
assert_eq!(state.active().name, "untitled-1.txt");
// Открытие и закрытие новых вкладок.
state.open_new();
assert_eq!(state.documents().len(), 3);
assert_eq!(state.active_index(), 2);
state.close(2);
assert_eq!(state.documents().len(), 2);
assert_eq!(state.active_index(), 1);
state.close(1);
assert_eq!(state.active_index(), 0);
// Редактор не должен оставаться пустым.
while state.documents().len() > 1 {
state.close(0);
@@ -83,7 +85,7 @@ fn closing_tab_before_active_keeps_the_same_document_active() {
state.close(0);
assert_eq!(state.active().id, id);
assert_eq!(state.active_index(), 1);
assert_eq!(state.active_index(), 0);
}
#[test]
@@ -112,6 +114,8 @@ fn revision_changes_only_for_external_edits() {
assert_ne!(state.revision(), revision);
// Переключение вкладки — тоже.
state.open_new();
state.set_active(0);
let before_switch = state.revision();
state.set_active(1);
assert_ne!(state.revision(), before_switch);
@@ -127,13 +131,15 @@ fn api_lists_tabs_and_closes_them_by_number() {
tabs.iter()
.map(|tab| (tab.index, tab.title.as_str()))
.collect::<Vec<_>>(),
vec![(0, "main.rs"), (1, "notes.md")]
vec![(0, "untitled-1.txt")]
);
// Несуществующий номер ничего не закрывает.
assert!(!api.close_tab(99));
assert_eq!(api.tabs().len(), 2);
assert_eq!(api.tabs().len(), 1);
api.open_tab();
assert_eq!(api.tabs().len(), 2);
assert!(api.close_tab(1));
assert_eq!(api.tabs().len(), 1);
assert_eq!(workspace.borrow().documents().len(), 1);
@@ -180,6 +186,7 @@ fn lua_script_can_set_tab_icons() {
assert(app:tab_icon(0) == "file")
assert(app:set_tab_icon(0, "book-open"))
assert(app:tab_icon(0) == "book-open")
app:open_tab()
assert(app:tabs()[2].icon == "file")
assert(not app:set_tab_icon(0, "no-such-icon"))
assert(app:clear_tab_icon(0))
@@ -200,7 +207,7 @@ fn api_save_writes_active_tab_to_file_named_after_it() {
// Тест пишет во временный файл, а не в каталог проекта.
let workspace = Rc::new(RefCell::new(Workspace::default()));
let path = std::env::temp_dir().join(format!(
"gpui-editor-test-save-{}-{}.rs",
"qode-save-{}-{}.rs",
std::process::id(),
workspace.borrow().active().id
));
@@ -231,6 +238,8 @@ fn api_switches_tabs_forward_and_back_in_a_cycle() {
let workspace = Rc::new(RefCell::new(Workspace::default()));
let (api, _keymap) = install_api(workspace.clone());
api.open_tab();
api.set_active_tab(0);
assert_eq!(api.active_tab_index(), 0);
assert_eq!(api.next_tab(), 1);
@@ -392,7 +401,8 @@ fn lua_tree_block_appends_sorts_and_removes_nodes() {
let panel = api.left_panel_handle();
let panel = panel.borrow();
let PanelBlock::Tree(tree) = &panel.blocks()[0];
let PanelBlock::Tree { tree, title } = &panel.blocks()[0];
assert_eq!(title, "Дерево");
let rows = tree.borrow().rows();
assert_eq!(
rows.iter()
@@ -414,6 +424,124 @@ fn lua_tree_block_appends_sorts_and_removes_nodes() {
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn lua_bottom_panel_tree_has_a_tab_title_and_clears() {
let workspace = Rc::new(RefCell::new(Workspace::default()));
let (api, _keymap) = install_api(workspace);
let bottom = api.bottom_panel_handle();
let dir = temp_script_dir("bottom-tree");
let script = dir.join("bottom.lua");
std::fs::write(
&script,
r#"
diag_tree = app.bottom_panel:add_tree("Диагностика")
diag_tree:append({
{ id = "a", label = "a", expandable = true, expanded = true },
}, "")
diag_tree:append({
{ id = "a:1", label = "ошибка" },
}, "a")
"#,
)
.expect("bottom.lua");
let clear = dir.join("clear.lua");
std::fs::write(&clear, "diag_tree:clear()\n").expect("clear.lua");
let engine = ScriptEngine::new(api).expect("движок Lua");
engine.run_file(&script).expect("блок нижней панели");
{
let panel = bottom.borrow();
assert_eq!(panel.blocks().len(), 1);
assert_eq!(panel.active(), 0);
assert_eq!(panel.blocks()[0].title(), "Диагностика");
let PanelBlock::Tree { tree, .. } = &panel.blocks()[0];
assert_eq!(tree.borrow().rows().len(), 2);
}
engine.run_file(&clear).expect("tree:clear");
let panel = bottom.borrow();
let PanelBlock::Tree { tree, .. } = &panel.blocks()[0];
assert!(tree.borrow().rows().is_empty());
drop(panel);
drop(engine);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn lua_diagnostics_handler_receives_every_server_and_file() {
let workspace = Rc::new(RefCell::new(Workspace::default()));
let (api, _keymap) = install_api(workspace.clone());
let dir = temp_script_dir("diagnostics-handler");
let script = dir.join("diagnostics.lua");
std::fs::write(
&script,
r#"
app:on_lsp_diagnostics(0, function(servers)
assert(#servers == 1, #servers)
local entry = servers[1]
assert(entry.server == "test")
assert(entry.path ~= nil)
assert(#entry.diagnostics == 2, #entry.diagnostics)
local first = entry.diagnostics[1]
assert(first.line == 2)
assert(first.character == 3)
assert(first.end_line == 2)
assert(first.end_character == 5)
assert(first.severity == 1)
assert(first.severity_name == "error")
assert(first.message == "плохо")
assert(entry.diagnostics[2].severity_name == "warning")
app:open_tab()
end)
"#,
)
.expect("diagnostics.lua");
let engine = ScriptEngine::new(api.clone()).expect("движок Lua");
engine.run_file(&script).expect("регистрация обработчика");
assert_eq!(api.diagnostics_delay(), Some(std::time::Duration::ZERO));
let uri = crate::lsp::path_to_uri(Path::new("/tmp/qode-diagnostics.go"));
let diagnostics = vec![
Diagnostic {
range: Range::new(Position::new(2, 3), Position::new(2, 5)),
severity: Some(DiagnosticSeverity::ERROR),
message: "плохо".to_owned(),
..Default::default()
},
Diagnostic {
range: Range::new(Position::new(4, 0), Position::new(4, 1)),
severity: Some(DiagnosticSeverity::WARNING),
message: "осторожно".to_owned(),
..Default::default()
},
];
api.lsp_handle()
.set_diagnostics_for_test(uri.as_str(), diagnostics);
let report = api.lsp_handle().diagnostics_snapshot();
assert_eq!(report.len(), 1);
assert_eq!(report[0].server, "test");
assert_eq!(report[0].path.as_deref(), Some("/tmp/qode-diagnostics.go"));
assert_eq!(report[0].diagnostics.len(), 2);
assert!(api.emit_diagnostics(report));
assert_eq!(
workspace.borrow().documents().len(),
2,
"обработчик должен был открыть новую вкладку"
);
drop(engine);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn lua_list_dir_returns_sorted_entries_with_paths() {
let workspace = Rc::new(RefCell::new(Workspace::default()));
@@ -455,7 +583,7 @@ fn lua_list_dir_returns_sorted_entries_with_paths() {
/// Временный каталог с уникальным именем для Lua-сценариев.
fn temp_script_dir(prefix: &str) -> std::path::PathBuf {
let dir = std::env::temp_dir().join(format!(
"gpui-editor-test-{prefix}-{}-{}",
"qode-{prefix}-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
@@ -482,7 +610,7 @@ fn lua_script_can_include_files_and_control_the_app() {
std::fs::write(
&main,
format!(
"include({:?})\nlua_config()\napp:next_tab()\napp:set_language(1, \"python\")\n",
"include({:?})\nlua_config()\napp:open_tab()\napp:set_language(1, \"python\")\n",
helper.to_string_lossy()
),
)
@@ -500,7 +628,7 @@ fn lua_script_can_include_files_and_control_the_app() {
assert_eq!(
state.active_index(),
1,
"app:next_tab() не переключил вкладку"
"app:open_tab() не переключил вкладку"
);
assert_eq!(
state.documents()[1].language,
@@ -538,6 +666,55 @@ fn api_opens_file_from_disk_and_detects_language() {
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn lua_open_controls_focus_and_cursor() {
let workspace = Rc::new(RefCell::new(Workspace::default()));
let (api, _keymap) = install_api(workspace.clone());
let dir = temp_script_dir("lua-open-focus");
let path = dir.join("main.go");
std::fs::write(&path, "package main\n\nfunc main() {}\n").unwrap();
let engine = ScriptEngine::new_in(api, &dir).expect("движок Lua");
// Без второго аргумента фокус остаётся прежним.
let plain = dir.join("plain.lua");
std::fs::write(&plain, format!("app:open({:?})\n", path.to_string_lossy())).unwrap();
engine.run_file(&plain).expect("app:open без позиции");
assert_eq!(
workspace.borrow_mut().take_focus_after_sync(),
Some(FocusAfterSync::Keep)
);
// Пустая таблица — фокус в редактор, курсор на месте.
let focus = dir.join("focus.lua");
std::fs::write(
&focus,
format!("app:open({:?}, {{}})\n", path.to_string_lossy()),
)
.unwrap();
engine.run_file(&focus).expect("app:open с пустой таблицей");
assert_eq!(
workspace.borrow_mut().take_focus_after_sync(),
Some(FocusAfterSync::Editor(None))
);
// Строка 2, столбец 5 — байтовое смещение 19.
let at = dir.join("at.lua");
std::fs::write(
&at,
format!("app:open({:?}, {{2, 5}})\n", path.to_string_lossy()),
)
.unwrap();
engine.run_file(&at).expect("app:open с позицией");
assert_eq!(
workspace.borrow_mut().take_focus_after_sync(),
Some(FocusAfterSync::Editor(Some(19)))
);
drop(engine);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn api_open_missing_file_creates_an_empty_buffer() {
let workspace = Rc::new(RefCell::new(Workspace::default()));
@@ -647,8 +824,13 @@ fn lua_key_bindings_match_press_release_and_modifiers() {
key_char: None,
};
let fire = |keystroke: &Keystroke, pressed: bool| {
for callback in keymap.borrow().callbacks_for(keystroke, pressed) {
callback.call::<()>(()).expect("Lua-обработчик");
for handler in keymap
.borrow()
.handlers_for(KeyContext::Global, keystroke, pressed)
{
if let KeyHandler::Lua(callback) = handler {
callback.call::<()>(()).expect("Lua-обработчик");
}
}
};
@@ -713,6 +895,81 @@ fn lua_key_bindings_match_press_release_and_modifiers() {
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn lua_keymap_accepts_command_names_and_nil() {
let workspace = Rc::new(RefCell::new(Workspace::default()));
let (api, keymap) = install_api(workspace);
let dir = temp_script_dir("key-commands");
let script = dir.join("keys.lua");
std::fs::write(
&script,
r#"
app:map_key("<C-n>", "next_tab")
app:map_completion_key("<C-n>", "completion_next")
local ok, err = pcall(function()
app:map_key("<C-x>", "no_such_command")
end)
assert(not ok)
assert(tostring(err):find("доступны", 1, true), tostring(err))
"#,
)
.expect("keys.lua");
let engine = ScriptEngine::new(api).expect("движок Lua");
engine
.run_file(&script)
.expect("команды должны назначиться");
let stroke = Keystroke {
modifiers: Modifiers {
control: true,
..Default::default()
},
key: "n".to_owned(),
key_char: None,
};
assert!(matches!(
keymap
.borrow()
.handlers_for(KeyContext::Global, &stroke, true)
.as_slice(),
[KeyHandler::App(crate::keymap::AppCommand::NextTab)]
));
assert!(matches!(
keymap
.borrow()
.handlers_for(KeyContext::Completion, &stroke, true)
.as_slice(),
[KeyHandler::Completion(
crate::keymap::CompletionCommand::Next
)]
));
// nil удаляет привязку, не оставляя заглушку.
std::fs::write(
&script,
"app:map_key(\"<C-n>\", nil)\napp:map_completion_key(\"<C-n>\", nil)\n",
)
.expect("keys.lua");
engine.run_file(&script).expect("привязки должны удалиться");
assert!(
keymap
.borrow()
.handlers_for(KeyContext::Global, &stroke, true)
.is_empty()
);
assert!(
keymap
.borrow()
.handlers_for(KeyContext::Completion, &stroke, true)
.is_empty()
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn language_names_cover_all_supported_grammars() {
for (name, expected) in [
@@ -737,6 +994,19 @@ fn language_names_cover_all_supported_grammars() {
assert_eq!(Language::Rust.name(), "rust");
assert_eq!(Language::JavaScript.name(), "js");
assert_eq!(Language::Python.name(), "python");
// Каждый язык перечитывается по имени и по полному имени для
// tree-sitter/LSP.
for language in [
Language::Rust,
Language::Go,
Language::JavaScript,
Language::Json,
Language::Python,
] {
assert_eq!(Language::from_name(language.name()), Some(language));
assert_eq!(Language::from_name(language.syntax_name()), Some(language));
}
}
#[test]
@@ -782,15 +1052,18 @@ fn keymap_bindings_are_parsed_and_replaced() {
.load("return function() end")
.eval::<mlua::Function>()
.unwrap();
keymap.bind(plain.clone(), first);
keymap.bind(plain, second);
keymap.set(KeyContext::Global, plain.clone(), KeyHandler::Lua(first));
keymap.set(KeyContext::Global, plain, KeyHandler::Lua(second));
let stroke = Keystroke {
modifiers: Modifiers::default(),
key: "a".to_owned(),
key_char: None,
};
assert_eq!(keymap.callbacks_for(&stroke, true).len(), 1);
assert_eq!(
keymap.handlers_for(KeyContext::Global, &stroke, true).len(),
1
);
}
#[gpui_kit::test]
@@ -838,7 +1111,7 @@ fn lua_key_bindings_receive_keys_while_editor_is_focused(cx: &mut TestAppContext
fn opened_panels_render_without_panicking(cx: &mut TestAppContext) {
cx.update(gpui_kit::init);
let (app, visual) = cx.add_window_view(|window, cx| MyApp::new(window, cx));
let (app, visual) = cx.add_window_view(MyApp::new);
let api = AppApi::global();
api.left_panel().open();
@@ -859,10 +1132,10 @@ fn opened_panels_render_without_panicking(cx: &mut TestAppContext) {
fn tree_block_renders_with_nested_nodes(cx: &mut TestAppContext) {
cx.update(gpui_kit::init);
let (app, visual) = cx.add_window_view(|window, cx| MyApp::new(window, cx));
let (app, visual) = cx.add_window_view(MyApp::new);
let api = AppApi::global();
let tree = api.left_panel_handle().borrow_mut().add_tree();
let tree = api.left_panel_handle().borrow_mut().add_tree(None);
tree.borrow_mut()
.append(
"",
@@ -898,14 +1171,54 @@ fn tree_block_renders_with_nested_nodes(cx: &mut TestAppContext) {
assert_eq!(tree.borrow().rows().len(), 2);
}
#[gpui_kit::test]
fn bottom_panel_renders_blocks_as_tabs(cx: &mut TestAppContext) {
cx.update(gpui_kit::init);
let (app, visual) = cx.add_window_view(MyApp::new);
let api = AppApi::global();
let panel = api.bottom_panel_handle();
panel
.borrow_mut()
.add_tree(Some("Диагностика".to_owned()))
.borrow_mut()
.append(
"",
vec![TreeNode {
id: "a".to_owned(),
label: "a".to_owned(),
icon: Some("file".to_owned()),
expandable: false,
expanded: false,
}],
)
.unwrap();
panel.borrow_mut().add_tree(Some("Тесты".to_owned()));
assert_eq!(panel.borrow().active(), 1);
assert_eq!(panel.borrow().blocks()[0].title(), "Диагностика");
assert_eq!(panel.borrow().blocks()[1].title(), "Тесты");
assert!(panel.borrow_mut().set_active(0));
assert!(!panel.borrow_mut().set_active(2));
api.bottom_panel().open();
visual.update(|_window, cx| {
app.update(cx, |_, cx| cx.notify());
});
visual.run_until_parked();
assert!(api.bottom_panel_handle().borrow().is_open());
}
#[gpui_kit::test]
fn focused_tree_moves_selection_with_arrow_keys(cx: &mut TestAppContext) {
cx.update(gpui_kit::init);
let (app, visual) = cx.add_window_view(|window, cx| MyApp::new(window, cx));
let (app, visual) = cx.add_window_view(MyApp::new);
let api = AppApi::global();
let tree = api.left_panel_handle().borrow_mut().add_tree();
let tree = api.left_panel_handle().borrow_mut().add_tree(None);
tree.borrow_mut()
.append(
"",
@@ -953,6 +1266,62 @@ fn focused_tree_moves_selection_with_arrow_keys(cx: &mut TestAppContext) {
assert_eq!(tree.borrow().selected(), Some("readme"));
}
#[gpui_kit::test]
fn lsp_diagnostics_are_underlined_in_editor(cx: &mut TestAppContext) {
cx.update(gpui_kit::init);
let (app, visual) = cx.add_window_view(MyApp::new);
let uri = visual.update(|window, cx| {
app.update(cx, |this, cx| {
this.editor_handle().update(cx, |state, cx| {
state.set_value("abc\n", window, cx);
});
let name = this.workspace_handle().borrow().active().name.clone();
crate::lsp::document_uri(&name).expect("у документа есть URI")
})
});
let diagnostic = |range: lsp_types::Range, severity| lsp_types::Diagnostic {
range,
severity: Some(severity),
message: "сообщение".to_owned(),
..Default::default()
};
let diagnostics = vec![
diagnostic(
lsp_types::Range::new(
lsp_types::Position::new(0, 0),
lsp_types::Position::new(0, 2),
),
lsp_types::DiagnosticSeverity::ERROR,
),
diagnostic(
lsp_types::Range::new(
lsp_types::Position::new(0, 2),
lsp_types::Position::new(0, 3),
),
lsp_types::DiagnosticSeverity::WARNING,
),
];
AppApi::global()
.lsp_handle()
.set_diagnostics_for_test(uri.as_str(), diagnostics);
let ranges = visual.update(|_window, cx| {
app.update(cx, |this, cx| {
this.refresh_diagnostics(cx);
this.diagnostic_ranges(cx)
})
});
visual.run_until_parked();
assert_eq!(
ranges,
vec![0..2, 2..3],
"ошибка и предупреждение должны стать подчёркнутыми диапазонами"
);
}
#[gpui_kit::test]
fn typing_into_editor_updates_active_document(cx: &mut TestAppContext) {
cx.update(gpui_kit::init);
@@ -1117,8 +1486,11 @@ fn horizontal_arrows_close_completion_and_move_cursor(cx: &mut TestAppContext) {
let before = visual.update(|window, cx| {
app.update(cx, |this, cx| {
this.editor_handle()
.update(cx, |state, cx| state.focus(window, cx));
this.editor_handle().update(cx, |state, cx| {
state.set_value("abc", window, cx);
state.set_selected_range(0..0, cx);
state.focus(window, cx);
});
this.open_test_completion(cx);
this.editor_handle().read(cx).cursor()
})
+32 -34
View File
@@ -92,6 +92,7 @@ pub enum TreeKeyResult {
}
/// Состояние абстрактного дерева.
#[derive(Default)]
pub struct TreeState {
nodes: HashMap<String, NodeRecord>,
roots: Vec<String>,
@@ -108,21 +109,6 @@ pub struct TreeState {
scroll: ScrollHandle,
}
impl Default for TreeState {
fn default() -> Self {
Self {
nodes: HashMap::new(),
roots: Vec::new(),
toggle_callback: None,
click_callback: None,
selected: None,
focus_handle: None,
focus_requested: false,
scroll: ScrollHandle::default(),
}
}
}
impl TreeState {
/// Создаёт пустое дерево.
pub fn new() -> Self {
@@ -153,11 +139,8 @@ impl TreeState {
return false;
}
self.expand_ancestors(id);
self.selected = Some(id.to_owned());
self.focus_requested = true;
if let Some(index) = self.rows().iter().position(|row| row.id == id) {
self.scroll.scroll_to_item(index);
}
self.select_and_scroll(id);
true
}
@@ -259,6 +242,16 @@ impl TreeState {
Ok(())
}
/// Удаляет все узлы и сбрасывает выбор.
///
/// Обработчики раскрытия и клика сохраняются: сценарий может наполнять
/// дерево заново, например при обновлении списка диагностики.
pub fn clear(&mut self) {
self.nodes.clear();
self.roots.clear();
self.selected = None;
}
/// Удаляет узел и все его дочерние узлы рекурсивно.
///
/// Возвращает `false`, если узла с таким `id` нет.
@@ -340,6 +333,14 @@ impl TreeState {
entries.into_iter().map(|(_, _, id)| id).collect()
}
/// Делает строку выбранной и прокручивает её в видимую часть.
fn select_and_scroll(&mut self, id: &str) {
self.selected = Some(id.to_owned());
if let Some(index) = self.rows().iter().position(|row| row.id == id) {
self.scroll.scroll_to_item(index);
}
}
/// Сдвигает выборку по видимым строкам.
fn move_selection(&mut self, delta: isize) {
let rows = self.rows();
@@ -357,8 +358,8 @@ impl TreeState {
None if delta < 0 => rows.len() - 1,
None => 0,
};
self.selected = Some(rows[next].id.clone());
self.scroll.scroll_to_item(next);
let id = rows[next].id.clone();
self.select_and_scroll(&id);
}
/// Enter: раскрывает папку или вызывает обработчик клика конечной ноды.
@@ -383,10 +384,7 @@ impl TreeState {
};
}
if let Some(parent) = self.nodes.get(&id).and_then(|record| record.parent.clone()) {
self.selected = Some(parent.clone());
if let Some(index) = self.rows().iter().position(|row| row.id == parent) {
self.scroll.scroll_to_item(index);
}
self.select_and_scroll(&parent);
}
TreeKeyResult::Handled
}
@@ -399,20 +397,20 @@ impl TreeState {
let Some(record) = self.nodes.get(&id) else {
return TreeKeyResult::Ignored;
};
if record.node.expandable && !record.node.expanded {
let expandable = record.node.expandable;
let expanded = record.node.expanded;
let first_child = expanded
.then(|| self.sorted(&record.children).first().cloned())
.flatten();
if expandable && !expanded {
return match self.activate(&id) {
Some(action) => TreeKeyResult::Action(action),
None => TreeKeyResult::Handled,
};
}
if record.node.expanded
&& let Some(child) = self.sorted(&record.children).first().cloned()
{
let index = self.rows().iter().position(|row| row.id == child);
self.selected = Some(child);
if let Some(index) = index {
self.scroll.scroll_to_item(index);
}
if let Some(child) = first_child {
self.select_and_scroll(&child);
}
TreeKeyResult::Handled
}