select
This commit is contained in:
@@ -13,7 +13,7 @@
|
||||
| `src/editor.rs` | Доменная модель: документы (`Rope`), вкладки, языки |
|
||||
| `src/api.rs` | `AppApi` / `EditorApi` / `PanelsApi` / `PanelApi` / `ToolbarApi` / `TreeApi` / `PathApi` / `WindowApi` / `KeymapApi` — единственная поверхность управления |
|
||||
| `src/script.rs` | Lua-движок, `api`, `include`, `require` |
|
||||
| `src/prompt.rs` | Плавающее поле ввода: запрос из Lua и открытое окно |
|
||||
| `src/prompt.rs` | Плавающее поле ввода и список выбора: запрос из Lua и открытое окно |
|
||||
| `src/keymap.rs` | `KeyBinding`, `KeyMap`, встроенные команды и Lua-обработчики |
|
||||
| `src/app.rs` | GPUI-вид, отрисовка, события, автодополнение |
|
||||
| `src/completion.rs` | Всплывающий список, `CaretPopup`, преобразования LSP-элементов |
|
||||
@@ -31,8 +31,8 @@ qode — не готовый редактор с фиксированным на
|
||||
своей IDE. Rust/GPUI даёт универсальные примитивы, Lua задаёт поведение:
|
||||
|
||||
- **Rust** отвечает за окно, вкладки, текстовый редактор, подсветку, gutter,
|
||||
автодополнение, панели, абстрактное дерево, плавающее поле ввода, доступ к
|
||||
файловой системе, клавиатуру и LSP-клиент.
|
||||
автодополнение, панели, абстрактное дерево, плавающее поле ввода и список
|
||||
выбора, доступ к файловой системе, клавиатуру и LSP-клиент.
|
||||
- **Lua** отвечает за всё, что делает редактор именно этой IDE: файловое
|
||||
дерево, дерево диагностики, команды, раскладки, статусы, проектные
|
||||
сценарии, оформление панелей и последовательности действий.
|
||||
|
||||
+2
-2
@@ -11,7 +11,7 @@ Rust-часть даёт только базовые универсальные
|
||||
- окно и его элементы;
|
||||
- вкладки, текстовый редактор, подсветка, gutter, автодополнение;
|
||||
- панели и абстрактное дерево;
|
||||
- плавающее поле ввода;
|
||||
- плавающее поле ввода и список выбора;
|
||||
- файловая система;
|
||||
- привязки клавиш;
|
||||
- LSP-клиент по stdio.
|
||||
@@ -64,7 +64,7 @@ api.keymap:map_key("", "<C-s>", "save")
|
||||
| `api.editor` | Вкладки, текст, gutter, подсветка, сохранение, LSP, привязки редактора | [editor.md](editor.md) |
|
||||
| `api.panels` | Левая, правая и нижняя панели, верхняя панель редактора, блоки-деревья | [panels.md](panels.md) |
|
||||
| `api.path` | Файловая система: чтение, запись, создание и удаление | [path.md](path.md) |
|
||||
| `api.window` | Окно и плавающее поле ввода | [window.md](window.md) |
|
||||
| `api.window` | Окно, плавающее поле ввода и список выбора | [window.md](window.md) |
|
||||
| `api.keymap` | Глобальные привязки клавиш и режимы | [keymap.md](keymap.md) |
|
||||
| `api.editor` (LSP) | Запуск серверов и обработка диагностики | [lsp.md](lsp.md) |
|
||||
|
||||
|
||||
+46
-1
@@ -1,6 +1,7 @@
|
||||
# Окно и ввод — `api.window`
|
||||
|
||||
Группа `api.window` управляет окном приложения и плавающим полем ввода.
|
||||
Группа `api.window` управляет окном приложения, плавающим полем ввода и
|
||||
списком выбора.
|
||||
|
||||
## Окно
|
||||
|
||||
@@ -39,3 +40,47 @@ api.window:open_input({
|
||||
end,
|
||||
})
|
||||
```
|
||||
|
||||
## Плавающий список выбора
|
||||
|
||||
`api.window:open_select(options)` открывает список выбора чуть выше центра
|
||||
окна. Параметры передаются таблицей:
|
||||
|
||||
| Поле | Описание |
|
||||
| --- | --- |
|
||||
| `items` | Непустой массив пунктов (см. ниже) |
|
||||
| `selected` | id пункта, выделенного при открытии; если не задан или не найден — первый пункт |
|
||||
| `title` | Текст над списком; отделяется горизонтальной чертой, без заголовка черты нет |
|
||||
| `callback` | `function(id)` — вызывается после Enter, когда окно уже закрыто; получает id выбранного пункта |
|
||||
|
||||
Каждый пункт — таблица с полями:
|
||||
|
||||
| Поле | Описание |
|
||||
| --- | --- |
|
||||
| `label` | Отображаемое значение (обязательно) |
|
||||
| `id` | Строка, которая вернётся в `callback` (обязательно) |
|
||||
| `icon` | Имя иконки Lucide, например `"file"`; пустое или неизвестное имя — пункт без иконки |
|
||||
| `description` | Серое описание справа от названия; обрезается троеточием, название имеет приоритет |
|
||||
|
||||
Каждый пункт занимает одну строку. Если иконка есть хотя бы у одного
|
||||
пункта, у остальных остаётся пустое место такой же ширины, чтобы названия
|
||||
начинались на одном уровне.
|
||||
|
||||
Пока список открыт, сочетания клавиш приложения не работают: стрелки меняют
|
||||
выделение, `Home`/`End` прыгают к краям, `Enter` подтверждает выбор и вызывает
|
||||
`callback` с id пункта, клик по пункту выбирает и подтверждает его, а `Esc` и
|
||||
клик вне окна закрывают список без колбэка.
|
||||
|
||||
```lua
|
||||
api.window:open_select({
|
||||
title = "Режим",
|
||||
selected = "release",
|
||||
items = {
|
||||
{ id = "debug", label = "Debug", icon = "file", description = "с отладкой" },
|
||||
{ id = "release", label = "Release", icon = "folder" },
|
||||
},
|
||||
callback = function(id)
|
||||
api.editor:set_active_text(id)
|
||||
end,
|
||||
})
|
||||
```
|
||||
|
||||
@@ -76,6 +76,16 @@ print("[lua] init.lua выполнен")
|
||||
-- -- Enter подтверждает и вызывает callback,
|
||||
-- -- Esc или клик вне окна закрывают без него;
|
||||
-- -- пока окно открыто, привязки клавиш молчат
|
||||
-- api.window:open_select(options) -- плавающий список выбора чуть выше центра;
|
||||
-- -- options = { title = "...",
|
||||
-- -- selected = "id",
|
||||
-- -- items = {{ id = "...", label = "...",
|
||||
-- -- icon = "file", description = "..." }},
|
||||
-- -- callback = function(id) ... end };
|
||||
-- -- стрелки/Home/End меняют выделение,
|
||||
-- -- Enter или клик по пункту вызывают callback,
|
||||
-- -- Esc или клик вне окна закрывают без него;
|
||||
-- -- пока окно открыто, привязки клавиш молчат
|
||||
--
|
||||
-- Редактор api.editor (номера вкладок — с нуля):
|
||||
-- api.editor:tabs() -- {{ index = 0, title = "main.rs",
|
||||
@@ -481,6 +491,56 @@ api.keymap:map_key("", "<C-g>", function()
|
||||
})
|
||||
end)
|
||||
|
||||
-- Демонстрация списка выбора. Ctrl+J открывает пункты с иконками и
|
||||
-- описаниями: title отделён разделителем, selected заранее выделяет пункт,
|
||||
-- у части пунктов иконки нет (их текст остаётся выровненным), длинное
|
||||
-- описание обрезается троеточием и не перетирает название. Enter или клик
|
||||
-- передаёт в callback id выбранного пункта.
|
||||
api.keymap:map_key("", "<C-j>", function()
|
||||
api.window:open_select({
|
||||
title = "Демонстрация списка выбора",
|
||||
selected = "info",
|
||||
items = {
|
||||
{
|
||||
id = "info",
|
||||
label = "Информация",
|
||||
icon = "info",
|
||||
description = "короткое описание",
|
||||
},
|
||||
{
|
||||
id = "file",
|
||||
label = "Открыть файл",
|
||||
icon = "file",
|
||||
description = "Ctrl+G",
|
||||
},
|
||||
{
|
||||
id = "folder",
|
||||
label = "Папка",
|
||||
icon = "folder",
|
||||
description = "длинное описание, которое обрезается троеточием, не перетирая название",
|
||||
},
|
||||
{
|
||||
id = "plain",
|
||||
label = "Без иконки",
|
||||
description = "место иконки остаётся пустым для выравнивания",
|
||||
},
|
||||
{
|
||||
id = "note",
|
||||
label = "Без описания",
|
||||
icon = "notebook",
|
||||
},
|
||||
},
|
||||
callback = function(id)
|
||||
-- Колбэк получает id уже после закрытия списка. Он может открыть
|
||||
-- новое окно — здесь это поле ввода с итогом выбора.
|
||||
api.window:open_input({
|
||||
title = "Выбрано: " .. id,
|
||||
placeholder = "Enter закроет окно",
|
||||
})
|
||||
end,
|
||||
})
|
||||
end)
|
||||
|
||||
api.keymap:map_key("", "<C-t>", "toggle_bottom_panel")
|
||||
|
||||
-- Дерево диагностики в нижней панели.
|
||||
|
||||
+39
-6
@@ -36,7 +36,7 @@ use crate::lsp::{
|
||||
DocumentDiagnostics, LspConfig, LspManager, WatcherConfig, document_uri, position_to_offset,
|
||||
};
|
||||
use crate::panel::{PanelHandle, PanelState};
|
||||
use crate::prompt::{PromptRequest, PromptRequestHandle};
|
||||
use crate::prompt::{PromptRequest, PromptRequestHandle, SelectRequest, SelectRequestHandle};
|
||||
use crate::toolbar::{ToolbarHandle, ToolbarItem, ToolbarKind, ToolbarSide, ToolbarState};
|
||||
use crate::tree::{TreeHandle, TreeNode};
|
||||
|
||||
@@ -139,7 +139,7 @@ struct DiagnosticsHandler {
|
||||
/// * `api.panels` — левая, правая и нижняя панели, а также верхняя панель
|
||||
/// редактора ([`PanelsApi`]);
|
||||
/// * `api.path` — файловая система ([`PathApi`]);
|
||||
/// * `api.window` — окно и плавающее поле ввода ([`WindowApi`]);
|
||||
/// * `api.window` — окно, плавающее поле ввода и список выбора ([`WindowApi`]);
|
||||
/// * `api.keymap` — глобальные привязки клавиш ([`KeymapApi`]).
|
||||
///
|
||||
/// Методы этого `impl`-блока автоматически экспортируются в Lua через
|
||||
@@ -181,6 +181,9 @@ pub struct AppApi {
|
||||
/// Запрос из Lua на открытие плавающего поля ввода.
|
||||
#[lua(skip)]
|
||||
prompt_request: PromptRequestHandle,
|
||||
/// Запрос из Lua на открытие плавающего списка выбора.
|
||||
#[lua(skip)]
|
||||
select_request: SelectRequestHandle,
|
||||
}
|
||||
|
||||
/// API редактора: вкладки, текст, gutter, сохранение, LSP и свои привязки
|
||||
@@ -307,18 +310,23 @@ impl PathApi {
|
||||
}
|
||||
}
|
||||
|
||||
/// API окна и плавающего поля ввода.
|
||||
/// API окна и плавающих окон ввода и выбора.
|
||||
///
|
||||
/// В Lua доступно как `api.window`.
|
||||
#[derive(Clone, mlua::UserData)]
|
||||
pub struct WindowApi {
|
||||
#[lua(skip)]
|
||||
prompt_request: PromptRequestHandle,
|
||||
#[lua(skip)]
|
||||
select_request: SelectRequestHandle,
|
||||
}
|
||||
|
||||
impl WindowApi {
|
||||
fn new(prompt_request: PromptRequestHandle) -> Self {
|
||||
Self { prompt_request }
|
||||
fn new(prompt_request: PromptRequestHandle, select_request: SelectRequestHandle) -> Self {
|
||||
Self {
|
||||
prompt_request,
|
||||
select_request,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -824,6 +832,24 @@ impl WindowApi {
|
||||
*self.prompt_request.borrow_mut() = Some(PromptRequest::from_options(options)?);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Открывает плавающий список выбора чуть выше центра окна.
|
||||
///
|
||||
/// `options` — таблица `{ title = "Выбор", selected = "b", items =
|
||||
/// { { id = "a", label = "Первый", icon = "file", description = "…" },
|
||||
/// ... }, callback = function(id) ... end }`.
|
||||
/// `title` и `selected` необязательны: без заголовка окно начинается сразу
|
||||
/// со списка, а без `selected` выделяется первый пункт. Неизвестный
|
||||
/// `selected` тоже выделяет первый пункт. Пункт без `icon` (или с
|
||||
/// неизвестным именем иконки) рисуется без иконки.
|
||||
/// Пока окно открыто, сочетания клавиш приложения не работают: стрелки
|
||||
/// меняют выделение, `Enter` подтверждает его и вызывает `callback` с id
|
||||
/// выбранного пункта уже после закрытия окна, `Esc` и клик вне окна
|
||||
/// закрывают список без колбэка.
|
||||
pub fn open_select(&self, options: Option<mlua::Table>) -> mlua::Result<()> {
|
||||
*self.select_request.borrow_mut() = Some(SelectRequest::from_options(options)?);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[mlua::userdata_impl]
|
||||
@@ -979,6 +1005,7 @@ impl AppApi {
|
||||
gutter_clicks: new_gutter_click_handlers(),
|
||||
gutter_shifts: new_gutter_shift_handlers(),
|
||||
prompt_request: Rc::new(RefCell::new(None)),
|
||||
select_request: Rc::new(RefCell::new(None)),
|
||||
};
|
||||
INSTANCE.with(|slot| *slot.borrow_mut() = Some(api.clone()));
|
||||
api
|
||||
@@ -1039,6 +1066,12 @@ impl AppApi {
|
||||
self.prompt_request.clone()
|
||||
}
|
||||
|
||||
/// Очередь запросов на открытие плавающего списка выбора (для вида).
|
||||
#[lua(skip)]
|
||||
pub fn select_request_handle(&self) -> SelectRequestHandle {
|
||||
self.select_request.clone()
|
||||
}
|
||||
|
||||
/// Обработчики клика по иконкам gutter (для вида).
|
||||
#[lua(skip)]
|
||||
pub fn gutter_click_handle(&self) -> GutterClickHandlers {
|
||||
@@ -1097,7 +1130,7 @@ impl AppApi {
|
||||
#[allow(dead_code)]
|
||||
#[lua(getter, infallible)]
|
||||
pub fn window(&self) -> WindowApi {
|
||||
WindowApi::new(self.prompt_request.clone())
|
||||
WindowApi::new(self.prompt_request.clone(), self.select_request.clone())
|
||||
}
|
||||
|
||||
/// API глобальных привязок клавиш.
|
||||
|
||||
+273
-2
@@ -23,6 +23,7 @@ use gpui_kit::component::input::{
|
||||
};
|
||||
use gpui_kit::component::label::Label;
|
||||
use gpui_kit::component::resizable::HandleEdge;
|
||||
use gpui_kit::component::separator::Separator;
|
||||
use gpui_kit::component::status_bar::StatusBar;
|
||||
use gpui_kit::component::tab::{Tab, TabBar};
|
||||
use gpui_kit::component::tag::Tag;
|
||||
@@ -53,7 +54,7 @@ use crate::icons;
|
||||
use crate::keymap::{AppCommand, CompletionCommand, KeyContext, KeyHandler, KeyMap};
|
||||
use crate::lsp::{LspManager, document_uri, position_to_offset, position_to_offset_rope};
|
||||
use crate::panel::{PanelBlock, PanelHandle};
|
||||
use crate::prompt::{PromptRequestHandle, PromptUi};
|
||||
use crate::prompt::{PromptRequestHandle, PromptUi, SelectRequestHandle, SelectUi};
|
||||
use crate::script::ScriptEngine;
|
||||
use crate::toolbar::{ToolbarHandle, ToolbarItem, ToolbarKind};
|
||||
use crate::tree::{TreeAction, TreeHandle, TreeKeyResult};
|
||||
@@ -233,6 +234,8 @@ pub struct MyApp {
|
||||
_key_interceptor: Subscription,
|
||||
/// Открытое плавающее поле ввода.
|
||||
prompt: Option<PromptUi>,
|
||||
/// Открытый плавающий список выбора.
|
||||
select: Option<SelectUi>,
|
||||
/// Обработчики клика по иконкам gutter, общие с `api.editor`.
|
||||
///
|
||||
/// Лежит перед `scripts`, чтобы обработчики (ссылки на Lua-значения)
|
||||
@@ -253,6 +256,10 @@ pub struct MyApp {
|
||||
///
|
||||
/// Живёт перед `scripts`, чтобы колбэк уничтожился раньше Lua-движка.
|
||||
prompt_request: PromptRequestHandle,
|
||||
/// Запрос из Lua на открытие плавающего списка выбора.
|
||||
///
|
||||
/// Живёт перед `scripts`, чтобы колбэк уничтожился раньше Lua-движка.
|
||||
select_request: SelectRequestHandle,
|
||||
/// Движок Lua, созданный при старте.
|
||||
///
|
||||
/// Хранится, чтобы глобальное окружение и загруженные модули жили, пока
|
||||
@@ -293,6 +300,7 @@ impl MyApp {
|
||||
let lsp = api.lsp_handle();
|
||||
let editor_keymap = api.editor_keymap_handle();
|
||||
let prompt_request = api.prompt_request_handle();
|
||||
let select_request = api.select_request_handle();
|
||||
let gutter_clicks = api.gutter_click_handle();
|
||||
let gutter_shifts = api.gutter_shift_handle();
|
||||
|
||||
@@ -445,7 +453,9 @@ impl MyApp {
|
||||
let _key_interceptor = cx.intercept_keystrokes(move |event, window, cx| {
|
||||
let handled = view
|
||||
.update(cx, |this, cx| {
|
||||
if this.prompt.is_some() {
|
||||
if this.select.is_some() {
|
||||
this.handle_select_key(&event.keystroke, cx)
|
||||
} else if this.prompt.is_some() {
|
||||
this.handle_prompt_key(&event.keystroke, window, cx)
|
||||
} else {
|
||||
this.handle_completion_key(&event.keystroke, window, cx)
|
||||
@@ -475,10 +485,12 @@ impl MyApp {
|
||||
_editor_subscription,
|
||||
_key_interceptor,
|
||||
prompt: None,
|
||||
select: None,
|
||||
gutter_clicks,
|
||||
gutter_shifts,
|
||||
gutter_hover: None,
|
||||
prompt_request,
|
||||
select_request,
|
||||
scripts: None,
|
||||
lsp,
|
||||
completion: CompletionUi::default(),
|
||||
@@ -572,6 +584,12 @@ impl MyApp {
|
||||
self.prompt.is_some()
|
||||
}
|
||||
|
||||
/// Открыт ли сейчас плавающий список выбора (доступ для тестов).
|
||||
#[cfg(test)]
|
||||
pub fn select_is_open(&self) -> bool {
|
||||
self.select.is_some()
|
||||
}
|
||||
|
||||
/// Применяет известную LSP-диагностику к редактору (доступ для тестов).
|
||||
#[cfg(test)]
|
||||
pub fn refresh_diagnostics(&mut self, cx: &mut Context<Self>) {
|
||||
@@ -811,6 +829,9 @@ impl MyApp {
|
||||
if self.prompt.is_some() {
|
||||
self.close_prompt(false, window, cx);
|
||||
}
|
||||
if self.select.is_some() {
|
||||
self.close_select(false, cx);
|
||||
}
|
||||
self.close_completion(cx);
|
||||
|
||||
let previous_focus = window.focused(cx);
|
||||
@@ -866,6 +887,66 @@ impl MyApp {
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Открывает плавающий список выбора, если Lua попросил об этом.
|
||||
///
|
||||
/// Состояние создаётся в кадре вида, чтобы запрос из Lua не зависел от
|
||||
/// `Context`. Новый запрос закрывает открытое поле ввода без колбэка.
|
||||
fn sync_select(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let Some(request) = self.select_request.borrow_mut().take() else {
|
||||
return;
|
||||
};
|
||||
|
||||
if self.select.is_some() {
|
||||
self.close_select(false, cx);
|
||||
}
|
||||
if self.prompt.is_some() {
|
||||
self.close_prompt(false, window, cx);
|
||||
}
|
||||
self.close_completion(cx);
|
||||
|
||||
self.select = Some(SelectUi {
|
||||
title: request.title,
|
||||
items: request.items,
|
||||
selected: request.selected,
|
||||
callback: request.callback,
|
||||
scroll: ScrollHandle::new(),
|
||||
});
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Закрывает плавающий список выбора.
|
||||
///
|
||||
/// При `submit = true` берёт id выделенного пункта и вызывает Lua-колбэк
|
||||
/// уже после закрытия окна. Закрытие без подтверждения колбэк не вызывает.
|
||||
fn close_select(&mut self, submit: bool, cx: &mut Context<Self>) {
|
||||
let Some(select) = self.select.take() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let id = submit
|
||||
.then(|| {
|
||||
select
|
||||
.items
|
||||
.get(select.selected)
|
||||
.map(|item| item.id.clone())
|
||||
})
|
||||
.flatten();
|
||||
let callback = select.callback.clone();
|
||||
// Колбэк относится к Lua-движку и не должен пережить окно.
|
||||
drop(select);
|
||||
|
||||
// Колбэк выполняется уже после закрытия окна. Он может открыть новое
|
||||
// окно — уведомление после вызова гарантирует, что вид увидит запрос.
|
||||
if let Some(id) = id
|
||||
&& let Some(callback) = callback
|
||||
&& let Err(err) = callback.call::<()>(id)
|
||||
{
|
||||
eprintln!("Lua: ошибка в обработчике api.window:open_select: {err}");
|
||||
}
|
||||
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Возвращает фокус туда, где он был до открытия плавающего окна.
|
||||
fn restore_focus(
|
||||
&self,
|
||||
@@ -904,6 +985,55 @@ impl MyApp {
|
||||
}
|
||||
}
|
||||
|
||||
/// Обрабатывает клавиши, пока открыт плавающий список выбора.
|
||||
///
|
||||
/// Список модален: у него нет поля ввода, которое могло бы получить текст,
|
||||
/// поэтому поглощаются все клавиши. `Enter` подтверждает выделение,
|
||||
/// стрелки меняют его, `Home`/`End` прыгают к краям, `Esc` закрывает
|
||||
/// список без колбэка.
|
||||
fn handle_select_key(&mut self, keystroke: &Keystroke, cx: &mut Context<Self>) -> bool {
|
||||
match keystroke.key.as_str() {
|
||||
"enter" => self.close_select(true, cx),
|
||||
"escape" => self.close_select(false, cx),
|
||||
"up" => self.move_select_selection(-1, cx),
|
||||
"down" => self.move_select_selection(1, cx),
|
||||
"home" => self.select_select_index(0, cx),
|
||||
"end" => {
|
||||
if let Some(select) = self.select.as_ref() {
|
||||
self.select_select_index(select.items.len().saturating_sub(1), cx);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Сдвигает выделение списка на `delta` строк, не выходя за его пределы.
|
||||
fn move_select_selection(&mut self, delta: isize, cx: &mut Context<Self>) {
|
||||
let Some(select) = self.select.as_ref() else {
|
||||
return;
|
||||
};
|
||||
if select.items.is_empty() {
|
||||
return;
|
||||
}
|
||||
let selected = select.selected.min(select.items.len() - 1) as isize;
|
||||
let next = (selected + delta).clamp(0, select.items.len() as isize - 1) as usize;
|
||||
self.select_select_index(next, cx);
|
||||
}
|
||||
|
||||
/// Выделяет пункт списка по индексу.
|
||||
fn select_select_index(&mut self, index: usize, cx: &mut Context<Self>) {
|
||||
let Some(select) = self.select.as_mut() else {
|
||||
return;
|
||||
};
|
||||
if index >= select.items.len() || index == select.selected {
|
||||
return;
|
||||
}
|
||||
select.selected = index;
|
||||
select.scroll.scroll_to_item(index);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Плавающее поле ввода чуть выше центра окна.
|
||||
///
|
||||
/// Без заголовка окно состоит из одной строки ввода; с заголовком текст
|
||||
@@ -954,6 +1084,145 @@ impl MyApp {
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
/// Плавающий список выбора чуть выше центра окна.
|
||||
///
|
||||
/// Заголовок отделён от списка разделителем. Если иконка есть хотя бы у
|
||||
/// одного пункта, у пунктов без иконки остаётся пустое место такой же
|
||||
/// ширины, чтобы текст выравнивался. Подложка перекрывает всё окно,
|
||||
/// поэтому клик вне списка закрывает его, не доходя до редактора и панелей.
|
||||
fn render_select(&self, cx: &mut Context<Self>) -> AnyElement {
|
||||
let Some(select) = self.select.as_ref() else {
|
||||
return div().into_any_element();
|
||||
};
|
||||
|
||||
let view = cx.entity().downgrade();
|
||||
let outside_view = view.clone();
|
||||
let mut dialog = div()
|
||||
.id("floating-select")
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_2()
|
||||
.w(px(PROMPT_WIDTH))
|
||||
.max_w(relative(0.9))
|
||||
.p_2()
|
||||
.occlude()
|
||||
.rounded(cx.theme().radius)
|
||||
.bg(cx.theme().popover)
|
||||
.border_1()
|
||||
.border_color(cx.theme().border)
|
||||
.shadow_md()
|
||||
.on_mouse_down_out(move |_, _window, cx| {
|
||||
let _ = outside_view.update(cx, |this, cx| this.close_select(false, cx));
|
||||
});
|
||||
|
||||
if let Some(title) = &select.title {
|
||||
dialog = dialog
|
||||
.child(div().text_sm().font_medium().child(title.clone()))
|
||||
.child(Separator::horizontal());
|
||||
}
|
||||
|
||||
let has_icons = select.items.iter().any(|item| item.icon.is_some());
|
||||
let mut rows = Vec::with_capacity(select.items.len());
|
||||
for (index, item) in select.items.iter().enumerate() {
|
||||
let is_selected = index == select.selected;
|
||||
let click_view = view.clone();
|
||||
let hover_view = view.clone();
|
||||
|
||||
let mut row = div()
|
||||
.id(format!("select-row-{index}"))
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_1p5()
|
||||
.px_1p5()
|
||||
.py_0p5()
|
||||
.flex_shrink_0()
|
||||
.overflow_hidden()
|
||||
.whitespace_nowrap()
|
||||
.rounded(cx.theme().radius)
|
||||
.on_click(move |_, _window, cx| {
|
||||
let _ = click_view.update(cx, |this, cx| {
|
||||
this.select_select_index(index, cx);
|
||||
this.close_select(true, cx);
|
||||
});
|
||||
})
|
||||
.on_hover(move |hovered, _window, cx| {
|
||||
if *hovered {
|
||||
let _ =
|
||||
hover_view.update(cx, |this, cx| this.select_select_index(index, cx));
|
||||
}
|
||||
});
|
||||
|
||||
// Ширина пустого места совпадает с xsmall-иконкой (12 px), чтобы
|
||||
// названия пунктов без иконки начинались на том же уровне.
|
||||
if has_icons {
|
||||
row = row.child(match item.icon {
|
||||
Some(icon) => Icon::new(icon)
|
||||
.xsmall()
|
||||
.flex_shrink_0()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.into_any_element(),
|
||||
None => div().w(px(12.)).flex_shrink_0().into_any_element(),
|
||||
});
|
||||
}
|
||||
|
||||
// Название занимает свою естественную ширину, описание получает
|
||||
// остаток и прижимается вправо: длинное название не перетирает
|
||||
// описание, а обрезка с троеточием остаётся у каждого.
|
||||
row = row.child(
|
||||
div()
|
||||
.min_w_0()
|
||||
.overflow_hidden()
|
||||
.text_sm()
|
||||
.whitespace_nowrap()
|
||||
.text_ellipsis()
|
||||
.child(item.label.clone()),
|
||||
);
|
||||
|
||||
if let Some(description) = &item.description {
|
||||
row = row.child(
|
||||
div()
|
||||
.flex_1()
|
||||
.min_w_0()
|
||||
.overflow_hidden()
|
||||
.text_xs()
|
||||
.text_right()
|
||||
.whitespace_nowrap()
|
||||
.text_ellipsis()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child(description.clone()),
|
||||
);
|
||||
}
|
||||
|
||||
if is_selected {
|
||||
row = row.bg(cx.theme().selection);
|
||||
}
|
||||
rows.push(row.into_any_element());
|
||||
}
|
||||
|
||||
dialog = dialog.child(
|
||||
div()
|
||||
.id("floating-select-list")
|
||||
.flex()
|
||||
.flex_col()
|
||||
.max_h(px(320.))
|
||||
.overflow_y_scroll()
|
||||
.track_scroll(&select.scroll)
|
||||
.children(rows),
|
||||
);
|
||||
|
||||
div()
|
||||
.id("floating-select-overlay")
|
||||
.absolute()
|
||||
.inset_0()
|
||||
.occlude()
|
||||
.flex()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.pb(px(PROMPT_CENTER_OFFSET))
|
||||
.child(dialog)
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn select_completion(&mut self, selected: usize, cx: &mut Context<Self>) {
|
||||
if selected >= self.completion.visible_len() || selected == self.completion.selected {
|
||||
return;
|
||||
@@ -2392,6 +2661,7 @@ impl Render for MyApp {
|
||||
// Запрос из Lua обрабатывается в кадре: только здесь есть `Window`
|
||||
// и `Context`, чтобы создать поле ввода и передать ему фокус.
|
||||
self.sync_prompt(window, cx);
|
||||
self.sync_select(window, cx);
|
||||
// Документ мог открыться до запуска сервера (init.lua выполняется
|
||||
// сразу после создания окна) — отдаём его LSP при первом кадре.
|
||||
let text = self.editor.read(cx).text().clone();
|
||||
@@ -2506,6 +2776,7 @@ impl Render for MyApp {
|
||||
.child(self.render_status_bar(cx))
|
||||
.child(self.render_completion_popup(cx))
|
||||
.child(self.render_prompt(cx))
|
||||
.child(self.render_select(cx))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+162
-23
@@ -1,17 +1,25 @@
|
||||
//! Плавающее поле ввода, открываемое из Lua.
|
||||
//! Плавающие окна, открываемые из Lua.
|
||||
//!
|
||||
//! Сценарий вызывает `api.window:open_input{ title = "Имя", callback = function(text) end }`.
|
||||
//! Запрос складывается в общее состояние API, а вид при следующем кадре
|
||||
//! создаёт поле ввода, показывает его чуть выше центра и забирает фокус.
|
||||
//! Пока окно открыто, сочетания клавиш приложения не работают: текст набируется
|
||||
//! в поле, `Enter` подтверждает ввод и запускает колбэк, `Esc` и клик вне окна
|
||||
//! закрывают его без вызова колбэка.
|
||||
//! Сценарий вызывает `api.window:open_input{ title = "Имя", callback = function(text) end }`
|
||||
//! или `api.window:open_select{ title = "Выбор", items = {...},
|
||||
//! callback = function(id) end }`. Запрос складывается в общее состояние API, а
|
||||
//! вид при следующем кадре создаёт окно, показывает его чуть выше центра и
|
||||
//! забирает фокус (поле ввода) или перехватывает клавиши (список).
|
||||
//!
|
||||
//! Пока окно открыто, сочетания клавиш приложения не работают. У поля ввода
|
||||
//! текст набивается в поле, `Enter` подтверждает ввод и запускает колбэк, `Esc`
|
||||
//! и клик вне окна закрывают его без вызова колбэка. У списка стрелки меняют
|
||||
//! выделение, `Enter` подтверждает его, клик по пункту выбирает и подтверждает,
|
||||
//! `Esc` и клик вне окна закрывают список без колбэка.
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
|
||||
use gpui_kit::assets::IconName;
|
||||
use gpui_kit::component::input::InputState;
|
||||
use gpui_kit::{Entity, FocusHandle, Subscription};
|
||||
use gpui_kit::{Entity, FocusHandle, ScrollHandle, Subscription};
|
||||
|
||||
use crate::icons;
|
||||
|
||||
/// Запрос на открытие плавающего поля ввода.
|
||||
pub struct PromptRequest {
|
||||
@@ -38,18 +46,9 @@ impl PromptRequest {
|
||||
});
|
||||
};
|
||||
|
||||
let title = optional_text(&options, "title")?;
|
||||
let placeholder = optional_text(&options, "placeholder")?;
|
||||
|
||||
let callback = match options.get::<mlua::Value>("callback")? {
|
||||
mlua::Value::Nil => None,
|
||||
mlua::Value::Function(callback) => Some(callback),
|
||||
_ => {
|
||||
return Err(mlua::Error::RuntimeError(
|
||||
"api.window:open_input: поле callback должно быть функцией".to_owned(),
|
||||
));
|
||||
}
|
||||
};
|
||||
let title = optional_text(&options, "title", "api.window:open_input")?;
|
||||
let placeholder = optional_text(&options, "placeholder", "api.window:open_input")?;
|
||||
let callback = optional_callback(&options, "callback", "api.window:open_input")?;
|
||||
|
||||
Ok(Self {
|
||||
title,
|
||||
@@ -59,8 +58,116 @@ impl PromptRequest {
|
||||
}
|
||||
}
|
||||
|
||||
/// Пункт списка выбора.
|
||||
pub struct SelectItem {
|
||||
/// Идентификатор, который получит колбэк после выбора.
|
||||
pub id: String,
|
||||
/// Отображаемое значение.
|
||||
pub label: String,
|
||||
/// Иконка Lucide; `None` — у пункта иконки нет.
|
||||
pub icon: Option<IconName>,
|
||||
/// Описание справа от названия; `None` — описания нет.
|
||||
pub description: Option<String>,
|
||||
}
|
||||
|
||||
/// Запрос на открытие плавающего списка выбора.
|
||||
pub struct SelectRequest {
|
||||
/// Текст над списком; `None` — окно без заголовка и разделителя.
|
||||
pub title: Option<String>,
|
||||
/// Пункты списка; непустой массив таблиц.
|
||||
pub items: Vec<SelectItem>,
|
||||
/// Индекс пункта, выделенного при открытии.
|
||||
pub selected: usize,
|
||||
/// Колбэк, вызываемый после `Enter` уже после закрытия окна.
|
||||
pub callback: Option<mlua::Function>,
|
||||
}
|
||||
|
||||
impl SelectRequest {
|
||||
/// Разбирает Lua-таблицу опций `api.window:open_select`.
|
||||
///
|
||||
/// # Errors
|
||||
/// Возвращает ошибку Lua, если `items` не непустой массив таблиц, поля
|
||||
/// пунктов не строки или `callback` не функция.
|
||||
pub fn from_options(options: Option<mlua::Table>) -> mlua::Result<Self> {
|
||||
let Some(options) = options else {
|
||||
return Err(mlua::Error::RuntimeError(
|
||||
"api.window:open_select: нужна таблица с полем items".to_owned(),
|
||||
));
|
||||
};
|
||||
|
||||
let title = optional_text(&options, "title", "api.window:open_select")?;
|
||||
let selected = optional_text(&options, "selected", "api.window:open_select")?;
|
||||
let callback = optional_callback(&options, "callback", "api.window:open_select")?;
|
||||
let items = parse_select_items(&options)?;
|
||||
|
||||
// Неизвестный идентификатор не мешает открыть список: выделяется
|
||||
// первый пункт, как будто `selected` не задан.
|
||||
let selected = selected
|
||||
.and_then(|id| items.iter().position(|item| item.id == id))
|
||||
.unwrap_or(0);
|
||||
|
||||
Ok(Self {
|
||||
title,
|
||||
items,
|
||||
selected,
|
||||
callback,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Разбирает массив пунктов, переданный в `api.window:open_select`.
|
||||
fn parse_select_items(options: &mlua::Table) -> mlua::Result<Vec<SelectItem>> {
|
||||
const METHOD: &str = "api.window:open_select";
|
||||
|
||||
let items = match options.get::<mlua::Value>("items")? {
|
||||
mlua::Value::Table(items) => items,
|
||||
_ => {
|
||||
return Err(mlua::Error::RuntimeError(format!(
|
||||
"{METHOD}: поле items должно быть массивом пунктов"
|
||||
)));
|
||||
}
|
||||
};
|
||||
|
||||
let mut parsed = Vec::new();
|
||||
for pair in items.sequence_values::<mlua::Table>() {
|
||||
let item = pair?;
|
||||
let id = required_text(&item, "id", METHOD)?;
|
||||
let label = required_text(&item, "label", METHOD)?;
|
||||
|
||||
// Неизвестное имя иконки не подменяется молча: пункт рисуется без неё,
|
||||
// как и пункт с пустым полем icon.
|
||||
let icon = optional_text(&item, "icon", METHOD)?.and_then(|name| icons::from_name(&name));
|
||||
let description = optional_text(&item, "description", METHOD)?;
|
||||
|
||||
parsed.push(SelectItem {
|
||||
id,
|
||||
label,
|
||||
icon,
|
||||
description,
|
||||
});
|
||||
}
|
||||
|
||||
if parsed.is_empty() {
|
||||
return Err(mlua::Error::RuntimeError(format!(
|
||||
"{METHOD}: поле items не должно быть пустым"
|
||||
)));
|
||||
}
|
||||
|
||||
Ok(parsed)
|
||||
}
|
||||
|
||||
/// Читает обязательную строковую опцию.
|
||||
fn required_text(table: &mlua::Table, field: &str, method: &str) -> mlua::Result<String> {
|
||||
match table.get::<mlua::Value>(field)? {
|
||||
mlua::Value::String(text) => Ok(text.to_str()?.to_string()),
|
||||
_ => Err(mlua::Error::RuntimeError(format!(
|
||||
"{method}: поле {field} должно быть строкой"
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Читает необязательную строковую опцию; пустая строка означает `None`.
|
||||
fn optional_text(options: &mlua::Table, field: &str) -> mlua::Result<Option<String>> {
|
||||
fn optional_text(options: &mlua::Table, field: &str, method: &str) -> mlua::Result<Option<String>> {
|
||||
match options.get::<mlua::Value>(field)? {
|
||||
mlua::Value::Nil => Ok(None),
|
||||
mlua::Value::String(text) => {
|
||||
@@ -68,14 +175,32 @@ fn optional_text(options: &mlua::Table, field: &str) -> mlua::Result<Option<Stri
|
||||
Ok((!text.is_empty()).then_some(text))
|
||||
}
|
||||
_ => Err(mlua::Error::RuntimeError(format!(
|
||||
"api.window:open_input: поле {field} должно быть строкой"
|
||||
"{method}: поле {field} должно быть строкой"
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Общее состояние: запрос, ожидающий ближайшего кадра.
|
||||
/// Читает необязательный колбэк; `nil` означает, что его нет.
|
||||
fn optional_callback(
|
||||
options: &mlua::Table,
|
||||
field: &str,
|
||||
method: &str,
|
||||
) -> mlua::Result<Option<mlua::Function>> {
|
||||
match options.get::<mlua::Value>(field)? {
|
||||
mlua::Value::Nil => Ok(None),
|
||||
mlua::Value::Function(callback) => Ok(Some(callback)),
|
||||
_ => Err(mlua::Error::RuntimeError(format!(
|
||||
"{method}: поле {field} должно быть функцией"
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Общее состояние: запрос на поле ввода, ожидающий ближайшего кадра.
|
||||
pub type PromptRequestHandle = Rc<RefCell<Option<PromptRequest>>>;
|
||||
|
||||
/// Общее состояние: запрос на список выбора, ожидающий ближайшего кадра.
|
||||
pub type SelectRequestHandle = Rc<RefCell<Option<SelectRequest>>>;
|
||||
|
||||
/// Открытое плавающее поле ввода и его окружение.
|
||||
pub struct PromptUi {
|
||||
/// Поле ввода, созданное видом.
|
||||
@@ -89,3 +214,17 @@ pub struct PromptUi {
|
||||
/// Подписка на события поля; живёт вместе с окном.
|
||||
pub _subscription: Subscription,
|
||||
}
|
||||
|
||||
/// Открытый плавающий список выбора.
|
||||
pub struct SelectUi {
|
||||
/// Текст над списком; `None` — окно без заголовка и разделителя.
|
||||
pub title: Option<String>,
|
||||
/// Пункты списка.
|
||||
pub items: Vec<SelectItem>,
|
||||
/// Индекс выделенного пункта.
|
||||
pub selected: usize,
|
||||
/// Колбэк `Enter`; получает id выбранного пункта.
|
||||
pub callback: Option<mlua::Function>,
|
||||
/// Прокрутка списка: выделение всегда прокручивается в видимую часть.
|
||||
pub scroll: ScrollHandle,
|
||||
}
|
||||
|
||||
+215
@@ -15,6 +15,7 @@ use lsp_types::{Diagnostic, DiagnosticSeverity, Position, Range};
|
||||
use crate::api::{AppApi, GutterIconOptions, WindowCommand, drain_window_commands};
|
||||
use crate::app::MyApp;
|
||||
use crate::editor::{FocusAfterSync, Language, Workspace};
|
||||
use crate::icons;
|
||||
use crate::keymap::{KeyBinding, KeyContext, KeyHandler, KeyMap, Modifiers as KeyModifiers};
|
||||
use crate::panel::PanelBlock;
|
||||
use crate::script::ScriptEngine;
|
||||
@@ -886,6 +887,107 @@ fn lua_app_open_input_queues_options() {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lua_app_open_select_queues_options() {
|
||||
let workspace = Rc::new(RefCell::new(Workspace::default()));
|
||||
let (api, _keymap) = install_api(workspace);
|
||||
let requests = api.select_request_handle();
|
||||
|
||||
let dir = temp_script_dir("open-select");
|
||||
let script = dir.join("select.lua");
|
||||
std::fs::write(
|
||||
&script,
|
||||
r#"
|
||||
api.window:open_select({
|
||||
title = "Режим",
|
||||
selected = "release",
|
||||
items = {
|
||||
{ id = "debug", label = "Debug", icon = "file", description = "с отладкой" },
|
||||
{ id = "release", label = "Release" },
|
||||
},
|
||||
callback = function(id) end,
|
||||
})
|
||||
"#,
|
||||
)
|
||||
.expect("select.lua");
|
||||
|
||||
let engine = ScriptEngine::new(api).expect("движок Lua");
|
||||
engine
|
||||
.run_file(&script)
|
||||
.expect("api.window:open_select с опциями");
|
||||
|
||||
let request = requests
|
||||
.borrow_mut()
|
||||
.take()
|
||||
.expect("запрос на список выбора");
|
||||
assert_eq!(request.title.as_deref(), Some("Режим"));
|
||||
assert_eq!(request.selected, 1, "должен выделяться пункт release");
|
||||
assert!(request.callback.is_some());
|
||||
assert_eq!(request.items.len(), 2);
|
||||
assert_eq!(request.items[0].id, "debug");
|
||||
assert_eq!(request.items[0].label, "Debug");
|
||||
assert_eq!(request.items[0].description.as_deref(), Some("с отладкой"));
|
||||
assert_eq!(request.items[0].icon, icons::from_name("file"));
|
||||
assert!(request.items[1].icon.is_none());
|
||||
assert!(request.items[1].description.is_none());
|
||||
|
||||
// Без selected и с неизвестным selected выделяется первый пункт.
|
||||
std::fs::write(
|
||||
&script,
|
||||
r#"
|
||||
api.window:open_select({ items = {{ id = "a", label = "A" }} })
|
||||
"#,
|
||||
)
|
||||
.expect("select.lua");
|
||||
engine.run_file(&script).expect("open_select без selected");
|
||||
let request = requests.borrow_mut().take().expect("запрос без selected");
|
||||
assert_eq!(request.selected, 0);
|
||||
assert!(request.title.is_none());
|
||||
assert!(request.callback.is_none());
|
||||
|
||||
std::fs::write(
|
||||
&script,
|
||||
r#"
|
||||
api.window:open_select({ selected = "missing", items = {{ id = "a", label = "A" }} })
|
||||
"#,
|
||||
)
|
||||
.expect("select.lua");
|
||||
engine
|
||||
.run_file(&script)
|
||||
.expect("open_select с неизвестным selected");
|
||||
assert_eq!(
|
||||
requests
|
||||
.borrow_mut()
|
||||
.take()
|
||||
.expect("запрос с неизвестным selected")
|
||||
.selected,
|
||||
0
|
||||
);
|
||||
|
||||
// Некорректные опции — ошибка Lua, а не молчаливый пропуск.
|
||||
std::fs::write(
|
||||
&script,
|
||||
r#"
|
||||
assert(not pcall(function() api.window:open_select() end))
|
||||
assert(not pcall(function() api.window:open_select({}) end))
|
||||
assert(not pcall(function() api.window:open_select({ items = {} }) end))
|
||||
assert(not pcall(function() api.window:open_select({ items = "x" }) end))
|
||||
assert(not pcall(function() api.window:open_select({ items = {{ label = "A" }} }) end))
|
||||
assert(not pcall(function() api.window:open_select({ items = {{ id = "a" }} }) end))
|
||||
assert(not pcall(function() api.window:open_select({ items = {{ id = 1, label = "A" }} }) end))
|
||||
assert(not pcall(function() api.window:open_select({ title = 1, items = {{ id = "a", label = "A" }} }) end))
|
||||
assert(not pcall(function() api.window:open_select({ selected = 1, items = {{ id = "a", label = "A" }} }) end))
|
||||
assert(not pcall(function() api.window:open_select({ callback = "x", items = {{ id = "a", label = "A" }} }) end))
|
||||
"#,
|
||||
)
|
||||
.expect("select.lua");
|
||||
engine.run_file(&script).expect("проверка типов опций");
|
||||
assert!(requests.borrow().is_none());
|
||||
|
||||
drop(engine);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lua_path_list_returns_sorted_entries_with_paths() {
|
||||
let workspace = Rc::new(RefCell::new(Workspace::default()));
|
||||
@@ -2502,6 +2604,119 @@ fn floating_input_blocks_keys_and_submits_on_enter(cx: &mut TestAppContext) {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[gpui_kit::test]
|
||||
fn floating_select_navigates_and_submits_id(cx: &mut TestAppContext) {
|
||||
cx.update(gpui_kit::init);
|
||||
|
||||
let mut workspace = None;
|
||||
let (app, visual) = cx.add_window_view(|window, cx| {
|
||||
let app = MyApp::new(window, cx);
|
||||
workspace = Some(app.workspace_handle());
|
||||
app
|
||||
});
|
||||
let workspace = workspace.expect("вид создан");
|
||||
|
||||
let dir = temp_script_dir("floating-select");
|
||||
let keys = dir.join("keys.lua");
|
||||
let open = dir.join("open.lua");
|
||||
let open_first = dir.join("open-first.lua");
|
||||
std::fs::write(
|
||||
&keys,
|
||||
r#"api.keymap:map_key("", "<A-r>", function() api.editor:set_active_text("blocked") end)"#,
|
||||
)
|
||||
.expect("keys.lua");
|
||||
std::fs::write(
|
||||
&open,
|
||||
r#"
|
||||
api.window:open_select({
|
||||
title = "Режим",
|
||||
selected = "b",
|
||||
items = {
|
||||
{ id = "a", label = "Альфа", icon = "file", description = "первый" },
|
||||
{ id = "b", label = "Бета" },
|
||||
{ id = "c", label = "Гамма", icon = "folder" },
|
||||
},
|
||||
callback = function(id) api.editor:set_active_text(id) end,
|
||||
})
|
||||
"#,
|
||||
)
|
||||
.expect("open.lua");
|
||||
std::fs::write(
|
||||
&open_first,
|
||||
r#"
|
||||
api.window:open_select({
|
||||
items = {
|
||||
{ id = "first", label = "Первый" },
|
||||
{ id = "second", label = "Второй" },
|
||||
},
|
||||
callback = function(id) api.editor:set_active_text(id) end,
|
||||
})
|
||||
"#,
|
||||
)
|
||||
.expect("open-first.lua");
|
||||
|
||||
let engine = ScriptEngine::new(AppApi::global()).expect("движок Lua");
|
||||
engine.run_file(&keys).expect("привязка");
|
||||
engine.run_file(&open).expect("api.window:open_select");
|
||||
|
||||
let notify = |visual: &mut gpui_kit::VisualTestContext| {
|
||||
visual.update(|_window, cx| {
|
||||
app.update(cx, |_, cx| cx.notify());
|
||||
});
|
||||
visual.run_until_parked();
|
||||
};
|
||||
let select_open = |visual: &mut gpui_kit::VisualTestContext| {
|
||||
visual.update(|_window, cx| app.read(cx).select_is_open())
|
||||
};
|
||||
|
||||
notify(visual);
|
||||
assert!(select_open(visual), "список выбора должен открыться");
|
||||
|
||||
// Пока список открыт, все клавиши перехватываются, включая привязки.
|
||||
visual.simulate_keystrokes("alt-r");
|
||||
visual.run_until_parked();
|
||||
assert_eq!(workspace.borrow().active().content_string(), "");
|
||||
|
||||
// Выделен пункт b; стрелка вниз переводит на c, Enter возвращает его id.
|
||||
visual.simulate_keystrokes("down");
|
||||
visual.simulate_keystrokes("enter");
|
||||
visual.run_until_parked();
|
||||
assert!(!select_open(visual));
|
||||
assert_eq!(workspace.borrow().active().content_string(), "c");
|
||||
|
||||
// Esc закрывает список без колбэка.
|
||||
engine.run_file(&open).expect("open_select повторно");
|
||||
notify(visual);
|
||||
assert!(select_open(visual));
|
||||
visual.simulate_keystrokes("escape");
|
||||
visual.run_until_parked();
|
||||
assert!(!select_open(visual));
|
||||
assert_eq!(workspace.borrow().active().content_string(), "c");
|
||||
|
||||
// Клик вне окна тоже закрывает список без колбэка.
|
||||
engine.run_file(&open).expect("open_select в третий раз");
|
||||
notify(visual);
|
||||
assert!(select_open(visual));
|
||||
visual.simulate_click(point(px(2.), px(2.)), Modifiers::default());
|
||||
visual.run_until_parked();
|
||||
assert!(!select_open(visual));
|
||||
assert_eq!(workspace.borrow().active().content_string(), "c");
|
||||
|
||||
// Без selected Enter выбирает первый пункт.
|
||||
engine
|
||||
.run_file(&open_first)
|
||||
.expect("open_select без selected");
|
||||
notify(visual);
|
||||
assert!(select_open(visual));
|
||||
visual.simulate_keystrokes("enter");
|
||||
visual.run_until_parked();
|
||||
assert!(!select_open(visual));
|
||||
assert_eq!(workspace.borrow().active().content_string(), "first");
|
||||
|
||||
drop(engine);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[gpui_kit::test]
|
||||
fn arrows_move_completion_selection_without_moving_cursor(cx: &mut TestAppContext) {
|
||||
cx.update(gpui_kit::init);
|
||||
|
||||
Reference in New Issue
Block a user