gutter
This commit is contained in:
+30
-9
@@ -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() -- все иконки вкладки
|
||||
```
|
||||
|
||||
Иконки привязаны к номерам строк и автоматически не сдвигаются при правке
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
+205
-38
@@ -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
|
||||
.iter()
|
||||
.map(|(line, marker)| (line, marker.clone()))
|
||||
.collect::<Vec<_>>();
|
||||
let (markers, columns) = {
|
||||
let workspace = self.workspace.borrow();
|
||||
let gutter = &workspace.active().gutter;
|
||||
(
|
||||
gutter
|
||||
.iter()
|
||||
.map(|(line, marker)| (line, marker.clone()))
|
||||
.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 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::<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();
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
+187
-33
@@ -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 {
|
||||
icon: icon.into(),
|
||||
color,
|
||||
},
|
||||
);
|
||||
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
@@ -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")
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user