Files
qode/src/gutter.rs
T
2026-10-31 15:58:33 +03:00

276 lines
11 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Иконки на поле редактора (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.));
}
}