From b8292acc96451082ea8947ead4ab9a5359b994fc Mon Sep 17 00:00:00 2001 From: "dedal.qq" Date: Sun, 1 Nov 2026 01:54:53 +0300 Subject: [PATCH] select --- AGENTS.md | 6 +- docs/README.md | 4 +- docs/window.md | 47 ++++++++- init.lua | 60 +++++++++++ src/api.rs | 45 ++++++-- src/app.rs | 275 ++++++++++++++++++++++++++++++++++++++++++++++++- src/prompt.rs | 185 ++++++++++++++++++++++++++++----- src/tests.rs | 215 ++++++++++++++++++++++++++++++++++++++ 8 files changed, 800 insertions(+), 37 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index c819d61..c45213a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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: файловое дерево, дерево диагностики, команды, раскладки, статусы, проектные сценарии, оформление панелей и последовательности действий. diff --git a/docs/README.md b/docs/README.md index 94749bb..9731449 100644 --- a/docs/README.md +++ b/docs/README.md @@ -11,7 +11,7 @@ Rust-часть даёт только базовые универсальные - окно и его элементы; - вкладки, текстовый редактор, подсветка, gutter, автодополнение; - панели и абстрактное дерево; -- плавающее поле ввода; +- плавающее поле ввода и список выбора; - файловая система; - привязки клавиш; - LSP-клиент по stdio. @@ -64,7 +64,7 @@ api.keymap:map_key("", "", "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) | diff --git a/docs/window.md b/docs/window.md index e3ecdca..d48172f 100644 --- a/docs/window.md +++ b/docs/window.md @@ -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, +}) +``` diff --git a/init.lua b/init.lua index 9c037e1..a25d45d 100644 --- a/init.lua +++ b/init.lua @@ -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("", "", function() }) end) +-- Демонстрация списка выбора. Ctrl+J открывает пункты с иконками и +-- описаниями: title отделён разделителем, selected заранее выделяет пункт, +-- у части пунктов иконки нет (их текст остаётся выровненным), длинное +-- описание обрезается троеточием и не перетирает название. Enter или клик +-- передаёт в callback id выбранного пункта. +api.keymap:map_key("", "", 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("", "", "toggle_bottom_panel") -- Дерево диагностики в нижней панели. diff --git a/src/api.rs b/src/api.rs index e1cb222..9628771 100644 --- a/src/api.rs +++ b/src/api.rs @@ -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::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 глобальных привязок клавиш. diff --git a/src/app.rs b/src/app.rs index fe7606e..bad1c61 100644 --- a/src/app.rs +++ b/src/app.rs @@ -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, + /// Открытый плавающий список выбора. + select: Option, /// Обработчики клика по иконкам 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) { @@ -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) { + 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) { + 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) -> 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) { + 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) { + 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) -> 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) { 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)) } } diff --git a/src/prompt.rs b/src/prompt.rs index 314d458..353c75f 100644 --- a/src/prompt.rs +++ b/src/prompt.rs @@ -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::("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, + /// Описание справа от названия; `None` — описания нет. + pub description: Option, +} + +/// Запрос на открытие плавающего списка выбора. +pub struct SelectRequest { + /// Текст над списком; `None` — окно без заголовка и разделителя. + pub title: Option, + /// Пункты списка; непустой массив таблиц. + pub items: Vec, + /// Индекс пункта, выделенного при открытии. + pub selected: usize, + /// Колбэк, вызываемый после `Enter` уже после закрытия окна. + pub callback: Option, +} + +impl SelectRequest { + /// Разбирает Lua-таблицу опций `api.window:open_select`. + /// + /// # Errors + /// Возвращает ошибку Lua, если `items` не непустой массив таблиц, поля + /// пунктов не строки или `callback` не функция. + pub fn from_options(options: Option) -> mlua::Result { + 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> { + const METHOD: &str = "api.window:open_select"; + + let items = match options.get::("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::() { + 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 { + match table.get::(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> { +fn optional_text(options: &mlua::Table, field: &str, method: &str) -> mlua::Result> { match options.get::(field)? { mlua::Value::Nil => Ok(None), mlua::Value::String(text) => { @@ -68,14 +175,32 @@ fn optional_text(options: &mlua::Table, field: &str) -> mlua::Result 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> { + match options.get::(field)? { + mlua::Value::Nil => Ok(None), + mlua::Value::Function(callback) => Ok(Some(callback)), + _ => Err(mlua::Error::RuntimeError(format!( + "{method}: поле {field} должно быть функцией" + ))), + } +} + +/// Общее состояние: запрос на поле ввода, ожидающий ближайшего кадра. pub type PromptRequestHandle = Rc>>; +/// Общее состояние: запрос на список выбора, ожидающий ближайшего кадра. +pub type SelectRequestHandle = Rc>>; + /// Открытое плавающее поле ввода и его окружение. pub struct PromptUi { /// Поле ввода, созданное видом. @@ -89,3 +214,17 @@ pub struct PromptUi { /// Подписка на события поля; живёт вместе с окном. pub _subscription: Subscription, } + +/// Открытый плавающий список выбора. +pub struct SelectUi { + /// Текст над списком; `None` — окно без заголовка и разделителя. + pub title: Option, + /// Пункты списка. + pub items: Vec, + /// Индекс выделенного пункта. + pub selected: usize, + /// Колбэк `Enter`; получает id выбранного пункта. + pub callback: Option, + /// Прокрутка списка: выделение всегда прокручивается в видимую часть. + pub scroll: ScrollHandle, +} diff --git a/src/tests.rs b/src/tests.rs index 0104bf0..455a879 100644 --- a/src/tests.rs +++ b/src/tests.rs @@ -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("", "", 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);