This commit is contained in:
2026-10-31 12:18:07 +03:00
parent 16e88117d5
commit e8e2d25600
9 changed files with 568 additions and 9 deletions
+1
View File
@@ -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-элементов |
+25
View File
@@ -284,6 +284,31 @@ tree:map_key("v", "<A-f>", 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-сценарием.
+78 -1
View File
@@ -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("", "<C-q>", function() app:close() end)
-- Плавающее поле ввода. Пока оно открыто, сочетания клавиш приложения
-- заблокированы; Enter вызывает callback, Esc и клик вне окна — отменяют.
app:map_key("", "<C-g>", function()
app:open_input({
-- title = "Открыть файл",
placeholder = "src/main.rs",
callback = function(text) app.editor:open(text, {}) end,
})
end)
app:map_key("", "<C-e>", "toggle_left_panel")
app:map_key("", "<C-t>", "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,
+25
View File
@@ -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<LspManager>,
#[lua(skip)]
diagnostics_handler: Rc<RefCell<Option<DiagnosticsHandler>>>,
/// Запрос из 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::Table>) -> mlua::Result<()> {
*self.prompt_request.borrow_mut() = Some(PromptRequest::from_options(options)?);
Ok(())
}
/// Назначает глобальный обработчик на нажатие или отпускание клавиши.
///
/// Первый аргумент `modes` — строка режимов, в которых действует
+200 -4
View File
@@ -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<PromptUi>,
/// Запрос из 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| {
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<Self>) {
@@ -722,6 +742,163 @@ impl MyApp {
}
}
/// Создаёт плавающее поле ввода, если Lua попросил об этом.
///
/// `InputState` нельзя создать из Lua: там нет ни `Window`, ни `Context`.
/// Поэтому API складывает запрос, а вид обрабатывает его в начале кадра.
fn sync_prompt(&mut self, window: &mut Window, cx: &mut Context<Self>) {
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<Self>) {
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<FocusHandle>,
window: &mut Window,
cx: &mut Context<Self>,
) {
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<Self>,
) -> 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<Self>) -> 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<Self>) {
if selected >= self.completion.visible_len() || selected == self.completion.selected {
return;
@@ -2053,21 +2230,30 @@ 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;
// Открытое поле ввода уже забрало фокус; не отбираем его.
if self.prompt.is_none() {
self.editor.update(cx, |state, cx| state.focus(window, cx));
}
}
// Панели могут отложить возврат фокуса своему дереву — явный запрос
// `app:open(path, ...)` применяем ещё раз уже после их отрисовки.
let workspace = self.render_workspace(window, cx).into_any_element();
if self.focus_editor_after_render {
self.focus_editor_after_render = false;
// Пока открыто плавающее поле ввода, фокус принадлежит ему.
if self.prompt.is_none() {
self.editor.update(cx, |state, cx| state.focus(window, cx));
}
}
let title = {
let workspace = self.workspace.borrow();
@@ -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))
}
}
+6
View File
@@ -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-сценарии по умолчанию.
///
/// Это текущий рабочий каталог процесса — именно там приложение ожидает
+1
View File
@@ -15,6 +15,7 @@ mod icons;
mod keymap;
mod lsp;
mod panel;
mod prompt;
mod script;
mod toolbar;
mod tree;
+91
View File
@@ -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<String>,
/// Подсказка внутри пустого поля ввода.
pub placeholder: Option<String>,
/// Колбэк, вызываемый после `Enter` уже после закрытия окна.
pub callback: Option<mlua::Function>,
}
impl PromptRequest {
/// Разбирает Lua-таблицу опций `app:open_input`.
///
/// # Errors
/// Возвращает ошибку Lua, если текстовое поле не строка или `callback`
/// не функция.
pub fn from_options(options: Option<mlua::Table>) -> mlua::Result<Self> {
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::<mlua::Value>("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<Option<String>> {
match options.get::<mlua::Value>(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<RefCell<Option<PromptRequest>>>;
/// Открытое плавающее поле ввода и его окружение.
pub struct PromptUi {
/// Поле ввода, созданное видом.
pub input: Entity<InputState>,
/// Текст над полем; `None` — окно из одной строки.
pub title: Option<String>,
/// Колбэк `Enter`; вызывается после закрытия окна.
pub callback: Option<mlua::Function>,
/// Элемент, владевший фокусом до открытия окна.
pub previous_focus: Option<FocusHandle>,
/// Подписка на события поля; живёт вместе с окном.
pub _subscription: Subscription,
}
+138 -1
View File
@@ -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("", "<A-r>", 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);