This commit is contained in:
2026-10-31 15:58:33 +03:00
parent 838627fb33
commit cf1505eca5
7 changed files with 846 additions and 189 deletions
+187 -33
View File
@@ -1,35 +1,78 @@
//! Иконки на поле редактора (gutter).
//!
//! Gutter — узкая колонка слева от редактора. Сценарий ставит на строку
//! иконку через `api.editor:set_gutter_icon`, а вид рисует её на той же высоте, что и
//! строка в редакторе, учитывая прокрутку.
//! иконку через `api.editor:set_gutter_icon`, а вид рисует её на той же высоте,
//! что и строка в редакторе, учитывая прокрутку.
//!
//! На строке может жить несколько иконок разных типов: тип задаёт сценарий
//! (например, `diagnostics` или `breakpoint`). Иконка одного типа на строке
//! одна: повторная установка заменяет её. Тип также используется как ключ
//! обработчика клика ([`GutterClickHandlers`]).
//!
//! Состояние принадлежит документу: у каждой вкладки свои иконки, и при
//! переключении вкладок показываются иконки активного документа. Номера строк
//! нумеруются с нуля и не сдвигаются автоматически при правке текста:
//! сценарий сам решает, когда переставить иконку.
use std::collections::BTreeMap;
use std::cell::RefCell;
use std::collections::{BTreeMap, HashMap};
use std::rc::Rc;
use mlua::Function;
use gpui_kit::{Hsla, Pixels};
/// Иконка одной строки.
/// Обработчики клика по иконкам gutter, ключ — тип иконки.
pub type GutterClickHandlers = Rc<RefCell<HashMap<String, Function>>>;
/// Создаёт пустой реестр обработчиков клика.
pub fn new_gutter_click_handlers() -> GutterClickHandlers {
Rc::new(RefCell::new(HashMap::new()))
}
/// Адрес иконки: строка и тип.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct GutterMarkerKey {
pub line: usize,
pub kind: String,
}
impl GutterMarkerKey {
pub fn new(line: usize, kind: impl Into<String>) -> Self {
Self {
line,
kind: kind.into(),
}
}
}
/// Иконка одного типа на строке.
#[derive(Clone, Debug, PartialEq)]
pub struct GutterMarker {
/// Тип иконки, заданный сценарием.
pub kind: String,
/// Каноническое имя иконки Lucide.
pub icon: String,
/// Цвет иконки; `None` — цвет текста темы.
pub color: Option<Hsla>,
/// Цвет иконки при наведении; `None` — [`Self::color`] или цвет темы.
pub hover_color: Option<Hsla>,
}
/// Иконки одной строки, упорядоченные по типу.
#[derive(Clone, Debug, Default, PartialEq)]
struct GutterLine {
markers: BTreeMap<String, GutterMarker>,
}
/// Иконки gutter документа по номеру строки.
///
/// Одна строка несёт не больше одной иконки: повторный вызов заменяет её.
/// `BTreeMap` держит строки отсортированными, поэтому вид рисует их сверху
/// вниз без дополнительной сортировки.
/// На строке хранится по одной иконке каждого типа: повторный вызов `set` с
/// тем же типом заменяет её. `BTreeMap` держит строки и типы отсортированными,
/// поэтому вид рисует их в стабильном порядке без дополнительной сортировки.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct GutterState {
markers: BTreeMap<usize, GutterMarker>,
lines: BTreeMap<usize, GutterLine>,
}
impl GutterState {
@@ -38,39 +81,96 @@ impl GutterState {
Self::default()
}
/// Ставит иконку на строку `line`, заменяя прежнюю.
/// Ставит иконку типа `kind` на строку `line`, заменяя прежнюю иконку
/// этого типа.
///
/// Имя иконки проверяет API: здесь хранится уже каноническое имя.
pub fn set(&mut self, line: usize, icon: impl Into<String>, color: Option<Hsla>) {
self.markers.insert(
line,
GutterMarker {
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]