use std::ops::Range;
use std::rc::Rc;
use ropey::{LineType, Rope};
use crate::{
ActiveTheme as _, AnyElement, App, Bounds, Context, ElementExt as _, Entity,
InteractiveElement, IntoElement, MouseButton, ParentElement, Pixels, Styled, div, px,
};
use super::state::EditorState;
const MAX_MINIMAP_BARS: usize = 200;
const BAR_HEIGHT: Pixels = crate::px(2.);
const MINIMAP_PAD_Y: Pixels = crate::px(4.);
const BAR_FULL_WIDTH: f32 = 66.0;
const MIN_BAR_WIDTH: Pixels = crate::px(4.);
pub(super) struct MinimapState {
enabled: bool,
pub(super) dragging: bool,
bar_cache: Option<(usize, usize, Vec<usize>)>,
last_jump_row: Option<usize>,
strip_bounds: Bounds<Pixels>,
}
impl MinimapState {
pub(super) fn new() -> Self {
Self {
enabled: false,
dragging: false,
bar_cache: None,
last_jump_row: None,
strip_bounds: Bounds::default(),
}
}
}
pub(crate) fn minimap_rows(total_rows: usize, max_bars: usize) -> Vec<Range<usize>> {
if total_rows == 0 {
return vec![0..0];
}
let max_bars = max_bars.max(1);
let stride = total_rows.div_ceil(max_bars).max(1);
(0..total_rows)
.step_by(stride)
.map(|start| start..(start + stride).min(total_rows))
.collect()
}
pub(crate) fn minimap_bar_lengths(text: &Rope, total_rows: usize, max_bars: usize) -> Vec<usize> {
if total_rows == 0 {
return vec![0];
}
let stride = total_rows.div_ceil(max_bars.max(1)).max(1);
let bar_count = total_rows.div_ceil(stride);
let mut lengths = vec![0usize; bar_count];
for (row, line) in text.lines(LineType::LF).enumerate().take(total_rows) {
let ix = (row / stride).min(bar_count - 1);
let mut len = line.chars().count();
if len > 0 && line.chars().last() == Some('\n') {
len -= 1;
}
if len > lengths[ix] {
lengths[ix] = len;
}
}
lengths
}
fn bar_visible(bar: &Range<usize>, visible: &Option<Range<usize>>) -> bool {
visible
.as_ref()
.is_some_and(|range| bar.start < range.end && range.start < bar.end)
}
impl EditorState {
pub fn set_minimap_enabled(&mut self, enabled: bool, cx: &mut Context<Self>) {
self.minimap.enabled = enabled;
cx.notify();
}
pub fn minimap_enabled(&self) -> bool {
self.minimap.enabled
}
fn minimap_offset(&self, row: usize, cx: &App) -> usize {
self.input.read_with(cx, |state, _| {
let text = state.text();
let last_row = text.len_lines(LineType::LF).saturating_sub(1);
let row = row.min(last_row);
text.line_to_byte_idx(row, LineType::LF).min(text.len())
})
}
fn minimap_row_at_y(&self, y: Pixels, total_rows: usize) -> usize {
if total_rows == 0 {
return 0;
}
let rel = y - self.minimap.strip_bounds.top() - MINIMAP_PAD_Y;
if rel <= px(0.) {
return 0;
}
let stride = total_rows.div_ceil(MAX_MINIMAP_BARS).max(1);
let bar_ix = (rel / BAR_HEIGHT) as usize;
let start = bar_ix.saturating_mul(stride).min(total_rows - 1);
let end = (bar_ix + 1).saturating_mul(stride).min(total_rows);
((start + end) / 2).min(total_rows - 1)
}
}
pub(super) fn render_minimap(editor: &Entity<EditorState>, cx: &mut App) -> AnyElement {
let (input, byte_len, total_rows, visible, cached_lengths) =
editor.read_with(cx, |state, cx| {
let (byte_len, total, visible, cached) = state.input.read_with(cx, |input, _| {
let text = input.text();
let total = text.len_lines(LineType::LF);
let visible = input.visible_row_range();
let cached = state
.minimap
.bar_cache
.as_ref()
.and_then(|(b, r, v)| (*b == text.len() && *r == total).then(|| v.clone()));
(text.len(), total, visible, cached)
});
(state.input().clone(), byte_len, total, visible, cached)
});
let bar_lengths = match cached_lengths {
Some(v) => v,
None => {
let v = editor.read_with(cx, |state, cx| {
state.input.read_with(cx, |input, _| {
minimap_bar_lengths(input.text(), total_rows, MAX_MINIMAP_BARS)
})
});
editor.update(cx, |state, _| {
state.minimap.bar_cache = Some((byte_len, total_rows, v.clone()));
});
v
}
};
let bars = minimap_rows(total_rows, MAX_MINIMAP_BARS);
let max_len = bar_lengths.iter().copied().max().unwrap_or(0);
let editor_for_down = editor.clone();
let editor_for_move = editor.clone();
let editor_for_up = editor.clone();
let editor_for_up_out = editor.clone();
let editor_for_paint = editor.clone();
let editor_for_jump = editor.clone();
let jump_at_y = Rc::new(move |y: Pixels, cx: &mut App| {
let row = editor_for_jump.read_with(cx, |state, _| state.minimap_row_at_y(y, total_rows));
let dominated =
editor_for_jump.read_with(cx, |state, _| state.minimap.last_jump_row == Some(row));
if dominated {
return;
}
editor_for_jump.update(cx, |state, _| {
state.minimap.last_jump_row = Some(row);
});
let offset = editor_for_jump.read_with(cx, |state, cx| state.minimap_offset(row, cx));
input.update(cx, |state, cx| {
state.reveal_offset(offset, cx);
});
});
let jump_at_y_for_move = jump_at_y.clone();
div()
.absolute()
.top_0()
.bottom_0()
.right_0()
.w(px(76.))
.py(MINIMAP_PAD_Y)
.pl(px(6.))
.pr(px(4.))
.border_l_1()
.border_color(cx.theme().border)
.overflow_hidden()
.occlude()
.cursor_pointer()
.on_prepaint(move |bounds, _, cx| {
editor_for_paint.update(cx, |state, _| {
state.minimap.strip_bounds = bounds;
});
})
.on_mouse_down(MouseButton::Left, move |e, _, cx| {
editor_for_down.update(cx, |state, _| {
state.minimap.dragging = true;
});
jump_at_y(e.position.y, cx);
})
.on_mouse_move(move |event, _, cx| {
if event.pressed_button == Some(MouseButton::Left) {
let dragging = editor_for_move.read_with(cx, |state, _| state.minimap.dragging);
if dragging {
jump_at_y_for_move(event.position.y, cx);
}
} else {
let stale = editor_for_move.read_with(cx, |state, _| {
state.minimap.dragging || state.minimap.last_jump_row.is_some()
});
if stale {
editor_for_move.update(cx, |state, _| {
state.minimap.dragging = false;
state.minimap.last_jump_row = None;
});
}
}
})
.on_mouse_up(MouseButton::Left, move |_, _, cx| {
editor_for_up.update(cx, |state, _| {
state.minimap.dragging = false;
state.minimap.last_jump_row = None;
});
})
.on_mouse_up_out(MouseButton::Left, move |_, _, cx| {
editor_for_up_out.update(cx, |state, _| {
state.minimap.dragging = false;
state.minimap.last_jump_row = None;
});
})
.children(bars.into_iter().enumerate().map(|(ix, bar)| {
let bright = bar_visible(&bar, &visible);
let len = bar_lengths.get(ix).copied().unwrap_or(0);
let width = if max_len == 0 || len == 0 {
px(0.)
} else {
MIN_BAR_WIDTH.max(px(len as f32 / max_len as f32 * BAR_FULL_WIDTH))
};
div()
.id(("minimap-bar", ix))
.w(width)
.h(BAR_HEIGHT)
.rounded_full()
.bg(if bright {
cx.theme().foreground.opacity(0.55)
} else {
cx.theme().muted_foreground.opacity(0.35)
})
}))
.into_any_element()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::AppContext as _;
use crate::Render;
struct Probe {
state: Entity<EditorState>,
}
impl Render for Probe {
fn render(
&mut self,
window: &mut crate::Window,
cx: &mut Context<Self>,
) -> impl crate::IntoElement {
use crate::{IntoElement as _, RenderOnce as _};
super::super::Editor::new(&self.state)
.render(window, cx)
.into_element()
}
}
#[test]
fn minimap_rows_cover_all() {
assert_eq!(minimap_rows(0, 200), vec![0..0]);
assert_eq!(minimap_rows(3, 200), vec![0..1, 1..2, 2..3]);
let rows = minimap_rows(500, 200);
assert!(rows.len() <= 200);
assert_eq!(rows.first().unwrap().start, 0);
assert_eq!(rows.last().unwrap().end, 500);
for pair in rows.windows(2) {
assert_eq!(pair[0].end, pair[1].start);
}
}
#[test]
fn bar_visibility_intersects() {
assert!(bar_visible(&(10..20), &Some(15..25)));
assert!(!bar_visible(&(10..20), &Some(20..30)));
assert!(!bar_visible(&(10..20), &None));
}
#[test]
fn minimap_bar_lengths_shape() {
let text = ropey::Rope::from_str("a\nabcdefghij\n\nabc");
assert_eq!(minimap_bar_lengths(&text, 4, 200), vec![1, 10, 0, 3]);
assert_eq!(minimap_bar_lengths(&text, 4, 2), vec![10, 3]);
assert_eq!(
minimap_bar_lengths(&ropey::Rope::from_str(""), 0, 200),
vec![0]
);
}
#[rgpui::test]
fn minimap_row_at_y_maps_strip(cx: &mut crate::TestAppContext) {
use crate::{point, size};
cx.update(crate::input_ui::init);
cx.update(crate::theme::init);
let text = (0..100)
.map(|i| format!("line {i}"))
.collect::<Vec<_>>()
.join("\n");
let (probe, cx) = cx.add_window_view(|window, cx| {
let editor = cx.new(|cx| EditorState::new(window, cx, &text));
Probe { state: editor }
});
let editor = probe.read_with(cx, |probe, _| probe.state.clone());
cx.update(|_, cx| {
editor.update(cx, |state, _| {
state.minimap.strip_bounds =
Bounds::new(point(px(700.), px(100.)), size(px(76.), px(408.)));
});
});
let row_at = |y: f32| editor.read_with(cx, |state, _| state.minimap_row_at_y(px(y), 100));
assert_eq!(row_at(102.), 0);
assert_eq!(row_at(125.), 10);
assert_eq!(row_at(400.), 99);
assert_eq!(
editor.read_with(cx, |state, _| state.minimap_row_at_y(px(200.), 0)),
0
);
}
#[rgpui::test]
fn minimap_toggle_and_render_smoke(cx: &mut crate::TestAppContext) {
cx.update(crate::input_ui::init);
cx.update(crate::theme::init);
let text = (0..300)
.map(|i| format!("line {i}"))
.collect::<Vec<_>>()
.join("\n");
let (probe, cx) = cx.add_window_view(|window, cx| {
let editor = cx.new(|cx| EditorState::new(window, cx, &text));
Probe { state: editor }
});
let editor = probe.read_with(cx, |probe, _| probe.state.clone());
assert!(!editor.read_with(cx, |state, _| state.minimap_enabled()));
cx.update(|_, cx| {
editor.update(cx, |state, cx| {
state.set_minimap_enabled(true, cx);
});
});
assert!(editor.read_with(cx, |state, _| state.minimap_enabled()));
cx.run_until_parked();
}
}