- Mini-map initial implements

- Improved editor mode rendering efficiency when editing large files (10k+ locs)
This commit is contained in:
jess 2026-05-10 19:38:58 -07:00
parent db4e30dc6d
commit ba983e3776
8 changed files with 361 additions and 71 deletions

View File

@ -398,6 +398,14 @@ impl super::EditorState {
!tb.selection.is_empty() || tb.spillover.is_some()
}
/// jumps the focused text block's internal scroll to the given fraction (0.0..1.0).
pub(super) fn jump_to_fraction(&mut self, frac: f32) {
let frac = frac.clamp(0.0, 1.0);
let line_count = self.content().line_count().max(1);
let target = ((line_count as f32 * frac) as usize).min(line_count.saturating_sub(1));
self.content_mut().jump_to_line(target);
}
/// builds the clipboard payload from the focused table
pub(super) fn copy_focused_table_selection(&self) -> Option<String> {
let block = self.block_at(self.focused_block)?;

View File

@ -361,13 +361,20 @@ impl EditorState {
inner
};
let with_ctx: Element<'_, Message, Theme, iced_wgpu::Renderer> =
if let Some(menu_state) = &self.context_menu {
iced_widget::stack![inner, self.context_menu_view(menu_state)].into()
let with_minimap: Element<'_, Message, Theme, iced_wgpu::Renderer> =
if let Some(overlay) = self.minimap_overlay() {
iced_widget::stack![inner, overlay].into()
} else {
inner
};
let with_ctx: Element<'_, Message, Theme, iced_wgpu::Renderer> =
if let Some(menu_state) = &self.context_menu {
iced_widget::stack![with_minimap, self.context_menu_view(menu_state)].into()
} else {
with_minimap
};
if let Some(popup) = self.spillover_view() {
iced_widget::stack![with_ctx, popup].into()
} else {
@ -375,6 +382,46 @@ impl EditorState {
}
}
/// builds the right-edge minimap as a hover-aware overlay.
fn minimap_overlay(&self) -> Option<Element<'_, Message, Theme, iced_wgpu::Renderer>> {
if !self.minimap_enabled { return None; }
if self.render_mode != RenderMode::Editor { return None; }
let text = self.full_text();
if text.is_empty() { return None; }
let lines = crate::minimap::classify_text(&text);
if lines.is_empty() { return None; }
let scroll = self.content().scroll_line();
let line_h = self.line_height().max(1.0);
let visible_lines = (self.viewport_size.1 / line_h).max(1.0) as usize;
let suppressed = self.minimap_hover_only && !self.minimap_hovered;
let data = crate::minimap::MinimapData {
lines,
viewport_first: scroll,
viewport_last: scroll.saturating_add(visible_lines),
hovered: self.minimap_hovered,
suppressed,
};
let strip_w = self.font_size * 6.0;
let canvas = crate::minimap::minimap(data, strip_w, Message::MinimapJump);
let hover_zone = iced_widget::mouse_area(canvas)
.on_enter(Message::MinimapHover(true))
.on_exit(Message::MinimapHover(false));
let aligned = iced_widget::container(hover_zone)
.width(Length::Fill)
.height(Length::Fill)
.align_x(iced_wgpu::core::alignment::Horizontal::Right);
Some(aligned.into())
}
/// renders the spillover popup of the first table that has one open
fn spillover_view(&self) -> Option<Element<'_, Message, Theme, iced_wgpu::Renderer>> {
let p = palette::current();

View File

@ -74,6 +74,14 @@ pub struct EditorState {
pub line_indicator: LineIndicator,
/// whether the gutter line numbers cycle through the rainbow palette
pub gutter_rainbow: bool,
/// minimap on/off master switch
pub minimap_enabled: bool,
/// minimap fades in only on hover (and only when no mouse button is held)
pub minimap_hover_only: bool,
/// last frame any pointer button was held — suppresses the hover reveal
pub minimap_drag_suppress: bool,
/// pointer is currently inside the minimap region
pub minimap_hovered: bool,
/// pending clipboard text, drained by the shell each frame
pub pending_clipboard: Option<String>,
@ -141,6 +149,10 @@ impl EditorState {
inline_press: None,
line_indicator: LineIndicator::On,
gutter_rainbow: true,
minimap_enabled: true,
minimap_hover_only: true,
minimap_drag_suppress: false,
minimap_hovered: false,
pending_clipboard: None,
computed_images: Vec::new(),
image_cache: HashMap::new(),

View File

@ -152,6 +152,14 @@ pub enum Message {
ToggleMenu(MenuCategory),
CloseMenu,
Shell(ShellAction),
/// turns the minimap on/off entirely.
ToggleMinimap(bool),
/// toggles between hover-only fade and always-on visibility.
ToggleMinimapHoverOnly(bool),
/// pointer entered or left the minimap region.
MinimapHover(bool),
/// click on the minimap, value is the y-fraction in 0.0..1.0.
MinimapJump(f32),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]

View File

@ -935,6 +935,18 @@ impl super::EditorState {
Message::ToggleSnapping => {
self.snapping = !self.snapping;
}
Message::ToggleMinimap(on) => {
self.minimap_enabled = on;
}
Message::ToggleMinimapHoverOnly(on) => {
self.minimap_hover_only = on;
}
Message::MinimapHover(over) => {
self.minimap_hovered = over;
}
Message::MinimapJump(frac) => {
self.jump_to_fraction(frac);
}
}
}
}

View File

@ -42,6 +42,7 @@ pub mod export;
pub mod handle;
pub mod heading_block;
pub mod hr_block;
pub mod minimap;
pub mod module;
pub mod oklab;
pub mod palette;

188
viewport/src/minimap.rs Normal file
View File

@ -0,0 +1,188 @@
use iced_wgpu::core::{
mouse, Color, Element, Length, Point, Rectangle, Size, Theme,
};
use iced_widget::canvas::{self, Frame};
use crate::palette;
#[derive(Clone, Copy, Debug)]
pub enum LineKind {
Empty,
Plain,
Heading,
Code,
List,
Quote,
}
#[derive(Clone, Debug)]
pub struct MinimapLine {
pub width_chars: u16,
pub kind: LineKind,
}
/// classifies a single source line for minimap colouring.
pub fn classify(line: &str) -> MinimapLine {
let trimmed = line.trim_start();
let kind = if trimmed.is_empty() {
LineKind::Empty
} else if trimmed.starts_with('#') {
LineKind::Heading
} else if trimmed.starts_with("```") || trimmed.starts_with(" ") || trimmed.starts_with('\t') {
LineKind::Code
} else if trimmed.starts_with("- ") || trimmed.starts_with("* ") || trimmed.starts_with("+ ") {
LineKind::List
} else if trimmed.starts_with("> ") {
LineKind::Quote
} else {
LineKind::Plain
};
let width = line.chars().count().min(u16::MAX as usize) as u16;
MinimapLine { width_chars: width, kind }
}
/// turns the document text into per-line minimap data in one pass.
pub fn classify_text(text: &str) -> Vec<MinimapLine> {
text.lines().map(classify).collect()
}
#[derive(Clone)]
pub struct MinimapData {
pub lines: Vec<MinimapLine>,
pub viewport_first: usize,
pub viewport_last: usize,
pub hovered: bool,
pub suppressed: bool,
}
struct MinimapProgram<M, F>
where
M: Clone + 'static,
F: Fn(f32) -> M,
{
data: MinimapData,
on_jump: F,
_marker: std::marker::PhantomData<fn() -> M>,
}
impl<M, F> canvas::Program<M, Theme, iced_wgpu::Renderer> for MinimapProgram<M, F>
where
M: Clone + 'static,
F: Fn(f32) -> M,
{
type State = ();
fn draw(
&self,
_state: &(),
renderer: &iced_wgpu::Renderer,
_theme: &Theme,
bounds: Rectangle,
_cursor: mouse::Cursor,
) -> Vec<canvas::Geometry<iced_wgpu::Renderer>> {
let mut frame = Frame::new(renderer, bounds.size());
let p = palette::current();
let total = self.data.lines.len().max(1) as f32;
let h = bounds.height;
let pixels_per_line = (h / total).max(0.5);
let bar_h = pixels_per_line.max(1.0);
let alpha = if self.data.suppressed {
0.0
} else if self.data.hovered {
0.55
} else {
0.18
};
if alpha == 0.0 {
return vec![frame.into_geometry()];
}
let max_chars = self.data.lines.iter().map(|l| l.width_chars as f32).fold(1.0, f32::max);
for (i, line) in self.data.lines.iter().enumerate() {
let y = (i as f32 / total) * h;
let bar_w = (line.width_chars as f32 / max_chars) * bounds.width * 0.85;
let color = match line.kind {
LineKind::Empty => continue,
LineKind::Heading => Color { a: alpha + 0.20, ..p.mauve },
LineKind::Code => Color { a: alpha + 0.05, ..p.peach },
LineKind::List => Color { a: alpha + 0.05, ..p.green },
LineKind::Quote => Color { a: alpha + 0.05, ..p.teal },
LineKind::Plain => Color { a: alpha, ..p.text },
};
frame.fill_rectangle(
Point::new(bounds.width * 0.075, y),
Size::new(bar_w, bar_h),
color,
);
}
if self.data.viewport_last > self.data.viewport_first {
let top = (self.data.viewport_first as f32 / total) * h;
let bot = ((self.data.viewport_last as f32) / total) * h;
let height = (bot - top).max(8.0);
let indicator_alpha = if self.data.hovered { 0.22 } else { 0.12 };
frame.fill_rectangle(
Point::new(0.0, top),
Size::new(bounds.width, height),
Color { a: indicator_alpha, ..p.text },
);
}
vec![frame.into_geometry()]
}
fn update(
&self,
_state: &mut (),
event: &canvas::Event,
bounds: Rectangle,
cursor: mouse::Cursor,
) -> Option<canvas::Action<M>> {
if self.data.suppressed { return None; }
let pos = cursor.position_in(bounds)?;
match event {
canvas::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) => {
let frac = (pos.y / bounds.height).clamp(0.0, 1.0);
Some(canvas::Action::publish((self.on_jump)(frac)).and_capture())
}
_ => None,
}
}
fn mouse_interaction(
&self,
_state: &(),
bounds: Rectangle,
cursor: mouse::Cursor,
) -> mouse::Interaction {
if !self.data.suppressed && cursor.is_over(bounds) {
mouse::Interaction::Pointer
} else {
mouse::Interaction::default()
}
}
}
/// builds a minimap canvas pinned at a fixed width.
pub fn minimap<'a, M, F>(
data: MinimapData,
width: f32,
on_jump: F,
) -> Element<'a, M, Theme, iced_wgpu::Renderer>
where
M: Clone + 'static,
F: Fn(f32) -> M + 'a,
{
canvas::Canvas::new(MinimapProgram::<M, F> {
data,
on_jump,
_marker: std::marker::PhantomData,
})
.width(Length::Fixed(width))
.height(Length::Fill)
.into()
}

