From e8e2d2560025ea62d8915430a651650d020041a8 Mon Sep 17 00:00:00 2001 From: "dedal.qq" Date: Sat, 31 Oct 2026 12:18:07 +0300 Subject: [PATCH] input --- AGENTS.md | 1 + DOCS.md | 25 ++++++ init.lua | 79 +++++++++++++++++- src/api.rs | 25 ++++++ src/app.rs | 210 +++++++++++++++++++++++++++++++++++++++++++++-- src/constants.rs | 6 ++ src/main.rs | 1 + src/prompt.rs | 91 ++++++++++++++++++++ src/tests.rs | 139 ++++++++++++++++++++++++++++++- 9 files changed, 568 insertions(+), 9 deletions(-) create mode 100644 src/prompt.rs diff --git a/AGENTS.md b/AGENTS.md index 1b8acf5..8233db9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -13,6 +13,7 @@ | `src/editor.rs` | Доменная модель: документы, вкладки, языки | | `src/api.rs` | `AppApi` / `EditorApi` / `PanelApi` / `TreeApi` / `ToolbarApi` — единственная поверхность управления | | `src/script.rs` | Lua-движок, `app`, `include`, `require` | +| `src/prompt.rs` | Плавающее поле ввода: запрос из Lua и открытое окно | | `src/keymap.rs` | `KeyBinding`, `KeyMap`, встроенные команды и Lua-обработчики | | `src/app.rs` | GPUI-вид, отрисовка, события, автодополнение | | `src/completion.rs` | Всплывающий список, `CaretPopup`, преобразования LSP-элементов | diff --git a/DOCS.md b/DOCS.md index 46261f6..377dbf3 100644 --- a/DOCS.md +++ b/DOCS.md @@ -284,6 +284,31 @@ tree:map_key("v", "", function() print("tree v") end) | `app:minimize()` | Свернуть окно | | `app:is_maximized()` | Развёрнуто ли окно прямо сейчас | +### Плавающее поле ввода + +`app:open_input(options)` открывает однострочное поле ввода чуть выше +центра окна. Параметры передаются таблицей: + +| Поле | Описание | +| --- | --- | +| `title` | Текст над полем; если не задан, окно состоит из одной строки ввода | +| `placeholder` | Подсказка внутри пустого поля ввода | +| `callback` | `function(text)` — вызывается после Enter, когда окно уже закрыто | + +Пока окно открыто, сочетания клавиш приложения не работают. Текст набирается +в поле; `Enter` подтверждает ввод и запускает `callback`, `Esc` и клик мышью +вне окна закрывают его без вызова колбэка. + +```lua +app:open_input({ + title = "Имя файла", + placeholder = "src/main.rs", + callback = function(text) + app.editor:open(text, {}) + end, +}) +``` + ## LSP-серверы Запуск серверов целиком настраивается Lua-сценарием. diff --git a/init.lua b/init.lua index b8f446c..2d0cb2d 100644 --- a/init.lua +++ b/init.lua @@ -53,6 +53,13 @@ print("[lua] init.lua выполнен") -- app:set_maximized(true/false) -- app:minimize() -- app:is_maximized() +-- app:open_input(options) -- плавающее поле ввода чуть выше центра; +-- -- options = { title = "...", +-- -- placeholder = "...", +-- -- callback = function(text) ... end }; +-- -- Enter подтверждает и вызывает callback, +-- -- Esc или клик вне окна закрывают без него; +-- -- пока окно открыто, привязки клавиш молчат -- -- Редактор app.editor (номера вкладок — с нуля): -- app.editor:tabs() -- {{ index = 0, title = "main.rs", @@ -292,6 +299,16 @@ end) app:map_key("", "", function() app:close() end) +-- Плавающее поле ввода. Пока оно открыто, сочетания клавиш приложения +-- заблокированы; Enter вызывает callback, Esc и клик вне окна — отменяют. +app:map_key("", "", function() + app:open_input({ + -- title = "Открыть файл", + placeholder = "src/main.rs", + callback = function(text) app.editor:open(text, {}) end, + }) +end) + app:map_key("", "", "toggle_left_panel") app:map_key("", "", "toggle_bottom_panel") @@ -323,9 +340,48 @@ local function diagnostic_rank(diagnostic) or 1 end +-- Строки активной вкладки, занятые иконками диагностики. Gutter действует +-- только на активную вкладку, поэтому запоминаем поставленные строки, чтобы +-- при следующем обновлении снять устаревшие иконки и не трогать остальные. +local diagnostic_gutter = {} + +local function active_tab_title() + local active = app.editor:active_tab() + for _, tab in ipairs(app.editor:tabs()) do + if tab.index == active then + return tab.title + end + end + return nil +end + +-- Заголовок вкладки — путь, которым открыли файл (обычно относительный), а LSP +-- присылает абсолютный путь. Сверяем полный путь с хвостом заголовка. +local function path_matches_title(path, title) + if not path or not title then + return false + end + path = path:gsub("\\", "/") + title = title:gsub("\\", "/") + if path == title then + return true + end + local suffix = "/" .. title + return #path > #suffix and path:sub(-#suffix) == suffix +end + app.editor:on_lsp_diagnostics(100, function(servers) diagnostics_tree:clear() + -- Иконки прошлого обновления активной вкладки могли устареть. + local title = active_tab_title() + if title and diagnostic_gutter[title] then + for line in pairs(diagnostic_gutter[title]) do + app.editor:clear_gutter_icon(line) + end + diagnostic_gutter[title] = nil + end + -- Сначала собираем диагностику по файлам: одинаковые проблемы от разных -- серверов попадают в одну запись. local files = {} @@ -383,9 +439,19 @@ app.editor:on_lsp_diagnostics(100, function(servers) end end + -- Подсветка и gutter в редакторе относятся к активной вкладке, поэтому + -- иконки ставим только для её файла. + local active = title ~= nil and path_matches_title(path, title) + local gutter_lines = active and {} or nil + local errors = {} for index, key in ipairs(group_order) do local group = groups[key] + local icon = severity_icons[group.severity] or "circle-alert" + if active then + app.editor:set_gutter_icon(group.line, icon) + gutter_lines[group.line] = true + end errors[#errors + 1] = { -- Префикс отделяет позицию от id файла, даже если в пути есть ':'. id = string.format( @@ -401,10 +467,21 @@ app.editor:on_lsp_diagnostics(100, function(servers) group.character + 1, table.concat(group.messages, "; ") ), - icon = severity_icons[group.severity] or "circle-alert", + icon = icon, } end + if active and next(gutter_lines) then + local stored = diagnostic_gutter[title] + if not stored then + stored = {} + diagnostic_gutter[title] = stored + end + for line in pairs(gutter_lines) do + stored[line] = true + end + end + diagnostics_tree:append({ { id = path, diff --git a/src/api.rs b/src/api.rs index 0ee491f..d2f0e43 100644 --- a/src/api.rs +++ b/src/api.rs @@ -28,6 +28,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::toolbar::{ToolbarHandle, ToolbarItem, ToolbarKind, ToolbarSide, ToolbarState}; use crate::tree::{TreeHandle, TreeNode}; @@ -136,6 +137,9 @@ pub struct AppApi { lsp: Arc, #[lua(skip)] diagnostics_handler: Rc>>, + /// Запрос из Lua на открытие плавающего поля ввода. + #[lua(skip)] + prompt_request: PromptRequestHandle, } /// API редактора: вкладки, текст, gutter, сохранение, LSP и свои привязки @@ -483,6 +487,7 @@ impl AppApi { toolbar: Rc::new(RefCell::new(ToolbarState::new())), lsp: Arc::new(LspManager::new()), diagnostics_handler: Rc::new(RefCell::new(None)), + prompt_request: Rc::new(RefCell::new(None)), }; INSTANCE.with(|slot| *slot.borrow_mut() = Some(api.clone())); api @@ -537,6 +542,12 @@ impl AppApi { self.editor_keymap.clone() } + /// Очередь запросов на открытие плавающего поля ввода (для вида). + #[lua(skip)] + pub fn prompt_request_handle(&self) -> PromptRequestHandle { + self.prompt_request.clone() + } + /// API редактора. /// /// В Lua доступно как `app.editor`: `app.editor:open_tab()`, @@ -676,6 +687,20 @@ impl AppApi { WINDOW_MAXIMIZED.with(Cell::get) } + /// Открывает плавающее поле ввода чуть выше центра окна. + /// + /// `options` — таблица `{ title = "Имя", placeholder = "main.rs", + /// callback = function(text) ... end }`. + /// `title` и `placeholder` необязательны: без заголовка окно состоит из + /// одной строки ввода, а без placeholder подсказка не рисуется. + /// Пока окно открыто, сочетания клавиш приложения не работают. + /// `Enter` подтверждает ввод и вызывает `callback` уже после закрытия окна, + /// `Esc` и клик вне окна закрывают его без колбэка. + pub fn open_input(&self, options: Option) -> mlua::Result<()> { + *self.prompt_request.borrow_mut() = Some(PromptRequest::from_options(options)?); + Ok(()) + } + /// Назначает глобальный обработчик на нажатие или отпускание клавиши. /// /// Первый аргумент `modes` — строка режимов, в которых действует diff --git a/src/app.rs b/src/app.rs index 24437a3..a27c5e3 100644 --- a/src/app.rs +++ b/src/app.rs @@ -17,8 +17,8 @@ use std::time::Duration; use gpui_kit::assets::IconName as AssetIcon; use gpui_kit::component::button::{Button, ButtonVariants}; use gpui_kit::component::input::{ - Editor, EditorState, HoverProvider, InputEvent, Rope, TabSize, TextDecoration, - TextDecorationCollection, + Editor, EditorState, HoverProvider, Input, InputEvent, InputState, Rope, TabSize, + TextDecoration, TextDecorationCollection, }; use gpui_kit::component::label::Label; use gpui_kit::component::resizable::HandleEdge; @@ -40,8 +40,8 @@ use crate::completion::{ parse_completion_response, range_for_lsp, word_start, }; use crate::constants::{ - GUTTER_ICON_SIZE, GUTTER_WIDTH, MIN_LEFT_PANEL_WIDTH, MIN_MAIN_AREA_WIDTH, TAB_SIZE, - WINDOW_TITLE, + GUTTER_ICON_SIZE, GUTTER_WIDTH, MIN_LEFT_PANEL_WIDTH, MIN_MAIN_AREA_WIDTH, + PROMPT_CENTER_OFFSET, PROMPT_WIDTH, TAB_SIZE, WINDOW_TITLE, }; use crate::editor::{FocusAfterSync, Workspace}; use crate::gutter::GutterMarker; @@ -49,6 +49,7 @@ use crate::icons; use crate::keymap::{AppCommand, CompletionCommand, KeyContext, KeyHandler, KeyMap}; use crate::lsp::{LspManager, document_uri, position_to_offset}; use crate::panel::{PanelBlock, PanelHandle}; +use crate::prompt::{PromptRequestHandle, PromptUi}; use crate::script::ScriptEngine; use crate::toolbar::{ToolbarHandle, ToolbarItem, ToolbarKind}; use crate::tree::{TreeAction, TreeHandle, TreeKeyResult}; @@ -221,6 +222,12 @@ pub struct MyApp { /// Глобальный перехватчик клавиш: слой автодополнения срабатывает /// раньше действий редактора и не мешает движению курсора. _key_interceptor: Subscription, + /// Открытое плавающее поле ввода. + prompt: Option, + /// Запрос из Lua на открытие плавающего поля ввода. + /// + /// Живёт перед `scripts`, чтобы колбэк уничтожился раньше Lua-движка. + prompt_request: PromptRequestHandle, /// Движок Lua, созданный при старте. /// /// Хранится, чтобы глобальное окружение и загруженные модули жили, пока @@ -260,6 +267,7 @@ impl MyApp { let api = AppApi::install(workspace.clone(), keymap.clone()); let lsp = api.lsp_handle(); let editor_keymap = api.editor_keymap_handle(); + let prompt_request = api.prompt_request_handle(); let initial_text = workspace.borrow().active().content.clone(); let editor = cx.new(|cx| { @@ -401,7 +409,11 @@ impl MyApp { let _key_interceptor = cx.intercept_keystrokes(move |event, window, cx| { let handled = view .update(cx, |this, cx| { - this.handle_completion_key(&event.keystroke, window, cx) + if this.prompt.is_some() { + this.handle_prompt_key(&event.keystroke, window, cx) + } else { + this.handle_completion_key(&event.keystroke, window, cx) + } }) .unwrap_or(false); if handled { @@ -425,6 +437,8 @@ impl MyApp { focus_editor_after_render: false, _editor_subscription, _key_interceptor, + prompt: None, + prompt_request, scripts: None, lsp, completion: CompletionUi::default(), @@ -512,6 +526,12 @@ impl MyApp { self.completion.selected } + /// Открыто ли сейчас плавающее поле ввода (доступ для тестов). + #[cfg(test)] + pub fn prompt_is_open(&self) -> bool { + self.prompt.is_some() + } + /// Применяет известную LSP-диагностику к редактору (доступ для тестов). #[cfg(test)] pub fn refresh_diagnostics(&mut self, cx: &mut Context) { @@ -722,6 +742,163 @@ impl MyApp { } } + /// Создаёт плавающее поле ввода, если Lua попросил об этом. + /// + /// `InputState` нельзя создать из Lua: там нет ни `Window`, ни `Context`. + /// Поэтому API складывает запрос, а вид обрабатывает его в начале кадра. + fn sync_prompt(&mut self, window: &mut Window, cx: &mut Context) { + let Some(request) = self.prompt_request.borrow_mut().take() else { + return; + }; + + // Новый запрос заменяет открытое окно. Старое закрывается без + // колбэка: иначе обработчик получил бы неподтверждённый текст. + if self.prompt.is_some() { + self.close_prompt(false, window, cx); + } + self.close_completion(cx); + + let previous_focus = window.focused(cx); + let input = cx.new(|cx| match request.placeholder { + Some(placeholder) => InputState::new(window, cx).placeholder(placeholder), + None => InputState::new(window, cx), + }); + let subscription = + cx.subscribe_in(&input, window, |this, _, event: &InputEvent, window, cx| { + if matches!(event, InputEvent::PressEnter { .. }) { + this.close_prompt(true, window, cx); + } + }); + input.update(cx, |state, cx| state.focus(window, cx)); + + self.prompt = Some(PromptUi { + input, + title: request.title, + callback: request.callback, + previous_focus, + _subscription: subscription, + }); + cx.notify(); + } + + /// Закрывает плавающее поле ввода. + /// + /// При `submit = true` забирает текст и после восстановления фокуса + /// вызывает Lua-колбэк. Закрытие без подтверждения колбэк не вызывает. + fn close_prompt(&mut self, submit: bool, window: &mut Window, cx: &mut Context) { + let Some(prompt) = self.prompt.take() else { + return; + }; + + let text = submit.then(|| prompt.input.read(cx).value().to_string()); + let callback = prompt.callback.clone(); + let previous_focus = prompt.previous_focus.clone(); + // Подписка относится к окну и должна исчезнуть вместе с ним. + drop(prompt); + + self.restore_focus(previous_focus, window, cx); + + // Колбэк выполняется уже после закрытия окна и восстановления + // фокуса. Он может открыть новое поле ввода — уведомление после + // вызова гарантирует, что вид увидит новый запрос. + if let Some(text) = text + && let Some(callback) = callback + && let Err(err) = callback.call::<()>(text) + { + eprintln!("Lua: ошибка в обработчике app:open_input: {err}"); + } + + cx.notify(); + } + + /// Возвращает фокус туда, где он был до открытия плавающего окна. + fn restore_focus( + &self, + previous: Option, + window: &mut Window, + cx: &mut Context, + ) { + if let Some(focus) = previous { + focus.focus(window, cx); + } else { + self.editor.update(cx, |state, cx| state.focus(window, cx)); + } + } + + /// Обрабатывает клавиши, пока открыто плавающее поле ввода. + /// + /// Возвращает `true`, если клавиша поглощена окном. Остальные клавиши + /// получает само поле ввода, а `KeyMap` приложения не рассматривает их + /// вовсе (см. проверку в обработчике корня). + fn handle_prompt_key( + &mut self, + keystroke: &Keystroke, + window: &mut Window, + cx: &mut Context, + ) -> bool { + match keystroke.key.as_str() { + "enter" => { + self.close_prompt(true, window, cx); + true + } + "escape" => { + self.close_prompt(false, window, cx); + true + } + _ => false, + } + } + + /// Плавающее поле ввода чуть выше центра окна. + /// + /// Без заголовка окно состоит из одной строки ввода; с заголовком текст + /// рисуется над полем. Подложка перекрывает всё окно, поэтому клик вне + /// поля закрывает его, не доходя до редактора и панелей. + fn render_prompt(&self, cx: &mut Context) -> AnyElement { + let Some(prompt) = self.prompt.as_ref() else { + return div().into_any_element(); + }; + + // Окно рисуется всегда: без заголовка его содержимым остаётся только + // поле ввода, с заголовком — две строки. + let view = cx.entity().downgrade(); + let mut dialog = div() + .id("floating-input") + .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 _ = view.update(cx, |this, cx| this.close_prompt(false, window, cx)); + }); + + if let Some(title) = &prompt.title { + dialog = dialog.child(div().text_sm().font_medium().child(title.clone())); + } + + dialog = dialog.child(Input::new(&prompt.input).w_full()); + + div() + .id("floating-input-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; @@ -2053,12 +2230,18 @@ impl Render for MyApp { apply_window_commands(window); self.sync_editor(window, cx); + // Запрос из Lua обрабатывается в кадре: только здесь есть `Window` + // и `Context`, чтобы создать поле ввода и передать ему фокус. + self.sync_prompt(window, cx); // Документ мог открыться до запуска сервера (init.lua выполняется // сразу после создания окна) — отдаём его LSP при первом кадре. self.sync_lsp_document(); if !self.focused_once { self.focused_once = true; - self.editor.update(cx, |state, cx| state.focus(window, cx)); + // Открытое поле ввода уже забрало фокус; не отбираем его. + if self.prompt.is_none() { + self.editor.update(cx, |state, cx| state.focus(window, cx)); + } } // Панели могут отложить возврат фокуса своему дереву — явный запрос @@ -2066,7 +2249,10 @@ impl Render for MyApp { let workspace = self.render_workspace(window, cx).into_any_element(); if self.focus_editor_after_render { self.focus_editor_after_render = false; - self.editor.update(cx, |state, cx| state.focus(window, cx)); + // Пока открыто плавающее поле ввода, фокус принадлежит ему. + if self.prompt.is_none() { + self.editor.update(cx, |state, cx| state.focus(window, cx)); + } } let title = { @@ -2087,6 +2273,7 @@ impl Render for MyApp { div() .id("app-root") .size_full() + .relative() .flex() .flex_col() .bg(cx.theme().background) @@ -2098,6 +2285,11 @@ impl Render for MyApp { let panels = panels.clone(); let entity = entity.clone(); move |event, window, cx| { + // Пока открыто плавающее поле ввода, сочетания клавиш + // приложения не работают: клавиши получает только оно. + if entity.read(cx).prompt.is_some() { + return; + } // Сначала всегда срабатывают глобальные привязки. let handled = run_keymap(&keymap, KeyContext::Global, &event.keystroke, true); // Затем — привязки редактора или блока, на котором фокус. @@ -2124,6 +2316,9 @@ impl Render for MyApp { let panels = panels.clone(); let entity = entity.clone(); move |event, window, cx| { + if entity.read(cx).prompt.is_some() { + return; + } let handled = run_keymap(&keymap, KeyContext::Global, &event.keystroke, false); let focused = run_focused_keymap( &editor, @@ -2146,6 +2341,7 @@ impl Render for MyApp { .child(div().flex_1().w_full().min_h_0().child(workspace)) .child(self.render_status_bar(cx)) .child(self.render_completion_popup(cx)) + .child(self.render_prompt(cx)) } } diff --git a/src/constants.rs b/src/constants.rs index a3366de..7e96fe7 100644 --- a/src/constants.rs +++ b/src/constants.rs @@ -43,6 +43,12 @@ pub const MIN_MAIN_AREA_WIDTH: f32 = 240.0; /// Высота нижней панели при открытии. pub const BOTTOM_PANEL_HEIGHT: f32 = 160.0; +/// Ширина плавающего поля ввода. +pub const PROMPT_WIDTH: f32 = 480.0; + +/// Насколько плавающее поле ввода поднято выше центра окна. +pub const PROMPT_CENTER_OFFSET: f32 = 96.0; + /// Каталог, в котором приложение ищет Lua-сценарии по умолчанию. /// /// Это текущий рабочий каталог процесса — именно там приложение ожидает diff --git a/src/main.rs b/src/main.rs index 25175f4..391223c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -15,6 +15,7 @@ mod icons; mod keymap; mod lsp; mod panel; +mod prompt; mod script; mod toolbar; mod tree; diff --git a/src/prompt.rs b/src/prompt.rs new file mode 100644 index 0000000..9fe116c --- /dev/null +++ b/src/prompt.rs @@ -0,0 +1,91 @@ +//! Плавающее поле ввода, открываемое из Lua. +//! +//! Сценарий вызывает `app:open_input{ title = "Имя", callback = function(text) end }`. +//! Запрос складывается в общее состояние API, а вид при следующем кадре +//! создаёт поле ввода, показывает его чуть выше центра и забирает фокус. +//! Пока окно открыто, сочетания клавиш приложения не работают: текст набируется +//! в поле, `Enter` подтверждает ввод и запускает колбэк, `Esc` и клик вне окна +//! закрывают его без вызова колбэка. + +use std::cell::RefCell; +use std::rc::Rc; + +use gpui_kit::component::input::InputState; +use gpui_kit::{Entity, FocusHandle, Subscription}; + +/// Запрос на открытие плавающего поля ввода. +pub struct PromptRequest { + /// Текст над полем ввода; `None` — окно из одной строки. + pub title: Option, + /// Подсказка внутри пустого поля ввода. + pub placeholder: Option, + /// Колбэк, вызываемый после `Enter` уже после закрытия окна. + pub callback: Option, +} + +impl PromptRequest { + /// Разбирает Lua-таблицу опций `app:open_input`. + /// + /// # Errors + /// Возвращает ошибку Lua, если текстовое поле не строка или `callback` + /// не функция. + pub fn from_options(options: Option) -> mlua::Result { + let Some(options) = options else { + return Ok(Self { + title: None, + placeholder: None, + callback: None, + }); + }; + + 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( + "app:open_input: поле callback должно быть функцией".to_owned(), + )); + } + }; + + Ok(Self { + title, + placeholder, + callback, + }) + } +} + +/// Читает необязательную строковую опцию; пустая строка означает `None`. +fn optional_text(options: &mlua::Table, field: &str) -> mlua::Result> { + match options.get::(field)? { + mlua::Value::Nil => Ok(None), + mlua::Value::String(text) => { + let text = text.to_str()?.to_string(); + Ok((!text.is_empty()).then_some(text)) + } + _ => Err(mlua::Error::RuntimeError(format!( + "app:open_input: поле {field} должно быть строкой" + ))), + } +} + +/// Общее состояние: запрос, ожидающий ближайшего кадра. +pub type PromptRequestHandle = Rc>>; + +/// Открытое плавающее поле ввода и его окружение. +pub struct PromptUi { + /// Поле ввода, созданное видом. + pub input: Entity, + /// Текст над полем; `None` — окно из одной строки. + pub title: Option, + /// Колбэк `Enter`; вызывается после закрытия окна. + pub callback: Option, + /// Элемент, владевший фокусом до открытия окна. + pub previous_focus: Option, + /// Подписка на события поля; живёт вместе с окном. + pub _subscription: Subscription, +} diff --git a/src/tests.rs b/src/tests.rs index a3e05d1..8a1b566 100644 --- a/src/tests.rs +++ b/src/tests.rs @@ -8,7 +8,7 @@ use std::cell::RefCell; use std::path::Path; use std::rc::Rc; -use gpui_kit::{Keystroke, Modifiers, TestAppContext, px}; +use gpui_kit::{Keystroke, Modifiers, TestAppContext, point, px}; use lsp_types::{Diagnostic, DiagnosticSeverity, Position, Range}; use crate::api::{AppApi, WindowCommand, drain_window_commands}; @@ -718,6 +718,55 @@ fn lua_diagnostics_handler_receives_every_server_and_file() { let _ = std::fs::remove_dir_all(&dir); } +#[test] +fn lua_app_open_input_queues_options() { + let workspace = Rc::new(RefCell::new(Workspace::default())); + let (api, _keymap) = install_api(workspace); + let requests = api.prompt_request_handle(); + + let dir = temp_script_dir("open-input"); + let script = dir.join("input.lua"); + std::fs::write( + &script, + r#" + app:open_input({ title = "Имя", placeholder = "main.rs", callback = function(text) end }) + "#, + ) + .expect("input.lua"); + + let engine = ScriptEngine::new(api).expect("движок Lua"); + engine.run_file(&script).expect("app:open_input с опциями"); + + let request = requests.borrow_mut().take().expect("запрос на поле ввода"); + assert_eq!(request.title.as_deref(), Some("Имя")); + assert_eq!(request.placeholder.as_deref(), Some("main.rs")); + assert!(request.callback.is_some()); + + // Без аргументов окно состоит из одной строки ввода, колбэка нет. + std::fs::write(&script, "app:open_input()\n").expect("input.lua"); + engine.run_file(&script).expect("app:open_input()"); + let request = requests.borrow_mut().take().expect("запрос без опций"); + assert!(request.title.is_none()); + assert!(request.placeholder.is_none()); + assert!(request.callback.is_none()); + + // Некорректные типы опций — ошибка Lua, а не молчаливый пропуск. + std::fs::write( + &script, + r#" + assert(not pcall(function() app:open_input({ title = 1 }) end)) + assert(not pcall(function() app:open_input({ placeholder = 1 }) end)) + assert(not pcall(function() app:open_input({ callback = "x" }) end)) + "#, + ) + .expect("input.lua"); + engine.run_file(&script).expect("проверка типов опций"); + assert!(requests.borrow().is_none()); + + drop(engine); + let _ = std::fs::remove_dir_all(&dir); +} + #[test] fn lua_list_dir_returns_sorted_entries_with_paths() { let workspace = Rc::new(RefCell::new(Workspace::default())); @@ -1789,6 +1838,94 @@ fn typing_into_editor_updates_active_document(cx: &mut TestAppContext) { assert!(doc.is_dirty(), "правка должна помечать документ изменённым"); } +#[gpui_kit::test] +fn floating_input_blocks_keys_and_submits_on_enter(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-input"); + let keys = dir.join("keys.lua"); + let open = dir.join("open.lua"); + std::fs::write( + &keys, + r#"app:map_key("", "", function() app.editor:set_active_text("blocked") end)"#, + ) + .expect("keys.lua"); + std::fs::write( + &open, + r#" + app:open_input({ + title = "Имя", + placeholder = "main.rs", + callback = function(text) app.editor:set_active_text(text) end, + }) + "#, + ) + .expect("open.lua"); + + let engine = ScriptEngine::new(AppApi::global()).expect("движок Lua"); + engine.run_file(&keys).expect("привязка"); + engine.run_file(&open).expect("app:open_input"); + + let notify = |visual: &mut gpui_kit::VisualTestContext| { + visual.update(|_window, cx| { + app.update(cx, |_, cx| cx.notify()); + }); + visual.run_until_parked(); + }; + let prompt_open = |visual: &mut gpui_kit::VisualTestContext| { + visual.update(|_window, cx| app.read(cx).prompt_is_open()) + }; + + notify(visual); + assert!(prompt_open(visual), "поле ввода должно открыться"); + + // Пока окно открыто, привязки приложения не срабатывают. + visual.simulate_keystrokes("alt-r"); + visual.run_until_parked(); + assert_eq!(workspace.borrow().active().content, ""); + + // Esc закрывает окно без колбэка. + visual.simulate_keystrokes("escape"); + visual.run_until_parked(); + assert!(!prompt_open(visual), "Esc должен закрыть поле ввода"); + assert_eq!(workspace.borrow().active().content, ""); + + // Клик вне окна тоже закрывает его без колбэка. + engine.run_file(&open).expect("app:open_input повторно"); + notify(visual); + assert!(prompt_open(visual)); + visual.simulate_click(point(px(2.), px(2.)), Modifiers::default()); + visual.run_until_parked(); + assert!( + !prompt_open(visual), + "клик вне окна должен закрыть поле ввода" + ); + assert_eq!(workspace.borrow().active().content, ""); + + // Enter подтверждает ввод и вызывает колбэк уже после закрытия окна. + engine.run_file(&open).expect("app:open_input в третий раз"); + notify(visual); + assert!(prompt_open(visual)); + + visual.simulate_input("main.go"); + visual.simulate_keystrokes("enter"); + visual.run_until_parked(); + + assert!(!prompt_open(visual)); + assert_eq!(workspace.borrow().active().content, "main.go"); + + 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);