gutter and keys
This commit is contained in:
+598
-326
File diff suppressed because it is too large
Load Diff
+314
-16
@@ -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`, если хотя бы одна привязка сработала. Заём карты
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 {
|
||||
/// Условное имя, по которому сервер останавливают и перезапускают.
|
||||
|
||||
@@ -10,6 +10,7 @@ mod app;
|
||||
mod completion;
|
||||
mod constants;
|
||||
mod editor;
|
||||
mod gutter;
|
||||
mod icons;
|
||||
mod keymap;
|
||||
mod lsp;
|
||||
|
||||
+3
-2
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user