276 lines
11 KiB
Rust
276 lines
11 KiB
Rust
//! Иконки на поле редактора (gutter).
|
||
//!
|
||
//! Gutter — узкая колонка слева от редактора. Сценарий ставит на строку
|
||
//! иконку через `api.editor:set_gutter_icon`, а вид рисует её на той же высоте,
|
||
//! что и строка в редакторе, учитывая прокрутку.
|
||
//!
|
||
//! На строке может жить несколько иконок разных типов: тип задаёт сценарий
|
||
//! (например, `diagnostics` или `breakpoint`). Иконка одного типа на строке
|
||
//! одна: повторная установка заменяет её. Тип также используется как ключ
|
||
//! обработчика клика ([`GutterClickHandlers`]).
|
||
//!
|
||
//! Состояние принадлежит документу: у каждой вкладки свои иконки, и при
|
||
//! переключении вкладок показываются иконки активного документа. Номера строк
|
||
//! нумеруются с нуля и не сдвигаются автоматически при правке текста:
|
||
//! сценарий сам решает, когда переставить иконку.
|
||
|
||
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 документа по номеру строки.
|
||
///
|
||
/// На строке хранится по одной иконке каждого типа: повторный вызов `set` с
|
||
/// тем же типом заменяет её. `BTreeMap` держит строки и типы отсортированными,
|
||
/// поэтому вид рисует их в стабильном порядке без дополнительной сортировки.
|
||
#[derive(Clone, Debug, Default, PartialEq)]
|
||
pub struct GutterState {
|
||
lines: BTreeMap<usize, GutterLine>,
|
||
}
|
||
|
||
impl GutterState {
|
||
/// Создаёт пустое поле.
|
||
pub fn new() -> Self {
|
||
Self::default()
|
||
}
|
||
|
||
/// Ставит иконку типа `kind` на строку `line`, заменяя прежнюю иконку
|
||
/// этого типа.
|
||
///
|
||
/// Имя иконки проверяет API: здесь хранится уже каноническое имя.
|
||
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`, если на этой строке иконок не было.
|
||
pub fn remove(&mut self, line: usize) -> bool {
|
||
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.lines.clear();
|
||
}
|
||
|
||
/// Иконка типа `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.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)
|
||
}
|
||
}
|
||
|
||
/// Вертикальный отступ видимой строки `row` от верхнего края gutter.
|
||
///
|
||
/// `scroll_y` — смещение прокрутки редактора (отрицательное при прокрутке
|
||
/// вниз), `line_height` — высота строки из раскладки редактора. `row` — не
|
||
/// номер строки буфера, а номер видимой строки: свёрнутые строки не занимают
|
||
/// места.
|
||
pub fn row_offset(row: usize, line_height: Pixels, scroll_y: Pixels) -> Pixels {
|
||
scroll_y + line_height * row as f32
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use gpui_kit::{hsla, px};
|
||
|
||
#[test]
|
||
fn markers_are_keyed_by_line_and_kind_and_sorted() {
|
||
let mut gutter = GutterState::new();
|
||
assert_eq!(gutter.iter().count(), 0);
|
||
|
||
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, 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, "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_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]
|
||
fn row_offset_follows_scroll_and_line_height() {
|
||
assert_eq!(row_offset(0, px(20.), px(0.)), px(0.));
|
||
assert_eq!(row_offset(3, px(20.), px(-40.)), px(20.));
|
||
assert_eq!(row_offset(1, px(18.), px(-9.)), px(9.));
|
||
}
|
||
}
|