34 KiB
Lua API
Приложение выполняет init.lua при запуске и предоставляет сценариям объект
app для управления окном, панелями и глобальными привязками клавиш.
Вкладки, текст, gutter, подсветка, сохранение и LSP-серверы живут в app.editor,
а блоки панелей (деревья) — в своих объектах.
Сам редактор не содержит демонстрационных документов: при запуске открыта
одна пустая вкладка untitled-1.txt. Всё остальное — файлы, деревья, серверы —
настраивается сценарием.
Запуск и каталог сценариев
Каталог поиска init.lua задаётся константой SCRIPT_DIR в src/constants.rs
(по умолчанию — текущий каталог). Его можно переопределить аргументом командной строки:
qode --config /path/to/project main.go
cargo run -- --config ./config
cargo run -- main.go
- В каталоге
--configдолжны лежатьinit.luaи подключаемые им модули. - Первый позиционный аргумент открывается после выполнения
init.luaчерезapp.editor:open(path)— какvim main.goилиnano main.go. --configменяет только каталог сценариев. Рабочий каталог процесса, а вместе с ним путиapp.editor:open,app.editor:saveиrootу LSP не меняются.
Объект app
Объект app управляет окном и панелями. Всё, что относится к самому
редактору — вкладки, текст, gutter, сохранение, LSP и привязки клавиш
редактора, — живёт в app.editor:
app.editor:open("src/main.rs")
app.editor:save()
app.editor:map_key("n", "<C-s>", "save")
Номера вкладок во всех методах нумеруются с нуля. Массив, который возвращает
app.editor:tabs(), остаётся обычной Lua-таблицей с индексами от 1, поэтому поле
index — единственный надёжный способ сослаться на вкладку.
Вкладки
| Метод | Описание |
|---|---|
app.editor:tabs() |
Список вкладок: {{ index = 0, title = "main.rs", icon = "file" }, ...} |
app.editor:active_tab() |
Индекс активной вкладки |
app.editor:set_active_tab(index) |
Сделать вкладку активной; true, если вкладка существует |
app.editor:next_tab() |
Переключить на следующую вкладку, вернуть индекс |
app.editor:previous_tab() |
Переключить на предыдущую вкладку, вернуть индекс |
app.editor:open_tab() |
Открыть пустую вкладку, вернуть индекс |
app.editor:open(path, position) |
Открыть файл с диска, вернуть индекс вкладки. Если файла нет — открывается пустой буфер с таким именем. Без второго аргумента фокус не меняется; {} — перевести фокус в редактор, оставив курсор; {line, column} — ещё и поставить курсор (с нуля) |
app.editor:close_tab(index) |
Закрыть вкладку; true, если вкладка закрыта |
app.editor:set_tab_icon(index, name) |
Назначить иконку вкладке; false, если вкладки нет или иконка неизвестна |
app.editor:clear_tab_icon(index) |
Вернуть иконку по умолчанию (file) |
app.editor:tab_icon(index) |
Имя иконки вкладки или nil, если вкладки нет |
app.editor:open("src/main.rs") -- открыть, не трогая фокус
app.editor:open("src/main.rs", {}) -- фокус в редактор, курсор на месте
app.editor:open("src/main.rs", {4, 2}) -- фокус в редактор, курсор на строку 4, столбец 2
Строка и столбец нумеруются с нуля, как в диагностике LSP; столбец считается в UTF-16.
Иконки вкладок
Слева от названия вкладки показывается иконка; по умолчанию это file.
Имя — название иконки Lucide, регистр не важен.
Например:
app.editor:set_tab_icon(0, "file-code")
app.editor:set_tab_icon(1, "book-open")
app.editor:clear_tab_icon(1) -- снова "file"
Подчёркивания и пробелы считаются дефисом, а icons/ и .svg можно
не писать: file_code, file-code и icons/file-code.svg равнозначны.
Неизвестное имя не назначается — метод вернёт false.
Иконки на полях редактора (gutter)
Слева от редактора есть узкая колонка с иконками. Методы действуют на активную вкладку, а номера строк нумеруются с нуля, как в LSP. На одной строке живёт одна иконка; повторный вызов заменяет прежнюю.
| Метод | Описание |
|---|---|
app.editor:set_gutter_icon(line, icon, color) |
Поставить иконку на строку; color необязателен (#RGB, #RGBA, #RRGGBB, #RRGGBBAA). Без цвета иконка рисуется цветом темы. false — иконка или цвет неизвестны |
app.editor:gutter_icon(line) |
Имя иконки на строке или nil |
app.editor:gutter_color(line) |
Цвет иконки или nil, если цвет не задан |
app.editor:clear_gutter_icon(line) |
Убрать иконку со строки; false, если её не было |
app.editor:clear_gutter() |
Убрать все иконки активной вкладки |
app.editor:set_gutter_icon(4, "circle", "#e5484d") -- красная точка на 5-й строке
app.editor:set_gutter_icon(9, "play") -- цвет темы
app.editor:clear_gutter_icon(9)
Иконки привязаны к номерам строк и автоматически не сдвигаются при правке текста: сценарий сам решает, когда их переставить. При прокрутке колонка двигается вместе с редактором.
Текст
| Метод | Описание |
|---|---|
app.editor:active_text() |
Текст активной вкладки |
app.editor:set_active_text(text) |
Заменить текст активной вкладки |
Подсветка синтаксиса
Подсветка по умолчанию выключена. Поддерживаемые языки: rust, go, js, json,
python.
| Метод | Описание |
|---|---|
app.editor:set_language(index, "rust") |
Включить подсветку для вкладки; false, если вкладки нет или язык не поддерживается |
app.editor:language(index) |
Язык подсветки вкладки или nil |
app.editor:clear_language(index) |
Выключить подсветку для вкладки |
app.editor:set_language(0, "rust")
Сохранение
| Метод | Описание |
|---|---|
app.editor:save() |
Сохранить активную вкладку в её файл |
Файловая система
| Метод | Описание |
|---|---|
app:list_dir(path) |
Содержимое каталога: {{ id = "src/main.rs", name = "main.rs", is_dir = false }, ...} |
Путь отсчитывается от текущего рабочего каталога процесса. Пустая строка или
"." означают сам текущий каталог. Поле id — путь элемента относительно
текущего каталога: его можно передать в app.editor:open(id) и использовать как
идентификатор узла дерева. Элементы уже отсортированы по имени без учёта
регистра.
for _, entry in ipairs(app:list_dir("src")) do
print(entry.name, entry.is_dir)
end
Привязка клавиш
Привязки живут у трёх видов объектов: у приложения (app), у редактора
(app.editor) и у каждого блока панели (tree). Встроенные сочетания можно
заменить или удалить: обработчиком принимается Lua-функция, имя встроенной
команды или nil.
app:map_key(modes, keys, handler) -- глобальные
app.editor:map_key(modes, keys, handler) -- только когда фокус в редакторе
app:map_completion_key(keys, handler) -- список автодополнения
Слой автодополнения режимами не управляется: map_completion_key принимает
только клавишу и обработчик.
Первым аргументом map_key идёт строка режимов. Порядок обработки всегда
такой: сначала глобальные привязки, затем — если фокус в редакторе — привязки
редактора, а если фокус на блоке панели — привязки этого блока. Привязки
неактивного объекта не срабатывают.
Запись клавиши в духе nvim:
| Запись | Значение |
|---|---|
"a" |
нажатие A |
"1" |
нажатие 1 |
"!a" |
отпускание A |
"<S-c>" |
C при зажатом Shift |
"<C-z>" |
Z при зажатом Ctrl |
"<A-!r>" |
отпускание R при зажатом Alt |
"<D-s>" |
S при зажатом Cmd на macOS или Super на Linux |
Клавиша сравнивается с именем клавиши от GPUI. Caps Lock не важен. На Linux
и Windows буквы берутся по аппаратному keycode, поэтому <C-q> работает
и на русской раскладке.
Режимы
У каждого объекта своя текущая режимная метка: один символ (n, i, v, r
или любой другой) либо - — режим по умолчанию. Режимы динамические: заранее
создавать их не нужно, достаточно переключить метку.
Значение modes |
Когда срабатывает |
|---|---|
"" |
во всех режимах |
"-" |
только в режиме по умолчанию |
"niv" |
в режимах n, i, v |
"-iv" |
в режиме по умолчанию, а также i и v |
Текущий режим меняется методами set_mode(mode) и читается методом mode().
Режимы у приложения, редактора и каждого блока независимы.
app:set_mode("n")
app.editor:set_mode("i")
app:map_key("n", "<C-n>", "next_tab") -- только в режиме n
app.editor:map_key("i", "<C-s>", "save") -- только в режиме i редактора
app.editor:map_key("", "<C-space>", function() end) -- в любом режиме
Встроенные команды:
| Имя | Действие |
|---|---|
"save" |
Сохранить активную вкладку |
"next_tab" |
Следующая вкладка |
"previous_tab" |
Предыдущая вкладка |
"open_tab" |
Новая пустая вкладка |
"close_tab" |
Закрыть активную вкладку |
"toggle_left_panel" |
Переключить левую панель |
"toggle_right_panel" |
Переключить правую панель |
"toggle_bottom_panel" |
Переключить нижнюю панель |
Встроенные команды списка автодополнения:
| Имя | Действие |
|---|---|
"completion_trigger" |
Открыть список |
"completion_next" / "completion_previous" |
Сдвинуть выделение |
"completion_page_down" / "completion_page_up" |
Пролистать список |
"completion_accept" |
Вставить выбранный элемент |
"completion_cancel" |
Закрыть список |
"completion_dismiss" |
Закрыть список и передать клавишу редактору |
При запуске уже назначены:
Ctrl/Cmd+S→saveво всех режимах;Ctrl/Cmd+Space→completion_trigger;Enter/Tab,Esc,↑/↓,PageUp/PageDown,←/→,Home/End→ команды слоя автодополнения.
-- Переопределить сохранение своей функцией во всех режимах.
app:map_key("", "<C-s>", function() app.editor:save() end)
-- Убрать встроенную привязку.
app:map_key("", "<C-s>", nil)
-- Назначить встроенную команду на другую клавишу.
app:map_key("", "<C-n>", "next_tab")
app:map_key("", "<C-b>", "toggle_left_panel")
-- Навигация по списку автодополнения на Ctrl+N / Ctrl+P.
app:map_completion_key("<C-n>", "completion_next")
app:map_completion_key("<C-p>", "completion_previous")
-- Привязка, которая срабатывает только при фокусе в редакторе.
app.editor:map_key("", "<A-r>", function()
app.editor:set_active_text("editor mode")
end)
-- Привязка блока дерева: работает, когда фокус на этом дереве.
tree:map_key("v", "<A-f>", function() print("tree v") end)
Пока список открыт, его слой проверяется раньше действий редактора. Сочетания
с модификаторами и горизонтальные стрелки закрывают список, даже если для них
нет своей привязки, — так Shift+↑ продолжает выделять текст.
Окно
| Метод | Описание |
|---|---|
app:close() |
Закрыть окно |
app:toggle_maximize() |
Развернуть окно / вернуть прежний размер |
app:set_maximized(true/false) |
Развернуть (true) или восстановить (false) окно |
app:minimize() |
Свернуть окно |
app:is_maximized() |
Развёрнуто ли окно прямо сейчас |
LSP-серверы
Запуск серверов целиком настраивается Lua-сценарием.
app.editor:run_lsp({
name = "gopls", -- условное имя для stop/restart
cmd = "gopls", -- путь к бинарнику или имя в PATH
args = { "serve" }, -- аргументы процесса
env = { GOFLAGS = "-mod=mod" }, -- переменные окружения
languages = { "go" }, -- какие languageId обслуживает
watcher = "**/*.go", -- шаблон файлового вотчера
root = ".", -- корень workspace (по умолчанию cwd)
initialization_options = {}, -- initializationOptions сервера
})
| Метод | Описание |
|---|---|
app.editor:run_lsp(config) |
Запустить сервер по описанию |
app.editor:lsp_servers() |
Имена запущенных серверов: { "gopls", ... } |
app.editor:stop_lsp("gopls") |
Остановить сервер; true, если сервер был запущен |
app.editor:restart_lsp("gopls") |
Перезапустить сервер с прежними настройками |
app.editor:on_lsp_diagnostics(delay_ms, fn) |
Обработчик диагностики всех серверов; nil вместо fn удаляет его |
cmd обязателен. Если root не задан или относителен, он отсчитывается от
текущего рабочего каталога процесса. Поле watcher принимает строку, таблицу
или boolean:
watcher = "**/*.go"
watcher = { pattern = "**/*.go", ignore = { "vendor/**" } }
watcher = true -- все файлы, игнорируя только стандартные каталоги сборки
Автодополнение включается само при вводе идентификатора; Ctrl/Cmd+Space
открывает список вручную. Поведение клавиш описано в разделе «Привязка
клавиш» и может быть переопределено через app:map_completion_key.
Документация выбранного элемента показывается второй всплывающей панелью
справа от списка.
Диагностику, которую сервер присылает после правок, редактор подчёркивает волнистой линией: ошибки — красной, предупреждения и остальные сообщения — жёлтой. Подчёркивание работает и при выключенной подсветке синтаксиса.
Обработчик диагностики
app.editor:on_lsp_diagnostics(delay_ms, fn) регистрирует один обработчик на все
LSP-серверы. delay_ms — пауза тишины: если сообщения приходят чаще, вызовы
пропускаются, а обработчик получает последний снимок после того, как
обновления прекратились. Если зарегистрировать обработчик, когда диагностика
уже накоплена, он сработает сразу после этой паузы.
app.editor:on_lsp_diagnostics(1000, function(servers)
for _, entry in ipairs(servers) do
print(entry.server, entry.path, #entry.diagnostics)
end
end)
servers — массив записей по одной на пару «документ, сервер»:
{
server = "gopls", -- имя сервера, приславшего диагностику
uri = "file:///project/main.go", -- URI документа
path = "/project/main.go", -- путь файла; nil для не-file URI
diagnostics = {
{
line = 0, character = 2, -- начало (с нуля, как в LSP)
end_line = 0, end_character = 8,
severity = 1, -- 1 ошибка, 2 предупреждение, 3 info, 4 hint
severity_name = "error", -- error | warning | information | hint
message = "undefined: foo",
source = "gopls",
code = "UndeclaredName",
tags = {}, -- unnecessary, deprecated
},
},
}
Позиции нумеруются с нуля; для отображения прибавляйте единицу к line и
character. Остановка сервера удаляет его сообщения из следующих снимков.
Примеры запуска:
app.editor:run_lsp({ name = "gopls", cmd = "gopls", args = { "serve" },
languages = { "go" }, watcher = "**/*.go" })
app.editor:run_lsp({ name = "rust-analyzer", cmd = "rust-analyzer",
languages = { "rust" }, watcher = "**/*.rs" })
Пример из init.lua с защитой от ошибки запуска:
local gopls_ok, gopls_err = pcall(function()
app.editor:run_lsp({
name = "gopls",
cmd = "gopls",
args = { "serve" },
root = ".",
languages = { "go" },
watcher = { pattern = "**/*.go", ignore = { "vendor/**" } },
initialization_options = {
usePlaceholders = true,
completeUnimported = true,
},
})
end)
if not gopls_ok then
print("[lsp] gopls не запущен: " .. tostring(gopls_err))
end
--configменяет только каталогinit.lua, поэтомуroot = "."указывает на текущий каталог запуска — проект должен быть текущим рабочим каталогом.
Верхняя панель редактора
Полоса между вкладками и редактором. У панели две области: элементы левой выстраиваются от левого края вправо, элементы правой — от правого края влево.
| Метод | Описание |
|---|---|
app.toolbar:add_label(text, icon, background, id, side) |
Добавить лейбл |
app.toolbar:add_button(text, icon, background, id, side, fn) |
Добавить кнопку |
app.toolbar:hide(id) |
Скрыть элемент; false, если элемента с таким id нет |
app.toolbar:show(id) |
Снова показать скрытый элемент |
app.toolbar:clear() |
Удалить все элементы панели |
icon— имя иконки Lucide (play,settings,file, ...) илиnil.background—#RGB,#RGBA,#RRGGBB,#RRGGBBAAилиnil(прозрачный фон). Цвет текста подбирается автоматически: тёмный на светлом фоне, светлый на тёмном.id— идентификатор элемента дляhideиshow. Повторное добавление с тем жеidзаменяет элемент, а не создаёт дубликат.side—"left"или"right".fn— обработчик нажатия; вызывается без аргументов.
app.toolbar:add_label("Готово", nil, nil, "status", "left")
app.toolbar:add_label("UTF-8", nil, "#3f3f46", "encoding", "right")
app.toolbar:add_button("Запустить", "play", nil, "run", "right", function()
print("Запуск")
end)
app.toolbar:hide("run")
app.toolbar:show("run")
Панели окна
По умолчанию панели закрыты, размер меняется перетаскиванием.
Для app.left_panel, app.right_panel и app.bottom_panel доступны одинаковые методы:
| Метод | Описание |
|---|---|
app.left_panel:open() |
Открыть панель |
app.left_panel:close() |
Закрыть панель |
app.left_panel:toggle() |
Переключить панель; вернуть true, если панель открыта |
app.left_panel:is_open() |
Открыта ли панель |
app.left_panel:open()
app.right_panel:toggle()
app.bottom_panel:close()
Если у правой панели нет блоков, она показывает состояние LSP-серверов. Блоки нижней панели показываются вкладками, как вкладки редактора; боковые панели рисуют все свои блоки подряд и заголовки не показывают.
Блоки панелей
Панель может содержать блоки. Блок создаётся через API панели и живёт вместе с ней; сейчас поддерживается один вид блока — абстрактное дерево.
local tree = app.left_panel:add_tree()
local diagnostics = app.bottom_panel:add_tree("Диагностика")
Необязательный аргумент add_tree — заголовок вкладки. В нижней панели он
показывается на вкладке; в боковых панелях заголовок сохраняется, но не
рисуется. Без заголовка используется «Дерево». Новый блок становится
активной вкладкой нижней панели.
Редактор отвечает только за отрисовку дерева. Что означает каждый узел,
решает сценарий: это могут быть файлы проекта, задачи, настройки — что угодно.
Файловое дерево проекта в init.lua собирается из app:list_dir, но сам
редактор о файловой системе в блоке ничего не знает.
| Метод | Описание |
|---|---|
tree:append(nodes, parent) |
Добавить узлы к parent; parent = "" — корень |
tree:clear() |
Удалить все узлы и сбросить выбор |
tree:remove(id) |
Удалить узел и всех потомков рекурсивно; false, если узла нет |
tree:on_toggle(fn) |
Обработчик раскрытия: fn(expanded, id, loaded) |
tree:on_click(fn) |
Обработчик клика по конечной ноде: fn(id) |
tree:selected() |
Идентификатор выбранной строки или nil |
tree:focus(id) |
Раскрыть всех родителей, выбрать узел и передать фокус дереву |
tree:map_key(modes, keys, handler) |
Привязка, которая действует, когда фокус на этом блоке |
tree:set_mode(mode) |
Переключить режим привязок блока |
tree:mode() |
Текущий режим привязок блока |
Узел — таблица:
{
id = "src/main.rs", -- строковый идентификатор (путь файла)
label = "main.rs", -- отображаемый текст
icon = "file-code", -- имя иконки Lucide (необязательно)
expandable = false, -- раскрываемая ли нода (папка)
expanded = false, -- раскрыта ли она сейчас
}
append добавляет узлы, а не заменяет дерево. Повторная передача того же id
обновляет описание узла, но не создаёт копию. clear удаляет всё дерево;
обработчики on_toggle и on_click продолжают действовать, поэтому дерево
удобно строить заново на каждое обновление данных.
Дети каждого уровня сортируются по label без учёта регистра. Сначала идут
раскрываемые узлы (expandable = true), затем конечные; внутри каждой группы —
по алфавиту.
В обработчик on_toggle приходит loaded = true, если дочерние узлы уже
добавляли в эту ноду; при первом раскрытии — false.
Клик по строке выбирает её. Выбранную строку можно перемещать стрелками
↑/↓; ↑/↓ двигают выбор, → раскрывает папку или переходит к первому
ребёнку, ← сворачивает папку или выбирает родителя, Enter раскрывает папку
или вызывает on_click для конечной ноды. Выбор сохраняется, пока панель
закрыта; при повторном открытии панели фокус возвращается на выбранную строку.
Привязки блока (tree:map_key) обрабатываются дополнительно к глобальным и
только пока фокус находится на этом дереве; у каждого блока свой режим.
Пример отложенной загрузки дерева:
local tree = app.left_panel:add_tree()
local function load_dir(path)
local nodes = {}
for _, entry in ipairs(app:list_dir(path)) do
nodes[#nodes + 1] = {
id = entry.id,
label = entry.name,
icon = entry.is_dir and "folder" or "file",
expandable = entry.is_dir,
expanded = false,
}
end
tree:append(nodes, path)
end
tree:on_toggle(function(expanded, id, loaded)
if expanded and not loaded then
load_dir(id)
end
end)
tree:on_click(function(id)
-- Фокус в редактор, курсор остаётся на прежнем месте.
app.editor:open(id, {})
end)
app.left_panel:open()
load_dir("")
-- При открытии панели вернуть фокус на последнюю выбранную строку.
app:map_key("", "<C-b>", function()
if app.left_panel:toggle() then
local selected = tree:selected()
if selected then
tree:focus(selected)
end
end
end)
Пример дерева диагностики в нижней панели. На первом уровне — файлы с полным путём, на втором — сообщения с иконкой уровня. Сообщения с одинаковыми началом и концом диапазона объединяются в одну запись; клик открывает файл на нужной позиции.
local diagnostics = app.bottom_panel:add_tree("Диагностика")
local icons = {
error = "circle-x",
warning = "triangle-alert",
information = "circle-alert",
hint = "lightbulb",
}
app.editor:on_lsp_diagnostics(1000, function(servers)
diagnostics:clear()
local files = {}
local order = {}
for _, entry in ipairs(servers) do
local path = entry.path or entry.uri
if not files[path] then
files[path] = {}
order[#order + 1] = path
end
for _, item in ipairs(entry.diagnostics) do
table.insert(files[path], item)
end
end
for _, path in ipairs(order) do
local groups = {}
local keys = {}
for _, item in ipairs(files[path]) do
local key = string.format("%d:%d:%d:%d", item.line, item.character,
item.end_line, item.end_character)
if not groups[key] then
groups[key] = { item = item, messages = {}, seen = {} }
keys[#keys + 1] = key
end
local group = groups[key]
if not group.seen[item.message] then
group.seen[item.message] = true
group.messages[#group.messages + 1] = item.message
end
end
diagnostics:append({
{ id = path, label = path, icon = "file",
expandable = true, expanded = true },
}, "")
local errors = {}
for index, key in ipairs(keys) do
local group = groups[key]
local item = group.item
local severity = item.severity_name or "error"
errors[#errors + 1] = {
id = string.format("diag:%s:%d:%d:%d", path, item.line,
item.character, index),
label = string.format("%d:%d %s", item.line + 1,
item.character + 1,
table.concat(group.messages, "; ")),
icon = icons[severity] or "circle-alert",
}
end
diagnostics:append(errors, path)
end
end)
diagnostics:on_click(function(id)
local path, line, character = id:match("^diag:(.*):(%d+):(%d+):%d+$")
if path then
app.editor:open(path, { tonumber(line), tonumber(character) })
else
app.editor:open(id, {})
end
end)
app.bottom_panel:open()
Подключение других Lua-файлов
Файлы подключаются из каталога сценариев:
| Способ | Описание |
|---|---|
include("tools.lua") |
Выполнить файл сразу |
require("tools") |
Выполнить один раз (как обычный Lua-модуль) |
Добавление новых методов
Методы добавляются в src/api.rs: всё, что есть в impl AppApi или
impl EditorApi, помеченном #[mlua::userdata_impl], автоматически доступно
из Lua как app:method(...) или app.editor:method(...).
Чтобы не экспортировать метод, его помечают #[lua(skip)].
Новый вид блока панели должен добавить методы в свой *Api и отдавать свою
KeyMap, как это делает TreeApi.
Полный пример init.lua
print("[qode] init.lua выполнен")
app:map_key("", "<C-q>", function() app:close() end)
app:map_key("", "<C-e>", "toggle_left_panel")
app:map_key("", "<C-t>", "toggle_bottom_panel")
app.editor:run_lsp({
name = "rust-analyzer",
cmd = "rust-analyzer",
languages = { "rust" },
watcher = "**/*.rs",
})