//! Иконки на поле редактора (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>>; /// Создаёт пустой реестр обработчиков клика. 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 документа по номеру строки. /// /// На строке хранится по одной иконке каждого типа: повторный вызов `set` с /// тем же типом заменяет её. `BTreeMap` держит строки и типы отсортированными, /// поэтому вид рисует их в стабильном порядке без дополнительной сортировки. #[derive(Clone, Debug, Default, PartialEq)] pub struct GutterState { lines: BTreeMap, } impl GutterState { /// Создаёт пустое поле. pub fn new() -> Self { Self::default() } /// Ставит иконку типа `kind` на строку `line`, заменяя прежнюю иконку /// этого типа. /// /// Имя иконки проверяет API: здесь хранится уже каноническое имя. 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`, если на этой строке иконок не было. 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 { 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 = 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.)); } }