diff --git a/viewport/src/editor/content.rs b/viewport/src/editor/content.rs index e982d90..f6244b8 100644 --- a/viewport/src/editor/content.rs +++ b/viewport/src/editor/content.rs @@ -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 { let block = self.block_at(self.focused_block)?; diff --git a/viewport/src/editor/mod.rs b/viewport/src/editor/mod.rs index 61d0c5d..740dfa7 100644 --- a/viewport/src/editor/mod.rs +++ b/viewport/src/editor/mod.rs @@ -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> { + 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> { let p = palette::current(); diff --git a/viewport/src/editor/state.rs b/viewport/src/editor/state.rs index e819046..a118575 100644 --- a/viewport/src/editor/state.rs +++ b/viewport/src/editor/state.rs @@ -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, @@ -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(), diff --git a/viewport/src/editor/types.rs b/viewport/src/editor/types.rs index aa161c9..cc7df31 100644 --- a/viewport/src/editor/types.rs +++ b/viewport/src/editor/types.rs @@ -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)] diff --git a/viewport/src/editor/update.rs b/viewport/src/editor/update.rs index 0911286..9bfe659 100644 --- a/viewport/src/editor/update.rs +++ b/viewport/src/editor/update.rs @@ -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); + } } } } diff --git a/viewport/src/lib.rs b/viewport/src/lib.rs index 8af8f33..9775de2 100644 --- a/viewport/src/lib.rs +++ b/viewport/src/lib.rs @@ -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; diff --git a/viewport/src/minimap.rs b/viewport/src/minimap.rs new file mode 100644 index 0000000..2788b10 --- /dev/null +++ b/viewport/src/minimap.rs @@ -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 { + text.lines().map(classify).collect() +} + +#[derive(Clone)] +pub struct MinimapData { + pub lines: Vec, + pub viewport_first: usize, + pub viewport_last: usize, + pub hovered: bool, + pub suppressed: bool, +} + +struct MinimapProgram +where + M: Clone + 'static, + F: Fn(f32) -> M, +{ + data: MinimapData, + on_jump: F, + _marker: std::marker::PhantomData M>, +} + +impl canvas::Program for MinimapProgram +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> { + 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> { + 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:: { + data, + on_jump, + _marker: std::marker::PhantomData, + }) + .width(Length::Fixed(width)) + .height(Length::Fill) + .into() +} diff --git a/viewport/src/text_widget.rs b/viewport/src/text_widget.rs index d46e414..d24ad02 100644 --- a/viewport/src/text_widget.rs +++ b/viewport/src/text_widget.rs @@ -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::(); - { - 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 = - 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;