From 0ee5850016650f158dbdc9033ccdde6237e8ad50 Mon Sep 17 00:00:00 2001 From: Nik Revenco <154856872+nik-rev@users.noreply.github.com> Date: Sun, 23 Mar 2025 21:07:02 +0000 Subject: [PATCH] =?UTF-8?q?=20Color=20swatches=20(=20=F0=9F=9F=A9=20green?= =?UTF-8?q?=20=F0=9F=9F=A5=20#ffaaaa=20)=20=20(#12308)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- book/src/editor.md | 1 + helix-core/src/syntax.rs | 2 + helix-lsp/src/client.rs | 20 ++ helix-term/src/handlers.rs | 6 + helix-term/src/handlers/document_colors.rs | 204 +++++++++++++++++++++ helix-view/src/document.rs | 16 ++ helix-view/src/editor.rs | 3 + helix-view/src/handlers.rs | 1 + helix-view/src/handlers/lsp.rs | 4 +- helix-view/src/theme.rs | 91 ++++++++- helix-view/src/view.rs | 19 +- 11 files changed, 363 insertions(+), 4 deletions(-) create mode 100644 helix-term/src/handlers/document_colors.rs diff --git a/book/src/editor.md b/book/src/editor.md index 3fe650e09..1e5c2a507 100644 --- a/book/src/editor.md +++ b/book/src/editor.md @@ -152,6 +152,7 @@ The following statusline elements can be configured: | `display-progress-messages` | Display LSP progress messages below statusline[^1] | `false` | | `auto-signature-help` | Enable automatic popup of signature help (parameter hints) | `true` | | `display-inlay-hints` | Display inlay hints[^2] | `false` | +| `display-color-swatches` | Show color swatches next to colors | `true` | | `display-signature-help-docs` | Display docs under signature help popup | `true` | | `snippets` | Enables snippet completions. Requires a server restart (`:lsp-restart`) to take effect after `:config-reload`/`:set`. | `true` | | `goto-reference-include-declaration` | Include declaration in the goto references popup. | `true` | diff --git a/helix-core/src/syntax.rs b/helix-core/src/syntax.rs index 7c50a5797..677cdfa0b 100644 --- a/helix-core/src/syntax.rs +++ b/helix-core/src/syntax.rs @@ -334,6 +334,7 @@ pub enum LanguageServerFeature { Diagnostics, RenameSymbol, InlayHints, + DocumentColors, } impl Display for LanguageServerFeature { @@ -357,6 +358,7 @@ impl Display for LanguageServerFeature { Diagnostics => "diagnostics", RenameSymbol => "rename-symbol", InlayHints => "inlay-hints", + DocumentColors => "document-colors", }; write!(f, "{feature}",) } diff --git a/helix-lsp/src/client.rs b/helix-lsp/src/client.rs index e5a116d73..f2b78a118 100644 --- a/helix-lsp/src/client.rs +++ b/helix-lsp/src/client.rs @@ -356,6 +356,7 @@ impl Client { capabilities.inlay_hint_provider, Some(OneOf::Left(true) | OneOf::Right(InlayHintServerCapabilities::Options(_))) ), + LanguageServerFeature::DocumentColors => capabilities.color_provider.is_some(), } } @@ -1095,6 +1096,25 @@ impl Client { Some(self.call::(params)) } + pub fn text_document_document_color( + &self, + text_document: lsp::TextDocumentIdentifier, + work_done_token: Option, + ) -> Option>>> { + self.capabilities.get().unwrap().color_provider.as_ref()?; + let params = lsp::DocumentColorParams { + text_document, + work_done_progress_params: lsp::WorkDoneProgressParams { + work_done_token: work_done_token.clone(), + }, + partial_result_params: helix_lsp_types::PartialResultParams { + partial_result_token: work_done_token, + }, + }; + + Some(self.call::(params)) + } + pub fn text_document_hover( &self, text_document: lsp::TextDocumentIdentifier, diff --git a/helix-term/src/handlers.rs b/helix-term/src/handlers.rs index 24d8491b1..c7d71526c 100644 --- a/helix-term/src/handlers.rs +++ b/helix-term/src/handlers.rs @@ -10,9 +10,12 @@ use crate::handlers::signature_help::SignatureHelpHandler; pub use helix_view::handlers::Handlers; +use self::document_colors::DocumentColorsHandler; + mod auto_save; pub mod completion; mod diagnostics; +mod document_colors; mod signature_help; mod snippet; @@ -22,11 +25,13 @@ pub fn setup(config: Arc>) -> Handlers { let event_tx = completion::CompletionHandler::new(config).spawn(); let signature_hints = SignatureHelpHandler::new().spawn(); let auto_save = AutoSaveHandler::new().spawn(); + let document_colors = DocumentColorsHandler::default().spawn(); let handlers = Handlers { completions: helix_view::handlers::completion::CompletionHandler::new(event_tx), signature_hints, auto_save, + document_colors, }; helix_view::handlers::register_hooks(&handlers); @@ -35,5 +40,6 @@ pub fn setup(config: Arc>) -> Handlers { auto_save::register_hooks(&handlers); diagnostics::register_hooks(&handlers); snippet::register_hooks(&handlers); + document_colors::register_hooks(&handlers); handlers } diff --git a/helix-term/src/handlers/document_colors.rs b/helix-term/src/handlers/document_colors.rs new file mode 100644 index 000000000..cffe56888 --- /dev/null +++ b/helix-term/src/handlers/document_colors.rs @@ -0,0 +1,204 @@ +use std::{collections::HashSet, time::Duration}; + +use futures_util::{stream::FuturesOrdered, StreamExt}; +use helix_core::{syntax::LanguageServerFeature, text_annotations::InlineAnnotation}; +use helix_event::{cancelable_future, register_hook}; +use helix_lsp::lsp; +use helix_view::{ + document::DocumentColorSwatches, + events::{DocumentDidChange, DocumentDidOpen, LanguageServerExited, LanguageServerInitialized}, + handlers::{lsp::DocumentColorsEvent, Handlers}, + DocumentId, Editor, Theme, +}; +use tokio::time::Instant; + +use crate::job; + +#[derive(Default)] +pub(super) struct DocumentColorsHandler { + docs: HashSet, +} + +const DOCUMENT_CHANGE_DEBOUNCE: Duration = Duration::from_millis(250); + +impl helix_event::AsyncHook for DocumentColorsHandler { + type Event = DocumentColorsEvent; + + fn handle_event(&mut self, event: Self::Event, _timeout: Option) -> Option { + let DocumentColorsEvent(doc_id) = event; + self.docs.insert(doc_id); + Some(Instant::now() + DOCUMENT_CHANGE_DEBOUNCE) + } + + fn finish_debounce(&mut self) { + let docs = std::mem::take(&mut self.docs); + + job::dispatch_blocking(move |editor, _compositor| { + for doc in docs { + request_document_colors(editor, doc); + } + }); + } +} + +fn request_document_colors(editor: &mut Editor, doc_id: DocumentId) { + if !editor.config().lsp.display_color_swatches { + return; + } + + let Some(doc) = editor.document_mut(doc_id) else { + return; + }; + + let cancel = doc.color_swatch_controller.restart(); + + let mut seen_language_servers = HashSet::new(); + let mut futures: FuturesOrdered<_> = doc + .language_servers_with_feature(LanguageServerFeature::DocumentColors) + .filter(|ls| seen_language_servers.insert(ls.id())) + .map(|language_server| { + let text = doc.text().clone(); + let offset_encoding = language_server.offset_encoding(); + let future = language_server + .text_document_document_color(doc.identifier(), None) + .unwrap(); + + async move { + let colors: Vec<_> = future + .await? + .into_iter() + .filter_map(|color_info| { + let pos = helix_lsp::util::lsp_pos_to_pos( + &text, + color_info.range.start, + offset_encoding, + )?; + Some((pos, color_info.color)) + }) + .collect(); + anyhow::Ok(colors) + } + }) + .collect(); + + tokio::spawn(async move { + let mut all_colors = Vec::new(); + loop { + match cancelable_future(futures.next(), &cancel).await { + Some(Some(Ok(items))) => all_colors.extend(items), + Some(Some(Err(err))) => log::error!("document color request failed: {err}"), + Some(None) => break, + // The request was cancelled. + None => return, + } + } + job::dispatch(move |editor, _| attach_document_colors(editor, doc_id, all_colors)).await; + }); +} + +fn attach_document_colors( + editor: &mut Editor, + doc_id: DocumentId, + mut doc_colors: Vec<(usize, lsp::Color)>, +) { + if !editor.config().lsp.display_color_swatches { + return; + } + + let Some(doc) = editor.documents.get_mut(&doc_id) else { + return; + }; + + if doc_colors.is_empty() { + doc.color_swatches.take(); + return; + } + + doc_colors.sort_by_key(|(pos, _)| *pos); + + let mut color_swatches = Vec::with_capacity(doc_colors.len()); + let mut color_swatches_padding = Vec::with_capacity(doc_colors.len()); + let mut colors = Vec::with_capacity(doc_colors.len()); + + for (pos, color) in doc_colors { + color_swatches_padding.push(InlineAnnotation::new(pos, " ")); + color_swatches.push(InlineAnnotation::new(pos, "■")); + colors.push(Theme::rgb_highlight( + (color.red * 255.) as u8, + (color.green * 255.) as u8, + (color.blue * 255.) as u8, + )); + } + + doc.color_swatches = Some(DocumentColorSwatches { + color_swatches, + colors, + color_swatches_padding, + }); +} + +pub(super) fn register_hooks(handlers: &Handlers) { + register_hook!(move |event: &mut DocumentDidOpen<'_>| { + // when a document is initially opened, request colors for it + request_document_colors(event.editor, event.doc); + + Ok(()) + }); + + let tx = handlers.document_colors.clone(); + register_hook!(move |event: &mut DocumentDidChange<'_>| { + // Update the color swatch' positions, helping ensure they are displayed in the + // proper place. + let apply_color_swatch_changes = |annotations: &mut Vec| { + event.changes.update_positions( + annotations + .iter_mut() + .map(|annotation| (&mut annotation.char_idx, helix_core::Assoc::After)), + ); + }; + + if let Some(DocumentColorSwatches { + color_swatches, + colors: _colors, + color_swatches_padding, + }) = &mut event.doc.color_swatches + { + apply_color_swatch_changes(color_swatches); + apply_color_swatch_changes(color_swatches_padding); + } + + // Cancel the ongoing request, if present. + event.doc.color_swatch_controller.cancel(); + + helix_event::send_blocking(&tx, DocumentColorsEvent(event.doc.id())); + + Ok(()) + }); + + register_hook!(move |event: &mut LanguageServerInitialized<'_>| { + let doc_ids: Vec<_> = event.editor.documents().map(|doc| doc.id()).collect(); + + for doc_id in doc_ids { + request_document_colors(event.editor, doc_id); + } + + Ok(()) + }); + + register_hook!(move |event: &mut LanguageServerExited<'_>| { + // Clear and re-request all color swatches when a server exits. + for doc in event.editor.documents_mut() { + if doc.supports_language_server(event.server_id) { + doc.color_swatches.take(); + } + } + + let doc_ids: Vec<_> = event.editor.documents().map(|doc| doc.id()).collect(); + + for doc_id in doc_ids { + request_document_colors(event.editor, doc_id); + } + + Ok(()) + }); +} diff --git a/helix-view/src/document.rs b/helix-view/src/document.rs index b75aebe70..41c9ee1ef 100644 --- a/helix-view/src/document.rs +++ b/helix-view/src/document.rs @@ -11,6 +11,7 @@ use helix_core::encoding::Encoding; use helix_core::snippets::{ActiveSnippet, SnippetRenderCtx}; use helix_core::syntax::{Highlight, LanguageServerFeature}; use helix_core::text_annotations::{InlineAnnotation, Overlay}; +use helix_event::TaskController; use helix_lsp::util::lsp_pos_to_pos; use helix_stdx::faccess::{copy_metadata, readonly}; use helix_vcs::{DiffHandle, DiffProviderRegistry}; @@ -200,6 +201,19 @@ pub struct Document { pub focused_at: std::time::Instant, pub readonly: bool, + + /// Annotations for LSP document color swatches + pub color_swatches: Option, + // NOTE: ideally this would live on the handler for color swatches. This is blocked on a + // large refactor that would make `&mut Editor` available on the `DocumentDidChange` event. + pub color_swatch_controller: TaskController, +} + +#[derive(Debug, Clone, Default)] +pub struct DocumentColorSwatches { + pub color_swatches: Vec, + pub colors: Vec, + pub color_swatches_padding: Vec, } /// Inlay hints for a single `(Document, View)` combo. @@ -703,6 +717,8 @@ impl Document { focused_at: std::time::Instant::now(), readonly: false, jump_labels: HashMap::new(), + color_swatches: None, + color_swatch_controller: TaskController::new(), } } diff --git a/helix-view/src/editor.rs b/helix-view/src/editor.rs index 65976f2c1..27a985ac3 100644 --- a/helix-view/src/editor.rs +++ b/helix-view/src/editor.rs @@ -456,6 +456,8 @@ pub struct LspConfig { pub display_signature_help_docs: bool, /// Display inlay hints pub display_inlay_hints: bool, + /// Display document color swatches + pub display_color_swatches: bool, /// Whether to enable snippet support pub snippets: bool, /// Whether to include declaration in the goto reference query @@ -473,6 +475,7 @@ impl Default for LspConfig { display_inlay_hints: false, snippets: true, goto_reference_include_declaration: true, + display_color_swatches: true, } } } diff --git a/helix-view/src/handlers.rs b/helix-view/src/handlers.rs index 86217c23b..258ed89e5 100644 --- a/helix-view/src/handlers.rs +++ b/helix-view/src/handlers.rs @@ -21,6 +21,7 @@ pub struct Handlers { pub completions: CompletionHandler, pub signature_hints: Sender, pub auto_save: Sender, + pub document_colors: Sender, } impl Handlers { diff --git a/helix-view/src/handlers/lsp.rs b/helix-view/src/handlers/lsp.rs index 813885408..c1041b2aa 100644 --- a/helix-view/src/handlers/lsp.rs +++ b/helix-view/src/handlers/lsp.rs @@ -5,7 +5,7 @@ use crate::editor::Action; use crate::events::{ DiagnosticsDidChange, DocumentDidChange, DocumentDidClose, LanguageServerInitialized, }; -use crate::Editor; +use crate::{DocumentId, Editor}; use helix_core::diagnostic::DiagnosticProvider; use helix_core::Uri; use helix_event::register_hook; @@ -14,6 +14,8 @@ use helix_lsp::{lsp, LanguageServerId, OffsetEncoding}; use super::Handlers; +pub struct DocumentColorsEvent(pub DocumentId); + #[derive(Debug, PartialEq, Eq, Clone, Copy)] pub enum SignatureHelpInvoked { Automatic, diff --git a/helix-view/src/theme.rs b/helix-view/src/theme.rs index c3f6af882..af8f03bca 100644 --- a/helix-view/src/theme.rs +++ b/helix-view/src/theme.rs @@ -5,7 +5,7 @@ use std::{ }; use anyhow::{anyhow, Result}; -use helix_core::hashmap; +use helix_core::{hashmap, syntax::Highlight}; use helix_loader::merge_toml_values; use log::warn; use once_cell::sync::Lazy; @@ -293,9 +293,39 @@ fn build_theme_values( } impl Theme { + /// To allow `Highlight` to represent arbitrary RGB colors without turning it into an enum, + /// we interpret the last 3 bytes of a `Highlight` as RGB colors. + const RGB_START: usize = (usize::MAX << (8 + 8 + 8)) - 1; + + /// Interpret a Highlight with the RGB foreground + fn decode_rgb_highlight(rgb: usize) -> Option<(u8, u8, u8)> { + (rgb > Self::RGB_START).then(|| { + let [b, g, r, ..] = rgb.to_ne_bytes(); + (r, g, b) + }) + } + + /// Create a Highlight that represents an RGB color + pub fn rgb_highlight(r: u8, g: u8, b: u8) -> Highlight { + Highlight(usize::from_ne_bytes([ + b, + g, + r, + u8::MAX, + u8::MAX, + u8::MAX, + u8::MAX, + u8::MAX, + ])) + } + #[inline] pub fn highlight(&self, index: usize) -> Style { - self.highlights[index] + if let Some((red, green, blue)) = Self::decode_rgb_highlight(index) { + Style::new().fg(Color::Rgb(red, green, blue)) + } else { + self.highlights[index] + } } #[inline] @@ -589,4 +619,61 @@ mod tests { .add_modifier(Modifier::BOLD) ); } + + // tests for parsing an RGB `Highlight` + + #[test] + fn convert_to_and_from() { + let (r, g, b) = (0xFF, 0xFE, 0xFA); + let highlight = Theme::rgb_highlight(r, g, b); + assert_eq!(Theme::decode_rgb_highlight(highlight.0), Some((r, g, b))); + } + + /// make sure we can store all the colors at the end + /// ``` + /// FF FF FF FF FF FF FF FF + /// xor + /// FF FF FF FF FF 00 00 00 + /// = + /// 00 00 00 00 00 FF FF FF + /// ``` + /// + /// where the ending `(FF, FF, FF)` represents `(r, g, b)` + #[test] + fn full_numeric_range() { + assert_eq!(usize::MAX ^ Theme::RGB_START, 256_usize.pow(3)); + assert_eq!(Theme::RGB_START + 256_usize.pow(3), usize::MAX); + } + + #[test] + fn retrieve_color() { + // color in the middle + let (r, g, b) = (0x14, 0xAA, 0xF7); + assert_eq!( + Theme::default().highlight(Theme::rgb_highlight(r, g, b).0), + Style::new().fg(Color::Rgb(r, g, b)) + ); + // pure black + let (r, g, b) = (0x00, 0x00, 0x00); + assert_eq!( + Theme::default().highlight(Theme::rgb_highlight(r, g, b).0), + Style::new().fg(Color::Rgb(r, g, b)) + ); + // pure white + let (r, g, b) = (0xff, 0xff, 0xff); + assert_eq!( + Theme::default().highlight(Theme::rgb_highlight(r, g, b).0), + Style::new().fg(Color::Rgb(r, g, b)) + ); + } + + #[test] + #[should_panic( + expected = "index out of bounds: the len is 0 but the index is 18446744073692774399" + )] + fn out_of_bounds() { + let (r, g, b) = (0x00, 0x00, 0x00); + + Theme::default().highlight(Theme::rgb_highlight(r, g, b).0 - 1); + } } diff --git a/helix-view/src/view.rs b/helix-view/src/view.rs index a229f01ea..d6f10753a 100644 --- a/helix-view/src/view.rs +++ b/helix-view/src/view.rs @@ -1,7 +1,7 @@ use crate::{ align_view, annotations::diagnostics::InlineDiagnostics, - document::DocumentInlayHints, + document::{DocumentColorSwatches, DocumentInlayHints}, editor::{GutterConfig, GutterType}, graphics::Rect, handlers::diagnostics::DiagnosticsHandler, @@ -482,6 +482,23 @@ impl View { .add_inline_annotations(padding_after_inlay_hints, None); }; let config = doc.config.load(); + + if config.lsp.display_color_swatches { + if let Some(DocumentColorSwatches { + color_swatches, + colors, + color_swatches_padding, + }) = &doc.color_swatches + { + for (color_swatch, color) in color_swatches.iter().zip(colors) { + text_annotations + .add_inline_annotations(std::slice::from_ref(color_swatch), Some(*color)); + } + + text_annotations.add_inline_annotations(color_swatches_padding, None); + } + } + let width = self.inner_width(doc); let enable_cursor_line = self .diagnostics_handler