This commit is contained in:
2026-10-31 15:58:33 +03:00
parent 838627fb33
commit cf1505eca5
7 changed files with 846 additions and 189 deletions
+30 -9
View File
@@ -64,21 +64,42 @@ api.editor:clear_tab_icon(1) -- снова "file"
## Иконки на полях редактора (gutter)
Слева от редактора есть узкая колонка с иконками. Методы действуют на
активную вкладку, а номера строк нумеруются с нуля, как в LSP. На одной строке
живёт одна иконка; повторный вызов заменяет прежнюю.
активную вкладку, а номера строк нумеруются с нуля, как в LSP.
У каждой иконки есть тип (`kind`), который задаёт сценарий: например,
`diagnostics` или `breakpoint`. На строке может жить по одной иконке каждого
типа, поэтому диагностика и другие сценарии не затирают иконки друг друга.
Повторная установка иконки того же типа заменяет её. Колонка расширяется под
самую насыщенную строку, чтобы иконки разных типов не накладывались.
| Метод | Описание |
| --- | --- |
| `api.editor:set_gutter_icon(line, icon, color)` | Поставить иконку на строку; `color` необязателен (`#RGB`, `#RGBA`, `#RRGGBB`, `#RRGGBBAA`). Без цвета иконка рисуется цветом темы. `false` — иконка или цвет неизвестны |
| `api.editor:gutter_icon(line)` | Имя иконки на строке или `nil` |
| `api.editor:gutter_color(line)` | Цвет иконки или `nil`, если цвет не задан |
| `api.editor:clear_gutter_icon(line)` | Убрать иконку со строки; `false`, если её не было |
| `api.editor:set_gutter_icon(line, icon, kind[, options])` | Поставить иконку типа `kind` на строку. `options = { color = "#RRGGBB", hover_color = "#RRGGBB" }` задаёт цвет и цвет при наведении; оба необязательны. Без цвета иконка рисуется цветом темы. `false` — иконка или цвет неизвестны либо тип пуст |
| `api.editor:gutter_icon(line, kind)` | Имя иконки этого типа на строке или `nil` |
| `api.editor:gutter_color(line, kind)` | Цвет иконки или `nil`, если цвет не задан |
| `api.editor:gutter_hover_color(line, kind)` | Цвет иконки при наведении или `nil` |
| `api.editor:clear_gutter_icon(line, kind)` | Убрать иконку этого типа со строки; `false`, если её не было |
| `api.editor:clear_gutter_line(line)` | Убрать все иконки строки |
| `api.editor:clear_gutter_kind(kind)` | Убрать иконки типа во всех строках активной вкладки; возвращает число удалённых |
| `api.editor:clear_gutter()` | Убрать все иконки активной вкладки |
| `api.editor:on_gutter_click(kind, callback)` | Назначить обработчик клика по иконке типа `kind`; `callback(line, icon, kind)`. `nil` вместо функции снимает обработчик |
```lua
api.editor:set_gutter_icon(4, "circle", "#e5484d") -- красная точка на 5-й строке
api.editor:set_gutter_icon(9, "play") -- цвет темы
api.editor:clear_gutter_icon(9)
api.editor:set_gutter_icon(4, "circle", "diagnostics", { color = "#e5484d" })
api.editor:set_gutter_icon(4, "play", "breakpoint") -- вторая иконка на той же строке
api.editor:set_gutter_icon(9, "play", "breakpoint", {
color = "#888888",
hover_color = "#ffffff",
})
api.editor:on_gutter_click("breakpoint", function(line, icon, kind)
print("клик", line, icon, kind)
end)
api.editor:clear_gutter_icon(4, "diagnostics") -- только один тип строки
api.editor:clear_gutter_kind("diagnostics") -- перерисовать только диагностику
api.editor:clear_gutter_line(4) -- все иконки строки
api.editor:clear_gutter() -- все иконки вкладки
```
Иконки привязаны к номерам строк и автоматически не сдвигаются при правке
+24 -32
View File
@@ -97,15 +97,24 @@ print("[lua] init.lua выполнен")
-- -- false — нет вкладки или иконки
-- api.editor:clear_tab_icon(i) -- вернуть иконку по умолчанию (file)
-- api.editor:tab_icon(i) -- имя иконки вкладки или nil
-- api.editor:set_gutter_icon(line, name[, color])
-- api.editor:set_gutter_icon(line, name, kind[, options])
-- -- иконка на поле слева от редактора
-- -- (активная вкладка, строка с нуля);
-- -- color — #RGB/#RGBA/#RRGGBB/#RRGGBBAA;
-- -- kind — тип иконки, на строке может быть
-- -- по одной иконке каждого типа;
-- -- options = { color = "#RRGGBB",
-- -- hover_color = "#RRGGBB" };
-- -- false — неизвестная иконка или цвет
-- api.editor:gutter_icon(line) -- имя иконки gutter или nil
-- api.editor:gutter_color(line) -- цвет иконки gutter или nil
-- api.editor:clear_gutter_icon(line) -- убрать иконку со строки
-- api.editor:gutter_icon(line, kind) -- имя иконки gutter или nil
-- api.editor:gutter_color(line, kind) -- цвет иконки gutter или nil
-- api.editor:gutter_hover_color(line, kind) -- цвет при наведении или nil
-- api.editor:clear_gutter_icon(line, kind) -- убрать иконку этого типа
-- api.editor:clear_gutter_line(line) -- убрать все иконки строки
-- api.editor:clear_gutter_kind(kind) -- убрать иконки типа во всей вкладке
-- api.editor:clear_gutter() -- убрать все иконки активной вкладки
-- api.editor:on_gutter_click(kind, callback)
-- -- клик по иконке типа; callback(line, icon, kind),
-- -- nil снимает обработчик
-- api.editor:active_text() -- текст активной вкладки
-- api.editor:set_active_text(text) -- заменить текст активной вкладки
-- api.editor:set_language(i, "rust") -- включить подсветку
@@ -498,10 +507,9 @@ local function diagnostic_rank(diagnostic)
or 1
end
-- Строки активной вкладки, занятые иконками диагностики. Gutter действует
-- только на активную вкладку, поэтому запоминаем поставленные строки, чтобы
-- при следующем обновлении снять устаревшие иконки и не трогать остальные.
local diagnostic_gutter = {}
-- Строки активной вкладки, занятые иконками диагностики, больше не нужно
-- запоминать: иконки имеют тип "diagnostics", и перед перерисовкой сценарий
-- снимает только их, не трогая иконки других типов.
local function active_tab_title()
local active = api.editor:active_tab()
@@ -531,14 +539,11 @@ end
api.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
api.editor:clear_gutter_icon(line)
end
diagnostic_gutter[title] = nil
end
api.editor:clear_gutter_kind("diagnostics")
-- Сначала собираем диагностику по файлам: одинаковые проблемы от разных
-- серверов попадают в одну запись.
@@ -600,15 +605,13 @@ api.editor:on_lsp_diagnostics(100, function(servers)
-- Подсветка и 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
api.editor:set_gutter_icon(group.line, icon)
gutter_lines[group.line] = true
api.editor:set_gutter_icon(group.line, icon, "diagnostics")
end
errors[#errors + 1] = {
-- Префикс отделяет позицию от id файла, даже если в пути есть ':'.
@@ -629,17 +632,6 @@ api.editor:on_lsp_diagnostics(100, function(servers)
}
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,
@@ -672,5 +664,5 @@ api.panels.toolbar:add_button("Запустить", "play", nil, "run", "right",
print("[toolbar] кнопка «Запустить» нажата")
end)
api.editor:set_gutter_icon(2, "play", "#efefe6")
api.editor:set_gutter_icon(90, "play")
api.editor:set_gutter_icon(2, "play", "demo", { color = "#efefe6" })
api.editor:set_gutter_icon(90, "play", "demo")
+198 -28
View File
@@ -27,6 +27,7 @@ use gpui_kit::{Hsla, Rgba};
use crate::constants::{BOTTOM_PANEL_HEIGHT, LEFT_PANEL_WIDTH, RIGHT_PANEL_WIDTH};
use crate::editor::{FocusAfterSync, Language, Workspace};
use crate::gutter::{GutterClickHandlers, new_gutter_click_handlers};
use crate::icons;
use crate::keymap::{AppCommand, CompletionCommand, KeyBinding, KeyContext, KeyHandler, KeyMap};
use crate::lsp::{
@@ -101,6 +102,18 @@ pub struct DirEntryInfo {
pub is_dir: bool,
}
/// Настройки иконки gutter.
///
/// Цвета — необязательные строки `#RGB`, `#RGBA`, `#RRGGBB` или `#RRGGBBAA`.
/// Пустая строка и `transparent` означают «цвет не задан».
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct GutterIconOptions {
/// Цвет иконки в обычном состоянии.
pub color: Option<String>,
/// Цвет иконки при наведении.
pub hover_color: Option<String>,
}
/// Зарегистрированный Lua-обработчик диагностики.
///
/// `delay` — пауза тишины, после которой обработчик вызывается. Если сервер
@@ -152,6 +165,12 @@ pub struct AppApi {
lsp: Arc<LspManager>,
#[lua(skip)]
diagnostics_handler: Rc<RefCell<Option<DiagnosticsHandler>>>,
/// Обработчики клика по типам иконок gutter.
///
/// Живут вне документов: тип иконки задаёт сценарий, а обработчик общий
/// для всех вкладок.
#[lua(skip)]
gutter_clicks: GutterClickHandlers,
/// Запрос из Lua на открытие плавающего поля ввода.
#[lua(skip)]
prompt_request: PromptRequestHandle,
@@ -173,6 +192,8 @@ pub struct EditorApi {
lsp: Arc<LspManager>,
#[lua(skip)]
diagnostics_handler: Rc<RefCell<Option<DiagnosticsHandler>>>,
#[lua(skip)]
gutter_clicks: GutterClickHandlers,
}
impl EditorApi {
@@ -181,12 +202,14 @@ impl EditorApi {
keymap: Rc<RefCell<KeyMap>>,
lsp: Arc<LspManager>,
diagnostics_handler: Rc<RefCell<Option<DiagnosticsHandler>>>,
gutter_clicks: GutterClickHandlers,
) -> Self {
Self {
workspace,
keymap,
lsp,
diagnostics_handler,
gutter_clicks,
}
}
}
@@ -941,6 +964,7 @@ impl AppApi {
toolbar: Rc::new(RefCell::new(ToolbarState::new())),
lsp: Arc::new(LspManager::new()),
diagnostics_handler: Rc::new(RefCell::new(None)),
gutter_clicks: new_gutter_click_handlers(),
prompt_request: Rc::new(RefCell::new(None)),
};
INSTANCE.with(|slot| *slot.borrow_mut() = Some(api.clone()));
@@ -1002,6 +1026,12 @@ impl AppApi {
self.prompt_request.clone()
}
/// Обработчики клика по иконкам gutter (для вида).
#[lua(skip)]
pub fn gutter_click_handle(&self) -> GutterClickHandlers {
self.gutter_clicks.clone()
}
/// API редактора.
///
/// В Lua доступно как `api.editor`: `api.editor:open_tab()`,
@@ -1014,6 +1044,7 @@ impl AppApi {
self.editor_keymap.clone(),
self.lsp.clone(),
self.diagnostics_handler.clone(),
self.gutter_clicks.clone(),
)
}
@@ -1368,80 +1399,193 @@ impl EditorApi {
/// Ставит иконку на поле редактора (gutter) активной вкладки.
///
/// `line` — номер строки с нуля; `icon` — имя иконки Lucide, как у
/// [`EditorApi::set_tab_icon`]; `color` — необязательный цвет иконки
/// (`#RGB`, `#RGBA`, `#RRGGBB` или `#RRGGBBAA`). `color = None` рисует
/// иконку цветом текста темы.
/// [`EditorApi::set_tab_icon`]; `kind` — тип иконки, заданный сценарием
/// (например, `diagnostics` или `breakpoint`). Иконки разных типов могут
/// стоять на одной строке, а повторный вызов с тем же типом заменяет её.
///
/// На одной строке живёт одна иконка: повторный вызов заменяет прежнюю.
/// Возвращает `false`, если иконка или цвет неизвестны; тогда состояние
/// не меняется.
/// `options.color` и `options.hover_color` — необязательные цвета
/// (`#RGB`, `#RGBA`, `#RRGGBB` или `#RRGGBBAA`). Без цвета иконка
/// рисуется цветом текста темы, а без `hover_color` при наведении
/// используется обычный цвет.
///
/// Возвращает `false`, если иконка или цвет неизвестны либо тип пуст;
/// тогда состояние не меняется.
#[lua(skip)]
pub fn set_gutter_icon(&self, line: usize, icon: &str, color: Option<&str>) -> bool {
pub fn set_gutter_icon(
&self,
line: usize,
icon: &str,
kind: &str,
options: GutterIconOptions,
) -> bool {
let Some(name) = icons::canonical_name(icon) else {
return false;
};
let color = match color.map(str::trim) {
None | Some("") => None,
Some(value) if value.eq_ignore_ascii_case("transparent") => None,
Some(value) => match parse_hex_color(value) {
Some(color) => Some(color),
None => return false,
},
let kind = kind.trim();
if kind.is_empty() {
return false;
}
let Some(color) = parse_gutter_color(options.color.as_deref()) else {
return false;
};
let Some(hover_color) = parse_gutter_color(options.hover_color.as_deref()) else {
return false;
};
self.workspace
.borrow_mut()
.active_mut()
.gutter
.set(line, name, color);
.set(line, kind, name, color, hover_color);
true
}
/// Lua-версия [`EditorApi::set_gutter_icon`].
///
/// Опции передаются таблицей `{ color = "#e5484d", hover_color = "#ff8888" }`.
#[lua(infallible, name = "set_gutter_icon")]
fn lua_set_gutter_icon(&self, line: usize, icon: String, color: Option<String>) -> bool {
self.set_gutter_icon(line, &icon, color.as_deref())
fn lua_set_gutter_icon(
&self,
line: usize,
icon: String,
kind: String,
options: Option<mlua::Table>,
) -> bool {
let Some(options) = parse_gutter_options(options) else {
return false;
};
self.set_gutter_icon(line, &icon, &kind, options)
}
/// Имя иконки gutter на строке `line` активной вкладки или `nil`.
#[lua(infallible)]
pub fn gutter_icon(&self, line: usize) -> Option<String> {
/// Имя иконки типа `kind` на строке `line` активной вкладки или `nil`.
#[lua(skip)]
pub fn gutter_icon(&self, line: usize, kind: &str) -> Option<String> {
self.workspace
.borrow()
.active()
.gutter
.get(line)
.get(line, kind)
.map(|marker| marker.icon.clone())
}
/// Цвет иконки gutter (`#RRGGBB` или `#RRGGBBAA`) или `nil`.
/// Lua-версия [`EditorApi::gutter_icon`].
#[lua(infallible, name = "gutter_icon")]
fn lua_gutter_icon(&self, line: usize, kind: String) -> Option<String> {
self.gutter_icon(line, &kind)
}
/// Цвет иконки типа `kind` на строке `line` (`#RRGGBB` или `#RRGGBBAA`)
/// или `nil`.
///
/// `nil` возвращается и когда иконки нет, и когда она рисуется цветом
/// темы.
#[lua(infallible)]
pub fn gutter_color(&self, line: usize) -> Option<String> {
#[lua(skip)]
pub fn gutter_color(&self, line: usize, kind: &str) -> Option<String> {
self.workspace
.borrow()
.active()
.gutter
.get(line)
.get(line, kind)
.and_then(|marker| marker.color)
.map(format_hex_color)
}
/// Убирает иконку gutter со строки `line` активной вкладки.
/// Lua-версия [`EditorApi::gutter_color`].
#[lua(infallible, name = "gutter_color")]
fn lua_gutter_color(&self, line: usize, kind: String) -> Option<String> {
self.gutter_color(line, &kind)
}
/// Цвет иконки типа `kind` на строке `line` при наведении или `nil`.
#[lua(skip)]
pub fn gutter_hover_color(&self, line: usize, kind: &str) -> Option<String> {
self.workspace
.borrow()
.active()
.gutter
.get(line, kind)
.and_then(|marker| marker.hover_color)
.map(format_hex_color)
}
/// Lua-версия [`EditorApi::gutter_hover_color`].
#[lua(infallible, name = "gutter_hover_color")]
fn lua_gutter_hover_color(&self, line: usize, kind: String) -> Option<String> {
self.gutter_hover_color(line, &kind)
}
/// Убирает иконку типа `kind` со строки `line` активной вкладки.
///
/// Возвращает `false`, если иконки на этой строке не было.
/// Возвращает `false`, если такой иконки не было.
#[lua(skip)]
pub fn clear_gutter_icon(&self, line: usize, kind: &str) -> bool {
self.workspace
.borrow_mut()
.active_mut()
.gutter
.remove_kind(line, kind)
}
/// Lua-версия [`EditorApi::clear_gutter_icon`].
#[lua(infallible, name = "clear_gutter_icon")]
fn lua_clear_gutter_icon(&self, line: usize, kind: String) -> bool {
self.clear_gutter_icon(line, &kind)
}
/// Убирает все иконки строки `line` активной вкладки.
///
/// Возвращает `false`, если на этой строке иконок не было.
#[lua(infallible)]
pub fn clear_gutter_icon(&self, line: usize) -> bool {
pub fn clear_gutter_line(&self, line: usize) -> bool {
self.workspace.borrow_mut().active_mut().gutter.remove(line)
}
/// Убирает иконки типа `kind` со всех строк активной вкладки.
///
/// Возвращает число удалённых иконок. Диагностика может перерисовать
/// только свои иконки, не задевая иконки других типов.
#[lua(skip)]
pub fn clear_gutter_kind(&self, kind: &str) -> usize {
self.workspace
.borrow_mut()
.active_mut()
.gutter
.remove_all_kind(kind)
}
/// Lua-версия [`EditorApi::clear_gutter_kind`].
#[lua(infallible, name = "clear_gutter_kind")]
fn lua_clear_gutter_kind(&self, kind: String) -> usize {
self.clear_gutter_kind(&kind)
}
/// Убирает все иконки gutter активной вкладки.
#[lua(infallible)]
pub fn clear_gutter(&self) {
self.workspace.borrow_mut().active_mut().gutter.clear();
}
/// Назначает обработчик клика по иконке типа `kind`.
///
/// Обработчик получает `(line, icon, kind)`, где `line` — номер строки
/// с нуля. `callback = nil` удаляет обработчик этого типа. Обработчики
/// общие для всех вкладок: тип иконки задаёт сценарий.
#[lua(infallible)]
pub fn on_gutter_click(&self, kind: String, callback: Option<mlua::Function>) {
let kind = kind.trim();
if kind.is_empty() {
return;
}
let mut handlers = self.gutter_clicks.borrow_mut();
match callback {
Some(callback) => {
handlers.insert(kind.to_owned(), callback);
}
None => {
handlers.remove(kind);
}
}
}
/// Включает подсветку синтаксиса для вкладки `index`.
///
/// `language` — название языка: `rust`, `go`, `js`, `json`, `python`,
@@ -1894,6 +2038,32 @@ fn parse_toolbar_color(color: Option<String>) -> mlua::Result<Option<Hsla>> {
})
}
/// Переводит необязательный цвет gutter в `Hsla`.
///
/// `None`, пустая строка и `transparent` означают «цвет не задан», а
/// `Some(None)` — именно это состояние. `None` целиком возвращается только
/// для некорректного цвета.
fn parse_gutter_color(color: Option<&str>) -> Option<Option<Hsla>> {
match color.map(str::trim) {
None | Some("") => Some(None),
Some(value) if value.eq_ignore_ascii_case("transparent") => Some(None),
Some(value) => parse_hex_color(value).map(Some),
}
}
/// Разбирает таблицу опций `set_gutter_icon` из Lua.
///
/// `None` — таблица не передана или ключ имеет неверный тип.
fn parse_gutter_options(options: Option<mlua::Table>) -> Option<GutterIconOptions> {
let Some(options) = options else {
return Some(GutterIconOptions::default());
};
Some(GutterIconOptions {
color: options.get::<Option<String>>("color").ok()?,
hover_color: options.get::<Option<String>>("hover_color").ok()?,
})
}
/// Переводит цвет в шестнадцатеричную запись `#RRGGBB` или `#RRGGBBAA`.
fn format_hex_color(color: Hsla) -> String {
let rgba = Rgba::from(color);
+197 -30
View File
@@ -31,6 +31,7 @@ use gpui_kit::component::{
resize_handle, v_resizable,
};
use gpui_kit::*;
use gpui_kit::{DispatchPhase, MouseButton, MouseDownEvent, MouseExitEvent, MouseMoveEvent};
use lsp_types::{CompletionItem, Hover, TextEdit};
use crate::api::{self, AppApi, WindowCommand};
@@ -40,11 +41,11 @@ 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,
PROMPT_CENTER_OFFSET, PROMPT_WIDTH, TAB_SIZE, WINDOW_TITLE,
GUTTER_ICON_GAP, GUTTER_ICON_PADDING, 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;
use crate::gutter::{GutterClickHandlers, GutterMarker, GutterMarkerKey};
use crate::icons;
use crate::keymap::{AppCommand, CompletionCommand, KeyContext, KeyHandler, KeyMap};
use crate::lsp::{LspManager, document_uri, position_to_offset};
@@ -224,6 +225,17 @@ pub struct MyApp {
_key_interceptor: Subscription,
/// Открытое плавающее поле ввода.
prompt: Option<PromptUi>,
/// Обработчики клика по иконкам gutter, общие с `api.editor`.
///
/// Лежит перед `scripts`, чтобы обработчики (ссылки на Lua-значения)
/// уничтожились раньше самого Lua-движка.
gutter_clicks: GutterClickHandlers,
/// Иконка gutter, над которой сейчас находится мышь.
///
/// Вид хранит его отдельно от модели иконок: при наведении иконка
/// рисуется цветом `hover_color`, а состояние наведения не принадлежит
/// документу.
gutter_hover: Option<GutterMarkerKey>,
/// Запрос из Lua на открытие плавающего поля ввода.
///
/// Живёт перед `scripts`, чтобы колбэк уничтожился раньше Lua-движка.
@@ -268,6 +280,7 @@ impl MyApp {
let lsp = api.lsp_handle();
let editor_keymap = api.editor_keymap_handle();
let prompt_request = api.prompt_request_handle();
let gutter_clicks = api.gutter_click_handle();
let initial_text = workspace.borrow().active().content.clone();
let editor = cx.new(|cx| {
@@ -438,6 +451,8 @@ impl MyApp {
_editor_subscription,
_key_interceptor,
prompt: None,
gutter_clicks,
gutter_hover: None,
prompt_request,
scripts: None,
lsp,
@@ -1992,18 +2007,30 @@ impl MyApp {
/// фиксируется именно в его prepaint, и если считать `top` здесь, в
/// `render`, иконки отстают от текста на кадр.
fn render_gutter(&self, cx: &mut Context<Self>) -> impl IntoElement {
let markers = self
.workspace
.borrow()
.active()
.gutter
let (markers, columns) = {
let workspace = self.workspace.borrow();
let gutter = &workspace.active().gutter;
(
gutter
.iter()
.map(|(line, marker)| (line, marker.clone()))
.collect::<Vec<_>>();
.collect::<Vec<_>>(),
gutter.max_per_line(),
)
};
// Колонка растёт под число иконок на самой насыщенной строке, чтобы
// иконки разных типов не накладывались друг на друга. Одиночная
// иконка по-прежнему занимает привычную ширину `GUTTER_WIDTH`.
let columns = columns.max(1);
let icons_width =
GUTTER_ICON_SIZE * columns as f32 + GUTTER_ICON_GAP * columns.saturating_sub(1) as f32;
let width = px((icons_width + GUTTER_ICON_PADDING * 2.).max(GUTTER_WIDTH));
GutterElement {
editor: self.editor.clone(),
entity: cx.entity(),
markers,
width: px(GUTTER_WIDTH),
hovered: self.gutter_hover.clone(),
width,
background: cx
.theme()
.highlight_theme
@@ -2014,6 +2041,20 @@ impl MyApp {
}
}
/// Вызывает Lua-обработчик клика по иконке gutter.
///
/// Обработчик получает `(line, icon, kind)`. Если для типа обработчик не
/// назначен, клик ничего не делает.
fn run_gutter_click(&self, kind: &str, line: usize, icon: &str) {
let callback = self.gutter_clicks.borrow().get(kind).cloned();
let Some(callback) = callback else {
return;
};
if let Err(err) = callback.call::<()>((line, icon, kind)) {
eprintln!("Lua: ошибка в обработчике клика gutter: {err}");
}
}
/// Полоса между вкладками и редактором.
///
/// Элементы добавляются сценарием через `api.panels.toolbar`. Левые элементы
@@ -2379,15 +2420,30 @@ impl Render for MyApp {
/// текущего кадра, поэтому иконки не отстают от текста при скроллинге.
struct GutterElement {
editor: Entity<EditorState>,
/// Вид, которому принадлежит gutter: через него обновляются наведение
/// и клик по иконкам.
entity: Entity<MyApp>,
markers: Vec<(usize, GutterMarker)>,
/// Иконка, на которой сейчас находится мышь, если она известна.
hovered: Option<GutterMarkerKey>,
width: Pixels,
background: Hsla,
foreground: Hsla,
}
/// Приготовленные к рисованию иконки: границы, svg-путь и цвет.
/// Приготовленная к рисованию иконка.
struct GutterIconPaint {
line: usize,
kind: String,
icon: String,
bounds: Bounds<Pixels>,
path: SharedString,
color: Hsla,
}
/// Приготовленные к рисованию иконки и данные для обработки мыши.
struct GutterPrepaint {
icons: Vec<(Bounds<Pixels>, SharedString, Hsla)>,
icons: Vec<GutterIconPaint>,
}
impl IntoElement for GutterElement {
@@ -2461,35 +2517,65 @@ impl Element for GutterElement {
let line_count = display_map.buffer_line_count();
let icon_size = px(GUTTER_ICON_SIZE).min(line_height);
let gap = px(GUTTER_ICON_GAP);
let mut icons = Vec::new();
for (line, marker) in &self.markers {
// Иконка для несуществующей строки не рисуется: иначе редактор
// прижал бы её к последней строке документа.
if *line >= line_count {
let mut index = 0;
while index < self.markers.len() {
let line = self.markers[index].0;
let start = index;
while index < self.markers.len() && self.markers[index].0 == line {
index += 1;
}
let line_markers = &self.markers[start..index];
// Иконки для несуществующей строки не рисуются: иначе редактор
// прижал бы их к последней строке документа.
if line >= line_count {
continue;
}
// Номер строки буфера → номер видимой строки: свёрнутый код и
// перенос строк не сдвигают иконки.
let row = display_map.buffer_line_to_display_row(*line);
let row = display_map.buffer_line_to_display_row(line);
let y = text_top + crate::gutter::row_offset(row, line_height, scroll_y);
if y + line_height <= text_top || y >= bounds.bottom() {
continue;
}
// Иконки разных типов стоят в строку и центрируются группой.
let count = line_markers.len();
let group_width = icon_size * count as f32 + gap * count.saturating_sub(1) as f32;
let mut x = bounds.origin.x + (bounds.size.width - group_width).half();
for (_, marker) in line_markers {
let Some(icon) = icons::from_name(&marker.icon) else {
x += icon_size + gap;
continue;
};
let hovered = self
.hovered
.as_ref()
.is_some_and(|key| key.line == line && key.kind == marker.kind);
let color = if hovered {
marker
.hover_color
.or(marker.color)
.unwrap_or(self.foreground)
} else {
marker.color.unwrap_or(self.foreground)
};
let icon_bounds = Bounds::new(
point(
bounds.origin.x + (bounds.size.width - icon_size).half(),
y + (line_height - icon_size).half(),
),
point(x, y + (line_height - icon_size).half()),
size(icon_size, icon_size),
);
icons.push((
icon_bounds,
icon.path(),
marker.color.unwrap_or(self.foreground),
));
icons.push(GutterIconPaint {
line,
kind: marker.kind.clone(),
icon: marker.icon.clone(),
bounds: icon_bounds,
path: icon.path(),
color,
});
x += icon_size + gap;
}
}
GutterPrepaint { icons }
@@ -2506,16 +2592,97 @@ impl Element for GutterElement {
cx: &mut App,
) {
window.paint_quad(fill(bounds, self.background));
for (icon_bounds, path, color) in &prepaint.icons {
for icon in &prepaint.icons {
let _ = window.paint_svg(
*icon_bounds,
path.clone(),
icon.bounds,
icon.path.clone(),
None,
TransformationMatrix::default(),
*color,
icon.color,
cx,
);
}
// Для клика и наведения достаточно границ и типа: путь svg здесь не
// нужен.
let hit_icons = prepaint
.icons
.iter()
.map(|icon| (icon.bounds, icon.line, icon.kind.clone(), icon.icon.clone()))
.collect::<Vec<_>>();
let entity = self.entity.clone();
// Клик по иконке вызывает обработчик её типа.
window.on_mouse_event({
let entity = entity.clone();
let hit_icons = hit_icons.clone();
move |event: &MouseDownEvent,
phase: DispatchPhase,
_window: &mut Window,
cx: &mut App| {
if phase != DispatchPhase::Bubble || event.button != MouseButton::Left {
return;
}
let Some((_, line, kind, icon)) = hit_icons
.iter()
.find(|(bounds, ..)| bounds.contains(&event.position))
.cloned()
else {
return;
};
entity.update(cx, |this, cx| {
this.run_gutter_click(&kind, line, &icon);
cx.notify();
});
}
});
// Наведение меняет цвет иконки, поэтому о нём нужно сообщать виду:
// сама модель документа от мыши не зависит.
let mut hovered = self.hovered.clone();
window.on_mouse_event({
let entity = entity.clone();
let hit_icons = hit_icons.clone();
move |event: &MouseMoveEvent,
phase: DispatchPhase,
_window: &mut Window,
cx: &mut App| {
if phase != DispatchPhase::Bubble {
return;
}
let next = hit_icons
.iter()
.find(|(bounds, ..)| bounds.contains(&event.position))
.map(|(_, line, kind, _)| GutterMarkerKey::new(*line, kind.clone()));
if next == hovered {
return;
}
hovered = next.clone();
entity.update(cx, |this, cx| {
this.gutter_hover = next;
cx.notify();
});
}
});
// Уход мыши из окна сбрасывает наведение.
let mut hovered = self.hovered.clone();
window.on_mouse_event({
let entity = entity.clone();
move |_event: &MouseExitEvent,
phase: DispatchPhase,
_window: &mut Window,
cx: &mut App| {
if phase != DispatchPhase::Bubble || hovered.is_none() {
return;
}
hovered = None;
entity.update(cx, |this, cx| {
this.gutter_hover = None;
cx.notify();
});
}
});
}
}
+6
View File
@@ -28,6 +28,12 @@ pub const GUTTER_WIDTH: f32 = 24.0;
/// Размер иконки в gutter.
pub const GUTTER_ICON_SIZE: f32 = 14.0;
/// Расстояние между иконками разных типов на одной строке.
pub const GUTTER_ICON_GAP: f32 = 2.0;
/// Горизонтальный отступ иконок от краёв колонки gutter.
pub const GUTTER_ICON_PADDING: f32 = 4.0;
/// Ширина правой панели при открытии.
pub const RIGHT_PANEL_WIDTH: f32 = 240.0;
+185 -31
View File
@@ -1,35 +1,78 @@
//! Иконки на поле редактора (gutter).
//!
//! Gutter — узкая колонка слева от редактора. Сценарий ставит на строку
//! иконку через `api.editor:set_gutter_icon`, а вид рисует её на той же высоте, что и
//! строка в редакторе, учитывая прокрутку.
//! иконку через `api.editor:set_gutter_icon`, а вид рисует её на той же высоте,
//! что и строка в редакторе, учитывая прокрутку.
//!
//! На строке может жить несколько иконок разных типов: тип задаёт сценарий
//! (например, `diagnostics` или `breakpoint`). Иконка одного типа на строке
//! одна: повторная установка заменяет её. Тип также используется как ключ
//! обработчика клика ([`GutterClickHandlers`]).
//!
//! Состояние принадлежит документу: у каждой вкладки свои иконки, и при
//! переключении вкладок показываются иконки активного документа. Номера строк
//! нумеруются с нуля и не сдвигаются автоматически при правке текста:
//! сценарий сам решает, когда переставить иконку.
use std::collections::BTreeMap;
use std::cell::RefCell;
use std::collections::{BTreeMap, HashMap};
use std::rc::Rc;
use mlua::Function;
use gpui_kit::{Hsla, Pixels};
/// Иконка одной строки.
/// Обработчики клика по иконкам gutter, ключ — тип иконки.
pub type GutterClickHandlers = Rc<RefCell<HashMap<String, Function>>>;
/// Создаёт пустой реестр обработчиков клика.
pub fn new_gutter_click_handlers() -> GutterClickHandlers {
Rc::new(RefCell::new(HashMap::new()))
}
/// Адрес иконки: строка и тип.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct GutterMarkerKey {
pub line: usize,
pub kind: String,
}
impl GutterMarkerKey {
pub fn new(line: usize, kind: impl Into<String>) -> Self {
Self {
line,
kind: kind.into(),
}
}
}
/// Иконка одного типа на строке.
#[derive(Clone, Debug, PartialEq)]
pub struct GutterMarker {
/// Тип иконки, заданный сценарием.
pub kind: String,
/// Каноническое имя иконки Lucide.
pub icon: String,
/// Цвет иконки; `None` — цвет текста темы.
pub color: Option<Hsla>,
/// Цвет иконки при наведении; `None` — [`Self::color`] или цвет темы.
pub hover_color: Option<Hsla>,
}
/// Иконки одной строки, упорядоченные по типу.
#[derive(Clone, Debug, Default, PartialEq)]
struct GutterLine {
markers: BTreeMap<String, GutterMarker>,
}
/// Иконки gutter документа по номеру строки.
///
/// Одна строка несёт не больше одной иконки: повторный вызов заменяет её.
/// `BTreeMap` держит строки отсортированными, поэтому вид рисует их сверху
/// вниз без дополнительной сортировки.
/// На строке хранится по одной иконке каждого типа: повторный вызов `set` с
/// тем же типом заменяет её. `BTreeMap` держит строки и типы отсортированными,
/// поэтому вид рисует их в стабильном порядке без дополнительной сортировки.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct GutterState {
markers: BTreeMap<usize, GutterMarker>,
lines: BTreeMap<usize, GutterLine>,
}
impl GutterState {
@@ -38,39 +81,96 @@ impl GutterState {
Self::default()
}
/// Ставит иконку на строку `line`, заменяя прежнюю.
/// Ставит иконку типа `kind` на строку `line`, заменяя прежнюю иконку
/// этого типа.
///
/// Имя иконки проверяет API: здесь хранится уже каноническое имя.
pub fn set(&mut self, line: usize, icon: impl Into<String>, color: Option<Hsla>) {
self.markers.insert(
line,
GutterMarker {
pub fn set(
&mut self,
line: usize,
kind: impl Into<String>,
icon: impl Into<String>,
color: Option<Hsla>,
hover_color: Option<Hsla>,
) {
let kind = kind.into();
let marker = GutterMarker {
kind: kind.clone(),
icon: icon.into(),
color,
},
);
hover_color,
};
self.lines
.entry(line)
.or_default()
.markers
.insert(kind, marker);
}
/// Убирает иконку со строки.
/// Убирает все иконки строки `line`.
///
/// Возвращает `false`, если на этой строке иконки не было.
/// Возвращает `false`, если на этой строке иконок не было.
pub fn remove(&mut self, line: usize) -> bool {
self.markers.remove(&line).is_some()
self.lines.remove(&line).is_some()
}
/// Убирает иконку типа `kind` со строки `line`.
///
/// Возвращает `false`, если такой иконки не было.
pub fn remove_kind(&mut self, line: usize, kind: &str) -> bool {
let Some(line_markers) = self.lines.get_mut(&line) else {
return false;
};
let removed = line_markers.markers.remove(kind).is_some();
if line_markers.markers.is_empty() {
self.lines.remove(&line);
}
removed
}
/// Убирает иконки типа `kind` со всех строк.
///
/// Возвращает число удалённых иконок.
pub fn remove_all_kind(&mut self, kind: &str) -> usize {
let mut removed = 0;
self.lines.retain(|_, line_markers| {
if line_markers.markers.remove(kind).is_some() {
removed += 1;
}
!line_markers.markers.is_empty()
});
removed
}
/// Убирает все иконки.
pub fn clear(&mut self) {
self.markers.clear();
self.lines.clear();
}
/// Иконка строки, если она есть.
pub fn get(&self, line: usize) -> Option<&GutterMarker> {
self.markers.get(&line)
/// Иконка типа `kind` на строке `line`, если она есть.
pub fn get(&self, line: usize, kind: &str) -> Option<&GutterMarker> {
self.lines.get(&line)?.markers.get(kind)
}
/// Иконки в порядке возрастания номеров строк.
/// Иконки в порядке возрастания номеров строк, внутри строки — по типу.
pub fn iter(&self) -> impl Iterator<Item = (usize, &GutterMarker)> {
self.markers.iter().map(|(line, marker)| (*line, marker))
self.lines.iter().flat_map(|(line, line_markers)| {
line_markers
.markers
.values()
.map(move |marker| (*line, marker))
})
}
/// Максимальное число иконок на одной строке.
///
/// Вид использует его, чтобы колонка gutter вмещала все иконки строки.
pub fn max_per_line(&self) -> usize {
self.lines
.values()
.map(|line_markers| line_markers.markers.len())
.max()
.unwrap_or(0)
}
}
@@ -90,26 +190,80 @@ mod tests {
use gpui_kit::{hsla, px};
#[test]
fn markers_are_keyed_by_line_and_sorted() {
fn markers_are_keyed_by_line_and_kind_and_sorted() {
let mut gutter = GutterState::new();
assert_eq!(gutter.iter().count(), 0);
gutter.set(5, "file", None);
gutter.set(1, "play", Some(hsla(0.5, 1., 0.5, 1.)));
gutter.set(5, "breakpoint", "file", None, None);
gutter.set(1, "diagnostics", "play", Some(hsla(0.5, 1., 0.5, 1.)), None);
gutter.set(1, "breakpoint", "circle", None, None);
let lines: Vec<usize> = gutter.iter().map(|(line, _)| line).collect();
assert_eq!(lines, [1, 5]);
assert_eq!(lines, [1, 1, 5]);
// Внутри строки иконки упорядочены по типу.
let kinds: Vec<&str> = gutter
.iter()
.map(|(_, marker)| marker.kind.as_str())
.collect();
assert_eq!(kinds, ["breakpoint", "diagnostics", "breakpoint"]);
assert_eq!(
gutter.get(1).map(|marker| marker.icon.as_str()),
gutter
.get(1, "diagnostics")
.map(|marker| marker.icon.as_str()),
Some("play")
);
// Повторная установка того же типа заменяет иконку.
gutter.set(1, "diagnostics", "file-code", None, None);
assert_eq!(
gutter
.get(1, "diagnostics")
.map(|marker| marker.icon.as_str()),
Some("file-code")
);
assert_eq!(gutter.max_per_line(), 2);
}
#[test]
fn removing_icons_by_line_and_kind() {
let mut gutter = GutterState::new();
gutter.set(1, "diagnostics", "circle", None, None);
gutter.set(1, "breakpoint", "play", None, None);
gutter.set(2, "diagnostics", "triangle-alert", None, None);
gutter.set(3, "breakpoint", "file", None, None);
// Один тип на одной строке.
assert!(gutter.remove_kind(1, "diagnostics"));
assert!(!gutter.remove_kind(1, "diagnostics"));
assert_eq!(gutter.get(1, "diagnostics"), None);
assert!(gutter.get(1, "breakpoint").is_some());
// Все иконки строки.
assert!(gutter.remove(1));
assert!(!gutter.remove(1));
assert!(gutter.get(1).is_none());
assert_eq!(gutter.iter().count(), 1);
assert_eq!(gutter.iter().filter(|(line, _)| *line == 1).count(), 0);
// Все иконки типа во всех строках.
assert_eq!(gutter.remove_all_kind("diagnostics"), 1);
assert_eq!(gutter.remove_all_kind("diagnostics"), 0);
assert_eq!(gutter.get(2, "diagnostics"), None);
assert!(gutter.get(3, "breakpoint").is_some());
gutter.clear();
assert_eq!(gutter.iter().count(), 0);
assert_eq!(gutter.max_per_line(), 0);
}
#[test]
fn hover_color_is_stored_separately() {
let mut gutter = GutterState::new();
let color = hsla(0.1, 1., 0.5, 1.);
let hover = hsla(0.2, 1., 0.5, 1.);
gutter.set(0, "diagnostics", "circle", Some(color), Some(hover));
let marker = gutter.get(0, "diagnostics").expect("иконка");
assert_eq!(marker.color, Some(color));
assert_eq!(marker.hover_color, Some(hover));
}
#[test]
+196 -49
View File
@@ -11,7 +11,7 @@ use std::rc::Rc;
use gpui_kit::{Keystroke, Modifiers, TestAppContext, point, px};
use lsp_types::{Diagnostic, DiagnosticSeverity, Position, Range};
use crate::api::{AppApi, WindowCommand, drain_window_commands};
use crate::api::{AppApi, GutterIconOptions, WindowCommand, drain_window_commands};
use crate::app::MyApp;
use crate::editor::{FocusAfterSync, Language, Workspace};
use crate::keymap::{KeyBinding, KeyContext, KeyHandler, KeyMap, Modifiers as KeyModifiers};
@@ -26,6 +26,14 @@ fn install_api(workspace: Rc<RefCell<Workspace>>) -> (AppApi, Rc<RefCell<KeyMap>
(api, keymap)
}
/// Опции иконки gutter для тестов.
fn gutter_options(color: Option<&str>, hover_color: Option<&str>) -> GutterIconOptions {
GutterIconOptions {
color: color.map(str::to_owned),
hover_color: hover_color.map(str::to_owned),
}
}
#[test]
fn lua_lsp_api_lists_no_servers_and_validates_config() {
let workspace = Rc::new(RefCell::new(Workspace::default()));
@@ -208,39 +216,119 @@ fn api_sets_and_clears_gutter_icons_per_document() {
let workspace = Rc::new(RefCell::new(Workspace::default()));
let (api, _keymap) = install_api(workspace);
assert_eq!(api.editor().gutter_icon(0), None);
assert!(api.editor().set_gutter_icon(2, "circle", None));
assert_eq!(api.editor().gutter_icon(2).as_deref(), Some("circle"));
assert_eq!(api.editor().gutter_color(2), None);
assert_eq!(api.editor().gutter_icon(0, "diagnostics"), None);
assert!(
api.editor()
.set_gutter_icon(2, "circle", "diagnostics", GutterIconOptions::default())
);
assert_eq!(
api.editor().gutter_icon(2, "diagnostics").as_deref(),
Some("circle")
);
assert_eq!(api.editor().gutter_color(2, "diagnostics"), None);
// Неизвестная иконка и некорректный цвет ничего не меняют.
// Неизвестная иконка, некорректный цвет и пустой тип ничего не меняют.
assert!(!api.editor().set_gutter_icon(
3,
"definitely-not-an-icon",
"diagnostics",
GutterIconOptions::default()
));
assert!(!api.editor().set_gutter_icon(
3,
"circle",
"diagnostics",
gutter_options(Some("not-a-color"), None)
));
assert!(
!api.editor()
.set_gutter_icon(3, "definitely-not-an-icon", None)
.set_gutter_icon(3, "circle", " ", GutterIconOptions::default())
);
assert!(
!api.editor()
.set_gutter_icon(3, "circle", Some("not-a-color"))
);
assert_eq!(api.editor().gutter_icon(3), None);
assert_eq!(api.editor().gutter_icon(3, "diagnostics"), None);
assert!(api.editor().set_gutter_icon(2, "play", Some("#ff0000")));
assert_eq!(api.editor().gutter_icon(2).as_deref(), Some("play"));
assert_eq!(api.editor().gutter_color(2).as_deref(), Some("#ff0000"));
// Повторная установка того же типа заменяет иконку.
assert!(api.editor().set_gutter_icon(
2,
"play",
"diagnostics",
gutter_options(Some("#ff0000"), Some("#00ff00"))
));
assert_eq!(
api.editor().gutter_icon(2, "diagnostics").as_deref(),
Some("play")
);
assert_eq!(
api.editor().gutter_color(2, "diagnostics").as_deref(),
Some("#ff0000")
);
assert_eq!(
api.editor().gutter_hover_color(2, "diagnostics").as_deref(),
Some("#00ff00")
);
// Иконка другого типа добавляется на ту же строку, не заменяя прежнюю.
assert!(api.editor().set_gutter_icon(
2,
"triangle-alert",
"text-diagnostics",
GutterIconOptions::default()
));
assert_eq!(
api.editor().gutter_icon(2, "diagnostics").as_deref(),
Some("play")
);
assert_eq!(
api.editor().gutter_icon(2, "text-diagnostics").as_deref(),
Some("triangle-alert")
);
// Иконки принадлежат вкладке, а не приложению.
api.editor().open_tab();
assert_eq!(api.editor().gutter_icon(2), None);
assert!(api.editor().set_gutter_icon(0, "circle", None));
assert_eq!(api.editor().gutter_icon(2, "diagnostics"), None);
assert!(
api.editor()
.set_gutter_icon(0, "circle", "diagnostics", GutterIconOptions::default())
);
api.editor().set_active_tab(0);
assert_eq!(api.editor().gutter_icon(2).as_deref(), Some("play"));
assert_eq!(
api.editor().gutter_icon(2, "diagnostics").as_deref(),
Some("play")
);
assert!(api.editor().clear_gutter_icon(2));
assert!(!api.editor().clear_gutter_icon(2));
assert_eq!(api.editor().gutter_icon(2), None);
// Удаление одного типа оставляет остальные иконки строки.
assert!(api.editor().clear_gutter_icon(2, "diagnostics"));
assert!(!api.editor().clear_gutter_icon(2, "diagnostics"));
assert_eq!(api.editor().gutter_icon(2, "diagnostics"), None);
assert_eq!(
api.editor().gutter_icon(2, "text-diagnostics").as_deref(),
Some("triangle-alert")
);
// Удаление всех иконок строки.
assert!(api.editor().clear_gutter_line(2));
assert!(!api.editor().clear_gutter_line(2));
assert_eq!(api.editor().gutter_icon(2, "text-diagnostics"), None);
// Удаление всех иконок одного типа во всём документе.
assert!(
api.editor()
.set_gutter_icon(0, "circle", "diagnostics", GutterIconOptions::default())
);
assert!(api.editor().set_gutter_icon(
1,
"play",
"text-diagnostics",
GutterIconOptions::default()
));
assert_eq!(api.editor().clear_gutter_kind("diagnostics"), 1);
assert_eq!(api.editor().gutter_icon(0, "diagnostics"), None);
assert_eq!(
api.editor().gutter_icon(1, "text-diagnostics").as_deref(),
Some("play")
);
api.editor().clear_gutter();
assert_eq!(api.editor().gutter_icon(0), None);
assert_eq!(api.editor().gutter_icon(1, "text-diagnostics"), None);
}
#[test]
@@ -253,26 +341,49 @@ fn lua_script_can_set_gutter_icons() {
std::fs::write(
&script,
r##"
assert(api.editor:gutter_icon(0) == nil)
assert(api.editor:set_gutter_icon(4, "circle"))
assert(api.editor:gutter_icon(4) == "circle")
assert(api.editor:gutter_color(4) == nil)
assert(api.editor:gutter_icon(4, "diagnostics") == nil)
assert(api.editor:set_gutter_icon(4, "circle", "diagnostics"))
assert(api.editor:gutter_icon(4, "diagnostics") == "circle")
assert(api.editor:gutter_color(4, "diagnostics") == nil)
assert(api.editor:gutter_hover_color(4, "diagnostics") == nil)
assert(api.editor:set_gutter_icon(4, "play", "#00ff00"))
assert(api.editor:gutter_icon(4) == "play")
assert(api.editor:gutter_color(4) == "#00ff00")
assert(api.editor:set_gutter_icon(4, "play", "diagnostics",
{ color = "#00ff00", hover_color = "#ff00ff" }))
assert(api.editor:gutter_icon(4, "diagnostics") == "play")
assert(api.editor:gutter_color(4, "diagnostics") == "#00ff00")
assert(api.editor:gutter_hover_color(4, "diagnostics") == "#ff00ff")
assert(not api.editor:set_gutter_icon(1, "no-such-icon"))
assert(not api.editor:set_gutter_icon(1, "circle", "oops"))
assert(api.editor:gutter_icon(1) == nil)
-- Второй тип на той же строке не заменяет первый.
assert(api.editor:set_gutter_icon(4, "triangle-alert", "text-diagnostics"))
assert(api.editor:gutter_icon(4, "diagnostics") == "play")
assert(api.editor:gutter_icon(4, "text-diagnostics") == "triangle-alert")
assert(api.editor:clear_gutter_icon(4))
assert(not api.editor:clear_gutter_icon(4))
assert(api.editor:gutter_icon(4) == nil)
assert(not api.editor:set_gutter_icon(1, "no-such-icon", "diagnostics"))
assert(not api.editor:set_gutter_icon(1, "circle", "diagnostics",
{ color = "oops" }))
assert(not api.editor:set_gutter_icon(1, "circle", ""))
assert(api.editor:gutter_icon(1, "diagnostics") == nil)
assert(api.editor:clear_gutter_icon(4, "diagnostics"))
assert(not api.editor:clear_gutter_icon(4, "diagnostics"))
assert(api.editor:gutter_icon(4, "diagnostics") == nil)
assert(api.editor:gutter_icon(4, "text-diagnostics") == "triangle-alert")
-- Клик по типу: назначение и снятие обработчика.
api.editor:on_gutter_click("diagnostics", function(line, icon, kind) end)
api.editor:on_gutter_click("diagnostics", nil)
assert(api.editor:clear_gutter_line(4))
assert(api.editor:gutter_icon(4, "text-diagnostics") == nil)
api.editor:set_gutter_icon(0, "circle", "diagnostics")
api.editor:set_gutter_icon(1, "circle", "text-diagnostics")
assert(api.editor:clear_gutter_kind("diagnostics") == 1)
assert(api.editor:gutter_icon(0, "diagnostics") == nil)
assert(api.editor:gutter_icon(1, "text-diagnostics") == "circle")
api.editor:set_gutter_icon(0, "circle")
api.editor:clear_gutter()
assert(api.editor:gutter_icon(0) == nil)
assert(api.editor:gutter_icon(1, "text-diagnostics") == nil)
"##,
)
.unwrap();
@@ -1578,12 +1689,12 @@ fn editor_and_block_keymaps_depend_on_focus(cx: &mut TestAppContext) {
std::fs::write(
&script,
r#"
api.keymap:map_key("", "<A-g>", function() api.editor:set_gutter_icon(0, "circle") end)
api.editor:map_key("", "<A-r>", function() api.editor:set_gutter_icon(0, "play") end)
api.keymap:map_key("", "<A-g>", function() api.editor:set_gutter_icon(0, "circle", "test") end)
api.editor:map_key("", "<A-r>", function() api.editor:set_gutter_icon(0, "play", "test") end)
local tree = api.panels.left:add_tree()
tree:append({ { id = "a", label = "a" } }, "")
tree:map_key("", "<A-t>", function() api.editor:set_gutter_icon(0, "file-code") end)
tree:map_key("", "<A-t>", function() api.editor:set_gutter_icon(0, "file-code", "test") end)
api.panels.left:open()
"#,
)
@@ -1597,7 +1708,7 @@ fn editor_and_block_keymaps_depend_on_focus(cx: &mut TestAppContext) {
let PanelBlock::Tree { tree, .. } = &panel.blocks()[0];
tree.clone()
};
let icon = || AppApi::global().editor().gutter_icon(0);
let icon = || AppApi::global().editor().gutter_icon(0, "test");
// Фокус в редакторе: работают глобальные и редакторские привязки.
visual.update(|window, cx| {
@@ -1661,9 +1772,9 @@ fn global_keymap_sees_tree_focus_in_lua(cx: &mut TestAppContext) {
api.keymap:map_key("", "<A-h>", function()
if tree:has_focus() then
api.editor:set_gutter_icon(0, "folder")
api.editor:set_gutter_icon(0, "folder", "test")
else
api.editor:set_gutter_icon(0, "file")
api.editor:set_gutter_icon(0, "file", "test")
end
end)
"#,
@@ -1678,7 +1789,7 @@ fn global_keymap_sees_tree_focus_in_lua(cx: &mut TestAppContext) {
let PanelBlock::Tree { tree, .. } = &panel.blocks()[0];
tree.clone()
};
let icon = || AppApi::global().editor().gutter_icon(0);
let icon = || AppApi::global().editor().gutter_icon(0, "test");
// Фокус в редакторе: `tree:has_focus()` должен быть ложным.
visual.update(|window, cx| {
@@ -1926,8 +2037,21 @@ fn gutter_icons_render_from_api(cx: &mut TestAppContext) {
let (app, visual) = cx.add_window_view(MyApp::new);
let api = AppApi::global();
assert!(api.editor().set_gutter_icon(0, "circle", Some("#ff0000")));
assert!(api.editor().set_gutter_icon(5, "play", None));
assert!(api.editor().set_gutter_icon(
0,
"circle",
"diagnostics",
gutter_options(Some("#ff0000"), Some("#00ff00"))
));
// Две иконки разных типов на одной строке: вид раскладывает их рядом.
assert!(
api.editor()
.set_gutter_icon(0, "play", "breakpoint", GutterIconOptions::default())
);
assert!(
api.editor()
.set_gutter_icon(5, "play", "breakpoint", GutterIconOptions::default())
);
visual.update(|_window, cx| {
app.update(cx, |_, cx| cx.notify());
@@ -1935,15 +2059,38 @@ fn gutter_icons_render_from_api(cx: &mut TestAppContext) {
visual.run_until_parked();
assert_eq!(
AppApi::global().editor().gutter_icon(0).as_deref(),
AppApi::global()
.editor()
.gutter_icon(0, "diagnostics")
.as_deref(),
Some("circle")
);
assert_eq!(
AppApi::global().editor().gutter_color(0).as_deref(),
AppApi::global()
.editor()
.gutter_color(0, "diagnostics")
.as_deref(),
Some("#ff0000")
);
assert_eq!(
AppApi::global().editor().gutter_icon(5).as_deref(),
AppApi::global()
.editor()
.gutter_hover_color(0, "diagnostics")
.as_deref(),
Some("#00ff00")
);
assert_eq!(
AppApi::global()
.editor()
.gutter_icon(0, "breakpoint")
.as_deref(),
Some("play")
);
assert_eq!(
AppApi::global()
.editor()
.gutter_icon(5, "breakpoint")
.as_deref(),
Some("play")
);
}