gutter and keys

This commit is contained in:
2026-10-31 11:20:28 +03:00
parent afd7600150
commit 16e88117d5
14 changed files with 1844 additions and 558 deletions
+598 -326
View File
File diff suppressed because it is too large Load Diff
+314 -16
View File
@@ -27,7 +27,7 @@ use gpui_kit::component::tab::{Tab, TabBar};
use gpui_kit::component::tag::Tag;
use gpui_kit::component::text::TextView;
use gpui_kit::component::{
ActiveTheme, Icon, Sizable, StyledExt, TitleBar, h_flex, h_resizable, resizable_panel,
ActiveTheme, Icon, Sizable, Size, StyledExt, TitleBar, h_flex, h_resizable, resizable_panel,
resize_handle, v_resizable,
};
use gpui_kit::*;
@@ -39,8 +39,12 @@ use crate::completion::{
completion_kind_name, documentation_text, fallback_documentation, highlight_range, item_key,
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::constants::{
GUTTER_ICON_SIZE, GUTTER_WIDTH, MIN_LEFT_PANEL_WIDTH, MIN_MAIN_AREA_WIDTH, TAB_SIZE,
WINDOW_TITLE,
};
use crate::editor::{FocusAfterSync, Workspace};
use crate::gutter::GutterMarker;
use crate::icons;
use crate::keymap::{AppCommand, CompletionCommand, KeyContext, KeyHandler, KeyMap};
use crate::lsp::{LspManager, document_uri, position_to_offset};
@@ -182,6 +186,9 @@ pub struct MyApp {
/// Лежит перед `scripts`, чтобы обработчики (ссылки на Lua-значения)
/// уничтожились раньше самого Lua-движка.
keymap: Rc<RefCell<KeyMap>>,
/// Привязки клавиш редактора: действуют дополнительно к глобальным,
/// когда фокус находится в редакторе.
editor_keymap: Rc<RefCell<KeyMap>>,
/// Состояние левой панели, общее с `app.left_panel` в Lua.
left_panel: PanelHandle,
/// Состояние правой панели, общее с `app.right_panel` в Lua.
@@ -252,6 +259,7 @@ impl MyApp {
keymap.borrow_mut().install_defaults();
let api = AppApi::install(workspace.clone(), keymap.clone());
let lsp = api.lsp_handle();
let editor_keymap = api.editor_keymap_handle();
let initial_text = workspace.borrow().active().content.clone();
let editor = cx.new(|cx| {
@@ -319,7 +327,7 @@ impl MyApp {
// Диагностика приходит уведомлениями сервера, а не ответом на запрос.
// Одна задача применяет уже известную диагностику к редактору и вызывает
// Lua-обработчик; у хранилища один `Waker`, поэтому подписчик один.
let diagnostics_api = api.clone();
let diagnostics_api = api.editor();
let diagnostics_manager = lsp.clone();
cx.spawn(async move |this, cx| {
let mut revision = diagnostics_manager.diagnostics_revision();
@@ -404,6 +412,7 @@ impl MyApp {
Self {
workspace,
keymap,
editor_keymap,
left_panel,
right_panel,
bottom_panel,
@@ -445,7 +454,7 @@ impl MyApp {
// Первый позиционный аргумент запуска ведёт себя как `vim file.go`:
// открываем его тем же API-методом, который доступен из Lua.
if let Some(path) = crate::constants::startup_file()
&& let Err(err) = AppApi::global().open(path)
&& let Err(err) = AppApi::global().editor().open(path)
{
eprintln!("Не удалось открыть {}: {err}", path.display());
}
@@ -1414,8 +1423,10 @@ impl MyApp {
true
}
};
// Событие не останавливаем: глобальные привязки и привязки
// блока обрабатывает корень вида, а навигация дерева уже
// выполнена здесь.
if handled {
cx.stop_propagation();
entity.update(cx, |_, cx| cx.notify());
}
}
@@ -1682,8 +1693,34 @@ impl MyApp {
// Нижняя панель занимает ширину редактора, а правая тянется на всю
// высоту — как в большинстве редакторов кода.
// Gutter — отдельная колонка слева от редактора: сам `Editor` не
// умеет рисовать пользовательские иконки. Дети идут в порядке
// «редактор, затем gutter», хотя `flex_row_reverse` ставит gutter
// слева: так gutter подготавливается после редактора и видит его
// прокрутку текущего кадра, а не прошлого.
let editor = div()
.size_full()
.flex()
.flex_row_reverse()
.child(
div()
.flex_1()
.min_w_0()
.h_full()
// Самый компактный размер убирает верхний и нижний
// внутренние отступы редактора; слева место под иконки
// уже занимает gutter.
.child(
Editor::new(&self.editor)
.with_size(Size::XSmall)
.bordered(false)
.size_full(),
),
)
.child(self.render_gutter(cx));
let editor_area = v_resizable("editor-area")
.child(resizable_panel().child(Editor::new(&self.editor).bordered(false).size_full()))
.child(resizable_panel().child(editor))
.child(
resizable_panel()
.size(px(bottom_size))
@@ -1748,6 +1785,35 @@ impl MyApp {
})
}
/// Колонка иконок слева от редактора (gutter).
///
/// Позиции иконок вычисляются в [`GutterElement::prepaint`], после
/// раскладки самого редактора. Это важно: прокрутка окончательно
/// фиксируется именно в его prepaint, и если считать `top` здесь, в
/// `render`, иконки отстают от текста на кадр.
fn render_gutter(&self, cx: &mut Context<Self>) -> impl IntoElement {
let markers = self
.workspace
.borrow()
.active()
.gutter
.iter()
.map(|(line, marker)| (line, marker.clone()))
.collect::<Vec<_>>();
GutterElement {
editor: self.editor.clone(),
markers,
width: px(GUTTER_WIDTH),
background: cx
.theme()
.highlight_theme
.style
.editor_gutter_background
.unwrap_or(cx.theme().background),
foreground: cx.theme().muted_foreground,
}
}
/// Полоса между вкладками и редактором.
///
/// Элементы добавляются сценарием через `app.toolbar`. Левые элементы
@@ -1931,7 +1997,7 @@ impl MyApp {
// на соседнюю по устаревшему индексу.
cx.stop_propagation();
entity.update(cx, |_, cx| {
AppApi::global().close_tab(index);
AppApi::global().editor().close_tab(index);
cx.notify();
});
}
@@ -1961,7 +2027,7 @@ impl MyApp {
let entity = entity.clone();
move |_, _, cx| {
entity.update(cx, |_, cx| {
AppApi::global().open_tab();
AppApi::global().editor().open_tab();
cx.notify();
});
}
@@ -1971,7 +2037,7 @@ impl MyApp {
let entity = entity.clone();
move |index, _, cx| {
entity.update(cx, |_, cx| {
AppApi::global().set_active_tab(*index);
AppApi::global().editor().set_active_tab(*index);
cx.notify();
});
}
@@ -2009,6 +2075,13 @@ impl Render for MyApp {
};
let keymap = self.keymap.clone();
let editor_keymap = self.editor_keymap.clone();
let editor = self.editor.clone();
let panels = [
self.left_panel.clone(),
self.right_panel.clone(),
self.bottom_panel.clone(),
];
let entity = cx.entity();
div()
@@ -2020,23 +2093,49 @@ impl Render for MyApp {
.text_color(cx.theme().foreground)
.on_key_down({
let keymap = keymap.clone();
let editor_keymap = editor_keymap.clone();
let editor = editor.clone();
let panels = panels.clone();
let entity = entity.clone();
move |event, window, cx| {
// Сначала всегда срабатывают глобальные привязки.
let handled = run_keymap(&keymap, KeyContext::Global, &event.keystroke, true);
// Затем — привязки редактора или блока, на котором фокус.
let focused = run_focused_keymap(
&editor,
&editor_keymap,
&panels,
&event.keystroke,
true,
window,
cx,
);
// Обработчик мог поставить команду окна или изменить документы.
apply_window_commands(window);
if handled {
if handled || focused {
entity.update(cx, |_, cx| cx.notify());
}
}
})
.on_key_up({
let keymap = keymap.clone();
let editor_keymap = editor_keymap.clone();
let editor = editor.clone();
let panels = panels.clone();
let entity = entity.clone();
move |event, window, cx| {
let handled = run_keymap(&keymap, KeyContext::Global, &event.keystroke, false);
let focused = run_focused_keymap(
&editor,
&editor_keymap,
&panels,
&event.keystroke,
false,
window,
cx,
);
apply_window_commands(window);
if handled {
if handled || focused {
entity.update(cx, |_, cx| cx.notify());
}
}
@@ -2050,6 +2149,153 @@ impl Render for MyApp {
}
}
/// Колонка иконок gutter.
///
/// В отличие от обычного `div`, позиции иконок считаются в `prepaint`. К
/// этому моменту [`EditorState`] уже обновил раскладку и прокрутку для
/// текущего кадра, поэтому иконки не отстают от текста при скроллинге.
struct GutterElement {
editor: Entity<EditorState>,
markers: Vec<(usize, GutterMarker)>,
width: Pixels,
background: Hsla,
foreground: Hsla,
}
/// Приготовленные к рисованию иконки: границы, svg-путь и цвет.
struct GutterPrepaint {
icons: Vec<(Bounds<Pixels>, SharedString, Hsla)>,
}
impl IntoElement for GutterElement {
type Element = Self;
fn into_element(self) -> Self::Element {
self
}
}
impl Element for GutterElement {
type RequestLayoutState = ();
type PrepaintState = GutterPrepaint;
fn id(&self) -> Option<ElementId> {
None
}
fn source_location(&self) -> Option<&'static std::panic::Location<'static>> {
None
}
fn request_layout(
&mut self,
_id: Option<&GlobalElementId>,
_inspector_id: Option<&InspectorElementId>,
window: &mut Window,
cx: &mut App,
) -> (LayoutId, Self::RequestLayoutState) {
let mut style = Style::default();
style.size.width = self.width.into();
style.size.height = relative(1.).into();
style.flex_shrink = 0.;
(window.request_layout(style, [], cx), ())
}
fn prepaint(
&mut self,
_id: Option<&GlobalElementId>,
_inspector_id: Option<&InspectorElementId>,
bounds: Bounds<Pixels>,
_request_layout: &mut Self::RequestLayoutState,
_window: &mut Window,
cx: &mut App,
) -> Self::PrepaintState {
let state = self.editor.read(cx);
// Свежая геометрия кадра: редактор кладёт её в свой prepaint, а этот
// элемент подготавливается уже после него. Строки текста начинаются
// от `input_bounds`, а не от верха колонки: у редактора есть
// внутренние отступы (padding).
let (line_height, scroll_y, text_top) = match state.prepaint_caret_geometry() {
Some(geometry) => (
geometry.line_height(),
geometry.scroll_offset().y,
geometry.input_bounds().top(),
),
// До первой раскладки высота строки неизвестна: рисовать иконки
// некуда, следующий кадр всё равно придёт с геометрией.
None => {
let Some(line_height) = state.line_height() else {
return GutterPrepaint { icons: Vec::new() };
};
(
line_height,
state.scroll_offset().y,
state.input_bounds().top(),
)
}
};
let display_map = state.display_map();
let line_count = display_map.buffer_line_count();
let icon_size = px(GUTTER_ICON_SIZE).min(line_height);
let mut icons = Vec::new();
for (line, marker) in &self.markers {
// Иконка для несуществующей строки не рисуется: иначе редактор
// прижал бы её к последней строке документа.
if *line >= line_count {
continue;
}
// Номер строки буфера → номер видимой строки: свёрнутый код и
// перенос строк не сдвигают иконки.
let row = display_map.buffer_line_to_display_row(*line);
let y = text_top + crate::gutter::row_offset(row, line_height, scroll_y);
if y + line_height <= text_top || y >= bounds.bottom() {
continue;
}
let Some(icon) = icons::from_name(&marker.icon) else {
continue;
};
let icon_bounds = Bounds::new(
point(
bounds.origin.x + (bounds.size.width - icon_size).half(),
y + (line_height - icon_size).half(),
),
size(icon_size, icon_size),
);
icons.push((
icon_bounds,
icon.path(),
marker.color.unwrap_or(self.foreground),
));
}
GutterPrepaint { icons }
}
fn paint(
&mut self,
_id: Option<&GlobalElementId>,
_inspector_id: Option<&InspectorElementId>,
bounds: Bounds<Pixels>,
_request_layout: &mut Self::RequestLayoutState,
prepaint: &mut Self::PrepaintState,
window: &mut Window,
cx: &mut App,
) {
window.paint_quad(fill(bounds, self.background));
for (icon_bounds, path, color) in &prepaint.icons {
let _ = window.paint_svg(
*icon_bounds,
path.clone(),
None,
TransformationMatrix::default(),
*color,
cx,
);
}
}
}
/// Переводит диапазон LSP в байтовый диапазон текста.
///
/// Пустой диапазон (например, «ожидалось `;`») расширяется на следующий
@@ -2132,7 +2378,7 @@ fn contrast_foreground(background: Hsla) -> Hsla {
/// Сохраняет активную вкладку, печатая ошибку в stderr.
fn save_active_tab() {
if let Err(err) = AppApi::global().save() {
if let Err(err) = AppApi::global().editor().save() {
eprintln!("Не удалось сохранить вкладку: {err}");
}
}
@@ -2140,19 +2386,20 @@ fn save_active_tab() {
/// Выполняет встроенную команду приложения.
fn run_app_command(command: AppCommand) {
let app = AppApi::global();
let editor = app.editor();
match command {
AppCommand::Save => save_active_tab(),
AppCommand::NextTab => {
app.next_tab();
editor.next_tab();
}
AppCommand::PreviousTab => {
app.previous_tab();
editor.previous_tab();
}
AppCommand::OpenTab => {
app.open_tab();
editor.open_tab();
}
AppCommand::CloseTab => {
app.close_tab(app.active_tab_index());
editor.close_tab(editor.active_tab_index());
}
AppCommand::ToggleLeftPanel => {
app.left_panel().toggle();
@@ -2178,6 +2425,57 @@ fn dismisses_completion(keystroke: &Keystroke) -> bool {
modified || matches!(keystroke.key.as_str(), "left" | "right" | "home" | "end")
}
/// Выполняет привязки того объекта, на котором сейчас фокус.
///
/// Глобальные привязки обрабатываются отдельно и всегда; здесь — только
/// дополнительный слой: редактор, если фокус в нём, либо блок панели, если
/// фокус на нём. Фокус в других элементах (панель инструментов, вкладки,
/// строка состояния) дополнительного слоя не добавляет.
fn run_focused_keymap(
editor: &Entity<EditorState>,
editor_keymap: &Rc<RefCell<KeyMap>>,
panels: &[PanelHandle; 3],
keystroke: &Keystroke,
pressed: bool,
window: &Window,
cx: &App,
) -> bool {
if editor
.read(cx)
.presentation()
.focus_handle()
.is_focused(window)
{
return run_keymap(editor_keymap, KeyContext::Global, keystroke, pressed);
}
match focused_block_keymap(panels, window) {
Some(keymap) => run_keymap(&keymap, KeyContext::Global, keystroke, pressed),
None => false,
}
}
/// Ищет карту клавиш блока, владеющего фокусом.
///
/// Когда у панелей появятся другие виды блоков, каждый из них должен отдавать
/// свою [`KeyMap`] так же, как это делает дерево.
fn focused_block_keymap(panels: &[PanelHandle; 3], window: &Window) -> Option<Rc<RefCell<KeyMap>>> {
for panel in panels {
let panel = panel.borrow();
for block in panel.blocks() {
let PanelBlock::Tree { tree, .. } = block;
let tree = tree.borrow();
if tree
.existing_focus_handle()
.is_some_and(|focus| focus.is_focused(window))
{
return Some(tree.keymap_handle());
}
}
}
None
}
/// Вызывает обработчики одного слоя `KeyMap` для события клавиатуры.
///
/// Возвращает `true`, если хотя бы одна привязка сработала. Заём карты
+6
View File
@@ -22,6 +22,12 @@ pub const WINDOW_TITLE: &str = "qode";
/// Ширина одного уровня отступа в редакторе.
pub const TAB_SIZE: usize = 4;
/// Ширина колонки с иконками слева от редактора (gutter).
pub const GUTTER_WIDTH: f32 = 24.0;
/// Размер иконки в gutter.
pub const GUTTER_ICON_SIZE: f32 = 14.0;
/// Ширина правой панели при открытии.
pub const RIGHT_PANEL_WIDTH: f32 = 240.0;
+7 -2
View File
@@ -7,6 +7,8 @@
use std::sync::atomic::{AtomicU64, Ordering};
use crate::gutter::GutterState;
/// Счётчик идентификаторов документов.
static NEXT_ID: AtomicU64 = AtomicU64::new(1);
@@ -126,15 +128,17 @@ pub struct Document {
/// Имя иконки вкладки из каталога Lucide (см. `crate::icons`).
///
/// `None` — показывается иконка по умолчанию. Назначается через
/// `AppApi::set_tab_icon`.
/// `EditorApi::set_tab_icon`.
pub icon: Option<String>,
/// Язык подсветки синтаксиса.
///
/// `None` — подсветка выключена (поведение по умолчанию). Включается
/// только явно через `AppApi::set_language`, например из Lua-сценария.
/// только явно через `EditorApi::set_language`, например из Lua-сценария.
pub language: Option<Language>,
/// Позиция курсора в байтах на момент ухода с вкладки.
pub cursor: usize,
/// Иконки на поле редактора для этой вкладки.
pub gutter: GutterState,
/// Содержимое на момент последнего «сохранения».
saved_content: String,
}
@@ -150,6 +154,7 @@ impl Document {
icon: None,
language: None,
cursor: 0,
gutter: GutterState::new(),
}
}
+121
View File
@@ -0,0 +1,121 @@
//! Иконки на поле редактора (gutter).
//!
//! Gutter — узкая колонка слева от редактора. Сценарий ставит на строку
//! иконку через `app:set_gutter_icon`, а вид рисует её на той же высоте, что и
//! строка в редакторе, учитывая прокрутку.
//!
//! Состояние принадлежит документу: у каждой вкладки свои иконки, и при
//! переключении вкладок показываются иконки активного документа. Номера строк
//! нумеруются с нуля и не сдвигаются автоматически при правке текста:
//! сценарий сам решает, когда переставить иконку.
use std::collections::BTreeMap;
use gpui_kit::{Hsla, Pixels};
/// Иконка одной строки.
#[derive(Clone, Debug, PartialEq)]
pub struct GutterMarker {
/// Каноническое имя иконки Lucide.
pub icon: String,
/// Цвет иконки; `None` — цвет текста темы.
pub color: Option<Hsla>,
}
/// Иконки gutter документа по номеру строки.
///
/// Одна строка несёт не больше одной иконки: повторный вызов заменяет её.
/// `BTreeMap` держит строки отсортированными, поэтому вид рисует их сверху
/// вниз без дополнительной сортировки.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct GutterState {
markers: BTreeMap<usize, GutterMarker>,
}
impl GutterState {
/// Создаёт пустое поле.
pub fn new() -> Self {
Self::default()
}
/// Ставит иконку на строку `line`, заменяя прежнюю.
///
/// Имя иконки проверяет API: здесь хранится уже каноническое имя.
pub fn set(&mut self, line: usize, icon: impl Into<String>, color: Option<Hsla>) {
self.markers.insert(
line,
GutterMarker {
icon: icon.into(),
color,
},
);
}
/// Убирает иконку со строки.
///
/// Возвращает `false`, если на этой строке иконки не было.
pub fn remove(&mut self, line: usize) -> bool {
self.markers.remove(&line).is_some()
}
/// Убирает все иконки.
pub fn clear(&mut self) {
self.markers.clear();
}
/// Иконка строки, если она есть.
pub fn get(&self, line: usize) -> Option<&GutterMarker> {
self.markers.get(&line)
}
/// Иконки в порядке возрастания номеров строк.
pub fn iter(&self) -> impl Iterator<Item = (usize, &GutterMarker)> {
self.markers.iter().map(|(line, marker)| (*line, marker))
}
}
/// Вертикальный отступ видимой строки `row` от верхнего края gutter.
///
/// `scroll_y` — смещение прокрутки редактора (отрицательное при прокрутке
/// вниз), `line_height` — высота строки из раскладки редактора. `row` — не
/// номер строки буфера, а номер видимой строки: свёрнутые строки не занимают
/// места.
pub fn row_offset(row: usize, line_height: Pixels, scroll_y: Pixels) -> Pixels {
scroll_y + line_height * row as f32
}
#[cfg(test)]
mod tests {
use super::*;
use gpui_kit::{hsla, px};
#[test]
fn markers_are_keyed_by_line_and_sorted() {
let mut gutter = GutterState::new();
assert_eq!(gutter.iter().count(), 0);
gutter.set(5, "file", None);
gutter.set(1, "play", Some(hsla(0.5, 1., 0.5, 1.)));
let lines: Vec<usize> = gutter.iter().map(|(line, _)| line).collect();
assert_eq!(lines, [1, 5]);
assert_eq!(
gutter.get(1).map(|marker| marker.icon.as_str()),
Some("play")
);
assert!(gutter.remove(1));
assert!(!gutter.remove(1));
assert!(gutter.get(1).is_none());
assert_eq!(gutter.iter().count(), 1);
gutter.clear();
assert_eq!(gutter.iter().count(), 0);
}
#[test]
fn row_offset_follows_scroll_and_line_height() {
assert_eq!(row_offset(0, px(20.), px(0.)), px(0.));
assert_eq!(row_offset(3, px(20.), px(-40.)), px(20.));
assert_eq!(row_offset(1, px(18.), px(-9.)), px(9.));
}
}
+134 -4
View File
@@ -12,6 +12,13 @@
//! автодополнения. Оба слоя проходят через одну [`KeyMap`], поэтому сценарий
//! может переопределить и удалить любую встроенную привязку.
//!
//! У каждого объекта со своим `map_key` (приложение, редактор, блок панели)
//! отдельная [`KeyMap`]. Кроме слоя привязка хранит строку режимов: пустая
//! строка — все режимы, `"-"` — только режим по умолчанию, `"-iv"` — режим по
//! умолчанию, `i` и `v`. Текущий режим карты — один символ, по умолчанию `-`.
//! Режимы динамические: [`KeyMap::set_mode`] просто меняет метку, а
//! [`KeyMap::handlers_for`] отбирает подходящие привязки.
//!
//! Клавиша сравнивается с `Keystroke.key` из GPUI. Это не результат ввода, а
//! имя клавиши, которое платформа уже нормализует:
//!
@@ -348,38 +355,97 @@ pub enum KeyHandler {
Completion(CompletionCommand),
}
/// Режим по умолчанию. Тире — такой же идентификатор режима, как и
/// остальные однобуквенные режимы (`n`, `i`, `v` и т. п.).
pub const DEFAULT_MODE: char = '-';
/// Привязка вместе с обработчиком.
#[derive(Clone)]
struct Binding {
context: KeyContext,
binding: KeyBinding,
/// Режимы, в которых действует привязка. Пустая строка — все режимы.
modes: String,
handler: KeyHandler,
}
/// Все назначенные привязки клавиш.
#[derive(Default)]
impl Binding {
/// Действует ли привязка в режиме `mode`.
fn accepts(&self, mode: char) -> bool {
self.modes.is_empty() || self.modes.contains(mode)
}
}
/// Все назначенные привязки клавиш и текущий режим объекта.
///
/// У каждого объекта со своим `map_key` (приложение, редактор, блок панели)
/// отдельная карта: режимы одного объекта не влияют на другой.
pub struct KeyMap {
bindings: Vec<Binding>,
mode: char,
}
impl Default for KeyMap {
fn default() -> Self {
Self {
bindings: Vec::new(),
mode: DEFAULT_MODE,
}
}
}
impl KeyMap {
/// Назначает или заменяет обработчик в указанном слое.
pub fn set(&mut self, context: KeyContext, binding: KeyBinding, handler: KeyHandler) {
///
/// `modes` — строка идентификаторов режимов, в которых работает
/// привязка. Пустая строка означает все режимы, `"-"` — только режим по
/// умолчанию, `"-iv"` — режим по умолчанию и режимы `i`, `v`.
pub fn set(
&mut self,
context: KeyContext,
modes: impl Into<String>,
binding: KeyBinding,
handler: KeyHandler,
) {
let modes = modes.into();
if let Some(existing) = self
.bindings
.iter_mut()
.find(|existing| existing.context == context && existing.binding == binding)
{
existing.modes = modes;
existing.handler = handler;
} else {
self.bindings.push(Binding {
context,
binding,
modes,
handler,
});
}
}
/// Переключает карту в режим `mode`.
///
/// Режимы динамические: заранее их создавать не нужно. Идентификатор —
/// ровно один символ; тире означает режим по умолчанию.
pub fn set_mode(&mut self, mode: &str) -> Result<(), String> {
let mode = mode.trim();
let mut chars = mode.chars();
match (chars.next(), chars.next()) {
(Some(mode), None) => {
self.mode = mode;
Ok(())
}
_ => Err(format!("режим должен быть одним символом: {mode:?}")),
}
}
/// Текущий режим карты.
pub fn mode(&self) -> char {
self.mode
}
/// Удаляет привязку из указанного слоя.
///
/// Возвращает `true`, если привязка была.
@@ -404,7 +470,9 @@ impl KeyMap {
self.bindings
.iter()
.filter(|binding| {
binding.context == context && binding.binding.matches(keystroke, pressed)
binding.context == context
&& binding.accepts(self.mode)
&& binding.binding.matches(keystroke, pressed)
})
.map(|binding| binding.handler.clone())
.collect()
@@ -418,6 +486,7 @@ impl KeyMap {
for spec in ["<C-s>", "<D-s>"] {
self.set(
KeyContext::Global,
"",
parse_default(spec),
KeyHandler::App(AppCommand::Save),
);
@@ -425,6 +494,7 @@ impl KeyMap {
for spec in ["<C-space>", "<D-space>"] {
self.set(
KeyContext::Completion,
"",
parse_default(spec),
KeyHandler::Completion(CompletionCommand::Trigger),
);
@@ -445,6 +515,7 @@ impl KeyMap {
] {
self.set(
KeyContext::Completion,
"",
parse_default(spec),
KeyHandler::Completion(command),
);
@@ -718,4 +789,63 @@ mod tests {
.is_empty()
);
}
#[test]
fn bindings_respect_the_current_mode() {
let mut keymap = KeyMap::default();
let plain = gpui_kit::Modifiers::default();
let bound = |keymap: &KeyMap, key: &str| {
!keymap
.handlers_for(KeyContext::Global, &stroke(key, plain), true)
.is_empty()
};
keymap.set(
KeyContext::Global,
"",
parse("a"),
KeyHandler::App(AppCommand::Save),
);
keymap.set(
KeyContext::Global,
"-",
parse("b"),
KeyHandler::App(AppCommand::Save),
);
keymap.set(
KeyContext::Global,
"iv",
parse("c"),
KeyHandler::App(AppCommand::Save),
);
keymap.set(
KeyContext::Global,
"-iv",
parse("d"),
KeyHandler::App(AppCommand::Save),
);
// По умолчанию: все общие, только "-" и "-iv".
assert!(bound(&keymap, "a"));
assert!(bound(&keymap, "b"));
assert!(!bound(&keymap, "c"));
assert!(bound(&keymap, "d"));
keymap.set_mode("i").expect("режим i");
assert!(bound(&keymap, "a"));
assert!(!bound(&keymap, "b"));
assert!(bound(&keymap, "c"));
assert!(bound(&keymap, "d"));
keymap.set_mode("n").expect("режим n");
assert!(bound(&keymap, "a"));
assert!(!bound(&keymap, "b"));
assert!(!bound(&keymap, "c"));
assert!(!bound(&keymap, "d"));
// Режим — ровно один символ.
assert!(keymap.set_mode("iv").is_err());
assert!(keymap.set_mode("").is_err());
assert_eq!(keymap.mode(), 'n');
}
}
+2 -2
View File
@@ -10,7 +10,7 @@
//! * файловый вотчер, который пересылает серверу `workspace/didChangeWatchedFiles`.
//!
//! Всё остальное — какие серверы запускать, с какими аргументами и когда —
//! решает Lua-сценарий через [`AppApi::run_lsp`](crate::api::AppApi::run_lsp).
//! решает Lua-сценарий через [`EditorApi::run_lsp`](crate::api::EditorApi::run_lsp).
//!
//! Обмен с сервером идёт в фоновых потоках: поток-читатель разбирает ответы и
//! складывает их в каналы конкретных запросов, а UI ждёт ответ в
@@ -140,7 +140,7 @@ pub struct WatcherConfig {
/// Описание запускаемого LSP-сервера.
///
/// Создаётся из Lua-таблицы в [`AppApi::run_lsp`](crate::api::AppApi::run_lsp).
/// Создаётся из Lua-таблицы в [`EditorApi::run_lsp`](crate::api::EditorApi::run_lsp).
#[derive(Clone, Debug)]
pub struct LspConfig {
/// Условное имя, по которому сервер останавливают и перезапускают.
+1
View File
@@ -10,6 +10,7 @@ mod app;
mod completion;
mod constants;
mod editor;
mod gutter;
mod icons;
mod keymap;
mod lsp;
+3 -2
View File
@@ -3,8 +3,9 @@
//! При запуске приложение выполняет [`STARTUP_SCRIPT`] из каталога [`script_dir`]
//! (см. [`ScriptEngine::run_startup`]). Скриптам доступны:
//!
//! * объект `app` — управление окном и редактором (методы [`AppApi`], вызываются
//! через двоеточие: `app:next_tab()`);
//! * объект `app` — управление окном и панелями (методы [`AppApi`], вызываются
//! через двоеточие: `app:next_tab()`); во вложенном `app.editor` живёт API
//! редактора ([`EditorApi`](crate::api::EditorApi)): `app.editor:save()`;
//! * функция `include(path)` — выполняет другой Lua-файл относительно [`script_dir`]
//! (или по абсолютному пути) в общем глобальном окружении;
//! * стандартный `require`, который также ищет модули в [`script_dir`].
+378 -80
View File
@@ -36,12 +36,12 @@ fn lua_lsp_api_lists_no_servers_and_validates_config() {
std::fs::write(
&script,
r#"
assert(#app:lsp_servers() == 0)
assert(app:stop_lsp("missing") == false)
assert(app:restart_lsp("missing") == false)
assert(#app.editor:lsp_servers() == 0)
assert(app.editor:stop_lsp("missing") == false)
assert(app.editor:restart_lsp("missing") == false)
-- Без cmd сервер не запускается, а ошибка объясняет, чего не хватает.
local ok, err = pcall(function() app:run_lsp({ name = "broken" }) end)
local ok, err = pcall(function() app.editor:run_lsp({ name = "broken" }) end)
assert(not ok)
assert(tostring(err):find("cmd", 1, true), tostring(err))
"#,
@@ -127,7 +127,7 @@ fn api_lists_tabs_and_closes_them_by_number() {
let workspace = Rc::new(RefCell::new(Workspace::default()));
let (api, _keymap) = install_api(workspace.clone());
let tabs = api.tabs();
let tabs = api.editor().tabs();
assert_eq!(
tabs.iter()
.map(|tab| (tab.index, tab.title.as_str()))
@@ -136,13 +136,13 @@ fn api_lists_tabs_and_closes_them_by_number() {
);
// Несуществующий номер ничего не закрывает.
assert!(!api.close_tab(99));
assert_eq!(api.tabs().len(), 1);
assert!(!api.editor().close_tab(99));
assert_eq!(api.editor().tabs().len(), 1);
api.open_tab();
assert_eq!(api.tabs().len(), 2);
assert!(api.close_tab(1));
assert_eq!(api.tabs().len(), 1);
api.editor().open_tab();
assert_eq!(api.editor().tabs().len(), 2);
assert!(api.editor().close_tab(1));
assert_eq!(api.editor().tabs().len(), 1);
assert_eq!(workspace.borrow().documents().len(), 1);
}
@@ -152,26 +152,26 @@ fn api_sets_and_clears_tab_icons() {
let (api, _keymap) = install_api(workspace);
// По умолчанию у всех вкладок иконка file.
assert_eq!(api.tabs()[0].icon, "file");
assert_eq!(api.tab_icon(0).as_deref(), Some("file"));
assert_eq!(api.editor().tabs()[0].icon, "file");
assert_eq!(api.editor().tab_icon(0).as_deref(), Some("file"));
assert!(api.set_tab_icon(0, "file-code"));
assert_eq!(api.tabs()[0].icon, "file-code");
assert_eq!(api.tab_icon(0).as_deref(), Some("file-code"));
assert!(api.editor().set_tab_icon(0, "file-code"));
assert_eq!(api.editor().tabs()[0].icon, "file-code");
assert_eq!(api.editor().tab_icon(0).as_deref(), Some("file-code"));
// Имя нормализуется, а вкладка возвращает канонический вариант.
assert!(api.set_tab_icon(0, "icons/book-open.svg"));
assert_eq!(api.tab_icon(0).as_deref(), Some("book-open"));
assert!(api.editor().set_tab_icon(0, "icons/book-open.svg"));
assert_eq!(api.editor().tab_icon(0).as_deref(), Some("book-open"));
// Неизвестная иконка и несуществующая вкладка ничего не меняют.
assert!(!api.set_tab_icon(0, "definitely-not-an-icon"));
assert_eq!(api.tab_icon(0).as_deref(), Some("book-open"));
assert!(!api.set_tab_icon(99, "file"));
assert!(!api.editor().set_tab_icon(0, "definitely-not-an-icon"));
assert_eq!(api.editor().tab_icon(0).as_deref(), Some("book-open"));
assert!(!api.editor().set_tab_icon(99, "file"));
assert!(api.clear_tab_icon(0));
assert_eq!(api.tab_icon(0).as_deref(), Some("file"));
assert!(!api.clear_tab_icon(99));
assert_eq!(api.tab_icon(99), None);
assert!(api.editor().clear_tab_icon(0));
assert_eq!(api.editor().tab_icon(0).as_deref(), Some("file"));
assert!(!api.editor().clear_tab_icon(99));
assert_eq!(api.editor().tab_icon(99), None);
}
#[test]
@@ -184,14 +184,14 @@ fn lua_script_can_set_tab_icons() {
std::fs::write(
&script,
r#"
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))
assert(app:tab_icon(0) == "file")
assert(app.editor:tab_icon(0) == "file")
assert(app.editor:set_tab_icon(0, "book-open"))
assert(app.editor:tab_icon(0) == "book-open")
app.editor:open_tab()
assert(app.editor:tabs()[2].icon == "file")
assert(not app.editor:set_tab_icon(0, "no-such-icon"))
assert(app.editor:clear_tab_icon(0))
assert(app.editor:tab_icon(0) == "file")
"#,
)
.unwrap();
@@ -203,6 +203,87 @@ fn lua_script_can_set_tab_icons() {
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn api_sets_and_clears_gutter_icons_per_document() {
let workspace = Rc::new(RefCell::new(Workspace::default()));
let (api, _keymap) = install_api(workspace);
assert_eq!(api.editor().gutter_icon(0), None);
assert!(api.editor().set_gutter_icon(2, "circle", None));
assert_eq!(api.editor().gutter_icon(2).as_deref(), Some("circle"));
assert_eq!(api.editor().gutter_color(2), None);
// Неизвестная иконка и некорректный цвет ничего не меняют.
assert!(
!api.editor()
.set_gutter_icon(3, "definitely-not-an-icon", None)
);
assert!(
!api.editor()
.set_gutter_icon(3, "circle", Some("not-a-color"))
);
assert_eq!(api.editor().gutter_icon(3), None);
assert!(api.editor().set_gutter_icon(2, "play", Some("#ff0000")));
assert_eq!(api.editor().gutter_icon(2).as_deref(), Some("play"));
assert_eq!(api.editor().gutter_color(2).as_deref(), Some("#ff0000"));
// Иконки принадлежат вкладке, а не приложению.
api.editor().open_tab();
assert_eq!(api.editor().gutter_icon(2), None);
assert!(api.editor().set_gutter_icon(0, "circle", None));
api.editor().set_active_tab(0);
assert_eq!(api.editor().gutter_icon(2).as_deref(), Some("play"));
assert!(api.editor().clear_gutter_icon(2));
assert!(!api.editor().clear_gutter_icon(2));
assert_eq!(api.editor().gutter_icon(2), None);
api.editor().clear_gutter();
assert_eq!(api.editor().gutter_icon(0), None);
}
#[test]
fn lua_script_can_set_gutter_icons() {
let workspace = Rc::new(RefCell::new(Workspace::default()));
let (api, _keymap) = install_api(workspace);
let dir = temp_script_dir("gutter-icons");
let script = dir.join("gutter.lua");
std::fs::write(
&script,
r##"
assert(app.editor:gutter_icon(0) == nil)
assert(app.editor:set_gutter_icon(4, "circle"))
assert(app.editor:gutter_icon(4) == "circle")
assert(app.editor:gutter_color(4) == nil)
assert(app.editor:set_gutter_icon(4, "play", "#00ff00"))
assert(app.editor:gutter_icon(4) == "play")
assert(app.editor:gutter_color(4) == "#00ff00")
assert(not app.editor:set_gutter_icon(1, "no-such-icon"))
assert(not app.editor:set_gutter_icon(1, "circle", "oops"))
assert(app.editor:gutter_icon(1) == nil)
assert(app.editor:clear_gutter_icon(4))
assert(not app.editor:clear_gutter_icon(4))
assert(app.editor:gutter_icon(4) == nil)
app.editor:set_gutter_icon(0, "circle")
app.editor:clear_gutter()
assert(app.editor:gutter_icon(0) == nil)
"##,
)
.unwrap();
let engine = ScriptEngine::new(api).expect("движок Lua");
engine.run_file(&script).expect("скрипт gutter");
drop(engine);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn api_save_writes_active_tab_to_file_named_after_it() {
// Тест пишет во временный файл, а не в каталог проекта.
@@ -223,7 +304,7 @@ fn api_save_writes_active_tab_to_file_named_after_it() {
assert!(workspace.borrow().active().is_dirty());
let (api, _keymap) = install_api(workspace.clone());
api.save().expect("сохранение должно удаться");
api.editor().save().expect("сохранение должно удаться");
assert_eq!(
std::fs::read_to_string(&path).expect("файл должен существовать"),
@@ -239,19 +320,19 @@ 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);
api.editor().open_tab();
api.editor().set_active_tab(0);
assert_eq!(api.editor().active_tab_index(), 0);
assert_eq!(api.next_tab(), 1);
assert_eq!(api.active_tab_index(), 1);
assert_eq!(api.editor().next_tab(), 1);
assert_eq!(api.editor().active_tab_index(), 1);
// С последней вкладки следующая — первая.
assert_eq!(api.next_tab(), 0);
assert_eq!(api.editor().next_tab(), 0);
// С первой вкладки предыдущая — последняя.
assert_eq!(api.previous_tab(), 1);
assert_eq!(api.previous_tab(), 0);
assert_eq!(api.editor().previous_tab(), 1);
assert_eq!(api.editor().previous_tab(), 0);
assert_eq!(workspace.borrow().active_index(), 0);
}
@@ -572,7 +653,7 @@ fn lua_diagnostics_handler_receives_every_server_and_file() {
std::fs::write(
&script,
r#"
app:on_lsp_diagnostics(0, function(servers)
app.editor:on_lsp_diagnostics(0, function(servers)
assert(#servers == 1, #servers)
local entry = servers[1]
assert(entry.server == "test")
@@ -589,7 +670,7 @@ fn lua_diagnostics_handler_receives_every_server_and_file() {
assert(first.message == "плохо")
assert(entry.diagnostics[2].severity_name == "warning")
app:open_tab()
app.editor:open_tab()
end)
"#,
)
@@ -597,7 +678,10 @@ fn lua_diagnostics_handler_receives_every_server_and_file() {
let engine = ScriptEngine::new(api.clone()).expect("движок Lua");
engine.run_file(&script).expect("регистрация обработчика");
assert_eq!(api.diagnostics_delay(), Some(std::time::Duration::ZERO));
assert_eq!(
api.editor().diagnostics_delay(),
Some(std::time::Duration::ZERO)
);
let uri = crate::lsp::path_to_uri(Path::new("/tmp/qode-diagnostics.go"));
let diagnostics = vec![
@@ -623,7 +707,7 @@ fn lua_diagnostics_handler_receives_every_server_and_file() {
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!(api.editor().emit_diagnostics(report));
assert_eq!(
workspace.borrow().documents().len(),
2,
@@ -696,13 +780,13 @@ fn lua_script_can_include_files_and_control_the_app() {
let main = dir.join("main.lua");
std::fs::write(
&helper,
"function lua_config() app:set_active_text(\"from lua\") end\n",
"function lua_config() app.editor:set_active_text(\"from lua\") end\n",
)
.expect("helper.lua");
std::fs::write(
&main,
format!(
"include({:?})\nlua_config()\napp:open_tab()\napp:set_language(1, \"python\")\n",
"include({:?})\nlua_config()\napp.editor:open_tab()\napp.editor:set_language(1, \"python\")\n",
helper.to_string_lossy()
),
)
@@ -720,12 +804,12 @@ fn lua_script_can_include_files_and_control_the_app() {
assert_eq!(
state.active_index(),
1,
"app:open_tab() не переключил вкладку"
"app.editor:open_tab() не переключил вкладку"
);
assert_eq!(
state.documents()[1].language,
Some(Language::Python),
"app:set_language() не включил подсветку из Lua"
"app.editor:set_language() не включил подсветку из Lua"
);
let _ = std::fs::remove_dir_all(&dir);
@@ -740,7 +824,7 @@ fn api_opens_file_from_disk_and_detects_language() {
let path = dir.join("main.go");
std::fs::write(&path, "package main\n").unwrap();
let index = api.open(&path).expect("файл открыт");
let index = api.editor().open(&path).expect("файл открыт");
{
let state = workspace.borrow();
assert_eq!(index, state.active_index());
@@ -752,7 +836,7 @@ fn api_opens_file_from_disk_and_detects_language() {
// Повторное открытие чистого файла переиспользует вкладку.
let tabs = workspace.borrow().documents().len();
assert_eq!(api.open(&path).unwrap(), index);
assert_eq!(api.editor().open(&path).unwrap(), index);
assert_eq!(workspace.borrow().documents().len(), tabs);
let _ = std::fs::remove_dir_all(&dir);
@@ -770,8 +854,14 @@ fn lua_open_controls_focus_and_cursor() {
// Без второго аргумента фокус остаётся прежним.
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 без позиции");
std::fs::write(
&plain,
format!("app.editor:open({:?})\n", path.to_string_lossy()),
)
.unwrap();
engine
.run_file(&plain)
.expect("app.editor:open без позиции");
assert_eq!(
workspace.borrow_mut().take_focus_after_sync(),
Some(FocusAfterSync::Keep)
@@ -781,10 +871,12 @@ fn lua_open_controls_focus_and_cursor() {
let focus = dir.join("focus.lua");
std::fs::write(
&focus,
format!("app:open({:?}, {{}})\n", path.to_string_lossy()),
format!("app.editor:open({:?}, {{}})\n", path.to_string_lossy()),
)
.unwrap();
engine.run_file(&focus).expect("app:open с пустой таблицей");
engine
.run_file(&focus)
.expect("app.editor:open с пустой таблицей");
assert_eq!(
workspace.borrow_mut().take_focus_after_sync(),
Some(FocusAfterSync::Editor(None))
@@ -794,10 +886,10 @@ fn lua_open_controls_focus_and_cursor() {
let at = dir.join("at.lua");
std::fs::write(
&at,
format!("app:open({:?}, {{2, 5}})\n", path.to_string_lossy()),
format!("app.editor:open({:?}, {{2, 5}})\n", path.to_string_lossy()),
)
.unwrap();
engine.run_file(&at).expect("app:open с позицией");
engine.run_file(&at).expect("app.editor:open с позицией");
assert_eq!(
workspace.borrow_mut().take_focus_after_sync(),
Some(FocusAfterSync::Editor(Some(19)))
@@ -815,7 +907,7 @@ fn api_open_missing_file_creates_an_empty_buffer() {
let dir = temp_script_dir("open-missing");
let path = dir.join("new.go");
let index = api.open(&path).expect("новый буфер");
let index = api.editor().open(&path).expect("новый буфер");
let state = workspace.borrow();
assert_eq!(index, state.active_index());
assert_eq!(state.active().name, path.to_string_lossy());
@@ -839,14 +931,14 @@ fn lua_app_open_opens_a_file_and_returns_its_tab() {
std::fs::write(
&script,
format!(
"local index = app:open({:?})\nassert(index == app:active_tab())\n",
"local index = app.editor:open({:?})\nassert(index == app.editor:active_tab())\n",
path.to_string_lossy()
),
)
.unwrap();
let engine = ScriptEngine::new_in(api, &dir).expect("движок Lua");
engine.run_file(&script).expect("app:open из Lua");
engine.run_file(&script).expect("app.editor:open из Lua");
let state = workspace.borrow();
assert_eq!(state.active().name, path.to_string_lossy());
@@ -866,7 +958,7 @@ fn lua_engine_loads_init_from_a_custom_config_dir() {
let dir = temp_script_dir("config-dir");
std::fs::write(
dir.join("helper.lua"),
"function config_value() app:set_active_text(\"custom config\") end\n",
"function config_value() app.editor:set_active_text(\"custom config\") end\n",
)
.expect("helper.lua");
std::fs::write(
@@ -895,11 +987,11 @@ fn lua_key_bindings_match_press_release_and_modifiers() {
std::fs::write(
&script,
r#"
app:map_key("a", function() app:set_active_text("a pressed") end)
app:map_key("!1", function() app:set_active_text("1 released") end)
app:map_key("<S-c>", function() app:set_active_text("shift c") end)
app:map_key("<C-z>", function() app:set_active_text("ctrl z") end)
app:map_key("<A-!r>", function() app:set_active_text("alt r released") end)
app:map_key("", "a", function() app.editor:set_active_text("a pressed") end)
app:map_key("", "!1", function() app.editor:set_active_text("1 released") end)
app:map_key("", "<S-c>", function() app.editor:set_active_text("shift c") end)
app:map_key("", "<C-z>", function() app.editor:set_active_text("ctrl z") end)
app:map_key("", "<A-!r>", function() app.editor:set_active_text("alt r released") end)
"#,
)
.expect("keys.lua");
@@ -997,11 +1089,11 @@ fn lua_keymap_accepts_command_names_and_nil() {
std::fs::write(
&script,
r#"
app:map_key("<C-n>", "next_tab")
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")
app:map_key("", "<C-x>", "no_such_command")
end)
assert(not ok)
assert(tostring(err):find("доступны", 1, true), tostring(err))
@@ -1042,7 +1134,7 @@ fn lua_keymap_accepts_command_names_and_nil() {
// nil удаляет привязку, не оставляя заглушку.
std::fs::write(
&script,
"app:map_key(\"<C-n>\", nil)\napp:map_completion_key(\"<C-n>\", nil)\n",
"app:map_key(\"\", \"<C-n>\", nil)\napp:map_completion_key(\"<C-n>\", nil)\n",
)
.expect("keys.lua");
engine.run_file(&script).expect("привязки должны удалиться");
@@ -1106,22 +1198,22 @@ fn highlighting_is_off_by_default_and_enabled_through_api() {
let workspace = Rc::new(RefCell::new(Workspace::default()));
let (api, _keymap) = install_api(workspace.clone());
assert_eq!(api.language(0), None);
assert_eq!(api.editor().language(0), None);
assert_eq!(workspace.borrow().documents()[0].language, None);
assert!(api.set_language(0, "rs"));
assert_eq!(api.language(0), Some("rust".to_owned()));
assert!(api.editor().set_language(0, "rs"));
assert_eq!(api.editor().language(0), Some("rust".to_owned()));
assert_eq!(
workspace.borrow().documents()[0].language,
Some(Language::Rust)
);
// Неизвестный язык и несуществующая вкладка.
assert!(!api.set_language(0, "cobol"));
assert!(!api.set_language(99, "rust"));
assert!(!api.editor().set_language(0, "cobol"));
assert!(!api.editor().set_language(99, "rust"));
assert!(api.clear_language(0));
assert_eq!(api.language(0), None);
assert!(api.editor().clear_language(0));
assert_eq!(api.editor().language(0), None);
}
#[test]
@@ -1144,8 +1236,13 @@ fn keymap_bindings_are_parsed_and_replaced() {
.load("return function() end")
.eval::<mlua::Function>()
.unwrap();
keymap.set(KeyContext::Global, plain.clone(), KeyHandler::Lua(first));
keymap.set(KeyContext::Global, plain, KeyHandler::Lua(second));
keymap.set(
KeyContext::Global,
"",
plain.clone(),
KeyHandler::Lua(first),
);
keymap.set(KeyContext::Global, "", plain, KeyHandler::Lua(second));
let stroke = Keystroke {
modifiers: Modifiers::default(),
@@ -1175,7 +1272,7 @@ fn lua_key_bindings_receive_keys_while_editor_is_focused(cx: &mut TestAppContext
let script = dir.join("keys.lua");
std::fs::write(
&script,
r#"app:map_key("<A-r>", function() app:set_active_text("alt r pressed") end)"#,
r#"app:map_key("", "<A-r>", function() app.editor:set_active_text("alt r pressed") end)"#,
)
.expect("keys.lua");
let engine = ScriptEngine::new(AppApi::global()).expect("движок Lua");
@@ -1199,6 +1296,178 @@ fn lua_key_bindings_receive_keys_while_editor_is_focused(cx: &mut TestAppContext
let _ = std::fs::remove_dir_all(&dir);
}
#[gpui_kit::test]
fn editor_and_block_keymaps_depend_on_focus(cx: &mut TestAppContext) {
cx.update(gpui_kit::init);
let (app, visual) = cx.add_window_view(MyApp::new);
let dir = temp_script_dir("focus-keys");
let script = dir.join("keys.lua");
std::fs::write(
&script,
r#"
app:map_key("", "<A-g>", function() app.editor:set_gutter_icon(0, "circle") end)
app.editor:map_key("", "<A-r>", function() app.editor:set_gutter_icon(0, "play") end)
local tree = app.left_panel:add_tree()
tree:append({ { id = "a", label = "a" } }, "")
tree:map_key("", "<A-t>", function() app.editor:set_gutter_icon(0, "file-code") end)
app.left_panel:open()
"#,
)
.expect("keys.lua");
let engine = ScriptEngine::new(AppApi::global()).expect("движок Lua");
engine.run_file(&script).expect("привязки");
let tree = {
let panel = AppApi::global().left_panel_handle();
let panel = panel.borrow();
let PanelBlock::Tree { tree, .. } = &panel.blocks()[0];
tree.clone()
};
let icon = || AppApi::global().editor().gutter_icon(0);
// Фокус в редакторе: работают глобальные и редакторские привязки.
visual.update(|window, cx| {
app.update(cx, |this, cx| {
this.editor_handle()
.update(cx, |state, cx| state.focus(window, cx));
});
});
visual.run_until_parked();
visual.simulate_keystrokes("alt-r");
visual.run_until_parked();
assert_eq!(icon().as_deref(), Some("play"));
// Привязка блока при фокусе в редакторе не срабатывает.
visual.simulate_keystrokes("alt-t");
visual.run_until_parked();
assert_eq!(icon().as_deref(), Some("play"));
visual.simulate_keystrokes("alt-g");
visual.run_until_parked();
assert_eq!(icon().as_deref(), Some("circle"));
// Фокус на дереве: работают глобальные и привязки блока, а не редактора.
tree.borrow_mut().focus("a");
visual.update(|_window, cx| {
app.update(cx, |_, cx| cx.notify());
});
visual.run_until_parked();
visual.simulate_keystrokes("alt-t");
visual.run_until_parked();
assert_eq!(icon().as_deref(), Some("file-code"));
visual.simulate_keystrokes("alt-r");
visual.run_until_parked();
assert_eq!(icon().as_deref(), Some("file-code"));
visual.simulate_keystrokes("alt-g");
visual.run_until_parked();
assert_eq!(icon().as_deref(), Some("circle"));
drop(engine);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn lua_key_modes_are_per_object_and_dynamic() {
let workspace = Rc::new(RefCell::new(Workspace::default()));
let (api, keymap) = install_api(workspace.clone());
let dir = temp_script_dir("key-modes");
let script = dir.join("modes.lua");
std::fs::write(
&script,
r#"
assert(app:mode() == "-")
app:set_mode("i")
assert(app:mode() == "i")
app:map_key("-", "<A-a>", function() app.editor:set_active_text("default") end)
app:map_key("i", "<A-b>", function() app.editor:set_active_text("insert") end)
app:map_key("", "<A-c>", function() app.editor:set_active_text("all") end)
assert(app.editor:mode() == "-")
app.editor:set_mode("n")
assert(app.editor:mode() == "n")
app.editor:map_key("n", "<A-d>", function() app.editor:set_active_text("editor n") end)
app.editor:map_key("i", "<A-e>", function() app.editor:set_active_text("editor i") end)
local tree = app.left_panel:add_tree()
assert(tree:mode() == "-")
tree:set_mode("v")
assert(tree:mode() == "v")
tree:map_key("v", "<A-f>", function() app.editor:set_active_text("tree v") end)
"#,
)
.expect("modes.lua");
let engine = ScriptEngine::new(api.clone()).expect("движок Lua");
engine.run_file(&script).expect("режимы");
let tree_keymap = {
let panel = api.left_panel_handle();
let panel = panel.borrow();
let PanelBlock::Tree { tree, .. } = &panel.blocks()[0];
tree.borrow().keymap_handle()
};
let editor_keymap = api.editor_keymap_handle();
let alt = Modifiers {
alt: true,
..Default::default()
};
let fire = |keymap: &Rc<RefCell<KeyMap>>, key: &str| {
let stroke = Keystroke {
modifiers: alt,
key: key.to_owned(),
key_char: None,
};
for handler in keymap
.borrow()
.handlers_for(KeyContext::Global, &stroke, true)
{
if let KeyHandler::Lua(callback) = handler {
callback.call::<()>(()).expect("Lua-обработчик");
}
}
};
let text = || workspace.borrow().active().content.clone();
// Глобальная карта в режиме i: "-" не действует, "i" и "" — действуют.
fire(&keymap, "a");
assert_eq!(text(), "");
fire(&keymap, "b");
assert_eq!(text(), "insert");
fire(&keymap, "c");
assert_eq!(text(), "all");
// Смена режима не пересоздаёт привязки.
keymap.borrow_mut().set_mode("n").unwrap();
fire(&keymap, "b");
assert_eq!(text(), "all");
// Привязка "-" действует только в режиме по умолчанию.
fire(&keymap, "a");
assert_eq!(text(), "all");
keymap.borrow_mut().set_mode("-").unwrap();
fire(&keymap, "a");
assert_eq!(text(), "default");
// У редактора и блока свои режимы и карты.
fire(&editor_keymap, "d");
assert_eq!(text(), "editor n");
fire(&editor_keymap, "e");
assert_eq!(text(), "editor n");
fire(&tree_keymap, "f");
assert_eq!(text(), "tree v");
drop(engine);
let _ = std::fs::remove_dir_all(&dir);
}
#[gpui_kit::test]
fn opened_panels_render_without_panicking(cx: &mut TestAppContext) {
cx.update(gpui_kit::init);
@@ -1265,6 +1534,35 @@ fn toolbar_items_render_from_api(cx: &mut TestAppContext) {
assert_eq!(toolbar.borrow().right().len(), 1);
}
#[gpui_kit::test]
fn gutter_icons_render_from_api(cx: &mut TestAppContext) {
cx.update(gpui_kit::init);
let (app, visual) = cx.add_window_view(MyApp::new);
let api = AppApi::global();
assert!(api.editor().set_gutter_icon(0, "circle", Some("#ff0000")));
assert!(api.editor().set_gutter_icon(5, "play", None));
visual.update(|_window, cx| {
app.update(cx, |_, cx| cx.notify());
});
visual.run_until_parked();
assert_eq!(
AppApi::global().editor().gutter_icon(0).as_deref(),
Some("circle")
);
assert_eq!(
AppApi::global().editor().gutter_color(0).as_deref(),
Some("#ff0000")
);
assert_eq!(
AppApi::global().editor().gutter_icon(5).as_deref(),
Some("play")
);
}
#[gpui_kit::test]
fn tree_block_renders_with_nested_nodes(cx: &mut TestAppContext) {
cx.update(gpui_kit::init);
+18
View File
@@ -18,6 +18,8 @@ use mlua::Function;
use gpui_kit::{App, FocusHandle, ScrollHandle};
use crate::keymap::KeyMap;
/// Общая ссылка на состояние блока дерева.
pub type TreeHandle = Rc<RefCell<TreeState>>;
@@ -107,6 +109,8 @@ pub struct TreeState {
focus_requested: bool,
/// Прокрутка списка: выбранная строка всегда прокручивается в видимую часть.
scroll: ScrollHandle,
/// Привязки клавиш блока и его текущий режим.
keymap: Rc<RefCell<KeyMap>>,
}
impl TreeState {
@@ -156,6 +160,20 @@ impl TreeState {
.clone()
}
/// Уже созданный фокус-обработчик, если дерево хотя бы раз рисовалось.
///
/// В отличие от [`Self::focus_handle`] ничего не создаёт: вид проверяет
/// по нему, владеет ли блок клавиатурным фокусом прямо сейчас.
pub fn existing_focus_handle(&self) -> Option<FocusHandle> {
self.focus_handle.clone()
}
/// Карта клавиш блока: `tree:map_key`, `tree:set_mode` и отрисовка вида
/// работают с одной и той же картой.
pub fn keymap_handle(&self) -> Rc<RefCell<KeyMap>> {
self.keymap.clone()
}
/// Забирает отложенный запрос фокуса.
pub fn take_focus_request(&mut self) -> bool {
std::mem::take(&mut self.focus_requested)