select
This commit is contained in:
@@ -13,7 +13,7 @@
|
|||||||
| `src/editor.rs` | Доменная модель: документы (`Rope`), вкладки, языки |
|
| `src/editor.rs` | Доменная модель: документы (`Rope`), вкладки, языки |
|
||||||
| `src/api.rs` | `AppApi` / `EditorApi` / `PanelsApi` / `PanelApi` / `ToolbarApi` / `TreeApi` / `PathApi` / `WindowApi` / `KeymapApi` — единственная поверхность управления |
|
| `src/api.rs` | `AppApi` / `EditorApi` / `PanelsApi` / `PanelApi` / `ToolbarApi` / `TreeApi` / `PathApi` / `WindowApi` / `KeymapApi` — единственная поверхность управления |
|
||||||
| `src/script.rs` | Lua-движок, `api`, `include`, `require` |
|
| `src/script.rs` | Lua-движок, `api`, `include`, `require` |
|
||||||
| `src/prompt.rs` | Плавающее поле ввода: запрос из Lua и открытое окно |
|
| `src/prompt.rs` | Плавающее поле ввода и список выбора: запрос из Lua и открытое окно |
|
||||||
| `src/keymap.rs` | `KeyBinding`, `KeyMap`, встроенные команды и Lua-обработчики |
|
| `src/keymap.rs` | `KeyBinding`, `KeyMap`, встроенные команды и Lua-обработчики |
|
||||||
| `src/app.rs` | GPUI-вид, отрисовка, события, автодополнение |
|
| `src/app.rs` | GPUI-вид, отрисовка, события, автодополнение |
|
||||||
| `src/completion.rs` | Всплывающий список, `CaretPopup`, преобразования LSP-элементов |
|
| `src/completion.rs` | Всплывающий список, `CaretPopup`, преобразования LSP-элементов |
|
||||||
@@ -31,8 +31,8 @@ qode — не готовый редактор с фиксированным на
|
|||||||
своей IDE. Rust/GPUI даёт универсальные примитивы, Lua задаёт поведение:
|
своей IDE. Rust/GPUI даёт универсальные примитивы, Lua задаёт поведение:
|
||||||
|
|
||||||
- **Rust** отвечает за окно, вкладки, текстовый редактор, подсветку, gutter,
|
- **Rust** отвечает за окно, вкладки, текстовый редактор, подсветку, gutter,
|
||||||
автодополнение, панели, абстрактное дерево, плавающее поле ввода, доступ к
|
автодополнение, панели, абстрактное дерево, плавающее поле ввода и список
|
||||||
файловой системе, клавиатуру и LSP-клиент.
|
выбора, доступ к файловой системе, клавиатуру и LSP-клиент.
|
||||||
- **Lua** отвечает за всё, что делает редактор именно этой IDE: файловое
|
- **Lua** отвечает за всё, что делает редактор именно этой IDE: файловое
|
||||||
дерево, дерево диагностики, команды, раскладки, статусы, проектные
|
дерево, дерево диагностики, команды, раскладки, статусы, проектные
|
||||||
сценарии, оформление панелей и последовательности действий.
|
сценарии, оформление панелей и последовательности действий.
|
||||||
|
|||||||
+2
-2
@@ -11,7 +11,7 @@ Rust-часть даёт только базовые универсальные
|
|||||||
- окно и его элементы;
|
- окно и его элементы;
|
||||||
- вкладки, текстовый редактор, подсветка, gutter, автодополнение;
|
- вкладки, текстовый редактор, подсветка, gutter, автодополнение;
|
||||||
- панели и абстрактное дерево;
|
- панели и абстрактное дерево;
|
||||||
- плавающее поле ввода;
|
- плавающее поле ввода и список выбора;
|
||||||
- файловая система;
|
- файловая система;
|
||||||
- привязки клавиш;
|
- привязки клавиш;
|
||||||
- LSP-клиент по stdio.
|
- LSP-клиент по stdio.
|
||||||
@@ -64,7 +64,7 @@ api.keymap:map_key("", "<C-s>", "save")
|
|||||||
| `api.editor` | Вкладки, текст, gutter, подсветка, сохранение, LSP, привязки редактора | [editor.md](editor.md) |
|
| `api.editor` | Вкладки, текст, gutter, подсветка, сохранение, LSP, привязки редактора | [editor.md](editor.md) |
|
||||||
| `api.panels` | Левая, правая и нижняя панели, верхняя панель редактора, блоки-деревья | [panels.md](panels.md) |
|
| `api.panels` | Левая, правая и нижняя панели, верхняя панель редактора, блоки-деревья | [panels.md](panels.md) |
|
||||||
| `api.path` | Файловая система: чтение, запись, создание и удаление | [path.md](path.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.keymap` | Глобальные привязки клавиш и режимы | [keymap.md](keymap.md) |
|
||||||
| `api.editor` (LSP) | Запуск серверов и обработка диагностики | [lsp.md](lsp.md) |
|
| `api.editor` (LSP) | Запуск серверов и обработка диагностики | [lsp.md](lsp.md) |
|
||||||
|
|
||||||
|
|||||||
+46
-1
@@ -1,6 +1,7 @@
|
|||||||
# Окно и ввод — `api.window`
|
# Окно и ввод — `api.window`
|
||||||
|
|
||||||
Группа `api.window` управляет окном приложения и плавающим полем ввода.
|
Группа `api.window` управляет окном приложения, плавающим полем ввода и
|
||||||
|
списком выбора.
|
||||||
|
|
||||||
## Окно
|
## Окно
|
||||||
|
|
||||||
@@ -39,3 +40,47 @@ api.window:open_input({
|
|||||||
end,
|
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,
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|||||||
@@ -76,6 +76,16 @@ print("[lua] init.lua выполнен")
|
|||||||
-- -- Enter подтверждает и вызывает callback,
|
-- -- Enter подтверждает и вызывает callback,
|
||||||
-- -- Esc или клик вне окна закрывают без него;
|
-- -- 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 (номера вкладок — с нуля):
|
||||||
-- api.editor:tabs() -- {{ index = 0, title = "main.rs",
|
-- api.editor:tabs() -- {{ index = 0, title = "main.rs",
|
||||||
@@ -481,6 +491,56 @@ api.keymap:map_key("", "<C-g>", function()
|
|||||||
})
|
})
|
||||||
end)
|
end)
|
||||||
|
|
||||||
|
-- Демонстрация списка выбора. Ctrl+J открывает пункты с иконками и
|
||||||
|
-- описаниями: title отделён разделителем, selected заранее выделяет пункт,
|
||||||
|
-- у части пунктов иконки нет (их текст остаётся выровненным), длинное
|
||||||
|
-- описание обрезается троеточием и не перетирает название. Enter или клик
|
||||||
|
-- передаёт в callback id выбранного пункта.
|
||||||
|
api.keymap:map_key("", "<C-j>", function()
|
||||||
|
api.window:open_select({
|
||||||
|
title = "Демонстрация списка выбора",
|
||||||
|
selected = "info",
|
||||||
|
items = {
|
||||||
|
{
|
||||||
|
id = "info",
|
||||||
|
label = "Информация",
|
||||||
|
icon = "info",
|
||||||
|
description = "короткое описание",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id = "file",
|
||||||
|
label = "Открыть файл",
|
||||||
|
icon = "file",
|
||||||
|
description = "Ctrl+G",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id = "folder",
|
||||||
|
label = "Папка",
|
||||||
|
icon = "folder",
|
||||||
|
description = "длинное описание, которое обрезается троеточием, не перетирая название",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id = "plain",
|
||||||
|
label = "Без иконки",
|
||||||
|
description = "место иконки остаётся пустым для выравнивания",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id = "note",
|
||||||
|
label = "Без описания",
|
||||||
|
icon = "notebook",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
callback = function(id)
|
||||||
|
-- Колбэк получает id уже после закрытия списка. Он может открыть
|
||||||
|
-- новое окно — здесь это поле ввода с итогом выбора.
|
||||||
|
api.window:open_input({
|
||||||
|
title = "Выбрано: " .. id,
|
||||||
|
placeholder = "Enter закроет окно",
|
||||||
|
})
|
||||||
|
end,
|
||||||
|
})
|
||||||
|
end)
|
||||||
|
|
||||||
api.keymap:map_key("", "<C-t>", "toggle_bottom_panel")
|
api.keymap:map_key("", "<C-t>", "toggle_bottom_panel")
|
||||||
|
|
||||||
-- Дерево диагностики в нижней панели.
|
-- Дерево диагностики в нижней панели.
|
||||||
|
|||||||
+39
-6
@@ -36,7 +36,7 @@ use crate::lsp::{
|
|||||||
DocumentDiagnostics, LspConfig, LspManager, WatcherConfig, document_uri, position_to_offset,
|
DocumentDiagnostics, LspConfig, LspManager, WatcherConfig, document_uri, position_to_offset,
|
||||||
};
|
};
|
||||||
use crate::panel::{PanelHandle, PanelState};
|
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::toolbar::{ToolbarHandle, ToolbarItem, ToolbarKind, ToolbarSide, ToolbarState};
|
||||||
use crate::tree::{TreeHandle, TreeNode};
|
use crate::tree::{TreeHandle, TreeNode};
|
||||||
|
|
||||||
@@ -139,7 +139,7 @@ struct DiagnosticsHandler {
|
|||||||
/// * `api.panels` — левая, правая и нижняя панели, а также верхняя панель
|
/// * `api.panels` — левая, правая и нижняя панели, а также верхняя панель
|
||||||
/// редактора ([`PanelsApi`]);
|
/// редактора ([`PanelsApi`]);
|
||||||
/// * `api.path` — файловая система ([`PathApi`]);
|
/// * `api.path` — файловая система ([`PathApi`]);
|
||||||
/// * `api.window` — окно и плавающее поле ввода ([`WindowApi`]);
|
/// * `api.window` — окно, плавающее поле ввода и список выбора ([`WindowApi`]);
|
||||||
/// * `api.keymap` — глобальные привязки клавиш ([`KeymapApi`]).
|
/// * `api.keymap` — глобальные привязки клавиш ([`KeymapApi`]).
|
||||||
///
|
///
|
||||||
/// Методы этого `impl`-блока автоматически экспортируются в Lua через
|
/// Методы этого `impl`-блока автоматически экспортируются в Lua через
|
||||||
@@ -181,6 +181,9 @@ pub struct AppApi {
|
|||||||
/// Запрос из Lua на открытие плавающего поля ввода.
|
/// Запрос из Lua на открытие плавающего поля ввода.
|
||||||
#[lua(skip)]
|
#[lua(skip)]
|
||||||
prompt_request: PromptRequestHandle,
|
prompt_request: PromptRequestHandle,
|
||||||
|
/// Запрос из Lua на открытие плавающего списка выбора.
|
||||||
|
#[lua(skip)]
|
||||||
|
select_request: SelectRequestHandle,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// API редактора: вкладки, текст, gutter, сохранение, LSP и свои привязки
|
/// API редактора: вкладки, текст, gutter, сохранение, LSP и свои привязки
|
||||||
@@ -307,18 +310,23 @@ impl PathApi {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// API окна и плавающего поля ввода.
|
/// API окна и плавающих окон ввода и выбора.
|
||||||
///
|
///
|
||||||
/// В Lua доступно как `api.window`.
|
/// В Lua доступно как `api.window`.
|
||||||
#[derive(Clone, mlua::UserData)]
|
#[derive(Clone, mlua::UserData)]
|
||||||
pub struct WindowApi {
|
pub struct WindowApi {
|
||||||
#[lua(skip)]
|
#[lua(skip)]
|
||||||
prompt_request: PromptRequestHandle,
|
prompt_request: PromptRequestHandle,
|
||||||
|
#[lua(skip)]
|
||||||
|
select_request: SelectRequestHandle,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl WindowApi {
|
impl WindowApi {
|
||||||
fn new(prompt_request: PromptRequestHandle) -> Self {
|
fn new(prompt_request: PromptRequestHandle, select_request: SelectRequestHandle) -> Self {
|
||||||
Self { prompt_request }
|
Self {
|
||||||
|
prompt_request,
|
||||||
|
select_request,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -824,6 +832,24 @@ impl WindowApi {
|
|||||||
*self.prompt_request.borrow_mut() = Some(PromptRequest::from_options(options)?);
|
*self.prompt_request.borrow_mut() = Some(PromptRequest::from_options(options)?);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Открывает плавающий список выбора чуть выше центра окна.
|
||||||
|
///
|
||||||
|
/// `options` — таблица `{ title = "Выбор", selected = "b", items =
|
||||||
|
/// { { id = "a", label = "Первый", icon = "file", description = "…" },
|
||||||
|
/// ... }, callback = function(id) ... end }`.
|
||||||
|
/// `title` и `selected` необязательны: без заголовка окно начинается сразу
|
||||||
|
/// со списка, а без `selected` выделяется первый пункт. Неизвестный
|
||||||
|
/// `selected` тоже выделяет первый пункт. Пункт без `icon` (или с
|
||||||
|
/// неизвестным именем иконки) рисуется без иконки.
|
||||||
|
/// Пока окно открыто, сочетания клавиш приложения не работают: стрелки
|
||||||
|
/// меняют выделение, `Enter` подтверждает его и вызывает `callback` с id
|
||||||
|
/// выбранного пункта уже после закрытия окна, `Esc` и клик вне окна
|
||||||
|
/// закрывают список без колбэка.
|
||||||
|
pub fn open_select(&self, options: Option<mlua::Table>) -> mlua::Result<()> {
|
||||||
|
*self.select_request.borrow_mut() = Some(SelectRequest::from_options(options)?);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[mlua::userdata_impl]
|
#[mlua::userdata_impl]
|
||||||
@@ -979,6 +1005,7 @@ impl AppApi {
|
|||||||
gutter_clicks: new_gutter_click_handlers(),
|
gutter_clicks: new_gutter_click_handlers(),
|
||||||
gutter_shifts: new_gutter_shift_handlers(),
|
gutter_shifts: new_gutter_shift_handlers(),
|
||||||
prompt_request: Rc::new(RefCell::new(None)),
|
prompt_request: Rc::new(RefCell::new(None)),
|
||||||
|
select_request: Rc::new(RefCell::new(None)),
|
||||||
};
|
};
|
||||||
INSTANCE.with(|slot| *slot.borrow_mut() = Some(api.clone()));
|
INSTANCE.with(|slot| *slot.borrow_mut() = Some(api.clone()));
|
||||||
api
|
api
|
||||||
@@ -1039,6 +1066,12 @@ impl AppApi {
|
|||||||
self.prompt_request.clone()
|
self.prompt_request.clone()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Очередь запросов на открытие плавающего списка выбора (для вида).
|
||||||
|
#[lua(skip)]
|
||||||
|
pub fn select_request_handle(&self) -> SelectRequestHandle {
|
||||||
|
self.select_request.clone()
|
||||||
|
}
|
||||||
|
|
||||||
/// Обработчики клика по иконкам gutter (для вида).
|
/// Обработчики клика по иконкам gutter (для вида).
|
||||||
#[lua(skip)]
|
#[lua(skip)]
|
||||||
pub fn gutter_click_handle(&self) -> GutterClickHandlers {
|
pub fn gutter_click_handle(&self) -> GutterClickHandlers {
|
||||||
@@ -1097,7 +1130,7 @@ impl AppApi {
|
|||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
#[lua(getter, infallible)]
|
#[lua(getter, infallible)]
|
||||||
pub fn window(&self) -> WindowApi {
|
pub fn window(&self) -> WindowApi {
|
||||||
WindowApi::new(self.prompt_request.clone())
|
WindowApi::new(self.prompt_request.clone(), self.select_request.clone())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// API глобальных привязок клавиш.
|
/// API глобальных привязок клавиш.
|
||||||
|
|||||||
+273
-2
@@ -23,6 +23,7 @@ use gpui_kit::component::input::{
|
|||||||
};
|
};
|
||||||
use gpui_kit::component::label::Label;
|
use gpui_kit::component::label::Label;
|
||||||
use gpui_kit::component::resizable::HandleEdge;
|
use gpui_kit::component::resizable::HandleEdge;
|
||||||
|
use gpui_kit::component::separator::Separator;
|
||||||
use gpui_kit::component::status_bar::StatusBar;
|
use gpui_kit::component::status_bar::StatusBar;
|
||||||
use gpui_kit::component::tab::{Tab, TabBar};
|
use gpui_kit::component::tab::{Tab, TabBar};
|
||||||
use gpui_kit::component::tag::Tag;
|
use gpui_kit::component::tag::Tag;
|
||||||
@@ -53,7 +54,7 @@ use crate::icons;
|
|||||||
use crate::keymap::{AppCommand, CompletionCommand, KeyContext, KeyHandler, KeyMap};
|
use crate::keymap::{AppCommand, CompletionCommand, KeyContext, KeyHandler, KeyMap};
|
||||||
use crate::lsp::{LspManager, document_uri, position_to_offset, position_to_offset_rope};
|
use crate::lsp::{LspManager, document_uri, position_to_offset, position_to_offset_rope};
|
||||||
use crate::panel::{PanelBlock, PanelHandle};
|
use crate::panel::{PanelBlock, PanelHandle};
|
||||||
use crate::prompt::{PromptRequestHandle, PromptUi};
|
use crate::prompt::{PromptRequestHandle, PromptUi, SelectRequestHandle, SelectUi};
|
||||||
use crate::script::ScriptEngine;
|
use crate::script::ScriptEngine;
|
||||||
use crate::toolbar::{ToolbarHandle, ToolbarItem, ToolbarKind};
|
use crate::toolbar::{ToolbarHandle, ToolbarItem, ToolbarKind};
|
||||||
use crate::tree::{TreeAction, TreeHandle, TreeKeyResult};
|
use crate::tree::{TreeAction, TreeHandle, TreeKeyResult};
|
||||||
@@ -233,6 +234,8 @@ pub struct MyApp {
|
|||||||
_key_interceptor: Subscription,
|
_key_interceptor: Subscription,
|
||||||
/// Открытое плавающее поле ввода.
|
/// Открытое плавающее поле ввода.
|
||||||
prompt: Option<PromptUi>,
|
prompt: Option<PromptUi>,
|
||||||
|
/// Открытый плавающий список выбора.
|
||||||
|
select: Option<SelectUi>,
|
||||||
/// Обработчики клика по иконкам gutter, общие с `api.editor`.
|
/// Обработчики клика по иконкам gutter, общие с `api.editor`.
|
||||||
///
|
///
|
||||||
/// Лежит перед `scripts`, чтобы обработчики (ссылки на Lua-значения)
|
/// Лежит перед `scripts`, чтобы обработчики (ссылки на Lua-значения)
|
||||||
@@ -253,6 +256,10 @@ pub struct MyApp {
|
|||||||
///
|
///
|
||||||
/// Живёт перед `scripts`, чтобы колбэк уничтожился раньше Lua-движка.
|
/// Живёт перед `scripts`, чтобы колбэк уничтожился раньше Lua-движка.
|
||||||
prompt_request: PromptRequestHandle,
|
prompt_request: PromptRequestHandle,
|
||||||
|
/// Запрос из Lua на открытие плавающего списка выбора.
|
||||||
|
///
|
||||||
|
/// Живёт перед `scripts`, чтобы колбэк уничтожился раньше Lua-движка.
|
||||||
|
select_request: SelectRequestHandle,
|
||||||
/// Движок Lua, созданный при старте.
|
/// Движок Lua, созданный при старте.
|
||||||
///
|
///
|
||||||
/// Хранится, чтобы глобальное окружение и загруженные модули жили, пока
|
/// Хранится, чтобы глобальное окружение и загруженные модули жили, пока
|
||||||
@@ -293,6 +300,7 @@ impl MyApp {
|
|||||||
let lsp = api.lsp_handle();
|
let lsp = api.lsp_handle();
|
||||||
let editor_keymap = api.editor_keymap_handle();
|
let editor_keymap = api.editor_keymap_handle();
|
||||||
let prompt_request = api.prompt_request_handle();
|
let prompt_request = api.prompt_request_handle();
|
||||||
|
let select_request = api.select_request_handle();
|
||||||
let gutter_clicks = api.gutter_click_handle();
|
let gutter_clicks = api.gutter_click_handle();
|
||||||
let gutter_shifts = api.gutter_shift_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 _key_interceptor = cx.intercept_keystrokes(move |event, window, cx| {
|
||||||
let handled = view
|
let handled = view
|
||||||
.update(cx, |this, cx| {
|
.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)
|
this.handle_prompt_key(&event.keystroke, window, cx)
|
||||||
} else {
|
} else {
|
||||||
this.handle_completion_key(&event.keystroke, window, cx)
|
this.handle_completion_key(&event.keystroke, window, cx)
|
||||||
@@ -475,10 +485,12 @@ impl MyApp {
|
|||||||
_editor_subscription,
|
_editor_subscription,
|
||||||
_key_interceptor,
|
_key_interceptor,
|
||||||
prompt: None,
|
prompt: None,
|
||||||
|
select: None,
|
||||||
gutter_clicks,
|
gutter_clicks,
|
||||||
gutter_shifts,
|
gutter_shifts,
|
||||||
gutter_hover: None,
|
gutter_hover: None,
|
||||||
prompt_request,
|
prompt_request,
|
||||||
|
select_request,
|
||||||
scripts: None,
|
scripts: None,
|
||||||
lsp,
|
lsp,
|
||||||
completion: CompletionUi::default(),
|
completion: CompletionUi::default(),
|
||||||
@@ -572,6 +584,12 @@ impl MyApp {
|
|||||||
self.prompt.is_some()
|
self.prompt.is_some()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Открыт ли сейчас плавающий список выбора (доступ для тестов).
|
||||||
|
#[cfg(test)]
|
||||||
|
pub fn select_is_open(&self) -> bool {
|
||||||
|
self.select.is_some()
|
||||||
|
}
|
||||||
|
|
||||||
/// Применяет известную LSP-диагностику к редактору (доступ для тестов).
|
/// Применяет известную LSP-диагностику к редактору (доступ для тестов).
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
pub fn refresh_diagnostics(&mut self, cx: &mut Context<Self>) {
|
pub fn refresh_diagnostics(&mut self, cx: &mut Context<Self>) {
|
||||||
@@ -811,6 +829,9 @@ impl MyApp {
|
|||||||
if self.prompt.is_some() {
|
if self.prompt.is_some() {
|
||||||
self.close_prompt(false, window, cx);
|
self.close_prompt(false, window, cx);
|
||||||
}
|
}
|
||||||
|
if self.select.is_some() {
|
||||||
|
self.close_select(false, cx);
|
||||||
|
}
|
||||||
self.close_completion(cx);
|
self.close_completion(cx);
|
||||||
|
|
||||||
let previous_focus = window.focused(cx);
|
let previous_focus = window.focused(cx);
|
||||||
@@ -866,6 +887,66 @@ impl MyApp {
|
|||||||
cx.notify();
|
cx.notify();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Открывает плавающий список выбора, если Lua попросил об этом.
|
||||||
|
///
|
||||||
|
/// Состояние создаётся в кадре вида, чтобы запрос из Lua не зависел от
|
||||||
|
/// `Context`. Новый запрос закрывает открытое поле ввода без колбэка.
|
||||||
|
fn sync_select(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
|
let Some(request) = self.select_request.borrow_mut().take() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
if self.select.is_some() {
|
||||||
|
self.close_select(false, cx);
|
||||||
|
}
|
||||||
|
if self.prompt.is_some() {
|
||||||
|
self.close_prompt(false, window, cx);
|
||||||
|
}
|
||||||
|
self.close_completion(cx);
|
||||||
|
|
||||||
|
self.select = Some(SelectUi {
|
||||||
|
title: request.title,
|
||||||
|
items: request.items,
|
||||||
|
selected: request.selected,
|
||||||
|
callback: request.callback,
|
||||||
|
scroll: ScrollHandle::new(),
|
||||||
|
});
|
||||||
|
cx.notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Закрывает плавающий список выбора.
|
||||||
|
///
|
||||||
|
/// При `submit = true` берёт id выделенного пункта и вызывает Lua-колбэк
|
||||||
|
/// уже после закрытия окна. Закрытие без подтверждения колбэк не вызывает.
|
||||||
|
fn close_select(&mut self, submit: bool, cx: &mut Context<Self>) {
|
||||||
|
let Some(select) = self.select.take() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let id = submit
|
||||||
|
.then(|| {
|
||||||
|
select
|
||||||
|
.items
|
||||||
|
.get(select.selected)
|
||||||
|
.map(|item| item.id.clone())
|
||||||
|
})
|
||||||
|
.flatten();
|
||||||
|
let callback = select.callback.clone();
|
||||||
|
// Колбэк относится к Lua-движку и не должен пережить окно.
|
||||||
|
drop(select);
|
||||||
|
|
||||||
|
// Колбэк выполняется уже после закрытия окна. Он может открыть новое
|
||||||
|
// окно — уведомление после вызова гарантирует, что вид увидит запрос.
|
||||||
|
if let Some(id) = id
|
||||||
|
&& let Some(callback) = callback
|
||||||
|
&& let Err(err) = callback.call::<()>(id)
|
||||||
|
{
|
||||||
|
eprintln!("Lua: ошибка в обработчике api.window:open_select: {err}");
|
||||||
|
}
|
||||||
|
|
||||||
|
cx.notify();
|
||||||
|
}
|
||||||
|
|
||||||
/// Возвращает фокус туда, где он был до открытия плавающего окна.
|
/// Возвращает фокус туда, где он был до открытия плавающего окна.
|
||||||
fn restore_focus(
|
fn restore_focus(
|
||||||
&self,
|
&self,
|
||||||
@@ -904,6 +985,55 @@ impl MyApp {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Обрабатывает клавиши, пока открыт плавающий список выбора.
|
||||||
|
///
|
||||||
|
/// Список модален: у него нет поля ввода, которое могло бы получить текст,
|
||||||
|
/// поэтому поглощаются все клавиши. `Enter` подтверждает выделение,
|
||||||
|
/// стрелки меняют его, `Home`/`End` прыгают к краям, `Esc` закрывает
|
||||||
|
/// список без колбэка.
|
||||||
|
fn handle_select_key(&mut self, keystroke: &Keystroke, cx: &mut Context<Self>) -> bool {
|
||||||
|
match keystroke.key.as_str() {
|
||||||
|
"enter" => self.close_select(true, cx),
|
||||||
|
"escape" => self.close_select(false, cx),
|
||||||
|
"up" => self.move_select_selection(-1, cx),
|
||||||
|
"down" => self.move_select_selection(1, cx),
|
||||||
|
"home" => self.select_select_index(0, cx),
|
||||||
|
"end" => {
|
||||||
|
if let Some(select) = self.select.as_ref() {
|
||||||
|
self.select_select_index(select.items.len().saturating_sub(1), cx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Сдвигает выделение списка на `delta` строк, не выходя за его пределы.
|
||||||
|
fn move_select_selection(&mut self, delta: isize, cx: &mut Context<Self>) {
|
||||||
|
let Some(select) = self.select.as_ref() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if select.items.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let selected = select.selected.min(select.items.len() - 1) as isize;
|
||||||
|
let next = (selected + delta).clamp(0, select.items.len() as isize - 1) as usize;
|
||||||
|
self.select_select_index(next, cx);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Выделяет пункт списка по индексу.
|
||||||
|
fn select_select_index(&mut self, index: usize, cx: &mut Context<Self>) {
|
||||||
|
let Some(select) = self.select.as_mut() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if index >= select.items.len() || index == select.selected {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
select.selected = index;
|
||||||
|
select.scroll.scroll_to_item(index);
|
||||||
|
cx.notify();
|
||||||
|
}
|
||||||
|
|
||||||
/// Плавающее поле ввода чуть выше центра окна.
|
/// Плавающее поле ввода чуть выше центра окна.
|
||||||
///
|
///
|
||||||
/// Без заголовка окно состоит из одной строки ввода; с заголовком текст
|
/// Без заголовка окно состоит из одной строки ввода; с заголовком текст
|
||||||
@@ -954,6 +1084,145 @@ impl MyApp {
|
|||||||
.into_any_element()
|
.into_any_element()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Плавающий список выбора чуть выше центра окна.
|
||||||
|
///
|
||||||
|
/// Заголовок отделён от списка разделителем. Если иконка есть хотя бы у
|
||||||
|
/// одного пункта, у пунктов без иконки остаётся пустое место такой же
|
||||||
|
/// ширины, чтобы текст выравнивался. Подложка перекрывает всё окно,
|
||||||
|
/// поэтому клик вне списка закрывает его, не доходя до редактора и панелей.
|
||||||
|
fn render_select(&self, cx: &mut Context<Self>) -> AnyElement {
|
||||||
|
let Some(select) = self.select.as_ref() else {
|
||||||
|
return div().into_any_element();
|
||||||
|
};
|
||||||
|
|
||||||
|
let view = cx.entity().downgrade();
|
||||||
|
let outside_view = view.clone();
|
||||||
|
let mut dialog = div()
|
||||||
|
.id("floating-select")
|
||||||
|
.flex()
|
||||||
|
.flex_col()
|
||||||
|
.gap_2()
|
||||||
|
.w(px(PROMPT_WIDTH))
|
||||||
|
.max_w(relative(0.9))
|
||||||
|
.p_2()
|
||||||
|
.occlude()
|
||||||
|
.rounded(cx.theme().radius)
|
||||||
|
.bg(cx.theme().popover)
|
||||||
|
.border_1()
|
||||||
|
.border_color(cx.theme().border)
|
||||||
|
.shadow_md()
|
||||||
|
.on_mouse_down_out(move |_, _window, cx| {
|
||||||
|
let _ = outside_view.update(cx, |this, cx| this.close_select(false, cx));
|
||||||
|
});
|
||||||
|
|
||||||
|
if let Some(title) = &select.title {
|
||||||
|
dialog = dialog
|
||||||
|
.child(div().text_sm().font_medium().child(title.clone()))
|
||||||
|
.child(Separator::horizontal());
|
||||||
|
}
|
||||||
|
|
||||||
|
let has_icons = select.items.iter().any(|item| item.icon.is_some());
|
||||||
|
let mut rows = Vec::with_capacity(select.items.len());
|
||||||
|
for (index, item) in select.items.iter().enumerate() {
|
||||||
|
let is_selected = index == select.selected;
|
||||||
|
let click_view = view.clone();
|
||||||
|
let hover_view = view.clone();
|
||||||
|
|
||||||
|
let mut row = div()
|
||||||
|
.id(format!("select-row-{index}"))
|
||||||
|
.flex()
|
||||||
|
.items_center()
|
||||||
|
.gap_1p5()
|
||||||
|
.px_1p5()
|
||||||
|
.py_0p5()
|
||||||
|
.flex_shrink_0()
|
||||||
|
.overflow_hidden()
|
||||||
|
.whitespace_nowrap()
|
||||||
|
.rounded(cx.theme().radius)
|
||||||
|
.on_click(move |_, _window, cx| {
|
||||||
|
let _ = click_view.update(cx, |this, cx| {
|
||||||
|
this.select_select_index(index, cx);
|
||||||
|
this.close_select(true, cx);
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.on_hover(move |hovered, _window, cx| {
|
||||||
|
if *hovered {
|
||||||
|
let _ =
|
||||||
|
hover_view.update(cx, |this, cx| this.select_select_index(index, cx));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Ширина пустого места совпадает с xsmall-иконкой (12 px), чтобы
|
||||||
|
// названия пунктов без иконки начинались на том же уровне.
|
||||||
|
if has_icons {
|
||||||
|
row = row.child(match item.icon {
|
||||||
|
Some(icon) => Icon::new(icon)
|
||||||
|
.xsmall()
|
||||||
|
.flex_shrink_0()
|
||||||
|
.text_color(cx.theme().muted_foreground)
|
||||||
|
.into_any_element(),
|
||||||
|
None => div().w(px(12.)).flex_shrink_0().into_any_element(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Название занимает свою естественную ширину, описание получает
|
||||||
|
// остаток и прижимается вправо: длинное название не перетирает
|
||||||
|
// описание, а обрезка с троеточием остаётся у каждого.
|
||||||
|
row = row.child(
|
||||||
|
div()
|
||||||
|
.min_w_0()
|
||||||
|
.overflow_hidden()
|
||||||
|
.text_sm()
|
||||||
|
.whitespace_nowrap()
|
||||||
|
.text_ellipsis()
|
||||||
|
.child(item.label.clone()),
|
||||||
|
);
|
||||||
|
|
||||||
|
if let Some(description) = &item.description {
|
||||||
|
row = row.child(
|
||||||
|
div()
|
||||||
|
.flex_1()
|
||||||
|
.min_w_0()
|
||||||
|
.overflow_hidden()
|
||||||
|
.text_xs()
|
||||||
|
.text_right()
|
||||||
|
.whitespace_nowrap()
|
||||||
|
.text_ellipsis()
|
||||||
|
.text_color(cx.theme().muted_foreground)
|
||||||
|
.child(description.clone()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if is_selected {
|
||||||
|
row = row.bg(cx.theme().selection);
|
||||||
|
}
|
||||||
|
rows.push(row.into_any_element());
|
||||||
|
}
|
||||||
|
|
||||||
|
dialog = dialog.child(
|
||||||
|
div()
|
||||||
|
.id("floating-select-list")
|
||||||
|
.flex()
|
||||||
|
.flex_col()
|
||||||
|
.max_h(px(320.))
|
||||||
|
.overflow_y_scroll()
|
||||||
|
.track_scroll(&select.scroll)
|
||||||
|
.children(rows),
|
||||||
|
);
|
||||||
|
|
||||||
|
div()
|
||||||
|
.id("floating-select-overlay")
|
||||||
|
.absolute()
|
||||||
|
.inset_0()
|
||||||
|
.occlude()
|
||||||
|
.flex()
|
||||||
|
.items_center()
|
||||||
|
.justify_center()
|
||||||
|
.pb(px(PROMPT_CENTER_OFFSET))
|
||||||
|
.child(dialog)
|
||||||
|
.into_any_element()
|
||||||
|
}
|
||||||
|
|
||||||
fn select_completion(&mut self, selected: usize, cx: &mut Context<Self>) {
|
fn select_completion(&mut self, selected: usize, cx: &mut Context<Self>) {
|
||||||
if selected >= self.completion.visible_len() || selected == self.completion.selected {
|
if selected >= self.completion.visible_len() || selected == self.completion.selected {
|
||||||
return;
|
return;
|
||||||
@@ -2392,6 +2661,7 @@ impl Render for MyApp {
|
|||||||
// Запрос из Lua обрабатывается в кадре: только здесь есть `Window`
|
// Запрос из Lua обрабатывается в кадре: только здесь есть `Window`
|
||||||
// и `Context`, чтобы создать поле ввода и передать ему фокус.
|
// и `Context`, чтобы создать поле ввода и передать ему фокус.
|
||||||
self.sync_prompt(window, cx);
|
self.sync_prompt(window, cx);
|
||||||
|
self.sync_select(window, cx);
|
||||||
// Документ мог открыться до запуска сервера (init.lua выполняется
|
// Документ мог открыться до запуска сервера (init.lua выполняется
|
||||||
// сразу после создания окна) — отдаём его LSP при первом кадре.
|
// сразу после создания окна) — отдаём его LSP при первом кадре.
|
||||||
let text = self.editor.read(cx).text().clone();
|
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_status_bar(cx))
|
||||||
.child(self.render_completion_popup(cx))
|
.child(self.render_completion_popup(cx))
|
||||||
.child(self.render_prompt(cx))
|
.child(self.render_prompt(cx))
|
||||||
|
.child(self.render_select(cx))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+162
-23
@@ -1,17 +1,25 @@
|
|||||||
//! Плавающее поле ввода, открываемое из Lua.
|
//! Плавающие окна, открываемые из Lua.
|
||||||
//!
|
//!
|
||||||
//! Сценарий вызывает `api.window:open_input{ title = "Имя", callback = function(text) end }`.
|
//! Сценарий вызывает `api.window:open_input{ title = "Имя", callback = function(text) end }`
|
||||||
//! Запрос складывается в общее состояние API, а вид при следующем кадре
|
//! или `api.window:open_select{ title = "Выбор", items = {...},
|
||||||
//! создаёт поле ввода, показывает его чуть выше центра и забирает фокус.
|
//! callback = function(id) end }`. Запрос складывается в общее состояние API, а
|
||||||
//! Пока окно открыто, сочетания клавиш приложения не работают: текст набируется
|
//! вид при следующем кадре создаёт окно, показывает его чуть выше центра и
|
||||||
//! в поле, `Enter` подтверждает ввод и запускает колбэк, `Esc` и клик вне окна
|
//! забирает фокус (поле ввода) или перехватывает клавиши (список).
|
||||||
//! закрывают его без вызова колбэка.
|
//!
|
||||||
|
//! Пока окно открыто, сочетания клавиш приложения не работают. У поля ввода
|
||||||
|
//! текст набивается в поле, `Enter` подтверждает ввод и запускает колбэк, `Esc`
|
||||||
|
//! и клик вне окна закрывают его без вызова колбэка. У списка стрелки меняют
|
||||||
|
//! выделение, `Enter` подтверждает его, клик по пункту выбирает и подтверждает,
|
||||||
|
//! `Esc` и клик вне окна закрывают список без колбэка.
|
||||||
|
|
||||||
use std::cell::RefCell;
|
use std::cell::RefCell;
|
||||||
use std::rc::Rc;
|
use std::rc::Rc;
|
||||||
|
|
||||||
|
use gpui_kit::assets::IconName;
|
||||||
use gpui_kit::component::input::InputState;
|
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 {
|
pub struct PromptRequest {
|
||||||
@@ -38,18 +46,9 @@ impl PromptRequest {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
let title = optional_text(&options, "title")?;
|
let title = optional_text(&options, "title", "api.window:open_input")?;
|
||||||
let placeholder = optional_text(&options, "placeholder")?;
|
let placeholder = optional_text(&options, "placeholder", "api.window:open_input")?;
|
||||||
|
let callback = optional_callback(&options, "callback", "api.window:open_input")?;
|
||||||
let callback = match options.get::<mlua::Value>("callback")? {
|
|
||||||
mlua::Value::Nil => None,
|
|
||||||
mlua::Value::Function(callback) => Some(callback),
|
|
||||||
_ => {
|
|
||||||
return Err(mlua::Error::RuntimeError(
|
|
||||||
"api.window:open_input: поле callback должно быть функцией".to_owned(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
title,
|
title,
|
||||||
@@ -59,8 +58,116 @@ impl PromptRequest {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Пункт списка выбора.
|
||||||
|
pub struct SelectItem {
|
||||||
|
/// Идентификатор, который получит колбэк после выбора.
|
||||||
|
pub id: String,
|
||||||
|
/// Отображаемое значение.
|
||||||
|
pub label: String,
|
||||||
|
/// Иконка Lucide; `None` — у пункта иконки нет.
|
||||||
|
pub icon: Option<IconName>,
|
||||||
|
/// Описание справа от названия; `None` — описания нет.
|
||||||
|
pub description: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Запрос на открытие плавающего списка выбора.
|
||||||
|
pub struct SelectRequest {
|
||||||
|
/// Текст над списком; `None` — окно без заголовка и разделителя.
|
||||||
|
pub title: Option<String>,
|
||||||
|
/// Пункты списка; непустой массив таблиц.
|
||||||
|
pub items: Vec<SelectItem>,
|
||||||
|
/// Индекс пункта, выделенного при открытии.
|
||||||
|
pub selected: usize,
|
||||||
|
/// Колбэк, вызываемый после `Enter` уже после закрытия окна.
|
||||||
|
pub callback: Option<mlua::Function>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SelectRequest {
|
||||||
|
/// Разбирает Lua-таблицу опций `api.window:open_select`.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// Возвращает ошибку Lua, если `items` не непустой массив таблиц, поля
|
||||||
|
/// пунктов не строки или `callback` не функция.
|
||||||
|
pub fn from_options(options: Option<mlua::Table>) -> mlua::Result<Self> {
|
||||||
|
let Some(options) = options else {
|
||||||
|
return Err(mlua::Error::RuntimeError(
|
||||||
|
"api.window:open_select: нужна таблица с полем items".to_owned(),
|
||||||
|
));
|
||||||
|
};
|
||||||
|
|
||||||
|
let title = optional_text(&options, "title", "api.window:open_select")?;
|
||||||
|
let selected = optional_text(&options, "selected", "api.window:open_select")?;
|
||||||
|
let callback = optional_callback(&options, "callback", "api.window:open_select")?;
|
||||||
|
let items = parse_select_items(&options)?;
|
||||||
|
|
||||||
|
// Неизвестный идентификатор не мешает открыть список: выделяется
|
||||||
|
// первый пункт, как будто `selected` не задан.
|
||||||
|
let selected = selected
|
||||||
|
.and_then(|id| items.iter().position(|item| item.id == id))
|
||||||
|
.unwrap_or(0);
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
title,
|
||||||
|
items,
|
||||||
|
selected,
|
||||||
|
callback,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Разбирает массив пунктов, переданный в `api.window:open_select`.
|
||||||
|
fn parse_select_items(options: &mlua::Table) -> mlua::Result<Vec<SelectItem>> {
|
||||||
|
const METHOD: &str = "api.window:open_select";
|
||||||
|
|
||||||
|
let items = match options.get::<mlua::Value>("items")? {
|
||||||
|
mlua::Value::Table(items) => items,
|
||||||
|
_ => {
|
||||||
|
return Err(mlua::Error::RuntimeError(format!(
|
||||||
|
"{METHOD}: поле items должно быть массивом пунктов"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut parsed = Vec::new();
|
||||||
|
for pair in items.sequence_values::<mlua::Table>() {
|
||||||
|
let item = pair?;
|
||||||
|
let id = required_text(&item, "id", METHOD)?;
|
||||||
|
let label = required_text(&item, "label", METHOD)?;
|
||||||
|
|
||||||
|
// Неизвестное имя иконки не подменяется молча: пункт рисуется без неё,
|
||||||
|
// как и пункт с пустым полем icon.
|
||||||
|
let icon = optional_text(&item, "icon", METHOD)?.and_then(|name| icons::from_name(&name));
|
||||||
|
let description = optional_text(&item, "description", METHOD)?;
|
||||||
|
|
||||||
|
parsed.push(SelectItem {
|
||||||
|
id,
|
||||||
|
label,
|
||||||
|
icon,
|
||||||
|
description,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if parsed.is_empty() {
|
||||||
|
return Err(mlua::Error::RuntimeError(format!(
|
||||||
|
"{METHOD}: поле items не должно быть пустым"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(parsed)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Читает обязательную строковую опцию.
|
||||||
|
fn required_text(table: &mlua::Table, field: &str, method: &str) -> mlua::Result<String> {
|
||||||
|
match table.get::<mlua::Value>(field)? {
|
||||||
|
mlua::Value::String(text) => Ok(text.to_str()?.to_string()),
|
||||||
|
_ => Err(mlua::Error::RuntimeError(format!(
|
||||||
|
"{method}: поле {field} должно быть строкой"
|
||||||
|
))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Читает необязательную строковую опцию; пустая строка означает `None`.
|
/// Читает необязательную строковую опцию; пустая строка означает `None`.
|
||||||
fn optional_text(options: &mlua::Table, field: &str) -> mlua::Result<Option<String>> {
|
fn optional_text(options: &mlua::Table, field: &str, method: &str) -> mlua::Result<Option<String>> {
|
||||||
match options.get::<mlua::Value>(field)? {
|
match options.get::<mlua::Value>(field)? {
|
||||||
mlua::Value::Nil => Ok(None),
|
mlua::Value::Nil => Ok(None),
|
||||||
mlua::Value::String(text) => {
|
mlua::Value::String(text) => {
|
||||||
@@ -68,14 +175,32 @@ fn optional_text(options: &mlua::Table, field: &str) -> mlua::Result<Option<Stri
|
|||||||
Ok((!text.is_empty()).then_some(text))
|
Ok((!text.is_empty()).then_some(text))
|
||||||
}
|
}
|
||||||
_ => Err(mlua::Error::RuntimeError(format!(
|
_ => Err(mlua::Error::RuntimeError(format!(
|
||||||
"api.window:open_input: поле {field} должно быть строкой"
|
"{method}: поле {field} должно быть строкой"
|
||||||
))),
|
))),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Общее состояние: запрос, ожидающий ближайшего кадра.
|
/// Читает необязательный колбэк; `nil` означает, что его нет.
|
||||||
|
fn optional_callback(
|
||||||
|
options: &mlua::Table,
|
||||||
|
field: &str,
|
||||||
|
method: &str,
|
||||||
|
) -> mlua::Result<Option<mlua::Function>> {
|
||||||
|
match options.get::<mlua::Value>(field)? {
|
||||||
|
mlua::Value::Nil => Ok(None),
|
||||||
|
mlua::Value::Function(callback) => Ok(Some(callback)),
|
||||||
|
_ => Err(mlua::Error::RuntimeError(format!(
|
||||||
|
"{method}: поле {field} должно быть функцией"
|
||||||
|
))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Общее состояние: запрос на поле ввода, ожидающий ближайшего кадра.
|
||||||
pub type PromptRequestHandle = Rc<RefCell<Option<PromptRequest>>>;
|
pub type PromptRequestHandle = Rc<RefCell<Option<PromptRequest>>>;
|
||||||
|
|
||||||
|
/// Общее состояние: запрос на список выбора, ожидающий ближайшего кадра.
|
||||||
|
pub type SelectRequestHandle = Rc<RefCell<Option<SelectRequest>>>;
|
||||||
|
|
||||||
/// Открытое плавающее поле ввода и его окружение.
|
/// Открытое плавающее поле ввода и его окружение.
|
||||||
pub struct PromptUi {
|
pub struct PromptUi {
|
||||||
/// Поле ввода, созданное видом.
|
/// Поле ввода, созданное видом.
|
||||||
@@ -89,3 +214,17 @@ pub struct PromptUi {
|
|||||||
/// Подписка на события поля; живёт вместе с окном.
|
/// Подписка на события поля; живёт вместе с окном.
|
||||||
pub _subscription: Subscription,
|
pub _subscription: Subscription,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Открытый плавающий список выбора.
|
||||||
|
pub struct SelectUi {
|
||||||
|
/// Текст над списком; `None` — окно без заголовка и разделителя.
|
||||||
|
pub title: Option<String>,
|
||||||
|
/// Пункты списка.
|
||||||
|
pub items: Vec<SelectItem>,
|
||||||
|
/// Индекс выделенного пункта.
|
||||||
|
pub selected: usize,
|
||||||
|
/// Колбэк `Enter`; получает id выбранного пункта.
|
||||||
|
pub callback: Option<mlua::Function>,
|
||||||
|
/// Прокрутка списка: выделение всегда прокручивается в видимую часть.
|
||||||
|
pub scroll: ScrollHandle,
|
||||||
|
}
|
||||||
|
|||||||
+215
@@ -15,6 +15,7 @@ use lsp_types::{Diagnostic, DiagnosticSeverity, Position, Range};
|
|||||||
use crate::api::{AppApi, GutterIconOptions, WindowCommand, drain_window_commands};
|
use crate::api::{AppApi, GutterIconOptions, WindowCommand, drain_window_commands};
|
||||||
use crate::app::MyApp;
|
use crate::app::MyApp;
|
||||||
use crate::editor::{FocusAfterSync, Language, Workspace};
|
use crate::editor::{FocusAfterSync, Language, Workspace};
|
||||||
|
use crate::icons;
|
||||||
use crate::keymap::{KeyBinding, KeyContext, KeyHandler, KeyMap, Modifiers as KeyModifiers};
|
use crate::keymap::{KeyBinding, KeyContext, KeyHandler, KeyMap, Modifiers as KeyModifiers};
|
||||||
use crate::panel::PanelBlock;
|
use crate::panel::PanelBlock;
|
||||||
use crate::script::ScriptEngine;
|
use crate::script::ScriptEngine;
|
||||||
@@ -886,6 +887,107 @@ fn lua_app_open_input_queues_options() {
|
|||||||
let _ = std::fs::remove_dir_all(&dir);
|
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]
|
#[test]
|
||||||
fn lua_path_list_returns_sorted_entries_with_paths() {
|
fn lua_path_list_returns_sorted_entries_with_paths() {
|
||||||
let workspace = Rc::new(RefCell::new(Workspace::default()));
|
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);
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[gpui_kit::test]
|
||||||
|
fn floating_select_navigates_and_submits_id(cx: &mut TestAppContext) {
|
||||||
|
cx.update(gpui_kit::init);
|
||||||
|
|
||||||
|
let mut workspace = None;
|
||||||
|
let (app, visual) = cx.add_window_view(|window, cx| {
|
||||||
|
let app = MyApp::new(window, cx);
|
||||||
|
workspace = Some(app.workspace_handle());
|
||||||
|
app
|
||||||
|
});
|
||||||
|
let workspace = workspace.expect("вид создан");
|
||||||
|
|
||||||
|
let dir = temp_script_dir("floating-select");
|
||||||
|
let keys = dir.join("keys.lua");
|
||||||
|
let open = dir.join("open.lua");
|
||||||
|
let open_first = dir.join("open-first.lua");
|
||||||
|
std::fs::write(
|
||||||
|
&keys,
|
||||||
|
r#"api.keymap:map_key("", "<A-r>", function() api.editor:set_active_text("blocked") end)"#,
|
||||||
|
)
|
||||||
|
.expect("keys.lua");
|
||||||
|
std::fs::write(
|
||||||
|
&open,
|
||||||
|
r#"
|
||||||
|
api.window:open_select({
|
||||||
|
title = "Режим",
|
||||||
|
selected = "b",
|
||||||
|
items = {
|
||||||
|
{ id = "a", label = "Альфа", icon = "file", description = "первый" },
|
||||||
|
{ id = "b", label = "Бета" },
|
||||||
|
{ id = "c", label = "Гамма", icon = "folder" },
|
||||||
|
},
|
||||||
|
callback = function(id) api.editor:set_active_text(id) end,
|
||||||
|
})
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.expect("open.lua");
|
||||||
|
std::fs::write(
|
||||||
|
&open_first,
|
||||||
|
r#"
|
||||||
|
api.window:open_select({
|
||||||
|
items = {
|
||||||
|
{ id = "first", label = "Первый" },
|
||||||
|
{ id = "second", label = "Второй" },
|
||||||
|
},
|
||||||
|
callback = function(id) api.editor:set_active_text(id) end,
|
||||||
|
})
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.expect("open-first.lua");
|
||||||
|
|
||||||
|
let engine = ScriptEngine::new(AppApi::global()).expect("движок Lua");
|
||||||
|
engine.run_file(&keys).expect("привязка");
|
||||||
|
engine.run_file(&open).expect("api.window:open_select");
|
||||||
|
|
||||||
|
let notify = |visual: &mut gpui_kit::VisualTestContext| {
|
||||||
|
visual.update(|_window, cx| {
|
||||||
|
app.update(cx, |_, cx| cx.notify());
|
||||||
|
});
|
||||||
|
visual.run_until_parked();
|
||||||
|
};
|
||||||
|
let select_open = |visual: &mut gpui_kit::VisualTestContext| {
|
||||||
|
visual.update(|_window, cx| app.read(cx).select_is_open())
|
||||||
|
};
|
||||||
|
|
||||||
|
notify(visual);
|
||||||
|
assert!(select_open(visual), "список выбора должен открыться");
|
||||||
|
|
||||||
|
// Пока список открыт, все клавиши перехватываются, включая привязки.
|
||||||
|
visual.simulate_keystrokes("alt-r");
|
||||||
|
visual.run_until_parked();
|
||||||
|
assert_eq!(workspace.borrow().active().content_string(), "");
|
||||||
|
|
||||||
|
// Выделен пункт b; стрелка вниз переводит на c, Enter возвращает его id.
|
||||||
|
visual.simulate_keystrokes("down");
|
||||||
|
visual.simulate_keystrokes("enter");
|
||||||
|
visual.run_until_parked();
|
||||||
|
assert!(!select_open(visual));
|
||||||
|
assert_eq!(workspace.borrow().active().content_string(), "c");
|
||||||
|
|
||||||
|
// Esc закрывает список без колбэка.
|
||||||
|
engine.run_file(&open).expect("open_select повторно");
|
||||||
|
notify(visual);
|
||||||
|
assert!(select_open(visual));
|
||||||
|
visual.simulate_keystrokes("escape");
|
||||||
|
visual.run_until_parked();
|
||||||
|
assert!(!select_open(visual));
|
||||||
|
assert_eq!(workspace.borrow().active().content_string(), "c");
|
||||||
|
|
||||||
|
// Клик вне окна тоже закрывает список без колбэка.
|
||||||
|
engine.run_file(&open).expect("open_select в третий раз");
|
||||||
|
notify(visual);
|
||||||
|
assert!(select_open(visual));
|
||||||
|
visual.simulate_click(point(px(2.), px(2.)), Modifiers::default());
|
||||||
|
visual.run_until_parked();
|
||||||
|
assert!(!select_open(visual));
|
||||||
|
assert_eq!(workspace.borrow().active().content_string(), "c");
|
||||||
|
|
||||||
|
// Без selected Enter выбирает первый пункт.
|
||||||
|
engine
|
||||||
|
.run_file(&open_first)
|
||||||
|
.expect("open_select без selected");
|
||||||
|
notify(visual);
|
||||||
|
assert!(select_open(visual));
|
||||||
|
visual.simulate_keystrokes("enter");
|
||||||
|
visual.run_until_parked();
|
||||||
|
assert!(!select_open(visual));
|
||||||
|
assert_eq!(workspace.borrow().active().content_string(), "first");
|
||||||
|
|
||||||
|
drop(engine);
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
}
|
||||||
|
|
||||||
#[gpui_kit::test]
|
#[gpui_kit::test]
|
||||||
fn arrows_move_completion_selection_without_moving_cursor(cx: &mut TestAppContext) {
|
fn arrows_move_completion_selection_without_moving_cursor(cx: &mut TestAppContext) {
|
||||||
cx.update(gpui_kit::init);
|
cx.update(gpui_kit::init);
|
||||||
|
|||||||
Reference in New Issue
Block a user