- Mini-map initial implements
- Improved editor mode rendering efficiency when editing large files (10k+ locs)
This commit is contained in:
parent
db4e30dc6d
commit
ba983e3776
|
|
@ -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)?;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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(
|
||||
¶s[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;
|
||||
|
|
|
|||
Loading…
Reference in New Issue