Self-Review Checklist: - [X] I've reviewed my own diff for quality, security, and reliability - [ ] Unsafe blocks (if any) have justifying comments - [ ] The content is consistent with the [UI/UX checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) - [ ] Tests cover the new/changed behavior - [ ] Performance impact has been considered and is acceptable Release Notes: - Added the option for highlights from languages to specify fallbacks. That means that if you have a pattern with the captures `@second.capture @first.capture`, Zed will first try resolving a highlight from your theme for the code fragment using the first capture, then look for the second capture if no match for the first capture could be found. --------- Co-authored-by: Kirill Bulatov <kirill@zed.dev>
1186 lines
43 KiB
Rust
1186 lines
43 KiB
Rust
use editor::{
|
|
Anchor, Editor, HighlightKey, MultiBufferSnapshot, SelectionEffects, ToPoint,
|
|
scroll::Autoscroll,
|
|
};
|
|
use gpui::{
|
|
Action, App, AppContext as _, Context, Corner, Div, Entity, EntityId, EventEmitter,
|
|
FocusHandle, Focusable, HighlightStyle, Hsla, InteractiveElement, IntoElement, MouseButton,
|
|
MouseDownEvent, MouseMoveEvent, ParentElement, Render, ScrollStrategy, SharedString, Styled,
|
|
Task, UniformListScrollHandle, WeakEntity, Window, actions, div, rems, uniform_list,
|
|
};
|
|
use language::{BufferId, Point, ToOffset};
|
|
use menu::{SelectNext, SelectPrevious};
|
|
use std::{mem, ops::Range};
|
|
use theme::ActiveTheme;
|
|
use ui::{
|
|
ButtonCommon, ButtonLike, ButtonStyle, Color, ContextMenu, FluentBuilder as _, IconButton,
|
|
IconName, IconPosition, IconSize, Label, LabelCommon, LabelSize, PopoverMenu,
|
|
PopoverMenuHandle, StyledExt, Toggleable, Tooltip, WithScrollbar, h_flex, v_flex,
|
|
};
|
|
use workspace::{
|
|
Event as WorkspaceEvent, SplitDirection, ToolbarItemEvent, ToolbarItemLocation,
|
|
ToolbarItemView, Workspace,
|
|
item::{Item, ItemHandle},
|
|
};
|
|
|
|
actions!(
|
|
dev,
|
|
[
|
|
/// Opens the highlights tree view for the current file.
|
|
OpenHighlightsTreeView,
|
|
]
|
|
);
|
|
|
|
actions!(
|
|
highlights_tree_view,
|
|
[
|
|
/// Toggles showing text highlights.
|
|
ToggleTextHighlights,
|
|
/// Toggles showing semantic token highlights.
|
|
ToggleSemanticTokens,
|
|
/// Toggles showing syntax token highlights.
|
|
ToggleSyntaxTokens,
|
|
]
|
|
);
|
|
|
|
pub fn init(cx: &mut App) {
|
|
cx.observe_new(move |workspace: &mut Workspace, _, _| {
|
|
workspace.register_action(move |workspace, _: &OpenHighlightsTreeView, window, cx| {
|
|
let active_item = workspace.active_item(cx);
|
|
let workspace_handle = workspace.weak_handle();
|
|
let highlights_tree_view =
|
|
cx.new(|cx| HighlightsTreeView::new(workspace_handle, active_item, window, cx));
|
|
workspace.split_item(
|
|
SplitDirection::Right,
|
|
Box::new(highlights_tree_view),
|
|
window,
|
|
cx,
|
|
)
|
|
});
|
|
})
|
|
.detach();
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
|
|
pub enum HighlightCategory {
|
|
Text(HighlightKey),
|
|
SyntaxToken {
|
|
capture_name: SharedString,
|
|
theme_key: Option<SharedString>,
|
|
},
|
|
SemanticToken {
|
|
token_type: Option<SharedString>,
|
|
token_modifiers: Option<SharedString>,
|
|
theme_key: Option<SharedString>,
|
|
},
|
|
}
|
|
|
|
impl HighlightCategory {
|
|
fn label(&self) -> SharedString {
|
|
match self {
|
|
HighlightCategory::Text(key) => format!("text: {key:?}").into(),
|
|
HighlightCategory::SyntaxToken {
|
|
capture_name,
|
|
theme_key: Some(theme_key),
|
|
} => format!("syntax: {capture_name} \u{2192} {theme_key}").into(),
|
|
HighlightCategory::SyntaxToken {
|
|
capture_name,
|
|
theme_key: None,
|
|
} => format!("syntax: {capture_name}").into(),
|
|
HighlightCategory::SemanticToken {
|
|
token_type,
|
|
token_modifiers,
|
|
theme_key,
|
|
} => {
|
|
let label = match (token_type, token_modifiers) {
|
|
(Some(token_type), Some(modifiers)) => {
|
|
format!("semantic token: {token_type} [{modifiers}]")
|
|
}
|
|
(Some(token_type), None) => format!("semantic token: {token_type}"),
|
|
(None, Some(modifiers)) => format!("semantic token [{modifiers}]"),
|
|
(None, None) => "semantic token".to_string(),
|
|
};
|
|
|
|
if let Some(theme_key) = theme_key {
|
|
format!("{label} \u{2192} {theme_key}").into()
|
|
} else {
|
|
label.into()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
struct HighlightEntry {
|
|
range: Range<Anchor>,
|
|
buffer_id: BufferId,
|
|
buffer_point_range: Range<Point>,
|
|
range_display: SharedString,
|
|
style: HighlightStyle,
|
|
category: HighlightCategory,
|
|
}
|
|
|
|
/// An item in the display list: either a separator between excerpts or a highlight entry.
|
|
#[derive(Debug, Clone)]
|
|
enum DisplayItem {
|
|
ExcerptSeparator {
|
|
label: SharedString,
|
|
},
|
|
Entry {
|
|
/// Index into `cached_entries`.
|
|
entry_ix: usize,
|
|
},
|
|
}
|
|
|
|
pub struct HighlightsTreeView {
|
|
workspace_handle: WeakEntity<Workspace>,
|
|
editor: Option<EditorState>,
|
|
list_scroll_handle: UniformListScrollHandle,
|
|
selected_item_ix: Option<usize>,
|
|
hovered_item_ix: Option<usize>,
|
|
focus_handle: FocusHandle,
|
|
cached_entries: Vec<HighlightEntry>,
|
|
display_items: Vec<DisplayItem>,
|
|
is_singleton: bool,
|
|
show_text_highlights: bool,
|
|
show_syntax_tokens: bool,
|
|
show_semantic_tokens: bool,
|
|
skip_next_scroll: bool,
|
|
}
|
|
|
|
pub struct HighlightsTreeToolbarItemView {
|
|
tree_view: Option<Entity<HighlightsTreeView>>,
|
|
_subscription: Option<gpui::Subscription>,
|
|
toggle_settings_handle: PopoverMenuHandle<ContextMenu>,
|
|
}
|
|
|
|
struct EditorState {
|
|
editor: Entity<Editor>,
|
|
_subscription: gpui::Subscription,
|
|
}
|
|
|
|
impl HighlightsTreeView {
|
|
pub fn new(
|
|
workspace_handle: WeakEntity<Workspace>,
|
|
active_item: Option<Box<dyn ItemHandle>>,
|
|
window: &mut Window,
|
|
cx: &mut Context<Self>,
|
|
) -> Self {
|
|
let mut this = Self {
|
|
workspace_handle: workspace_handle.clone(),
|
|
list_scroll_handle: UniformListScrollHandle::new(),
|
|
editor: None,
|
|
hovered_item_ix: None,
|
|
selected_item_ix: None,
|
|
focus_handle: cx.focus_handle(),
|
|
cached_entries: Vec::new(),
|
|
display_items: Vec::new(),
|
|
is_singleton: true,
|
|
show_text_highlights: true,
|
|
show_syntax_tokens: true,
|
|
show_semantic_tokens: true,
|
|
skip_next_scroll: false,
|
|
};
|
|
|
|
this.handle_item_updated(active_item, window, cx);
|
|
|
|
cx.subscribe_in(
|
|
&workspace_handle.upgrade().unwrap(),
|
|
window,
|
|
move |this, workspace, event, window, cx| match event {
|
|
WorkspaceEvent::ItemAdded { .. } | WorkspaceEvent::ActiveItemChanged => {
|
|
this.handle_item_updated(workspace.read(cx).active_item(cx), window, cx)
|
|
}
|
|
WorkspaceEvent::ItemRemoved { item_id } => {
|
|
this.handle_item_removed(item_id, window, cx);
|
|
}
|
|
_ => {}
|
|
},
|
|
)
|
|
.detach();
|
|
|
|
this
|
|
}
|
|
|
|
fn handle_item_updated(
|
|
&mut self,
|
|
active_item: Option<Box<dyn ItemHandle>>,
|
|
window: &mut Window,
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
let active_editor = match active_item {
|
|
Some(active_item) => {
|
|
if active_item.item_id() == cx.entity_id() {
|
|
return;
|
|
} else {
|
|
match active_item.downcast::<Editor>() {
|
|
Some(active_editor) => active_editor,
|
|
None => {
|
|
self.clear(cx);
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
None => {
|
|
self.clear(cx);
|
|
return;
|
|
}
|
|
};
|
|
|
|
let is_different_editor = self
|
|
.editor
|
|
.as_ref()
|
|
.is_none_or(|state| state.editor != active_editor);
|
|
if is_different_editor {
|
|
self.set_editor(active_editor, window, cx);
|
|
}
|
|
}
|
|
|
|
fn handle_item_removed(
|
|
&mut self,
|
|
item_id: &EntityId,
|
|
_window: &mut Window,
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
if self
|
|
.editor
|
|
.as_ref()
|
|
.is_some_and(|state| state.editor.entity_id() == *item_id)
|
|
{
|
|
self.clear(cx);
|
|
}
|
|
}
|
|
|
|
fn clear(&mut self, cx: &mut Context<Self>) {
|
|
self.cached_entries.clear();
|
|
self.display_items.clear();
|
|
self.selected_item_ix = None;
|
|
self.hovered_item_ix = None;
|
|
if let Some(state) = self.editor.take() {
|
|
Self::clear_editor_highlights(&state.editor, cx);
|
|
}
|
|
cx.notify();
|
|
}
|
|
|
|
fn set_editor(&mut self, editor: Entity<Editor>, window: &mut Window, cx: &mut Context<Self>) {
|
|
if let Some(state) = &self.editor {
|
|
if state.editor == editor {
|
|
return;
|
|
}
|
|
Self::clear_editor_highlights(&state.editor, cx);
|
|
}
|
|
|
|
let subscription =
|
|
cx.subscribe_in(&editor, window, |this, _, event, window, cx| match event {
|
|
editor::EditorEvent::Reparsed(_)
|
|
| editor::EditorEvent::SelectionsChanged { .. } => {
|
|
this.refresh_highlights(window, cx);
|
|
}
|
|
_ => return,
|
|
});
|
|
|
|
self.editor = Some(EditorState {
|
|
editor,
|
|
_subscription: subscription,
|
|
});
|
|
self.refresh_highlights(window, cx);
|
|
}
|
|
|
|
fn refresh_highlights(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
|
|
let Some(editor_state) = self.editor.as_ref() else {
|
|
self.clear(cx);
|
|
return;
|
|
};
|
|
|
|
let (display_map, project, multi_buffer, cursor_position) = {
|
|
let editor = editor_state.editor.read(cx);
|
|
let cursor = editor.selections.newest_anchor().head();
|
|
(
|
|
editor.display_map.clone(),
|
|
editor.project().cloned(),
|
|
editor.buffer().clone(),
|
|
cursor,
|
|
)
|
|
};
|
|
let Some(project) = project else {
|
|
return;
|
|
};
|
|
|
|
let multi_buffer_snapshot = multi_buffer.read(cx).snapshot(cx);
|
|
let is_singleton = multi_buffer_snapshot.is_singleton();
|
|
self.is_singleton = is_singleton;
|
|
|
|
let mut entries = Vec::new();
|
|
|
|
let semantic_theme = cx.theme().syntax().clone();
|
|
display_map.update(cx, |display_map, cx| {
|
|
for (key, text_highlights) in display_map.all_text_highlights() {
|
|
for range in &text_highlights.1 {
|
|
let Some((range_display, buffer_id, buffer_point_range)) =
|
|
format_anchor_range(range, &multi_buffer_snapshot)
|
|
else {
|
|
continue;
|
|
};
|
|
entries.push(HighlightEntry {
|
|
range: range.clone(),
|
|
buffer_id,
|
|
range_display,
|
|
style: text_highlights.0,
|
|
category: HighlightCategory::Text(*key),
|
|
buffer_point_range,
|
|
});
|
|
}
|
|
}
|
|
|
|
project.read(cx).lsp_store().update(cx, |lsp_store, cx| {
|
|
for (buffer_id, (tokens, interner)) in display_map.all_semantic_token_highlights() {
|
|
let language_name = multi_buffer
|
|
.read(cx)
|
|
.buffer(*buffer_id)
|
|
.and_then(|buf| buf.read(cx).language().map(|l| l.name()));
|
|
for token in tokens.iter() {
|
|
let range = token.range.start..token.range.end;
|
|
let Some((range_display, entry_buffer_id, buffer_point_range)) =
|
|
format_anchor_range(&range, &multi_buffer_snapshot)
|
|
else {
|
|
continue;
|
|
};
|
|
let Some(stylizer) = lsp_store.get_or_create_token_stylizer(
|
|
token.server_id,
|
|
language_name.as_ref(),
|
|
cx,
|
|
) else {
|
|
continue;
|
|
};
|
|
|
|
let theme_key =
|
|
stylizer
|
|
.rules_for_token(token.token_type)
|
|
.and_then(|rules| {
|
|
rules
|
|
.iter()
|
|
.filter(|rule| {
|
|
rule.token_modifiers.iter().all(|modifier| {
|
|
stylizer
|
|
.has_modifier(token.token_modifiers, modifier)
|
|
})
|
|
})
|
|
.fold(None, |theme_key, rule| {
|
|
rule.style
|
|
.iter()
|
|
.find(|style_name| {
|
|
semantic_theme
|
|
.style_for_name(style_name)
|
|
.is_some()
|
|
})
|
|
.map(|style_name| {
|
|
SharedString::from(style_name.clone())
|
|
})
|
|
.or(theme_key)
|
|
})
|
|
});
|
|
|
|
entries.push(HighlightEntry {
|
|
range,
|
|
buffer_id: entry_buffer_id,
|
|
range_display,
|
|
style: interner[token.style],
|
|
category: HighlightCategory::SemanticToken {
|
|
token_type: stylizer.token_type_name(token.token_type).cloned(),
|
|
token_modifiers: stylizer
|
|
.token_modifiers(token.token_modifiers)
|
|
.map(SharedString::from),
|
|
theme_key,
|
|
},
|
|
buffer_point_range,
|
|
});
|
|
}
|
|
}
|
|
});
|
|
});
|
|
|
|
let syntax_theme = cx.theme().syntax().clone();
|
|
for excerpt_range in multi_buffer_snapshot.excerpts() {
|
|
let Some(buffer_snapshot) =
|
|
multi_buffer_snapshot.buffer_for_id(excerpt_range.context.start.buffer_id)
|
|
else {
|
|
continue;
|
|
};
|
|
|
|
let start_offset = excerpt_range.context.start.to_offset(buffer_snapshot);
|
|
let end_offset = excerpt_range.context.end.to_offset(buffer_snapshot);
|
|
let range = start_offset..end_offset;
|
|
|
|
let captures = buffer_snapshot.captures(range, |grammar| {
|
|
grammar.highlights_config.as_ref().map(|c| &c.query)
|
|
});
|
|
let grammars: Vec<_> = captures.grammars().to_vec();
|
|
let highlight_maps: Vec<_> = grammars.iter().map(|g| g.highlight_map()).collect();
|
|
|
|
for capture in captures {
|
|
let Some(highlight_id) = highlight_maps[capture.grammar_index].get(capture.index)
|
|
else {
|
|
continue;
|
|
};
|
|
let Some(style) = syntax_theme.get(highlight_id).cloned() else {
|
|
continue;
|
|
};
|
|
|
|
let theme_key = syntax_theme
|
|
.get_capture_name(highlight_id)
|
|
.map(|theme_key| SharedString::from(theme_key.to_string()));
|
|
|
|
let capture_name = grammars[capture.grammar_index]
|
|
.highlights_config
|
|
.as_ref()
|
|
.and_then(|config| config.query.capture_names().get(capture.index as usize))
|
|
.map(|capture_name| SharedString::from((*capture_name).to_string()))
|
|
.unwrap_or_else(|| SharedString::from("unknown"));
|
|
|
|
let start_anchor = buffer_snapshot.anchor_before(capture.node.start_byte());
|
|
let end_anchor = buffer_snapshot.anchor_after(capture.node.end_byte());
|
|
|
|
let start = multi_buffer_snapshot.anchor_in_excerpt(start_anchor);
|
|
let end = multi_buffer_snapshot.anchor_in_excerpt(end_anchor);
|
|
|
|
let (start, end) = match (start, end) {
|
|
(Some(s), Some(e)) => (s, e),
|
|
_ => continue,
|
|
};
|
|
|
|
let range = start..end;
|
|
let Some((range_display, buffer_id, buffer_point_range)) =
|
|
format_anchor_range(&range, &multi_buffer_snapshot)
|
|
else {
|
|
continue;
|
|
};
|
|
|
|
entries.push(HighlightEntry {
|
|
range,
|
|
buffer_id,
|
|
range_display,
|
|
style,
|
|
category: HighlightCategory::SyntaxToken {
|
|
capture_name,
|
|
theme_key,
|
|
},
|
|
buffer_point_range,
|
|
});
|
|
}
|
|
}
|
|
|
|
entries.sort_by(|a, b| {
|
|
a.buffer_id
|
|
.cmp(&b.buffer_id)
|
|
.then_with(|| a.buffer_point_range.start.cmp(&b.buffer_point_range.start))
|
|
.then_with(|| a.buffer_point_range.end.cmp(&b.buffer_point_range.end))
|
|
.then_with(|| a.category.cmp(&b.category))
|
|
});
|
|
entries.dedup_by(|a, b| {
|
|
a.buffer_id == b.buffer_id
|
|
&& a.buffer_point_range == b.buffer_point_range
|
|
&& a.category == b.category
|
|
});
|
|
|
|
self.cached_entries = entries;
|
|
self.rebuild_display_items(&multi_buffer_snapshot, cx);
|
|
|
|
if self.skip_next_scroll {
|
|
self.skip_next_scroll = false;
|
|
} else {
|
|
self.scroll_to_cursor_position(&cursor_position, &multi_buffer_snapshot);
|
|
}
|
|
cx.notify();
|
|
}
|
|
|
|
fn rebuild_display_items(&mut self, snapshot: &MultiBufferSnapshot, cx: &App) {
|
|
self.display_items.clear();
|
|
|
|
let mut last_range_end: Option<Anchor> = None;
|
|
|
|
for (entry_ix, entry) in self.cached_entries.iter().enumerate() {
|
|
if !self.should_show_entry(entry) {
|
|
continue;
|
|
}
|
|
|
|
if !self.is_singleton {
|
|
let excerpt_changed = last_range_end.is_none_or(|anchor| {
|
|
snapshot
|
|
.excerpt_containing(anchor..entry.range.start)
|
|
.is_none()
|
|
});
|
|
if excerpt_changed {
|
|
last_range_end = Some(entry.range.end);
|
|
let label = excerpt_label_for(entry, snapshot, cx);
|
|
self.display_items
|
|
.push(DisplayItem::ExcerptSeparator { label });
|
|
}
|
|
}
|
|
|
|
self.display_items.push(DisplayItem::Entry { entry_ix });
|
|
}
|
|
}
|
|
|
|
fn should_show_entry(&self, entry: &HighlightEntry) -> bool {
|
|
match entry.category {
|
|
HighlightCategory::Text(_) => self.show_text_highlights,
|
|
HighlightCategory::SyntaxToken { .. } => self.show_syntax_tokens,
|
|
HighlightCategory::SemanticToken { .. } => self.show_semantic_tokens,
|
|
}
|
|
}
|
|
|
|
fn scroll_to_cursor_position(&mut self, cursor: &Anchor, snapshot: &MultiBufferSnapshot) {
|
|
let best = self
|
|
.display_items
|
|
.iter()
|
|
.enumerate()
|
|
.filter_map(|(display_ix, item)| match item {
|
|
DisplayItem::Entry { entry_ix } => {
|
|
let entry = &self.cached_entries[*entry_ix];
|
|
Some((display_ix, *entry_ix, entry))
|
|
}
|
|
_ => None,
|
|
})
|
|
.filter(|(_, _, entry)| {
|
|
entry.range.start.cmp(&cursor, snapshot).is_le()
|
|
&& cursor.cmp(&entry.range.end, snapshot).is_lt()
|
|
})
|
|
.min_by_key(|(_, _, entry)| {
|
|
(
|
|
entry.buffer_point_range.end.row - entry.buffer_point_range.start.row,
|
|
entry
|
|
.buffer_point_range
|
|
.end
|
|
.column
|
|
.saturating_sub(entry.buffer_point_range.start.column),
|
|
)
|
|
})
|
|
.map(|(display_ix, entry_ix, _)| (display_ix, entry_ix));
|
|
|
|
if let Some((display_ix, entry_ix)) = best {
|
|
self.selected_item_ix = Some(entry_ix);
|
|
self.list_scroll_handle
|
|
.scroll_to_item(display_ix, ScrollStrategy::Center);
|
|
}
|
|
}
|
|
|
|
fn update_editor_with_range_for_entry(
|
|
&self,
|
|
entry_ix: usize,
|
|
window: &mut Window,
|
|
cx: &mut Context<Self>,
|
|
f: &mut dyn FnMut(&mut Editor, Range<Anchor>, usize, &mut Window, &mut Context<Editor>),
|
|
) -> Option<()> {
|
|
let editor_state = self.editor.as_ref()?;
|
|
let entry = self.cached_entries.get(entry_ix)?;
|
|
let range = entry.range.clone();
|
|
let key = cx.entity_id().as_u64() as usize;
|
|
|
|
editor_state.editor.update(cx, |editor, cx| {
|
|
f(editor, range, key, window, cx);
|
|
});
|
|
Some(())
|
|
}
|
|
|
|
fn render_entry(&self, entry: &HighlightEntry, selected: bool, cx: &App) -> Div {
|
|
let colors = cx.theme().colors();
|
|
let style_preview = render_style_preview(entry.style, selected, cx);
|
|
|
|
h_flex()
|
|
.gap_1()
|
|
.child(style_preview)
|
|
.child(Label::new(entry.range_display.clone()).color(Color::Default))
|
|
.child(
|
|
Label::new(entry.category.label())
|
|
.size(LabelSize::Small)
|
|
.color(Color::Muted),
|
|
)
|
|
.text_bg(if selected {
|
|
colors.element_selected
|
|
} else {
|
|
Hsla::default()
|
|
})
|
|
.pl(rems(0.5))
|
|
.hover(|style| style.bg(colors.element_hover))
|
|
}
|
|
|
|
fn render_separator(&self, label: &SharedString, cx: &App) -> Div {
|
|
let colors = cx.theme().colors();
|
|
h_flex()
|
|
.gap_1()
|
|
.px(rems(0.5))
|
|
.bg(colors.surface_background)
|
|
.border_b_1()
|
|
.border_color(colors.border_variant)
|
|
.child(
|
|
Label::new(label.clone())
|
|
.size(LabelSize::Small)
|
|
.color(Color::Muted),
|
|
)
|
|
}
|
|
|
|
fn compute_items(
|
|
&mut self,
|
|
visible_range: Range<usize>,
|
|
_window: &mut Window,
|
|
cx: &mut Context<Self>,
|
|
) -> Vec<Div> {
|
|
let mut items = Vec::new();
|
|
|
|
for display_ix in visible_range {
|
|
let Some(display_item) = self.display_items.get(display_ix) else {
|
|
continue;
|
|
};
|
|
|
|
match display_item {
|
|
DisplayItem::ExcerptSeparator { label } => {
|
|
items.push(self.render_separator(label, cx));
|
|
}
|
|
DisplayItem::Entry { entry_ix } => {
|
|
let entry_ix = *entry_ix;
|
|
let entry = &self.cached_entries[entry_ix];
|
|
let selected = Some(entry_ix) == self.selected_item_ix;
|
|
let rendered = self
|
|
.render_entry(entry, selected, cx)
|
|
.on_mouse_down(
|
|
MouseButton::Left,
|
|
cx.listener(move |tree_view, _: &MouseDownEvent, window, cx| {
|
|
tree_view.selected_item_ix = Some(entry_ix);
|
|
tree_view.skip_next_scroll = true;
|
|
tree_view.update_editor_with_range_for_entry(
|
|
entry_ix,
|
|
window,
|
|
cx,
|
|
&mut |editor, mut range, _, window, cx| {
|
|
mem::swap(&mut range.start, &mut range.end);
|
|
editor.change_selections(
|
|
SelectionEffects::scroll(Autoscroll::newest()),
|
|
window,
|
|
cx,
|
|
|selections| {
|
|
selections.select_ranges([range]);
|
|
},
|
|
);
|
|
},
|
|
);
|
|
cx.notify();
|
|
}),
|
|
)
|
|
.on_mouse_move(cx.listener(
|
|
move |tree_view, _: &MouseMoveEvent, window, cx| {
|
|
if tree_view.hovered_item_ix != Some(entry_ix) {
|
|
tree_view.hovered_item_ix = Some(entry_ix);
|
|
tree_view.update_editor_with_range_for_entry(
|
|
entry_ix,
|
|
window,
|
|
cx,
|
|
&mut |editor, range, key, _, cx| {
|
|
Self::set_editor_highlights(editor, key, &[range], cx);
|
|
},
|
|
);
|
|
cx.notify();
|
|
}
|
|
},
|
|
));
|
|
|
|
items.push(rendered);
|
|
}
|
|
}
|
|
}
|
|
|
|
items
|
|
}
|
|
|
|
fn set_editor_highlights(
|
|
editor: &mut Editor,
|
|
key: usize,
|
|
ranges: &[Range<Anchor>],
|
|
cx: &mut Context<Editor>,
|
|
) {
|
|
editor.highlight_background(
|
|
HighlightKey::HighlightsTreeView(key),
|
|
ranges,
|
|
|_, theme| theme.colors().editor_document_highlight_write_background,
|
|
cx,
|
|
);
|
|
}
|
|
|
|
fn clear_editor_highlights(editor: &Entity<Editor>, cx: &mut Context<Self>) {
|
|
let highlight_key = HighlightKey::HighlightsTreeView(cx.entity_id().as_u64() as usize);
|
|
editor.update(cx, |editor, cx| {
|
|
editor.clear_background_highlights(highlight_key, cx);
|
|
});
|
|
}
|
|
|
|
fn select_previous(&mut self, _: &SelectPrevious, window: &mut Window, cx: &mut Context<Self>) {
|
|
self.move_selection(-1, window, cx);
|
|
}
|
|
|
|
fn select_next(&mut self, _: &SelectNext, window: &mut Window, cx: &mut Context<Self>) {
|
|
self.move_selection(1, window, cx);
|
|
}
|
|
|
|
fn move_selection(&mut self, delta: i32, window: &mut Window, cx: &mut Context<Self>) {
|
|
if self.display_items.is_empty() {
|
|
return;
|
|
}
|
|
|
|
let entry_display_items: Vec<(usize, usize)> = self
|
|
.display_items
|
|
.iter()
|
|
.enumerate()
|
|
.filter_map(|(display_ix, item)| match item {
|
|
DisplayItem::Entry { entry_ix } => Some((display_ix, *entry_ix)),
|
|
_ => None,
|
|
})
|
|
.collect();
|
|
|
|
if entry_display_items.is_empty() {
|
|
return;
|
|
}
|
|
|
|
let current_pos = self
|
|
.selected_item_ix
|
|
.and_then(|selected| {
|
|
entry_display_items
|
|
.iter()
|
|
.position(|(_, entry_ix)| *entry_ix == selected)
|
|
})
|
|
.unwrap_or(0);
|
|
|
|
let new_pos = if delta < 0 {
|
|
current_pos.saturating_sub((-delta) as usize)
|
|
} else {
|
|
(current_pos + delta as usize).min(entry_display_items.len() - 1)
|
|
};
|
|
|
|
if let Some(&(display_ix, entry_ix)) = entry_display_items.get(new_pos) {
|
|
self.selected_item_ix = Some(entry_ix);
|
|
self.skip_next_scroll = true;
|
|
self.list_scroll_handle
|
|
.scroll_to_item(display_ix, ScrollStrategy::Center);
|
|
|
|
self.update_editor_with_range_for_entry(
|
|
entry_ix,
|
|
window,
|
|
cx,
|
|
&mut |editor, mut range, _, window, cx| {
|
|
mem::swap(&mut range.start, &mut range.end);
|
|
editor.change_selections(
|
|
SelectionEffects::scroll(Autoscroll::newest()),
|
|
window,
|
|
cx,
|
|
|selections| {
|
|
selections.select_ranges([range]);
|
|
},
|
|
);
|
|
},
|
|
);
|
|
|
|
cx.notify();
|
|
}
|
|
}
|
|
|
|
fn entry_count(&self) -> usize {
|
|
self.cached_entries
|
|
.iter()
|
|
.filter(|entry| self.should_show_entry(entry))
|
|
.count()
|
|
}
|
|
}
|
|
|
|
impl Render for HighlightsTreeView {
|
|
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
|
let display_count = self.display_items.len();
|
|
|
|
div()
|
|
.flex_1()
|
|
.track_focus(&self.focus_handle)
|
|
.key_context("HighlightsTreeView")
|
|
.on_action(cx.listener(Self::select_previous))
|
|
.on_action(cx.listener(Self::select_next))
|
|
.bg(cx.theme().colors().editor_background)
|
|
.map(|this| {
|
|
if display_count > 0 {
|
|
this.child(
|
|
uniform_list(
|
|
"HighlightsTreeView",
|
|
display_count,
|
|
cx.processor(move |this, range: Range<usize>, window, cx| {
|
|
this.compute_items(range, window, cx)
|
|
}),
|
|
)
|
|
.size_full()
|
|
.track_scroll(&self.list_scroll_handle)
|
|
.text_bg(cx.theme().colors().background)
|
|
.into_any_element(),
|
|
)
|
|
.vertical_scrollbar_for(&self.list_scroll_handle, window, cx)
|
|
.into_any_element()
|
|
} else {
|
|
let inner_content = v_flex()
|
|
.items_center()
|
|
.text_center()
|
|
.gap_2()
|
|
.max_w_3_5()
|
|
.map(|this| {
|
|
if self.editor.is_some() {
|
|
let has_any = !self.cached_entries.is_empty();
|
|
if has_any {
|
|
this.child(Label::new("All highlights are filtered out"))
|
|
.child(
|
|
Label::new(
|
|
"Enable text, syntax, or semantic highlights in the toolbar",
|
|
)
|
|
.size(LabelSize::Small),
|
|
)
|
|
} else {
|
|
this.child(Label::new("No highlights found")).child(
|
|
Label::new(
|
|
"The editor has no text, syntax, or semantic token highlights",
|
|
)
|
|
.size(LabelSize::Small),
|
|
)
|
|
}
|
|
} else {
|
|
this.child(Label::new("Not attached to an editor")).child(
|
|
Label::new("Focus an editor to show highlights")
|
|
.size(LabelSize::Small),
|
|
)
|
|
}
|
|
});
|
|
|
|
this.h_flex()
|
|
.size_full()
|
|
.justify_center()
|
|
.child(inner_content)
|
|
.into_any_element()
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
impl EventEmitter<()> for HighlightsTreeView {}
|
|
|
|
impl Focusable for HighlightsTreeView {
|
|
fn focus_handle(&self, _: &App) -> gpui::FocusHandle {
|
|
self.focus_handle.clone()
|
|
}
|
|
}
|
|
|
|
impl Item for HighlightsTreeView {
|
|
type Event = ();
|
|
|
|
fn to_item_events(_: &Self::Event, _: &mut dyn FnMut(workspace::item::ItemEvent)) {}
|
|
|
|
fn tab_content_text(&self, _detail: usize, _cx: &App) -> SharedString {
|
|
"Highlights".into()
|
|
}
|
|
|
|
fn telemetry_event_text(&self) -> Option<&'static str> {
|
|
None
|
|
}
|
|
|
|
fn can_split(&self) -> bool {
|
|
true
|
|
}
|
|
|
|
fn clone_on_split(
|
|
&self,
|
|
_: Option<workspace::WorkspaceId>,
|
|
window: &mut Window,
|
|
cx: &mut Context<Self>,
|
|
) -> Task<Option<Entity<Self>>>
|
|
where
|
|
Self: Sized,
|
|
{
|
|
Task::ready(Some(cx.new(|cx| {
|
|
let mut clone = Self::new(self.workspace_handle.clone(), None, window, cx);
|
|
clone.show_text_highlights = self.show_text_highlights;
|
|
clone.show_syntax_tokens = self.show_syntax_tokens;
|
|
clone.show_semantic_tokens = self.show_semantic_tokens;
|
|
clone.skip_next_scroll = false;
|
|
if let Some(editor) = &self.editor {
|
|
clone.set_editor(editor.editor.clone(), window, cx)
|
|
}
|
|
clone
|
|
})))
|
|
}
|
|
|
|
fn on_removed(&self, cx: &mut Context<Self>) {
|
|
if let Some(state) = self.editor.as_ref() {
|
|
Self::clear_editor_highlights(&state.editor, cx);
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Default for HighlightsTreeToolbarItemView {
|
|
fn default() -> Self {
|
|
Self::new()
|
|
}
|
|
}
|
|
|
|
impl HighlightsTreeToolbarItemView {
|
|
pub fn new() -> Self {
|
|
Self {
|
|
tree_view: None,
|
|
_subscription: None,
|
|
toggle_settings_handle: PopoverMenuHandle::default(),
|
|
}
|
|
}
|
|
|
|
fn render_header(&self, cx: &Context<Self>) -> Option<ButtonLike> {
|
|
let tree_view = self.tree_view.as_ref()?;
|
|
let tree_view = tree_view.read(cx);
|
|
|
|
let total = tree_view.cached_entries.len();
|
|
let filtered = tree_view.entry_count();
|
|
|
|
let label = if filtered == total {
|
|
format!("{} highlights", total)
|
|
} else {
|
|
format!("{} / {} highlights", filtered, total)
|
|
};
|
|
|
|
Some(ButtonLike::new("highlights header").child(Label::new(label)))
|
|
}
|
|
|
|
fn render_settings_button(&self, cx: &Context<Self>) -> PopoverMenu<ContextMenu> {
|
|
let (show_text, show_syntax, show_semantic) = self
|
|
.tree_view
|
|
.as_ref()
|
|
.map(|view| {
|
|
let v = view.read(cx);
|
|
(
|
|
v.show_text_highlights,
|
|
v.show_syntax_tokens,
|
|
v.show_semantic_tokens,
|
|
)
|
|
})
|
|
.unwrap_or((true, true, true));
|
|
|
|
let tree_view = self.tree_view.as_ref().map(|v| v.downgrade());
|
|
|
|
PopoverMenu::new("highlights-tree-settings")
|
|
.trigger_with_tooltip(
|
|
IconButton::new("toggle-highlights-settings-icon", IconName::Sliders)
|
|
.icon_size(IconSize::Small)
|
|
.style(ButtonStyle::Subtle)
|
|
.toggle_state(self.toggle_settings_handle.is_deployed()),
|
|
Tooltip::text("Highlights Settings"),
|
|
)
|
|
.anchor(Corner::TopRight)
|
|
.with_handle(self.toggle_settings_handle.clone())
|
|
.menu(move |window, cx| {
|
|
let tree_view_for_text = tree_view.clone();
|
|
let tree_view_for_syntax = tree_view.clone();
|
|
let tree_view_for_semantic = tree_view.clone();
|
|
|
|
let menu = ContextMenu::build(window, cx, move |menu, _, _| {
|
|
menu.toggleable_entry(
|
|
"Text Highlights",
|
|
show_text,
|
|
IconPosition::Start,
|
|
Some(ToggleTextHighlights.boxed_clone()),
|
|
{
|
|
let tree_view = tree_view_for_text.clone();
|
|
move |_, cx| {
|
|
if let Some(view) = tree_view.as_ref() {
|
|
view.update(cx, |view, cx| {
|
|
view.show_text_highlights = !view.show_text_highlights;
|
|
let snapshot = view.editor.as_ref().map(|s| {
|
|
s.editor.read(cx).buffer().read(cx).snapshot(cx)
|
|
});
|
|
if let Some(snapshot) = snapshot {
|
|
view.rebuild_display_items(&snapshot, cx);
|
|
}
|
|
cx.notify();
|
|
})
|
|
.ok();
|
|
}
|
|
}
|
|
},
|
|
)
|
|
.toggleable_entry(
|
|
"Syntax Tokens",
|
|
show_syntax,
|
|
IconPosition::Start,
|
|
Some(ToggleSyntaxTokens.boxed_clone()),
|
|
{
|
|
let tree_view = tree_view_for_syntax.clone();
|
|
move |_, cx| {
|
|
if let Some(view) = tree_view.as_ref() {
|
|
view.update(cx, |view, cx| {
|
|
view.show_syntax_tokens = !view.show_syntax_tokens;
|
|
let snapshot = view.editor.as_ref().map(|s| {
|
|
s.editor.read(cx).buffer().read(cx).snapshot(cx)
|
|
});
|
|
if let Some(snapshot) = snapshot {
|
|
view.rebuild_display_items(&snapshot, cx);
|
|
}
|
|
cx.notify();
|
|
})
|
|
.ok();
|
|
}
|
|
}
|
|
},
|
|
)
|
|
.toggleable_entry(
|
|
"Semantic Tokens",
|
|
show_semantic,
|
|
IconPosition::Start,
|
|
Some(ToggleSemanticTokens.boxed_clone()),
|
|
{
|
|
move |_, cx| {
|
|
if let Some(view) = tree_view_for_semantic.as_ref() {
|
|
view.update(cx, |view, cx| {
|
|
view.show_semantic_tokens = !view.show_semantic_tokens;
|
|
let snapshot = view.editor.as_ref().map(|s| {
|
|
s.editor.read(cx).buffer().read(cx).snapshot(cx)
|
|
});
|
|
if let Some(snapshot) = snapshot {
|
|
view.rebuild_display_items(&snapshot, cx);
|
|
}
|
|
cx.notify();
|
|
})
|
|
.ok();
|
|
}
|
|
}
|
|
},
|
|
)
|
|
});
|
|
|
|
Some(menu)
|
|
})
|
|
}
|
|
}
|
|
|
|
impl Render for HighlightsTreeToolbarItemView {
|
|
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
|
h_flex()
|
|
.gap_1()
|
|
.children(self.render_header(cx))
|
|
.child(self.render_settings_button(cx))
|
|
}
|
|
}
|
|
|
|
impl EventEmitter<ToolbarItemEvent> for HighlightsTreeToolbarItemView {}
|
|
|
|
impl ToolbarItemView for HighlightsTreeToolbarItemView {
|
|
fn set_active_pane_item(
|
|
&mut self,
|
|
active_pane_item: Option<&dyn ItemHandle>,
|
|
window: &mut Window,
|
|
cx: &mut Context<Self>,
|
|
) -> ToolbarItemLocation {
|
|
if let Some(item) = active_pane_item
|
|
&& let Some(view) = item.downcast::<HighlightsTreeView>()
|
|
{
|
|
self.tree_view = Some(view.clone());
|
|
self._subscription = Some(cx.observe_in(&view, window, |_, _, _, cx| cx.notify()));
|
|
return ToolbarItemLocation::PrimaryLeft;
|
|
}
|
|
self.tree_view = None;
|
|
self._subscription = None;
|
|
ToolbarItemLocation::Hidden
|
|
}
|
|
}
|
|
|
|
fn excerpt_label_for(
|
|
entry: &HighlightEntry,
|
|
snapshot: &MultiBufferSnapshot,
|
|
cx: &App,
|
|
) -> SharedString {
|
|
let path_label = snapshot
|
|
.anchor_to_buffer_anchor(entry.range.start)
|
|
.and_then(|(anchor, _)| snapshot.buffer_for_id(anchor.buffer_id))
|
|
.and_then(|buf| buf.file())
|
|
.map(|file| {
|
|
let full_path = file.full_path(cx);
|
|
full_path.to_string_lossy().to_string()
|
|
})
|
|
.unwrap_or_else(|| "untitled".to_string());
|
|
path_label.into()
|
|
}
|
|
|
|
fn format_anchor_range(
|
|
range: &Range<Anchor>,
|
|
snapshot: &MultiBufferSnapshot,
|
|
) -> Option<(SharedString, BufferId, Range<Point>)> {
|
|
let start = range.start.to_point(snapshot);
|
|
let end = range.end.to_point(snapshot);
|
|
let ((start_buffer, start), (_, end)) = snapshot
|
|
.point_to_buffer_point(start)
|
|
.zip(snapshot.point_to_buffer_point(end))?;
|
|
let display = SharedString::from(format!(
|
|
"[{}:{} - {}:{}]",
|
|
start.row + 1,
|
|
start.column + 1,
|
|
end.row + 1,
|
|
end.column + 1,
|
|
));
|
|
Some((display, start_buffer.remote_id(), start..end))
|
|
}
|
|
|
|
fn render_style_preview(style: HighlightStyle, selected: bool, cx: &App) -> Div {
|
|
let colors = cx.theme().colors();
|
|
|
|
let display_color = style.color.or(style.background_color);
|
|
|
|
let mut preview = div().px_1().rounded_sm();
|
|
|
|
if let Some(color) = display_color {
|
|
if selected {
|
|
preview = preview.border_1().border_color(color).text_color(color);
|
|
} else {
|
|
preview = preview.bg(color);
|
|
}
|
|
} else {
|
|
preview = preview.bg(colors.element_background);
|
|
}
|
|
|
|
let mut parts = Vec::new();
|
|
|
|
if let Some(color) = display_color {
|
|
parts.push(format_hsla_as_hex(color));
|
|
}
|
|
if style.font_weight.is_some() {
|
|
parts.push("bold".to_string());
|
|
}
|
|
if style.font_style.is_some() {
|
|
parts.push("italic".to_string());
|
|
}
|
|
if style.strikethrough.is_some() {
|
|
parts.push("strike".to_string());
|
|
}
|
|
if style.underline.is_some() {
|
|
parts.push("underline".to_string());
|
|
}
|
|
|
|
let label_text = if parts.is_empty() {
|
|
"none".to_string()
|
|
} else {
|
|
parts.join(" ")
|
|
};
|
|
|
|
preview.child(Label::new(label_text).size(LabelSize::Small).when_some(
|
|
display_color.filter(|_| selected),
|
|
|label, display_color| label.color(Color::Custom(display_color)),
|
|
))
|
|
}
|
|
|
|
fn format_hsla_as_hex(color: Hsla) -> String {
|
|
let rgba = color.to_rgb();
|
|
let r = (rgba.r * 255.0).round() as u8;
|
|
let g = (rgba.g * 255.0).round() as u8;
|
|
let b = (rgba.b * 255.0).round() as u8;
|
|
let a = (rgba.a * 255.0).round() as u8;
|
|
if a == 255 {
|
|
format!("#{:02X}{:02X}{:02X}", r, g, b)
|
|
} else {
|
|
format!("#{:02X}{:02X}{:02X}{:02X}", r, g, b, a)
|
|
}
|
|
}
|