input
This commit is contained in:
@@ -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-элементов |
|
||||
|
||||
@@ -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-сценарием.
|
||||
|
||||
@@ -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
@@ -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` — строка режимов, в которых действует
|
||||
|
||||
+203
-7
@@ -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| {
|
||||
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<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,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))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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-сценарии по умолчанию.
|
||||
///
|
||||
/// Это текущий рабочий каталог процесса — именно там приложение ожидает
|
||||
|
||||
@@ -15,6 +15,7 @@ mod icons;
|
||||
mod keymap;
|
||||
mod lsp;
|
||||
mod panel;
|
||||
mod prompt;
|
||||
mod script;
|
||||
mod toolbar;
|
||||
mod tree;
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user