changeset 6670:c3df8cd07331

Color swatches ( ? green ? #ffaaaa ) (#12308)
author Nik Revenco <154856872+nik-rev@users.noreply.github.com>
date Sun, 23 Mar 2025 21:07:02 +0000
parents d32fd83eca94
children 31e6dc555d5d
files book/src/editor.md helix-core/src/syntax.rs helix-lsp/src/client.rs helix-term/src/handlers.rs helix-term/src/handlers/document_colors.rs helix-view/src/document.rs helix-view/src/editor.rs helix-view/src/handlers.rs helix-view/src/handlers/lsp.rs helix-view/src/theme.rs helix-view/src/view.rs
diffstat 11 files changed, 363 insertions(+), 4 deletions(-) [+]
line wrap: on
line diff
--- a/book/src/editor.md	Sun Mar 23 16:27:58 2025 +0100
+++ b/book/src/editor.md	Sun Mar 23 21:07:02 2025 +0000
@@ -152,6 +152,7 @@
 | `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`  |
--- a/helix-core/src/syntax.rs	Sun Mar 23 16:27:58 2025 +0100
+++ b/helix-core/src/syntax.rs	Sun Mar 23 21:07:02 2025 +0000
@@ -334,6 +334,7 @@
     Diagnostics,
     RenameSymbol,
     InlayHints,
+    DocumentColors,
 }
 
 impl Display for LanguageServerFeature {
@@ -357,6 +358,7 @@
             Diagnostics => "diagnostics",
             RenameSymbol => "rename-symbol",
             InlayHints => "inlay-hints",
+            DocumentColors => "document-colors",
         };
         write!(f, "{feature}",)
     }
--- a/helix-lsp/src/client.rs	Sun Mar 23 16:27:58 2025 +0100
+++ b/helix-lsp/src/client.rs	Sun Mar 23 21:07:02 2025 +0000
@@ -356,6 +356,7 @@
                 capabilities.inlay_hint_provider,
                 Some(OneOf::Left(true) | OneOf::Right(InlayHintServerCapabilities::Options(_)))
             ),
+            LanguageServerFeature::DocumentColors => capabilities.color_provider.is_some(),
         }
     }
 
@@ -1095,6 +1096,25 @@
         Some(self.call::<lsp::request::InlayHintRequest>(params))
     }
 
+    pub fn text_document_document_color(
+        &self,
+        text_document: lsp::TextDocumentIdentifier,
+        work_done_token: Option<lsp::ProgressToken>,
+    ) -> Option<impl Future<Output = Result<Vec<lsp::ColorInformation>>>> {
+        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::<lsp::request::DocumentColor>(params))
+    }
+
     pub fn text_document_hover(
         &self,
         text_document: lsp::TextDocumentIdentifier,
--- a/helix-term/src/handlers.rs	Sun Mar 23 16:27:58 2025 +0100
+++ b/helix-term/src/handlers.rs	Sun Mar 23 21:07:02 2025 +0000
@@ -10,9 +10,12 @@
 
 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 @@
     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 @@
     auto_save::register_hooks(&handlers);
     diagnostics::register_hooks(&handlers);
     snippet::register_hooks(&handlers);
+    document_colors::register_hooks(&handlers);
     handlers
 }
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/helix-term/src/handlers/document_colors.rs	Sun Mar 23 21:07:02 2025 +0000
@@ -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<DocumentId>,
+}
+
+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<Instant>) -> Option<Instant> {
+        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<InlineAnnotation>| {
+            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(())
+    });
+}
--- a/helix-view/src/document.rs	Sun Mar 23 16:27:58 2025 +0100
+++ b/helix-view/src/document.rs	Sun Mar 23 21:07:02 2025 +0000
@@ -11,6 +11,7 @@
 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 focused_at: std::time::Instant,
 
     pub readonly: bool,
+
+    /// Annotations for LSP document color swatches
+    pub color_swatches: Option<DocumentColorSwatches>,
+    // 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<InlineAnnotation>,
+    pub colors: Vec<Highlight>,
+    pub color_swatches_padding: Vec<InlineAnnotation>,
 }
 
 /// Inlay hints for a single `(Document, View)` combo.
@@ -703,6 +717,8 @@
             focused_at: std::time::Instant::now(),
             readonly: false,
             jump_labels: HashMap::new(),
+            color_swatches: None,
+            color_swatch_controller: TaskController::new(),
         }
     }
 
--- a/helix-view/src/editor.rs	Sun Mar 23 16:27:58 2025 +0100
+++ b/helix-view/src/editor.rs	Sun Mar 23 21:07:02 2025 +0000
@@ -456,6 +456,8 @@
     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 @@
             display_inlay_hints: false,
             snippets: true,
             goto_reference_include_declaration: true,
+            display_color_swatches: true,
         }
     }
 }
--- a/helix-view/src/handlers.rs	Sun Mar 23 16:27:58 2025 +0100
+++ b/helix-view/src/handlers.rs	Sun Mar 23 21:07:02 2025 +0000
@@ -21,6 +21,7 @@
     pub completions: CompletionHandler,
     pub signature_hints: Sender<lsp::SignatureHelpEvent>,
     pub auto_save: Sender<AutoSaveEvent>,
+    pub document_colors: Sender<lsp::DocumentColorsEvent>,
 }
 
 impl Handlers {
--- a/helix-view/src/handlers/lsp.rs	Sun Mar 23 16:27:58 2025 +0100
+++ b/helix-view/src/handlers/lsp.rs	Sun Mar 23 21:07:02 2025 +0000
@@ -5,7 +5,7 @@
 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 super::Handlers;
 
+pub struct DocumentColorsEvent(pub DocumentId);
+
 #[derive(Debug, PartialEq, Eq, Clone, Copy)]
 pub enum SignatureHelpInvoked {
     Automatic,
--- a/helix-view/src/theme.rs	Sun Mar 23 16:27:58 2025 +0100
+++ b/helix-view/src/theme.rs	Sun Mar 23 21:07:02 2025 +0000
@@ -5,7 +5,7 @@
 };
 
 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 @@
 }
 
 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 @@
                 .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);
+    }
 }
--- a/helix-view/src/view.rs	Sun Mar 23 16:27:58 2025 +0100
+++ b/helix-view/src/view.rs	Sun Mar 23 21:07:02 2025 +0000
@@ -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 @@
                 .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