editor: Add minimap (#26893)

## Overview

This PR adds the minimap feature to the Zed editor, closely following
the [design from Visual Studio
Code](https://code.visualstudio.com/docs/getstarted/userinterface#_minimap).
When configured, a second instance of the editor will appear to the left
of the scrollbar. This instance is not interactive and it has a slimmed
down set of annotations, but it is otherwise just a zoomed-out version
of the main editor instance. A thumb shows the line boundaries of the
main viewport, as well as the progress through the document. Clicking on
a section of code in the minimap will jump the editor to that code.
Dragging the thumb will act like the scrollbar, moving sequentially
through the document.

![screenshot of Zed with three editors open and the minimap enabled,
showing the
slider](https://github.com/user-attachments/assets/4178d23a-a5ea-4e38-b871-06dd2a8f9560)

## New settings

This adds a `minimap` section to the editor settings with the following
keys:

### `show`

When to show the minimap in the editor.
This setting can take three values:
1. Show the minimap if the editor's scrollbar is visible: `"auto"`
2. Always show the minimap: `"always"`
3. Never show the minimap: `"never"` (default)

### `thumb`

When to show the minimap thumb.
This setting can take two values:
1. Show the minimap thumb if the mouse is over the minimap: `"hover"`
2. Always show the minimap thumb: `"always"` (default)

### `width`

The width of the minimap in pixels.

Default: `100`

### `font_size`

The font size of the minimap in pixels.

Default: `2`

## Providing feedback

In order to keep the PR focused on development updates, please use the
discussion thread for feature suggestions and usability feedback: #26894


## Features left to add

- [x] fix scrolling performance
- [x] user settings for enable/disable, width, text size, etc.
- [x] show overview of visible lines in minimap
- [x] clicking on minimap should navigate to the corresponding section
of code
- ~[ ] more prominent highlighting in the minimap editor~
- ~[ ] override scrollbar auto setting to always when minimap is set to
always show~

Release Notes:

- Added minimap for high-level overview and quick navigation of editor
contents.

---------

Co-authored-by: MrSubidubi <dev@bahn.sh>
Co-authored-by: Kirill Bulatov <kirill@zed.dev>
This commit is contained in:
Evan Simkowitz 2025-05-07 13:11:09 -07:00 committed by GitHub
parent 902931fdfc
commit 607a9445fc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 1083 additions and 216 deletions

View file

@ -61,11 +61,11 @@ use collections::{BTreeMap, HashMap, HashSet, VecDeque};
use convert_case::{Case, Casing};
use display_map::*;
pub use display_map::{ChunkRenderer, ChunkRendererContext, DisplayPoint, FoldPlaceholder};
use editor_settings::GoToDefinitionFallback;
pub use editor_settings::{
CurrentLineHighlight, EditorSettings, HideMouseMode, ScrollBeyondLastLine, SearchSettings,
ShowScrollbar,
};
use editor_settings::{GoToDefinitionFallback, Minimap as MinimapSettings};
pub use editor_settings_controls::*;
use element::{AcceptEditPredictionBinding, LineWithInvisibles, PositionMap, layout_line};
pub use element::{
@ -231,6 +231,7 @@ pub(crate) const SCROLL_CENTER_TOP_BOTTOM_DEBOUNCE_TIMEOUT: Duration = Duration:
pub(crate) const EDIT_PREDICTION_KEY_CONTEXT: &str = "edit_prediction";
pub(crate) const EDIT_PREDICTION_CONFLICT_KEY_CONTEXT: &str = "edit_prediction_conflict";
pub(crate) const MIN_LINE_NUMBER_DIGITS: u32 = 4;
pub(crate) const MINIMAP_FONT_SIZE: AbsoluteLength = AbsoluteLength::Pixels(px(2.));
pub type RenderDiffHunkControlsFn = Arc<
dyn Fn(
@ -465,7 +466,7 @@ pub enum SelectMode {
All,
}
#[derive(Copy, Clone, PartialEq, Eq, Debug)]
#[derive(Clone, PartialEq, Eq, Debug)]
pub enum EditorMode {
SingleLine {
auto_width: bool,
@ -481,6 +482,9 @@ pub enum EditorMode {
/// When set to `true`, the editor's height will be determined by its content.
sized_by_content: bool,
},
Minimap {
parent: WeakEntity<Editor>,
},
}
impl EditorMode {
@ -495,6 +499,10 @@ impl EditorMode {
pub fn is_full(&self) -> bool {
matches!(self, Self::Full { .. })
}
fn is_minimap(&self) -> bool {
matches!(self, Self::Minimap { .. })
}
}
#[derive(Copy, Clone, Debug)]
@ -525,6 +533,7 @@ pub struct EditorStyle {
pub inlay_hints_style: HighlightStyle,
pub inline_completion_styles: InlineCompletionStyles,
pub unnecessary_code_fade: f32,
pub show_underlines: bool,
}
impl Default for EditorStyle {
@ -545,6 +554,7 @@ impl Default for EditorStyle {
whitespace: HighlightStyle::default(),
},
unnecessary_code_fade: Default::default(),
show_underlines: true,
}
}
}
@ -871,6 +881,7 @@ pub struct Editor {
show_breadcrumbs: bool,
show_gutter: bool,
show_scrollbars: bool,
show_minimap: bool,
disable_expand_excerpt_buttons: bool,
show_line_numbers: Option<bool>,
use_relative_line_numbers: Option<bool>,
@ -989,6 +1000,7 @@ pub struct Editor {
serialize_selections: Task<()>,
serialize_folds: Task<()>,
mouse_cursor_hidden: bool,
minimap: Option<Entity<Self>>,
hide_mouse_mode: HideMouseMode,
pub change_list: ChangeList,
inline_value_cache: InlineValueCache,
@ -1452,7 +1464,7 @@ impl Editor {
pub fn clone(&self, window: &mut Window, cx: &mut Context<Self>) -> Self {
let mut clone = Self::new(
self.mode,
self.mode.clone(),
self.buffer.clone(),
self.project.clone(),
window,
@ -1479,6 +1491,21 @@ impl Editor {
window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
Editor::new_internal(mode, buffer, project, None, window, cx)
}
fn new_internal(
mode: EditorMode,
buffer: Entity<MultiBuffer>,
project: Option<Entity<Project>>,
display_map: Option<Entity<DisplayMap>>,
window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
debug_assert!(
display_map.is_none() || mode.is_minimap(),
"Providing a display map for a new editor is only intended for the minimap and might have unindended side effects otherwise!"
);
let style = window.text_style();
let font_size = style.font_size.to_pixels(window.rem_size());
let editor = cx.entity().downgrade();
@ -1514,17 +1541,19 @@ impl Editor {
merge_adjacent: true,
..Default::default()
};
let display_map = cx.new(|cx| {
DisplayMap::new(
buffer.clone(),
style.font(),
font_size,
None,
FILE_HEADER_HEIGHT,
MULTI_BUFFER_EXCERPT_HEADER_HEIGHT,
fold_placeholder,
cx,
)
let display_map = display_map.unwrap_or_else(|| {
cx.new(|cx| {
DisplayMap::new(
buffer.clone(),
style.font(),
font_size,
None,
FILE_HEADER_HEIGHT,
MULTI_BUFFER_EXCERPT_HEADER_HEIGHT,
fold_placeholder,
cx,
)
})
});
let selections = SelectionsCollection::new(display_map.clone(), buffer.clone());
@ -1628,7 +1657,7 @@ impl Editor {
None
};
let breakpoint_store = match (mode, project.as_ref()) {
let breakpoint_store = match (&mode, project.as_ref()) {
(EditorMode::Full { .. }, Some(project)) => Some(project.read(cx).breakpoint_store()),
_ => None,
};
@ -1649,6 +1678,8 @@ impl Editor {
code_action_providers.push(Rc::new(project) as Rc<_>);
}
let full_mode = mode.is_full();
let mut this = Self {
focus_handle,
show_cursor_when_unfocused: false,
@ -1678,8 +1709,8 @@ impl Editor {
project,
blink_manager: blink_manager.clone(),
show_local_selections: true,
show_scrollbars: true,
mode,
show_scrollbars: full_mode,
show_minimap: full_mode,
show_breadcrumbs: EditorSettings::get_global(cx).toolbar.breadcrumbs,
show_gutter: mode.is_full(),
show_line_numbers: None,
@ -1727,7 +1758,7 @@ impl Editor {
workspace: None,
input_enabled: true,
use_modal_editing: mode.is_full(),
read_only: false,
read_only: mode.is_minimap(),
use_autoclose: true,
use_auto_surround: true,
auto_replace_emoji_shortcode: false,
@ -1771,9 +1802,10 @@ impl Editor {
show_git_blame_inline_delay_task: None,
git_blame_inline_enabled: ProjectSettings::get_global(cx).git.inline_blame_enabled(),
render_diff_hunk_controls: Arc::new(render_diff_hunk_controls),
serialize_dirty_buffers: ProjectSettings::get_global(cx)
.session
.restore_unsaved_buffers,
serialize_dirty_buffers: !mode.is_minimap()
&& ProjectSettings::get_global(cx)
.session
.restore_unsaved_buffers,
blame: None,
blame_subscription: None,
tasks: Default::default(),
@ -1816,10 +1848,12 @@ impl Editor {
load_diff_task: load_uncommitted_diff,
temporary_diff_override: false,
mouse_cursor_hidden: false,
minimap: None,
hide_mouse_mode: EditorSettings::get_global(cx)
.hide_mouse
.unwrap_or_default(),
change_list: ChangeList::new(),
mode,
};
if let Some(breakpoints) = this.breakpoint_store.as_ref() {
this._subscriptions
@ -1906,12 +1940,11 @@ impl Editor {
this.scroll_manager.show_scrollbars(window, cx);
jsx_tag_auto_close::refresh_enabled_in_any_buffer(&mut this, &buffer, cx);
if mode.is_full() {
if full_mode {
let should_auto_hide_scrollbars = cx.should_auto_hide_scrollbars();
cx.set_global(ScrollbarAutoHide(should_auto_hide_scrollbars));
if this.git_blame_inline_enabled {
this.git_blame_inline_enabled = true;
this.start_git_blame_inline(false, window, cx);
}
@ -1926,6 +1959,8 @@ impl Editor {
.insert(buffer.read(cx).remote_id(), handle);
}
}
this.minimap = this.create_minimap(EditorSettings::get_global(cx).minimap, window, cx);
}
this.report_editor_event("Editor Opened", None, cx);
@ -1969,6 +2004,7 @@ impl Editor {
let mode = match self.mode {
EditorMode::SingleLine { .. } => "single_line",
EditorMode::AutoHeight { .. } => "auto_height",
EditorMode::Minimap { .. } => "minimap",
EditorMode::Full { .. } => "full",
};
@ -2215,7 +2251,7 @@ impl Editor {
.flatten();
EditorSnapshot {
mode: self.mode,
mode: self.mode.clone(),
show_gutter: self.show_gutter,
show_line_numbers: self.show_line_numbers,
show_git_diff_gutter: self.show_git_diff_gutter,
@ -2251,8 +2287,8 @@ impl Editor {
.excerpt_containing(self.selections.newest_anchor().head(), cx)
}
pub fn mode(&self) -> EditorMode {
self.mode
pub fn mode(&self) -> &EditorMode {
&self.mode
}
pub fn set_mode(&mut self, mode: EditorMode) {
@ -2707,7 +2743,9 @@ impl Editor {
use text::ToOffset as _;
use text::ToPoint as _;
if WorkspaceSettings::get(None, cx).restore_on_startup == RestoreOnStartupBehavior::None {
if self.mode.is_minimap()
|| WorkspaceSettings::get(None, cx).restore_on_startup == RestoreOnStartupBehavior::None
{
return;
}
@ -7238,6 +7276,7 @@ impl Editor {
&mut self,
text_bounds: &Bounds<Pixels>,
content_origin: gpui::Point<Pixels>,
right_margin: Pixels,
editor_snapshot: &EditorSnapshot,
visible_row_range: Range<DisplayRow>,
scroll_top: f32,
@ -7251,6 +7290,9 @@ impl Editor {
window: &mut Window,
cx: &mut App,
) -> Option<(AnyElement, gpui::Point<Pixels>)> {
if self.mode().is_minimap() {
return None;
}
let active_inline_completion = self.active_inline_completion.as_ref()?;
if self.edit_prediction_visible_in_cursor_popover(true) {
@ -7328,6 +7370,7 @@ impl Editor {
} => self.render_edit_prediction_diff_popover(
text_bounds,
content_origin,
right_margin,
editor_snapshot,
visible_row_range,
line_layouts,
@ -7601,6 +7644,7 @@ impl Editor {
self: &Editor,
text_bounds: &Bounds<Pixels>,
content_origin: gpui::Point<Pixels>,
right_margin: Pixels,
editor_snapshot: &EditorSnapshot,
visible_row_range: Range<DisplayRow>,
line_layouts: &[LineWithInvisibles],
@ -7710,7 +7754,7 @@ impl Editor {
let viewport_bounds =
Bounds::new(Default::default(), window.viewport_size()).extend(Edges {
right: -EditorElement::SCROLLBAR_WIDTH,
right: -right_margin,
..Default::default()
});
@ -9286,10 +9330,11 @@ impl Editor {
placement: BlockPlacement::Above(anchor),
height: Some(height),
render: Arc::new(move |cx| {
*cloned_prompt.read(cx).gutter_dimensions.lock() = *cx.gutter_dimensions;
*cloned_prompt.read(cx).editor_margins.lock() = *cx.margins;
cloned_prompt.clone().into_any_element()
}),
priority: 0,
render_in_minimap: true,
}];
let focus_handle = bp_prompt.focus_handle(cx);
@ -14546,6 +14591,7 @@ impl Editor {
}
}),
priority: 0,
render_in_minimap: true,
}],
Some(Autoscroll::fit()),
cx,
@ -14928,6 +14974,10 @@ impl Editor {
}
fn refresh_active_diagnostics(&mut self, cx: &mut Context<Editor>) {
if self.mode.is_minimap() {
return;
}
if let ActiveDiagnostic::Group(active_diagnostics) = &mut self.active_diagnostics {
let buffer = self.buffer.read(cx).snapshot(cx);
let primary_range_start = active_diagnostics.active_range.start.to_offset(&buffer);
@ -15041,7 +15091,10 @@ impl Editor {
window: &mut Window,
cx: &mut Context<Self>,
) {
if !self.inline_diagnostics_enabled || !self.show_inline_diagnostics {
if self.mode.is_minimap()
|| !self.inline_diagnostics_enabled
|| !self.show_inline_diagnostics
{
self.inline_diagnostics_update = Task::ready(());
self.inline_diagnostics.clear();
return;
@ -16246,6 +16299,55 @@ impl Editor {
.text()
}
fn create_minimap(
&self,
minimap_settings: MinimapSettings,
window: &mut Window,
cx: &mut Context<Self>,
) -> Option<Entity<Self>> {
(minimap_settings.minimap_enabled() && self.is_singleton(cx))
.then(|| self.initialize_new_minimap(minimap_settings, window, cx))
}
fn initialize_new_minimap(
&self,
minimap_settings: MinimapSettings,
window: &mut Window,
cx: &mut Context<Self>,
) -> Entity<Self> {
const MINIMAP_FONT_WEIGHT: gpui::FontWeight = gpui::FontWeight::BLACK;
let mut minimap = Editor::new_internal(
EditorMode::Minimap {
parent: cx.weak_entity(),
},
self.buffer.clone(),
self.project.clone(),
Some(self.display_map.clone()),
window,
cx,
);
minimap.scroll_manager.clone_state(&self.scroll_manager);
minimap.set_text_style_refinement(TextStyleRefinement {
font_size: Some(MINIMAP_FONT_SIZE),
font_weight: Some(MINIMAP_FONT_WEIGHT),
..Default::default()
});
minimap.update_minimap_configuration(minimap_settings, cx);
cx.new(|_| minimap)
}
fn update_minimap_configuration(&mut self, minimap_settings: MinimapSettings, cx: &App) {
let current_line_highlight = minimap_settings
.current_line_highlight
.unwrap_or_else(|| EditorSettings::get_global(cx).current_line_highlight);
self.set_current_line_highlight(Some(current_line_highlight));
}
pub fn minimap(&self) -> Option<&Entity<Self>> {
self.minimap.as_ref().filter(|_| self.show_minimap)
}
pub fn wrap_guides(&self, cx: &App) -> SmallVec<[(usize, bool); 2]> {
let mut wrap_guides = smallvec::smallvec![];
@ -16313,14 +16415,19 @@ impl Editor {
window: &mut Window,
cx: &mut Context<Self>,
) {
let rem_size = window.rem_size();
self.display_map.update(cx, |map, cx| {
map.set_font(
style.text.font(),
style.text.font_size.to_pixels(rem_size),
cx,
)
});
// We intentionally do not inform the display map about the minimap style
// so that wrapping is not recalculated and stays consistent for the editor
// and its linked minimap.
if !self.mode.is_minimap() {
let rem_size = window.rem_size();
self.display_map.update(cx, |map, cx| {
map.set_font(
style.text.font(),
style.text.font_size.to_pixels(rem_size),
cx,
)
});
}
self.style = Some(style);
}
@ -16435,6 +16542,16 @@ impl Editor {
cx.notify();
}
pub fn set_show_minimap(&mut self, show_minimap: bool, cx: &mut Context<Self>) {
self.show_minimap = show_minimap;
cx.notify();
}
pub fn disable_scrollbars_and_minimap(&mut self, cx: &mut Context<Self>) {
self.set_show_scrollbars(false, cx);
self.set_show_minimap(false, cx);
}
pub fn set_show_line_numbers(&mut self, show_line_numbers: bool, cx: &mut Context<Self>) {
self.show_line_numbers = Some(show_line_numbers);
cx.notify();
@ -16808,7 +16925,7 @@ impl Editor {
}
pub fn render_git_blame_gutter(&self, cx: &App) -> bool {
self.show_git_blame_gutter && self.has_blame_entries(cx)
!self.mode().is_minimap() && self.show_git_blame_gutter && self.has_blame_entries(cx)
}
pub fn render_git_blame_inline(&self, window: &Window, cx: &App) -> bool {
@ -17910,7 +18027,8 @@ impl Editor {
}
let project_settings = ProjectSettings::get_global(cx);
self.serialize_dirty_buffers = project_settings.session.restore_unsaved_buffers;
self.serialize_dirty_buffers =
!self.mode.is_minimap() && project_settings.session.restore_unsaved_buffers;
if self.mode.is_full() {
let show_inline_diagnostics = project_settings.diagnostics.inline.enabled;
@ -17923,6 +18041,15 @@ impl Editor {
if self.git_blame_inline_enabled != inline_blame_enabled {
self.toggle_git_blame_inline_internal(false, window, cx);
}
let minimap_settings = EditorSettings::get_global(cx).minimap;
if self.minimap.as_ref().is_some() != minimap_settings.minimap_enabled() {
self.minimap = self.create_minimap(minimap_settings, window, cx);
} else if let Some(minimap_entity) = self.minimap.as_ref() {
minimap_entity.update(cx, |minimap_editor, cx| {
minimap_editor.update_minimap_configuration(minimap_settings, cx)
})
}
}
cx.notify();
@ -18618,6 +18745,9 @@ impl Editor {
}
pub fn register_addon<T: Addon>(&mut self, instance: T) {
if self.mode.is_minimap() {
return;
}
self.addons
.insert(std::any::TypeId::of::<T>(), Box::new(instance));
}
@ -18663,6 +18793,7 @@ impl Editor {
cx: &mut Context<Editor>,
) {
if self.is_singleton(cx)
&& !self.mode.is_minimap()
&& WorkspaceSettings::get(None, cx).restore_on_startup != RestoreOnStartupBehavior::None
{
let buffer_snapshot = OnceCell::new();
@ -20268,7 +20399,7 @@ impl Render for Editor {
line_height: relative(settings.buffer_line_height.value()),
..Default::default()
},
EditorMode::Full { .. } => TextStyle {
EditorMode::Full { .. } | EditorMode::Minimap { .. } => TextStyle {
color: cx.theme().colors().editor_foreground,
font_family: settings.buffer_font.family.clone(),
font_features: settings.buffer_font.features.clone(),
@ -20287,8 +20418,11 @@ impl Render for Editor {
EditorMode::SingleLine { .. } => cx.theme().system().transparent,
EditorMode::AutoHeight { max_lines: _ } => cx.theme().system().transparent,
EditorMode::Full { .. } => cx.theme().colors().editor_background,
EditorMode::Minimap { .. } => cx.theme().colors().editor_background.opacity(0.7),
};
let show_underlines = !self.mode.is_minimap();
EditorElement::new(
&cx.entity(),
EditorStyle {
@ -20301,6 +20435,7 @@ impl Render for Editor {
inlay_hints_style: make_inlay_hints_style(cx),
inline_completion_styles: make_suggestion_styles(cx),
unnecessary_code_fade: ThemeSettings::get_global(cx).unnecessary_code_fade,
show_underlines,
},
)
}
@ -20912,7 +21047,7 @@ struct BreakpointPromptEditor {
breakpoint: Breakpoint,
edit_action: BreakpointPromptEditAction,
block_ids: HashSet<CustomBlockId>,
gutter_dimensions: Arc<Mutex<GutterDimensions>>,
editor_margins: Arc<Mutex<EditorMargins>>,
_subscriptions: Vec<Subscription>,
}
@ -20968,7 +21103,7 @@ impl BreakpointPromptEditor {
breakpoint_anchor,
breakpoint,
edit_action,
gutter_dimensions: Arc::new(Mutex::new(GutterDimensions::default())),
editor_margins: Arc::new(Mutex::new(EditorMargins::default())),
block_ids: Default::default(),
_subscriptions: vec![],
}
@ -21053,7 +21188,8 @@ impl BreakpointPromptEditor {
impl Render for BreakpointPromptEditor {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let gutter_dimensions = *self.gutter_dimensions.lock();
let editor_margins = *self.editor_margins.lock();
let gutter_dimensions = editor_margins.gutter;
h_flex()
.key_context("Editor")
.bg(cx.theme().colors().editor_background)