Show color swatches for LSP completions (#18665)
Closes #11991 Release Notes: - Added support for color swatches for language server completions. <img width="502" alt="Screenshot 2024-10-02 at 19 02 22" src="https://github.com/user-attachments/assets/57e85492-3760-461a-9b17-a846dc40576b"> <img width="534" alt="Screenshot 2024-10-02 at 19 02 48" src="https://github.com/user-attachments/assets/713ac41c-16f0-4ad3-9103-d2c9b3fa8b2e"> This implementation is mostly a port of the VSCode version of the ColorExtractor. It seems reasonable the we should support _at least_ what VSCode does for detecting color swatches from LSP completions. This implementation could definitely be better perf-wise by writing a dedicated color parser. I also think it would be neat if, in the future, Zed handled _more_ color formats — especially wide-gamut colors. There are a few differences to the regexes in the VSCode implementation but mainly so simplify the implementation : - The hex vs rgb/hsl regexes were split into two parts - The rgb/hsl regexes allow 3 or 4 color components whether hsla/rgba or not and the parsing implementation accepts/rejects colors as needed --------- Co-authored-by: Marshall Bowers <elliott.codes@gmail.com>
This commit is contained in:
parent
cddd7875a4
commit
cac98b7bbf
4 changed files with 320 additions and 3 deletions
|
@ -1228,6 +1228,10 @@ impl CompletionsMenu {
|
||||||
None
|
None
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let color_swatch = completion
|
||||||
|
.color()
|
||||||
|
.map(|color| div().size_4().bg(color).rounded(px(2.)));
|
||||||
|
|
||||||
div().min_w(px(220.)).max_w(px(540.)).child(
|
div().min_w(px(220.)).max_w(px(540.)).child(
|
||||||
ListItem::new(mat.candidate_id)
|
ListItem::new(mat.candidate_id)
|
||||||
.inset(true)
|
.inset(true)
|
||||||
|
@ -1243,6 +1247,7 @@ impl CompletionsMenu {
|
||||||
task.detach_and_log_err(cx)
|
task.detach_and_log_err(cx)
|
||||||
}
|
}
|
||||||
}))
|
}))
|
||||||
|
.start_slot::<Div>(color_swatch)
|
||||||
.child(h_flex().overflow_hidden().child(completion_label))
|
.child(h_flex().overflow_hidden().child(completion_label))
|
||||||
.end_slot::<Label>(documentation_label),
|
.end_slot::<Label>(documentation_label),
|
||||||
)
|
)
|
||||||
|
|
297
crates/project/src/color_extractor.rs
Normal file
297
crates/project/src/color_extractor.rs
Normal file
|
@ -0,0 +1,297 @@
|
||||||
|
use std::sync::LazyLock;
|
||||||
|
|
||||||
|
use gpui::{Hsla, Rgba};
|
||||||
|
use lsp::{CompletionItem, Documentation};
|
||||||
|
use regex::{Regex, RegexBuilder};
|
||||||
|
|
||||||
|
const HEX: &'static str = r#"(#(?:[\da-fA-F]{3}){1,2})"#;
|
||||||
|
const RGB_OR_HSL: &'static str = r#"(rgba?|hsla?)\(\s*(\d{1,3}%?)\s*,\s*(\d{1,3}%?)\s*,\s*(\d{1,3}%?)\s*(?:,\s*(1|0?\.\d+))?\s*\)"#;
|
||||||
|
|
||||||
|
static RELAXED_HEX_REGEX: LazyLock<Regex> = LazyLock::new(|| {
|
||||||
|
RegexBuilder::new(HEX)
|
||||||
|
.case_insensitive(false)
|
||||||
|
.build()
|
||||||
|
.expect("Failed to create RELAXED_HEX_REGEX")
|
||||||
|
});
|
||||||
|
|
||||||
|
static STRICT_HEX_REGEX: LazyLock<Regex> = LazyLock::new(|| {
|
||||||
|
RegexBuilder::new(&format!("^{HEX}$"))
|
||||||
|
.case_insensitive(true)
|
||||||
|
.build()
|
||||||
|
.expect("Failed to create STRICT_HEX_REGEX")
|
||||||
|
});
|
||||||
|
|
||||||
|
static RELAXED_RGB_OR_HSL_REGEX: LazyLock<Regex> = LazyLock::new(|| {
|
||||||
|
RegexBuilder::new(RGB_OR_HSL)
|
||||||
|
.case_insensitive(false)
|
||||||
|
.build()
|
||||||
|
.expect("Failed to create RELAXED_RGB_OR_HSL_REGEX")
|
||||||
|
});
|
||||||
|
|
||||||
|
static STRICT_RGB_OR_HSL_REGEX: LazyLock<Regex> = LazyLock::new(|| {
|
||||||
|
RegexBuilder::new(&format!("^{RGB_OR_HSL}$"))
|
||||||
|
.case_insensitive(true)
|
||||||
|
.build()
|
||||||
|
.expect("Failed to create STRICT_RGB_OR_HSL_REGEX")
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Extracts a color from an LSP [`CompletionItem`].
|
||||||
|
///
|
||||||
|
/// Adapted from https://github.com/microsoft/vscode/blob/a6870fcb6d79093738c17e8319b760cf1c41764a/src/vs/editor/contrib/suggest/browser/suggestWidgetRenderer.ts#L34-L61
|
||||||
|
pub fn extract_color(item: &CompletionItem) -> Option<Hsla> {
|
||||||
|
// Try to extract from entire `label` field.
|
||||||
|
parse(&item.label, ParseMode::Strict)
|
||||||
|
// Try to extract from entire `detail` field.
|
||||||
|
.or_else(|| {
|
||||||
|
item.detail
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|detail| parse(detail, ParseMode::Strict))
|
||||||
|
})
|
||||||
|
// Try to extract from beginning or end of `documentation` field.
|
||||||
|
.or_else(|| match item.documentation {
|
||||||
|
Some(Documentation::String(ref str)) => parse(str, ParseMode::Relaxed),
|
||||||
|
Some(Documentation::MarkupContent(ref markup)) => {
|
||||||
|
parse(&markup.value, ParseMode::Relaxed)
|
||||||
|
}
|
||||||
|
None => None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ParseMode {
|
||||||
|
Strict,
|
||||||
|
Relaxed,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse(str: &str, mode: ParseMode) -> Option<Hsla> {
|
||||||
|
let (hex, rgb) = match mode {
|
||||||
|
ParseMode::Strict => (&STRICT_HEX_REGEX, &STRICT_RGB_OR_HSL_REGEX),
|
||||||
|
ParseMode::Relaxed => (&RELAXED_HEX_REGEX, &RELAXED_RGB_OR_HSL_REGEX),
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(captures) = hex.captures(str) {
|
||||||
|
let rmatch = captures.get(0)?;
|
||||||
|
|
||||||
|
// Color must be anchored to start or end of string.
|
||||||
|
if rmatch.start() > 0 && rmatch.end() != str.len() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let hex = captures.get(1)?.as_str();
|
||||||
|
|
||||||
|
return from_hex(hex);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(captures) = rgb.captures(str) {
|
||||||
|
let rmatch = captures.get(0)?;
|
||||||
|
|
||||||
|
// Color must be anchored to start or end of string.
|
||||||
|
if rmatch.start() > 0 && rmatch.end() != str.len() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let typ = captures.get(1)?.as_str();
|
||||||
|
let r_or_h = captures.get(2)?.as_str();
|
||||||
|
let g_or_s = captures.get(3)?.as_str();
|
||||||
|
let b_or_l = captures.get(4)?.as_str();
|
||||||
|
let a = captures.get(5).map(|a| a.as_str());
|
||||||
|
|
||||||
|
return match (typ, a) {
|
||||||
|
("rgb", None) | ("rgba", Some(_)) => from_rgb(r_or_h, g_or_s, b_or_l, a),
|
||||||
|
("hsl", None) | ("hsla", Some(_)) => from_hsl(r_or_h, g_or_s, b_or_l, a),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_component(value: &str, max: f32) -> Option<f32> {
|
||||||
|
if let Some(field) = value.strip_suffix("%") {
|
||||||
|
field.parse::<f32>().map(|value| value / 100.).ok()
|
||||||
|
} else {
|
||||||
|
value.parse::<f32>().map(|value| value / max).ok()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn from_hex(hex: &str) -> Option<Hsla> {
|
||||||
|
Rgba::try_from(hex).map(Hsla::from).ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn from_rgb(r: &str, g: &str, b: &str, a: Option<&str>) -> Option<Hsla> {
|
||||||
|
let r = parse_component(r, 255.)?;
|
||||||
|
let g = parse_component(g, 255.)?;
|
||||||
|
let b = parse_component(b, 255.)?;
|
||||||
|
let a = a.and_then(|a| parse_component(a, 1.0)).unwrap_or(1.0);
|
||||||
|
|
||||||
|
Some(Rgba { r, g, b, a }.into())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn from_hsl(h: &str, s: &str, l: &str, a: Option<&str>) -> Option<Hsla> {
|
||||||
|
let h = parse_component(h, 360.)?;
|
||||||
|
let s = parse_component(s, 100.)?;
|
||||||
|
let l = parse_component(l, 100.)?;
|
||||||
|
let a = a.and_then(|a| parse_component(a, 1.0)).unwrap_or(1.0);
|
||||||
|
|
||||||
|
Some(Hsla { h, s, l, a })
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use gpui::rgba;
|
||||||
|
use lsp::{CompletionItem, CompletionItemKind};
|
||||||
|
|
||||||
|
pub static COLOR_TABLE: LazyLock<Vec<(&'static str, Option<u32>)>> = LazyLock::new(|| {
|
||||||
|
vec![
|
||||||
|
// -- Invalid --
|
||||||
|
// Invalid hex
|
||||||
|
("f0f", None),
|
||||||
|
("#fof", None),
|
||||||
|
// Extra field
|
||||||
|
("rgb(255, 0, 0, 0.0)", None),
|
||||||
|
("hsl(120, 0, 0, 0.0)", None),
|
||||||
|
// Missing field
|
||||||
|
("rgba(255, 0, 0)", None),
|
||||||
|
("hsla(120, 0, 0)", None),
|
||||||
|
// No decimal after zero
|
||||||
|
("rgba(255, 0, 0, 0)", None),
|
||||||
|
("hsla(120, 0, 0, 0)", None),
|
||||||
|
// Decimal after one
|
||||||
|
("rgba(255, 0, 0, 1.0)", None),
|
||||||
|
("hsla(120, 0, 0, 1.0)", None),
|
||||||
|
// HEX (sRGB)
|
||||||
|
("#f0f", Some(0xFF00FFFF)),
|
||||||
|
("#ff0000", Some(0xFF0000FF)),
|
||||||
|
// RGB / RGBA (sRGB)
|
||||||
|
("rgb(255, 0, 0)", Some(0xFF0000FF)),
|
||||||
|
("rgba(255, 0, 0, 0.4)", Some(0xFF000066)),
|
||||||
|
("rgba(255, 0, 0, 1)", Some(0xFF0000FF)),
|
||||||
|
("rgb(20%, 0%, 0%)", Some(0x330000FF)),
|
||||||
|
("rgba(20%, 0%, 0%, 1)", Some(0x330000FF)),
|
||||||
|
("rgb(0%, 20%, 0%)", Some(0x003300FF)),
|
||||||
|
("rgba(0%, 20%, 0%, 1)", Some(0x003300FF)),
|
||||||
|
("rgb(0%, 0%, 20%)", Some(0x000033FF)),
|
||||||
|
("rgba(0%, 0%, 20%, 1)", Some(0x000033FF)),
|
||||||
|
// HSL / HSLA (sRGB)
|
||||||
|
("hsl(0, 100%, 50%)", Some(0xFF0000FF)),
|
||||||
|
("hsl(120, 100%, 50%)", Some(0x00FF00FF)),
|
||||||
|
("hsla(0, 100%, 50%, 0.0)", Some(0xFF000000)),
|
||||||
|
("hsla(0, 100%, 50%, 0.4)", Some(0xFF000066)),
|
||||||
|
("hsla(0, 100%, 50%, 1)", Some(0xFF0000FF)),
|
||||||
|
("hsla(120, 100%, 50%, 0.0)", Some(0x00FF0000)),
|
||||||
|
("hsla(120, 100%, 50%, 0.4)", Some(0x00FF0066)),
|
||||||
|
("hsla(120, 100%, 50%, 1)", Some(0x00FF00FF)),
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn can_extract_from_label() {
|
||||||
|
for (color_str, color_val) in COLOR_TABLE.iter() {
|
||||||
|
let color = extract_color(&CompletionItem {
|
||||||
|
kind: Some(CompletionItemKind::COLOR),
|
||||||
|
label: color_str.to_string(),
|
||||||
|
detail: None,
|
||||||
|
documentation: None,
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
|
||||||
|
assert_eq!(color, color_val.map(|v| Hsla::from(rgba(v))));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn only_whole_label_matches_are_allowed() {
|
||||||
|
for (color_str, _) in COLOR_TABLE.iter() {
|
||||||
|
let color = extract_color(&CompletionItem {
|
||||||
|
kind: Some(CompletionItemKind::COLOR),
|
||||||
|
label: format!("{} foo", color_str).to_string(),
|
||||||
|
detail: None,
|
||||||
|
documentation: None,
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
|
||||||
|
assert_eq!(color, None);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn can_extract_from_detail() {
|
||||||
|
for (color_str, color_val) in COLOR_TABLE.iter() {
|
||||||
|
let color = extract_color(&CompletionItem {
|
||||||
|
kind: Some(CompletionItemKind::COLOR),
|
||||||
|
label: "".to_string(),
|
||||||
|
detail: Some(color_str.to_string()),
|
||||||
|
documentation: None,
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
|
||||||
|
assert_eq!(color, color_val.map(|v| Hsla::from(rgba(v))));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn only_whole_detail_matches_are_allowed() {
|
||||||
|
for (color_str, _) in COLOR_TABLE.iter() {
|
||||||
|
let color = extract_color(&CompletionItem {
|
||||||
|
kind: Some(CompletionItemKind::COLOR),
|
||||||
|
label: "".to_string(),
|
||||||
|
detail: Some(format!("{} foo", color_str).to_string()),
|
||||||
|
documentation: None,
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
|
||||||
|
assert_eq!(color, None);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn can_extract_from_documentation_start() {
|
||||||
|
for (color_str, color_val) in COLOR_TABLE.iter() {
|
||||||
|
let color = extract_color(&CompletionItem {
|
||||||
|
kind: Some(CompletionItemKind::COLOR),
|
||||||
|
label: "".to_string(),
|
||||||
|
detail: None,
|
||||||
|
documentation: Some(Documentation::String(
|
||||||
|
format!("{} foo", color_str).to_string(),
|
||||||
|
)),
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
|
||||||
|
assert_eq!(color, color_val.map(|v| Hsla::from(rgba(v))));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn can_extract_from_documentation_end() {
|
||||||
|
for (color_str, color_val) in COLOR_TABLE.iter() {
|
||||||
|
let color = extract_color(&CompletionItem {
|
||||||
|
kind: Some(CompletionItemKind::COLOR),
|
||||||
|
label: "".to_string(),
|
||||||
|
detail: None,
|
||||||
|
documentation: Some(Documentation::String(
|
||||||
|
format!("foo {}", color_str).to_string(),
|
||||||
|
)),
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
|
||||||
|
assert_eq!(color, color_val.map(|v| Hsla::from(rgba(v))));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cannot_extract_from_documentation_middle() {
|
||||||
|
for (color_str, _) in COLOR_TABLE.iter() {
|
||||||
|
let color = extract_color(&CompletionItem {
|
||||||
|
kind: Some(CompletionItemKind::COLOR),
|
||||||
|
label: "".to_string(),
|
||||||
|
detail: None,
|
||||||
|
documentation: Some(Documentation::String(
|
||||||
|
format!("foo {} foo", color_str).to_string(),
|
||||||
|
)),
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
|
||||||
|
assert_eq!(color, None);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,4 +1,5 @@
|
||||||
pub mod buffer_store;
|
pub mod buffer_store;
|
||||||
|
mod color_extractor;
|
||||||
pub mod connection_manager;
|
pub mod connection_manager;
|
||||||
pub mod debounced_delay;
|
pub mod debounced_delay;
|
||||||
pub mod lsp_command;
|
pub mod lsp_command;
|
||||||
|
@ -36,7 +37,7 @@ use futures::{
|
||||||
|
|
||||||
use git::{blame::Blame, repository::GitRepository};
|
use git::{blame::Blame, repository::GitRepository};
|
||||||
use gpui::{
|
use gpui::{
|
||||||
AnyModel, AppContext, AsyncAppContext, BorrowAppContext, Context, EventEmitter, Model,
|
AnyModel, AppContext, AsyncAppContext, BorrowAppContext, Context, EventEmitter, Hsla, Model,
|
||||||
ModelContext, SharedString, Task, WeakModel, WindowContext,
|
ModelContext, SharedString, Task, WeakModel, WindowContext,
|
||||||
};
|
};
|
||||||
use itertools::Itertools;
|
use itertools::Itertools;
|
||||||
|
@ -47,7 +48,9 @@ use language::{
|
||||||
Documentation, File as _, Language, LanguageRegistry, LanguageServerName, PointUtf16, ToOffset,
|
Documentation, File as _, Language, LanguageRegistry, LanguageServerName, PointUtf16, ToOffset,
|
||||||
ToPointUtf16, Transaction, Unclipped,
|
ToPointUtf16, Transaction, Unclipped,
|
||||||
};
|
};
|
||||||
use lsp::{CompletionContext, DocumentHighlightKind, LanguageServer, LanguageServerId};
|
use lsp::{
|
||||||
|
CompletionContext, CompletionItemKind, DocumentHighlightKind, LanguageServer, LanguageServerId,
|
||||||
|
};
|
||||||
use lsp_command::*;
|
use lsp_command::*;
|
||||||
use node_runtime::NodeRuntime;
|
use node_runtime::NodeRuntime;
|
||||||
use parking_lot::{Mutex, RwLock};
|
use parking_lot::{Mutex, RwLock};
|
||||||
|
@ -4445,6 +4448,16 @@ impl Completion {
|
||||||
pub fn is_snippet(&self) -> bool {
|
pub fn is_snippet(&self) -> bool {
|
||||||
self.lsp_completion.insert_text_format == Some(lsp::InsertTextFormat::SNIPPET)
|
self.lsp_completion.insert_text_format == Some(lsp::InsertTextFormat::SNIPPET)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Returns the corresponding color for this completion.
|
||||||
|
///
|
||||||
|
/// Will return `None` if this completion's kind is not [`CompletionItemKind::COLOR`].
|
||||||
|
pub fn color(&self) -> Option<Hsla> {
|
||||||
|
match self.lsp_completion.kind {
|
||||||
|
Some(CompletionItemKind::COLOR) => color_extractor::extract_color(&self.lsp_completion),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
|
|
|
@ -58,6 +58,8 @@ extend-ignore-re = [
|
||||||
# ProtoLS crate with tree-sitter Protobuf grammar.
|
# ProtoLS crate with tree-sitter Protobuf grammar.
|
||||||
"protols",
|
"protols",
|
||||||
# x11rb SelectionNotifyEvent struct field
|
# x11rb SelectionNotifyEvent struct field
|
||||||
"requestor"
|
"requestor",
|
||||||
|
# Not an actual typo but an intentionally invalid color, in `color_extractor`
|
||||||
|
"#fof"
|
||||||
]
|
]
|
||||||
check-filename = true
|
check-filename = true
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue