use std::ops::Range; use gpui::{ AnyElement, Bounds, Context, ElementInputHandler, InteractiveElement, IntoElement, MouseButton, MouseDownEvent, MouseMoveEvent, ParentElement, Pixels, Render, Styled, StyledText, Window, canvas, div, fill, font, point, size, uniform_list, }; use super::{ decor::ReadOnlyBanner, keys::{CONTEXT, listen}, layout::{Row, around, hidden, rows}, state::{CodeEditor, EditorEvent}, syntax::{self, Bracket, brackets, folds}, }; use crate::theme::{ActiveTheme, TextSize}; /// What one render worked out, for the rows the list builds after it. #[derive(Default)] pub(crate) struct Frame { pub rows: Vec, pub folds: Vec<(usize, usize)>, pub brackets: Vec, pub advance: Pixels, pub line: Pixels, pub matched: Option<(usize, usize)>, pub occurrences: Vec>, /// The minimap's box, from its last paint. pub minimap: Bounds, } /// Lines of code a row's height holds, as leading. const LEADING: f32 = 1.6; /// Blocks the sticky header shows at most. const STICKY: usize = 3; /// Columns the minimap spans, in code characters. const MINIMAP: f32 = 12.0; impl CodeEditor { /// The text the primary selection or its word gives, for highlighting where else it appears. fn needle(&self) -> Option { let primary = self.primary(); let range = if primary.is_empty() { let word = self.buffer.word_at(primary.head); let text = &self.buffer.text()[word.clone()]; text.chars() .all(|ch| ch.is_alphanumeric() || ch == '_') .then_some(word)? } else { primary.range() }; (!range.is_empty()).then(|| self.buffer.text()[range].to_string()) } /// The first line the list shows, after scrolling. fn top_line(&self) -> Option { let scrolled = -self.scroll.0.borrow().base_handle.offset().y; let first = (scrolled / self.frame.line).floor().max(0.0) as usize; self.frame.rows[first.min(self.frame.rows.len().saturating_sub(1))..] .iter() .find_map(|row| match row { Row::Line(line) => Some(*line), _ => None, }) } fn sticky(&self, cx: &mut Context) -> Option { let colors = cx.theme().colors.clone(); let top = self.top_line()?; let headers = around(&self.frame.folds, top, STICKY); if headers.is_empty() { return None; } let rows = headers.into_iter().map(|line| { let text = self.buffer.line(line).to_string(); let styles = syntax::colors(&text, cx); let jump = cx.entity(); div() .id(("sticky", line)) .h(self.frame.line) .flex() .items_center() .cursor_pointer() .hover(|style| style.bg(colors.hover)) .on_mouse_down(MouseButton::Left, move |_, window, cx| { window.prevent_default(); jump.update(cx, |editor, cx| { let at = editor.buffer.line_range(line).start + editor.buffer.indent(line); let cursor = at..at; editor.select([cursor], cx); editor.reveal(false, cx); }); }) .child( div() .flex_none() .w(self.frame.advance * (self.gutter_columns() + 1) as f32), ) .child( div() .line_height(self.frame.line) .whitespace_nowrap() .text_color(colors.syntax.variable) .child(StyledText::new(text).with_highlights(styles)), ) }); Some( div() .absolute() .top_0() .left_0() .right_0() .bg(colors.surface) .border_b_1() .border_color(colors.border) .children(rows) .into_any_element(), ) } fn minimap_view(&self, cx: &mut Context) -> AnyElement { let colors = cx.theme().colors.clone(); let (advance, line) = (self.frame.advance, self.frame.line); let tokens: Vec> = self .frame .rows .iter() .map(|row| match row { Row::Line(at) => { let text = self.buffer.line(*at); syntax::colors(text, cx) .into_iter() .filter_map(|(range, style)| { let start = text[..range.start].chars().count(); let len = text[range].chars().count(); style.color.map(|color| (start, len, color)) }) .collect() } _ => Vec::new(), }) .collect(); let scroll = self.scroll.clone(); let entity = cx.entity(); let (drag, press) = (cx.entity(), cx.entity()); let shade = colors.hover; div() .id("minimap") .relative() .flex_none() .w(advance * MINIMAP) .h_full() .border_l_1() .border_color(colors.border) .cursor_pointer() .child( canvas( move |bounds, _, cx| { entity.update(cx, |editor, _| editor.frame.minimap = bounds); }, move |bounds, _, window, _| { let rows = tokens.len().max(1) as f32; let tall = (line * 0.15).min(bounds.size.height / rows); let wide = advance * 0.12; let state = scroll.0.borrow(); let viewport = state.base_handle.bounds().size.height; let scrolled = -state.base_handle.offset().y; let top = bounds.origin.y + scrolled / line * tall; let extent = size(bounds.size.width, viewport / line * tall); window.paint_quad(fill( Bounds::new(point(bounds.origin.x, top), extent), shade, )); for (ix, spans) in tokens.iter().enumerate() { for (start, len, color) in spans { let origin = point( bounds.origin.x + wide * (*start + 2) as f32, bounds.origin.y + tall * ix as f32, ); let extent = size(wide * *len as f32, tall * 0.7); window.paint_quad(fill( Bounds::new(origin, extent), color.opacity(0.7), )); } } }, ) .absolute() .top_0() .left_0() .size_full(), ) .on_mouse_down(MouseButton::Left, move |event: &MouseDownEvent, _, cx| { press.update(cx, |editor, cx| editor.jump(event.position.y, cx)); }) .on_mouse_move(move |event: &MouseMoveEvent, _, cx| { if event.dragging() { drag.update(cx, |editor, cx| editor.jump(event.position.y, cx)); } }) .into_any_element() } /// Scrolls so the row under a minimap point sits in the middle of the view. fn jump(&mut self, y: Pixels, cx: &mut Context) { let bounds = self.frame.minimap; let rows = self.frame.rows.len().max(1) as f32; let tall = (self.frame.line * 0.15).min(bounds.size.height / rows); let row = ((y - bounds.origin.y) / tall).max(0.0); let state = self.scroll.0.borrow(); let viewport = state.base_handle.bounds().size.height; let top = (self.frame.line * row - viewport / 2.0).max(Pixels::ZERO); state.base_handle.set_offset(point(Pixels::ZERO, -top)); drop(state); cx.notify(); } } impl Render for CodeEditor { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { let sticky = if self.options.sticky { self.sticky(cx) } else { None }; let theme = cx.theme(); let colors = theme.colors.clone(); let size = theme.text_size(TextSize::Sm); let pixels = size.to_pixels(window.rem_size()); let mono = window .text_system() .resolve_font(&font(theme.mono_family.clone())); let advance = window .text_system() .advance(mono, pixels, 'm') .expect("the code font has an m") .width; let line = (pixels * LEADING).round(); let found = folds(&self.buffer); let hide = hidden(self.buffer.lines(), &found, &self.folded); let found_brackets = brackets(&self.buffer); let matched = syntax::matched(&found_brackets, self.primary().head); let occurrences = match self.needle() { Some(needle) if self.focus.is_focused(window) => { syntax::occurrences(&self.buffer, &needle, 0..self.buffer.lines()) } _ => Vec::new(), }; let minimap = self.frame.minimap; self.frame = Frame { rows: rows(&self.buffer, &hide, &self.marks), folds: found, brackets: found_brackets, advance, line, matched, occurrences, minimap, }; self.metrics.advance = advance; self.metrics.line = line; let count = self.frame.rows.len(); let list = uniform_list( "editor-rows", count, cx.processor(|editor, range: Range, window, cx| { range.map(|ix| editor.row(ix, window, cx)).collect() }), ) .track_scroll(self.scroll.clone()) .size_full(); let (top, focus, input) = (cx.entity(), self.focus.clone(), cx.entity()); let unlock = cx.entity(); let banner = self.options.read_only.then(|| { ReadOnlyBanner::new( ("read-only", cx.entity_id().as_u64()), "This file is read-only.", ) .action("Make editable", move |_, cx| { unlock.update(cx, |editor, cx| { editor.set_read_only(false, cx); cx.emit(EditorEvent::Unlock); }) }) }); let body = div() .relative() .flex_1() .h_full() .child(list) .children(sticky) .child( canvas( move |bounds, _, cx| { top.update(cx, |editor, _| editor.metrics.top = bounds.origin.y); }, |_, _, _, _| {}, ) .absolute() .top_0() .left_0() .size_full(), ); let root = div() .id(("code-editor", cx.entity_id().as_u64())) .key_context(CONTEXT) .track_focus(&self.focus) .size_full() .flex() .flex_col() .overflow_hidden() .bg(colors.surface) .font_family(theme.mono_family.clone()) .text_size(size) .text_color(colors.fg) .on_mouse_up( MouseButton::Left, cx.listener(|editor, _, _, _| editor.dragging = false), ) .on_mouse_up_out( MouseButton::Left, cx.listener(|editor, _, _, _| editor.dragging = false), ) .children(banner) .child( div() .relative() .flex_1() .min_h_0() .flex() .child(body) .children(self.options.minimap.then(|| self.minimap_view(cx))), ) .child( canvas( |_, _, _| {}, move |bounds, _, window, cx| { window.handle_input( &focus, ElementInputHandler::new(bounds, input.clone()), cx, ); }, ) .absolute() .top_0() .left_0() .size_full(), ); listen(root, cx) } }