//! The code view: a virtualized, read-only rendering of highlighted source. //! Rows render only when visible, the line-number gutter is part of each //! row, and the overlay scrollbar is the core one. use gpui::prelude::FluentBuilder as _; use gpui::{ App, Bounds, ClipboardItem, Context, ElementId, HighlightStyle, Hsla, InteractiveElement as _, IntoElement, KeyBinding, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, ParentElement as _, Pixels, Point, Render, ScrollStrategy, SharedString, StatefulInteractiveElement as _, Styled as _, TextStyle, UniformListScrollHandle, Window, actions, canvas, div, uniform_list, }; use gpui_native_core::scrollbar::{Scrollbar, ScrollbarHandle, ScrollbarVisibility}; use gpui_native_theme::{ActiveTheme as _, ColorTokens, Ramps}; use crate::highlight::{HighlightedCode, Language, Role, highlight_with_tree}; actions!(code_view, [Copy]); /// The key context the code view declares while it holds focus. pub const CODE_VIEW_CONTEXT: &str = "CodeView"; /// Binds the code view's copy chord: cmd-c on macOS, ctrl-c elsewhere. /// /// Call once at startup. Without it, selection copy has no key. pub fn bind_code_view_keys(cx: &mut App) { let context = Some(CODE_VIEW_CONTEXT); #[cfg(target_os = "macos")] let copy = "cmd-c"; #[cfg(not(target_os = "macos"))] let copy = "ctrl-c"; cx.bind_keys([KeyBinding::new(copy, Copy, context)]); } /// A point in the source, in lines and characters (not bytes). #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub struct SelectionPoint { pub row: usize, pub col: usize, } impl SelectionPoint { /// The earlier of two points, in document order. fn earlier_than(&self, other: &Self) -> bool { (self.row, self.col) <= (other.row, other.col) } } /// Retained state for a code view. pub struct CodeViewState { id: ElementId, source: SharedString, language: Language, code: HighlightedCode, /// The parse tree of `source`, so updates reparse incrementally instead of from scratch. tree: Option, handle: UniformListScrollHandle, /// The hovered line, driving the active-line wash and number emphasis. active_line: Option, /// `(anchor, head)` in the order they were set. selection: Option<(SelectionPoint, SelectionPoint)>, /// Whether a drag that started on a row is still extending the selection. selecting: bool, focus_handle: gpui::FocusHandle, a11y_label: Option, } impl CodeViewState { /// Builds the state, highlighting `source` with `language`. pub fn new(id: I, source: S, language: Language, cx: &mut App) -> Self where I: Into, S: Into, { let source = source.into(); let (code, tree) = highlight_with_tree(&source, language, None); Self { id: id.into(), source, language, code, tree, handle: UniformListScrollHandle::new(), active_line: None, selection: None, selecting: false, focus_handle: cx.focus_handle(), a11y_label: None, } } /// Replaces the source and re-highlights it. pub fn set_source(&mut self, source: S, language: Language, cx: &mut Context) where S: Into, { let source = source.into(); // The old tree is only reusable when the grammar stays the same. let old_tree = if language == self.language { self.tree.as_ref() } else { None }; let (code, tree) = highlight_with_tree(&source, language, old_tree); self.source = source; self.language = language; self.code = code; self.tree = tree; self.selection = None; self.handle.scroll_to_item(0, ScrollStrategy::Top); cx.notify(); } /// Replaces one line's text and re-highlights the file. pub fn update_line(&mut self, line_ix: usize, text: T, cx: &mut Context) where T: AsRef, { let trailing_newline = self.source.ends_with('\n'); let mut lines: Vec = self.source.lines().map(str::to_string).collect(); let line_ix = line_ix.min(lines.len().saturating_sub(1)); let replacement: Vec = if text.as_ref().is_empty() { vec![String::new()] } else { text.as_ref().lines().map(str::to_string).collect() }; lines.splice(line_ix..line_ix + 1, replacement); self.source = (lines.join("\n") + if trailing_newline { "\n" } else { "" }).into(); let (code, tree) = highlight_with_tree(&self.source, self.language, self.tree.as_ref()); self.code = code; self.tree = tree; self.selection = None; cx.notify(); } /// The highlighted source text. pub fn source(&self) -> &str { &self.source } /// The language the source highlights as. pub fn language(&self) -> Language { self.language } /// The highlighted lines. pub fn code(&self) -> &HighlightedCode { &self.code } /// Scrolls `line` to the top of the view. pub fn scroll_to_line(&self, line: usize) { self.handle.scroll_to_item( line.min(self.code.lines.len().saturating_sub(1)), ScrollStrategy::Top, ); } /// The line the pointer last hovered, if any. pub fn active_line(&self) -> Option { self.active_line } /// Selects a range, endpoints in lines and characters. pub fn select_range( &mut self, start: SelectionPoint, end: SelectionPoint, cx: &mut Context, ) { self.selection = if start.earlier_than(&end) { Some((start, end)) } else { Some((end, start)) }; self.selecting = false; cx.notify(); } /// The selected range, endpoints in document order, when there is one. pub fn selection(&self) -> Option<(SelectionPoint, SelectionPoint)> { self.selection.map(|(anchor, head)| { if anchor.earlier_than(&head) { (anchor, head) } else { (head, anchor) } }) } /// The selected text, or `None` when nothing is selected. pub fn selected_text(&self) -> Option { let (start, end) = self.selection()?; let lines: Vec<&str> = self.source.split('\n').collect(); let mut selected = Vec::new(); for row in start.row..=end.row.min(lines.len().saturating_sub(1)) { let line_chars: Vec = lines.get(row)?.chars().collect(); let start_col = if row == start.row { start.col.min(line_chars.len()) } else { 0 }; let end_col = if row == end.row { end.col.min(line_chars.len()) } else { line_chars.len() }; selected.push(line_chars[start_col..end_col].iter().collect::()); } (!selected.is_empty()).then(|| selected.join("\n")) } /// Drops the selection. pub fn clear_selection(&mut self) { self.selection = None; self.selecting = false; } /// Overrides the accessibility label of the view. pub fn accessibility_label(mut self, label: L) -> Self where L: Into, { self.a11y_label = Some(label.into()); self } /// Maps a window position to a point in the source. fn position_at( &self, bounds: Bounds, position: Point, scroll_y: Pixels, line_height: Pixels, char_width: Pixels, text_x: Pixels, ) -> SelectionPoint { let row = (((position.y - bounds.origin.y - scroll_y) / line_height).floor() as isize) .clamp(0, self.code.lines.len() as isize - 1) .max(0) as usize; let chars = self.code.lines[row] .spans .iter() .map(|span| span.text.chars().count()) .sum::(); let col = (((position.x - text_x) / char_width).round() as isize) .clamp(0, chars as isize) .max(0) as usize; SelectionPoint { row, col } } } impl gpui::Focusable for CodeViewState { fn focus_handle(&self, _: &App) -> gpui::FocusHandle { self.focus_handle.clone() } } impl Render for CodeViewState { fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { let theme = cx.theme(); let colors = theme.colors; let mono_font = theme.mono_font; let text_size = theme.sizes.small.text_size; let line_height = text_size * 1.5; let char_width = text_size * 0.62; let root_font_size = theme.root_font_size; let id = self.id.clone(); let state = cx.entity(); let line_count = self.code.lines.len(); let selection = self.selection(); let gutter_chars = line_count.max(1).to_string().len().max(2); let gutter_width = text_size * 0.62 * (gutter_chars as f32 + 1.0); let bounds_cell: std::rc::Rc>>> = std::rc::Rc::new(std::cell::RefCell::new(None)); let rows = uniform_list((id.clone(), "rows"), line_count, { let id = id.clone(); let state = state.clone(); move |range, _window, cx| { let code = state.read(cx); let active_line = code.active_line; let theme = cx.theme(); range .map(|line_ix| { render_row( code, &id, line_ix, active_line == Some(line_ix), selection, theme.mono_font, theme.colors, theme.ramps, theme.sizes.small.text_size, line_height, gutter_width, ) }) .collect() } }) .track_scroll(&self.handle) .h_full() .w_full(); div() .id((id.clone(), "view")) .relative() .size_full() .text_size(text_size) .font_family(mono_font) .text_color(colors.foreground) .key_context(CODE_VIEW_CONTEXT) .track_focus(&self.focus_handle) .on_mouse_down(MouseButton::Left, { let state = state.clone(); let bounds_cell = bounds_cell.clone(); move |event: &MouseDownEvent, window: &mut Window, cx: &mut App| { window.focus(&state.read(cx).focus_handle.clone(), cx); let bounds = bounds_cell.borrow(); let Some(bounds) = bounds.as_ref() else { return; }; let text_x = bounds.origin.x + gutter_width + root_font_size; let point = { let code = state.read(cx); code.position_at( *bounds, event.position, Pixels::ZERO, line_height, char_width, text_x, ) }; state.update(cx, |state, cx| { if event.modifiers.shift { if let Some((anchor, _)) = state.selection { state.selection = Some((anchor, point)); } else { state.selection = Some((point, point)); } } else { state.selection = Some((point, point)); } state.selecting = true; cx.notify(); }); } }) .on_mouse_move({ let state = state.clone(); let bounds_cell = bounds_cell.clone(); move |event: &MouseMoveEvent, _window: &mut Window, cx: &mut App| { let bounds = bounds_cell.borrow(); let Some(bounds) = bounds.as_ref() else { return; }; let text_x = bounds.origin.x + gutter_width + root_font_size; let point = { let code = state.read(cx); code.position_at( *bounds, event.position, ScrollbarHandle::offset(&code.handle).y, line_height, char_width, text_x, ) }; let selecting = state.read(cx).selecting; if selecting { let changed = state .read(cx) .selection .map(|(_, head)| head != point) .unwrap_or(true); if changed { state.update(cx, |state, cx| { if let Some((anchor, _)) = state.selection { state.selection = Some((anchor, point)); } cx.notify(); }); } return; } let changed = state.read(cx).active_line != Some(point.row); if changed { state.update(cx, |state, cx| { state.active_line = Some(point.row); cx.notify(); }); } } }) .on_mouse_up(MouseButton::Left, { let state = state.clone(); move |_: &MouseUpEvent, _window: &mut Window, cx: &mut App| { if state.read(cx).selecting { state.update(cx, |state, cx| { state.selecting = false; cx.notify(); }); } } }) .on_action({ let state = state.clone(); move |_: &Copy, _window, cx| { if let Some(text) = state.read(cx).selected_text() { cx.write_to_clipboard(ClipboardItem::new_string(text)); } } }) .child( canvas( { let bounds_cell = bounds_cell.clone(); move |bounds, _window, _cx| { *bounds_cell.borrow_mut() = Some(bounds); } }, |_, _, _, _| {}, ) .absolute() .inset_0(), ) .child(rows) .child( Scrollbar::vertical(self.handle.clone()).visibility(ScrollbarVisibility::Platform), ) .when_some(self.a11y_label.clone(), |view, label| { view.aria_label(label) }) } } #[allow(clippy::too_many_arguments)] fn render_row( code: &CodeViewState, id: &ElementId, line_ix: usize, active: bool, selection: Option<(SelectionPoint, SelectionPoint)>, mono_font: &'static str, colors: ColorTokens, ramps: Ramps, text_size: Pixels, line_height: Pixels, gutter_width: Pixels, ) -> gpui::AnyElement { let line = code.code().lines.get(line_ix).cloned().unwrap_or_default(); let mut text = String::new(); for span in &line.spans { text.push_str(&span.text); } let char_count = text.chars().count(); let mut roles = vec![Role::Text; char_count]; let mut offset = 0usize; for span in &line.spans { let chars = span.text.chars().count(); for slot in &mut roles[offset..offset + chars] { *slot = span.role; } offset += chars; } let mut selected = vec![false; char_count]; if let Some((start, end)) = selection && line_ix >= start.row && line_ix <= end.row { let start_col = if line_ix == start.row { start.col.min(char_count) } else { 0 }; let end_col = if line_ix == end.row { end.col.min(char_count) } else { char_count }; for slot in &mut selected[start_col..end_col] { *slot = true; } } let mut highlights: Vec<(std::ops::Range, HighlightStyle)> = Vec::new(); let mut run_start = 0usize; let mut run_end = 0usize; let mut run_role = Role::Text; let mut run_selected = false; for (char_ix, (byte_ix, character)) in text.char_indices().enumerate() { let fresh = char_ix == 0 || roles[char_ix] != run_role || selected[char_ix] != run_selected; if fresh { if char_ix > 0 { highlights.push(( run_start..run_end, run_style(run_role, run_selected, colors, ramps), )); } run_start = byte_ix; run_role = roles[char_ix]; run_selected = selected[char_ix]; } run_end = byte_ix + character.len_utf8(); } if run_end > run_start || !text.is_empty() { highlights.push(( run_start..run_end, run_style(run_role, run_selected, colors, ramps), )); } let style = TextStyle { color: colors.foreground, font_family: SharedString::from(mono_font), font_size: text_size.into(), line_height: line_height.into(), white_space: gpui::WhiteSpace::Nowrap, ..Default::default() }; div() .debug_selector({ let name = format!("{id}-row-{line_ix}"); move || name }) .id(("code-row", line_ix)) .w_full() .h(line_height) .flex() .items_center() .overflow_hidden() .when(active, |row| { row.bg(Hsla { a: colors.muted.a * 0.45, ..colors.muted }) }) .child( gutter_cell( gutter_width, line_height, mono_font, text_size, if active { colors.foreground } else { colors.muted_foreground }, format!("{}", line_ix + 1), ) .justify_end() .pr_2() .whitespace_nowrap(), ) .child( div() .flex_1() .min_w_0() .ml_4() .overflow_hidden() .whitespace_nowrap() .child(gpui::StyledText::new(text).with_default_highlights(&style, highlights)), ) .into_any_element() } fn run_style(role: Role, selected: bool, colors: ColorTokens, ramps: Ramps) -> HighlightStyle { HighlightStyle { color: Some(role_color(role, colors, ramps)), background_color: selected.then_some(colors.selection), ..Default::default() } } /// A gutter cell sized to the whole row. pub(crate) fn gutter_cell( width: Pixels, height: Pixels, mono_font: &'static str, text_size: Pixels, color: Hsla, content: E, ) -> gpui::Div where E: IntoElement, { div() .w(width) .h(height) .flex() .items_center() .justify_center() .flex_none() .font_family(mono_font) .text_size(text_size) .line_height(gpui::relative(1.0)) .text_color(color) .child(content) } /// Maps a highlight role to the theme's semantic palette. pub(crate) fn role_color(role: Role, colors: ColorTokens, ramps: Ramps) -> Hsla { match role { Role::Text => colors.foreground, Role::Keyword => ramps.primary.base, Role::String | Role::Escape => ramps.success.base, Role::Comment | Role::Punctuation => colors.muted_foreground, Role::Function | Role::Property => ramps.info.base, Role::Type => ramps.warning.base, Role::Number | Role::Constant => ramps.secondary.base, Role::Operator => colors.foreground, Role::Tag => ramps.destructive.base, } }