This commit is contained in:
2026-11-01 01:54:53 +03:00
parent 51cfc86084
commit b8292acc96
8 changed files with 800 additions and 37 deletions
+3 -3
View File
@@ -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
View File
@@ -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
View File
@@ -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,
})
```
+60
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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);