diff --git a/docs/editor.md b/docs/editor.md index 9d53314..25182c6 100644 --- a/docs/editor.md +++ b/docs/editor.md @@ -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() -- все иконки вкладки ``` Иконки привязаны к номерам строк и автоматически не сдвигаются при правке diff --git a/init.lua b/init.lua index e0cd35c..058f727 100644 --- a/init.lua +++ b/init.lua @@ -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") diff --git a/src/api.rs b/src/api.rs index 15cdef0..5ea4b9d 100644 --- a/src/api.rs +++ b/src/api.rs @@ -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, + /// Цвет иконки при наведении. + pub hover_color: Option, +} + /// Зарегистрированный Lua-обработчик диагностики. /// /// `delay` — пауза тишины, после которой обработчик вызывается. Если сервер @@ -152,6 +165,12 @@ pub struct AppApi { lsp: Arc, #[lua(skip)] diagnostics_handler: Rc>>, + /// Обработчики клика по типам иконок gutter. + /// + /// Живут вне документов: тип иконки задаёт сценарий, а обработчик общий + /// для всех вкладок. + #[lua(skip)] + gutter_clicks: GutterClickHandlers, /// Запрос из Lua на открытие плавающего поля ввода. #[lua(skip)] prompt_request: PromptRequestHandle, @@ -173,6 +192,8 @@ pub struct EditorApi { lsp: Arc, #[lua(skip)] diagnostics_handler: Rc>>, + #[lua(skip)] + gutter_clicks: GutterClickHandlers, } impl EditorApi { @@ -181,12 +202,14 @@ impl EditorApi { keymap: Rc>, lsp: Arc, diagnostics_handler: Rc>>, + 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) -> bool { - self.set_gutter_icon(line, &icon, color.as_deref()) + fn lua_set_gutter_icon( + &self, + line: usize, + icon: String, + kind: String, + options: Option, + ) -> 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 { + /// Имя иконки типа `kind` на строке `line` активной вкладки или `nil`. + #[lua(skip)] + pub fn gutter_icon(&self, line: usize, kind: &str) -> Option { 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 { + self.gutter_icon(line, &kind) + } + + /// Цвет иконки типа `kind` на строке `line` (`#RRGGBB` или `#RRGGBBAA`) + /// или `nil`. /// /// `nil` возвращается и когда иконки нет, и когда она рисуется цветом /// темы. - #[lua(infallible)] - pub fn gutter_color(&self, line: usize) -> Option { + #[lua(skip)] + pub fn gutter_color(&self, line: usize, kind: &str) -> Option { 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 { + self.gutter_color(line, &kind) + } + + /// Цвет иконки типа `kind` на строке `line` при наведении или `nil`. + #[lua(skip)] + pub fn gutter_hover_color(&self, line: usize, kind: &str) -> Option { + 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 { + 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) { + 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) -> mlua::Result> { }) } +/// Переводит необязательный цвет gutter в `Hsla`. +/// +/// `None`, пустая строка и `transparent` означают «цвет не задан», а +/// `Some(None)` — именно это состояние. `None` целиком возвращается только +/// для некорректного цвета. +fn parse_gutter_color(color: Option<&str>) -> Option> { + 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) -> Option { + let Some(options) = options else { + return Some(GutterIconOptions::default()); + }; + Some(GutterIconOptions { + color: options.get::>("color").ok()?, + hover_color: options.get::>("hover_color").ok()?, + }) +} + /// Переводит цвет в шестнадцатеричную запись `#RRGGBB` или `#RRGGBBAA`. fn format_hex_color(color: Hsla) -> String { let rgba = Rgba::from(color); diff --git a/src/app.rs b/src/app.rs index 26b677a..f76bb99 100644 --- a/src/app.rs +++ b/src/app.rs @@ -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, + /// Обработчики клика по иконкам gutter, общие с `api.editor`. + /// + /// Лежит перед `scripts`, чтобы обработчики (ссылки на Lua-значения) + /// уничтожились раньше самого Lua-движка. + gutter_clicks: GutterClickHandlers, + /// Иконка gutter, над которой сейчас находится мышь. + /// + /// Вид хранит его отдельно от модели иконок: при наведении иконка + /// рисуется цветом `hover_color`, а состояние наведения не принадлежит + /// документу. + gutter_hover: Option, /// Запрос из 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) -> impl IntoElement { - let markers = self - .workspace - .borrow() - .active() - .gutter - .iter() - .map(|(line, marker)| (line, marker.clone())) - .collect::>(); + let (markers, columns) = { + let workspace = self.workspace.borrow(); + let gutter = &workspace.active().gutter; + ( + gutter + .iter() + .map(|(line, marker)| (line, marker.clone())) + .collect::>(), + 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, + /// Вид, которому принадлежит gutter: через него обновляются наведение + /// и клик по иконкам. + entity: Entity, markers: Vec<(usize, GutterMarker)>, + /// Иконка, на которой сейчас находится мышь, если она известна. + hovered: Option, width: Pixels, background: Hsla, foreground: Hsla, } -/// Приготовленные к рисованию иконки: границы, svg-путь и цвет. +/// Приготовленная к рисованию иконка. +struct GutterIconPaint { + line: usize, + kind: String, + icon: String, + bounds: Bounds, + path: SharedString, + color: Hsla, +} + +/// Приготовленные к рисованию иконки и данные для обработки мыши. struct GutterPrepaint { - icons: Vec<(Bounds, SharedString, Hsla)>, + icons: Vec, } 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 Some(icon) = icons::from_name(&marker.icon) else { - continue; - }; - let icon_bounds = Bounds::new( - point( - bounds.origin.x + (bounds.size.width - icon_size).half(), - y + (line_height - icon_size).half(), - ), - size(icon_size, icon_size), - ); - icons.push(( - icon_bounds, - icon.path(), - marker.color.unwrap_or(self.foreground), - )); + + // Иконки разных типов стоят в строку и центрируются группой. + 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(x, y + (line_height - icon_size).half()), + size(icon_size, icon_size), + ); + 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::>(); + 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(); + }); + } + }); } } diff --git a/src/constants.rs b/src/constants.rs index 7e96fe7..1f3402d 100644 --- a/src/constants.rs +++ b/src/constants.rs @@ -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; diff --git a/src/gutter.rs b/src/gutter.rs index 35e27fb..8761d2a 100644 --- a/src/gutter.rs +++ b/src/gutter.rs @@ -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>>; + +/// Создаёт пустой реестр обработчиков клика. +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) -> Self { + Self { + line, + kind: kind.into(), + } + } +} + +/// Иконка одного типа на строке. #[derive(Clone, Debug, PartialEq)] pub struct GutterMarker { + /// Тип иконки, заданный сценарием. + pub kind: String, /// Каноническое имя иконки Lucide. pub icon: String, /// Цвет иконки; `None` — цвет текста темы. pub color: Option, + /// Цвет иконки при наведении; `None` — [`Self::color`] или цвет темы. + pub hover_color: Option, +} + +/// Иконки одной строки, упорядоченные по типу. +#[derive(Clone, Debug, Default, PartialEq)] +struct GutterLine { + markers: BTreeMap, } /// Иконки gutter документа по номеру строки. /// -/// Одна строка несёт не больше одной иконки: повторный вызов заменяет её. -/// `BTreeMap` держит строки отсортированными, поэтому вид рисует их сверху -/// вниз без дополнительной сортировки. +/// На строке хранится по одной иконке каждого типа: повторный вызов `set` с +/// тем же типом заменяет её. `BTreeMap` держит строки и типы отсортированными, +/// поэтому вид рисует их в стабильном порядке без дополнительной сортировки. #[derive(Clone, Debug, Default, PartialEq)] pub struct GutterState { - markers: BTreeMap, + lines: BTreeMap, } impl GutterState { @@ -38,39 +81,96 @@ impl GutterState { Self::default() } - /// Ставит иконку на строку `line`, заменяя прежнюю. + /// Ставит иконку типа `kind` на строку `line`, заменяя прежнюю иконку + /// этого типа. /// /// Имя иконки проверяет API: здесь хранится уже каноническое имя. - pub fn set(&mut self, line: usize, icon: impl Into, color: Option) { - self.markers.insert( - line, - GutterMarker { - icon: icon.into(), - color, - }, - ); + pub fn set( + &mut self, + line: usize, + kind: impl Into, + icon: impl Into, + color: Option, + hover_color: Option, + ) { + 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 { - 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 = 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] diff --git a/src/tests.rs b/src/tests.rs index 446d726..7702d98 100644 --- a/src/tests.rs +++ b/src/tests.rs @@ -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>) -> (AppApi, Rc (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("", "", function() api.editor:set_gutter_icon(0, "circle") end) - api.editor:map_key("", "", function() api.editor:set_gutter_icon(0, "play") end) + api.keymap:map_key("", "", function() api.editor:set_gutter_icon(0, "circle", "test") end) + api.editor:map_key("", "", 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("", "", function() api.editor:set_gutter_icon(0, "file-code") end) + tree:map_key("", "", 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("", "", 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") ); }