View File

@ -881,6 +881,20 @@ impl Content {
pub fn is_empty(&self) -> bool {
self.0.borrow().editor.is_empty()
}
/// returns cosmic-text's current top-of-viewport scroll line.
pub fn scroll_line(&self) -> usize {
self.0.borrow().editor.buffer().scroll().line
}
/// scrolls cosmic-text so the given logical line lands at viewport top.
pub fn jump_to_line(&mut self, target: usize) {
let current = self.scroll_line() as i32;
let delta = target as i32 - current;
if delta != 0 {
self.perform(Action::Scroll { lines: delta });
}
}
}
impl Clone for Content {
@ -1498,7 +1512,7 @@ where
_defaults: &renderer::Style,
layout: Layout<'_>,
_cursor: mouse::Cursor,
_viewport: &Rectangle,
viewport: &Rectangle,
) {
let bounds = layout.bounds();
@ -1603,22 +1617,46 @@ where
let highlighter_borrow = state.highlighter.borrow();
let highlighter_any: &dyn std::any::Any = &*highlighter_borrow;
let syntax_highlighter = highlighter_any.downcast_ref::<crate::syntax::SyntaxHighlighter>();
{
let mut paras = state.retained_paragraphs.borrow_mut();
paras.clear();
let metrics = state.line_metrics.borrow();
for i in 0..line_count {
let line_text = buffer.lines[i].text();
let attrs_list = buffer.lines[i].attrs_list();
let p = crate::palette::current();
let font_size_px: f32 = f32::from(text_size);
let metrics = state.line_metrics.borrow();
let mut paras = state.retained_paragraphs.borrow_mut();
paras.clear();
// viewport-y bounds in screen space — anything outside is clipped
// away by the renderer, so skip the shape + paragraph build.
let view_top = viewport.y;
let view_bot = viewport.y + viewport.height;
for line_i in 0..line_count {
let m = match metrics.get(line_i) {
Some(m) => m,
None => continue,
};
// Pre-scroll lines carry visual_rows == 0 (cosmic hasn't
// shaped them, layout_opt returns None) — skip so we don't
// pile unshaped paragraphs at the same y.
if m.visual_rows == 0 {
continue;
}
let y = text_bounds.y + m.widget_y;
let row_h = m.visual_rows as f32 * line_h;
let visible = y + row_h > view_top && y < view_bot;
if visible {
let line_text = buffer.lines[line_i].text();
let attrs_list = buffer.lines[line_i].attrs_list();
let glyphs: Vec<cosmic_text::LayoutGlyph> =
buffer.lines[i].layout_opt()
buffer.lines[line_i].layout_opt()
.map(|layouts| layouts.iter().flat_map(|l| l.glyphs.iter().cloned()).collect())
.unwrap_or_default();
let marker_ranges = if active_cursor_line == Some(i) {
let marker_ranges = if active_cursor_line == Some(line_i) {
Vec::new()
} else {
syntax_highlighter
.map(|h| h.line_marker_ranges(i, line_text))
.map(|h| h.line_marker_ranges(line_i, line_text))
.unwrap_or_default()
};
let spans = build_color_spans(
@ -1626,13 +1664,12 @@ where
&glyphs,
attrs_list,
font,
f32::from(text_size),
font_size_px,
&marker_ranges,
);
let visual_rows = metrics.get(i).map(|m| m.visual_rows).unwrap_or(1).max(1);
paras.push(iced_graphics::text::Paragraph::with_spans(Text {
content: spans.as_slice(),
bounds: Size::new(text_bounds.width, visual_rows as f32 * line_h),
bounds: Size::new(text_bounds.width, m.visual_rows as f32 * line_h),
size: text_size,
line_height: self.line_height,
font,
@ -1641,62 +1678,40 @@ where
shaping: text::Shaping::Advanced,
wrapping: self.wrapping,
}));
}
}
let p = crate::palette::current();
let font_size_px: f32 = f32::from(text_size);
let paras = state.retained_paragraphs.borrow();
let metrics = state.line_metrics.borrow();
for line_i in 0..line_count {
// Pull line position from the Vec layout published.
let m = match metrics.get(line_i) {
Some(m) => m,
None => continue,
};
// Pre-scroll lines carry visual_rows == 0 (cosmic hasn't
// shaped them, layout_opt returns None) — skip them so
// we don't draw unshaped paragraphs piled at the same y.
if m.visual_rows == 0 {
continue;
}
let y = text_bounds.y + m.widget_y;
let row_h = m.visual_rows as f32 * line_h;
if self.is_focused_block
&& self.cursor_line == Some(line_i)
&& self.line_indicator != crate::editor::LineIndicator::Off
{
let band = Color { a: 0.06, ..p.text };
renderer.fill_quad(
renderer::Quad {
bounds: Rectangle::new(
Point::new(bounds.x, y),
Size::new(bounds.width, row_h),
),
border: Border::default(),
..renderer::Quad::default()
},
Background::Color(band),
);
}
// Cursorline tint — full editor width (incl. gutter),
// covers all visual rows of the wrapped logical line.
if self.is_focused_block
&& self.cursor_line == Some(line_i)
&& self.line_indicator != crate::editor::LineIndicator::Off
{
let band = Color { a: 0.06, ..p.text };
renderer.fill_quad(
renderer::Quad {
bounds: Rectangle::new(
Point::new(bounds.x, y),
Size::new(bounds.width, row_h),
),
border: Border::default(),
..renderer::Quad::default()
},
Background::Color(band),
if self.show_gutter {
self.draw_gutter_line(renderer, line_i, bounds, y, line_h, gw, &p, font_size_px);
}
renderer.fill_paragraph(
paras.last().unwrap(),
Point::new(text_bounds.x, y),
style.value,
text_bounds,
);
}
// Gutter — line decor stripe + line number, in the strip
// between bounds.x and text_bounds.x.
if self.show_gutter {
self.draw_gutter_line(renderer, line_i, bounds, y, line_h, gw, &p, font_size_px);
}
renderer.fill_paragraph(
&paras[line_i],
Point::new(text_bounds.x, y),
style.value,
text_bounds,
);
// After this line, draw any anchored children
// children are advanced regardless of visibility so they stay
// associated with the correct line; the child's own draw will
// be culled by the renderer when off-screen.
while child_idx < self.anchored_children.len()
&& self.anchored_children[child_idx].after_line == line_i
{
@ -1708,14 +1723,13 @@ where
_defaults,
children_layouts[child_idx],
_cursor,
_viewport,
viewport,
);
}
child_idx += 1;
}
}
// Draw remaining children after last text line
while child_idx < self.anchored_children.len() {
if child_idx < children_layouts.len() {
self.anchored_children[child_idx].element.as_widget().draw(
@ -1725,7 +1739,7 @@ where
_defaults,
children_layouts[child_idx],
_cursor,
_viewport,
viewport,
);
}
child_idx += 1;