use gpui::{ App, AvailableSpace, Bounds, ContentMask, DispatchPhase, Element, ElementId, ElementInputHandler, Entity, FontStyle, GlobalElementId, Hsla, InspectorElementId, IntoElement, LayoutId, MouseMoveEvent, Pixels, Point, SharedString, StrikethroughStyle, Style, TextAlign, TextRun, UnderlineStyle, Window, WrappedLine, fill, point, relative, size, }; use super::{Highlight, Layout, TextInput}; use crate::theme::ActiveTheme; pub(crate) struct TextElement { input: Entity, } impl TextElement { pub fn new(input: Entity) -> Self { Self { input } } } impl IntoElement for TextElement { type Element = Self; fn into_element(self) -> Self::Element { self } } pub(crate) struct Prepaint { lines: Vec<(usize, WrappedLine)>, selections: Vec>, caret: Option>, selection_color: Hsla, caret_color: Hsla, } /// What to show, and whether it is the placeholder. fn shown(input: &TextInput) -> (String, bool) { if input.is_empty() { (input.placeholder_text().to_string(), true) } else { (input.display_text(), false) } } fn plain_run(len: usize, window: &Window, color: Hsla) -> TextRun { TextRun { len, font: window.text_style().font(), color, background_color: None, underline: None, strikethrough: None, } } /// Splits `len` bytes into runs: highlights, and the underlined marked range, over a base run. fn runs( len: usize, base: TextRun, spans: Vec<(std::ops::Range, Highlight, bool)>, thickness: Pixels, ) -> Vec { let mut cuts: Vec = vec![0, len]; for (range, ..) in &spans { cuts.extend([range.start.min(len), range.end.min(len)]); } cuts.sort_unstable(); cuts.dedup(); cuts.windows(2) .map(|pair| { let (start, end) = (pair[0], pair[1]); let mut run = TextRun { len: end - start, ..base.clone() }; for (range, highlight, underline) in &spans { if range.start <= start && end <= range.end { run.color = highlight.color; if highlight.background.is_some() { run.background_color = highlight.background; } if let Some(weight) = highlight.weight { run.font.weight = weight; } if highlight.italic { run.font.style = FontStyle::Italic; } if highlight.strike { run.strikethrough = Some(StrikethroughStyle { thickness, color: Some(highlight.color), }); } if *underline { run.underline = Some(UnderlineStyle { color: Some(highlight.color), thickness, wavy: false, }); } } } run }) .filter(|run| run.len > 0) .collect() } /// What to show, whether it is the placeholder, and its runs, the same for measure and paint. fn shown_runs(input: &TextInput, window: &Window, cx: &App) -> (String, bool, Vec) { let theme = cx.theme(); let (text, placeholder) = shown(input); let color = window.text_style().color; let base = plain_run( text.len(), window, if placeholder { theme.colors.fg_subtle } else { color }, ); let mut spans = Vec::new(); if !placeholder { for (range, highlight) in input.highlights(cx) { spans.push((range, highlight, false)); } if let Some(marked) = input.marked() { let marked = input.display_offset(marked.start)..input.display_offset(marked.end); spans.push((marked, Highlight::new(color), true)); } } let runs = runs(text.len(), base, spans, theme.underline_thickness()); (text, placeholder, runs) } /// Starts of the hard lines in `text`. fn line_starts(text: &str) -> Vec { std::iter::once(0) .chain(text.match_indices('\n').map(|(ix, _)| ix + 1)) .collect() } impl Element for TextElement { type RequestLayoutState = (); type PrepaintState = Prepaint; fn id(&self) -> Option { None } fn source_location(&self) -> Option<&'static std::panic::Location<'static>> { None } fn request_layout( &mut self, _: Option<&GlobalElementId>, _: Option<&InspectorElementId>, window: &mut Window, cx: &mut App, ) -> (LayoutId, ()) { let input = self.input.read(cx); let line_height = window.line_height(); let mut style = Style::default(); style.size.width = relative(1.).into(); let Some((min, max)) = input.rows() else { style.size.height = line_height.into(); return (window.request_layout(style, [], cx), ()); }; let (text, _, runs) = shown_runs(input, window, cx); let font_size = window.text_style().font_size.to_pixels(window.rem_size()); let layout = window.request_measured_layout(style, move |known, available, window, _| { let width = known.width.or(match available.width { AvailableSpace::Definite(width) => Some(width), _ => None, }); let lines = window .text_system() .shape_text( SharedString::from(text.clone()), font_size, &runs, width, None, ) .expect("text input shaping failed"); let rows: usize = lines .iter() .map(|line| line.wrap_boundaries().len() + 1) .sum(); size( width.unwrap_or_default(), line_height * rows.clamp(min, max) as f32, ) }); (layout, ()) } fn prepaint( &mut self, _: Option<&GlobalElementId>, _: Option<&InspectorElementId>, bounds: Bounds, _: &mut (), window: &mut Window, cx: &mut App, ) -> Prepaint { let theme = cx.theme(); let (selection_color, caret_color) = (theme.colors.selection, theme.colors.focus); let caret_width = theme.caret_width().to_pixels(window.rem_size()); let input = self.input.read(cx); let (text, placeholder, runs) = shown_runs(input, window, cx); let font_size = window.text_style().font_size.to_pixels(window.rem_size()); let line_height = window.line_height(); let wrap = input.rows().map(|_| bounds.size.width); let shaped = window .text_system() .shape_text(text.clone().into(), font_size, &runs, wrap, None) .expect("text input shaping failed"); let lines: Vec<(usize, WrappedLine)> = line_starts(&text).into_iter().zip(shaped).collect(); let focused = input.focus().is_focused(window); let caret_on = input.caret_on(); self.input.update(cx, |input, _| { input.layout = Some(Layout { lines: lines.clone(), bounds, line_height, placeholder, }); }); let input = self.input.read(cx); let caret_at = input .position_for(input.display_offset(input.cursor())) .expect("caret offset lies inside the laid-out text"); let scroll = keep_in_view(input, &lines, caret_at, caret_width, bounds, line_height); let selection = input.selection(); let selections = if placeholder || selection.is_empty() { Vec::new() } else { let (start, end) = ( input.display_offset(selection.start), input.display_offset(selection.end), ); selection_rects(&lines, start, end, line_height, bounds.size.width) .into_iter() .map(|rect| Bounds::new(rect.origin + bounds.origin - scroll, rect.size)) .collect() }; let caret = (focused && caret_on && selection.is_empty()).then(|| { Bounds::new( bounds.origin + caret_at - scroll, size(caret_width, line_height), ) }); self.input.update(cx, |input, _| input.scroll = scroll); Prepaint { lines, selections, caret, selection_color, caret_color, } } fn paint( &mut self, _: Option<&GlobalElementId>, _: Option<&InspectorElementId>, bounds: Bounds, _: &mut (), prepaint: &mut Prepaint, window: &mut Window, cx: &mut App, ) { let input = self.input.read(cx); let (focus, scroll, selecting) = (input.focus().clone(), input.scroll, input.selecting()); window.handle_input( &focus, ElementInputHandler::new(bounds, self.input.clone()), cx, ); let line_height = window.line_height(); window.with_content_mask(Some(ContentMask { bounds }), |window| { let mut origins = Vec::with_capacity(prepaint.lines.len()); let mut top = Pixels::ZERO; for (_, line) in &prepaint.lines { origins.push(bounds.origin - scroll + point(Pixels::ZERO, top)); top += line.size(line_height).height; } for ((_, line), origin) in prepaint.lines.iter().zip(&origins) { line.paint_background(*origin, line_height, TextAlign::Left, None, window, cx) .expect("text input wash paint failed"); } for rect in prepaint.selections.drain(..) { window.paint_quad(fill(rect, prepaint.selection_color)); } for ((_, line), origin) in prepaint.lines.iter().zip(&origins) { line.paint(*origin, line_height, TextAlign::Left, None, window, cx) .expect("text input paint failed"); } if let Some(caret) = prepaint.caret.take() { window.paint_quad(fill(caret, prepaint.caret_color)); } }); if selecting { let input = self.input.clone(); window.on_mouse_event(move |event: &MouseMoveEvent, phase, _, cx| { if phase == DispatchPhase::Bubble && event.dragging() { input.update(cx, |input, cx| { let at = input.offset_for_point(event.position); input.select_to(at, cx); }); } }); } } } /// Scroll that keeps the caret inside the box, clamped to the text. fn keep_in_view( input: &TextInput, lines: &[(usize, WrappedLine)], caret: Point, caret_width: Pixels, bounds: Bounds, line_height: Pixels, ) -> Point { let mut scroll = input.scroll; if input.is_multi_line() { let content = lines.iter().fold(Pixels::ZERO, |sum, (_, line)| { sum + line.size(line_height).height }); let view = bounds.size.height; if caret.y < scroll.y { scroll.y = caret.y; } else if caret.y + line_height > scroll.y + view { scroll.y = caret.y + line_height - view; } scroll.y = scroll .y .clamp(Pixels::ZERO, (content - view).max(Pixels::ZERO)); scroll.x = Pixels::ZERO; } else { let content = lines.first().map_or(Pixels::ZERO, |(_, line)| line.width()) + caret_width; let view = bounds.size.width; if caret.x < scroll.x { scroll.x = caret.x; } else if caret.x + caret_width > scroll.x + view { scroll.x = caret.x + caret_width - view; } scroll.x = scroll .x .clamp(Pixels::ZERO, (content - view).max(Pixels::ZERO)); scroll.y = Pixels::ZERO; } scroll } /// Boxes covering display offsets `start..end`, relative to the text origin. pub(super) fn selection_rects( lines: &[(usize, WrappedLine)], start: usize, end: usize, line_height: Pixels, width: Pixels, ) -> Vec> { let mut rects = Vec::new(); let mut top = Pixels::ZERO; for (line_start, line) in lines { let line_end = line_start + line.len(); let (from, to) = (start.max(*line_start), end.min(line_end)); if from <= to && start <= line_end && end >= *line_start { let at = |ix: usize| { line.position_for_index(ix - line_start, line_height) .unwrap_or_default() }; let (a, b) = (at(from), at(to)); let edge = if end > line_end { width } else { b.x }; if a.y == b.y { rects.push(Bounds::from_corners( point(a.x, top + a.y), point(edge.max(a.x), top + a.y + line_height), )); } else { rects.push(Bounds::from_corners( point(a.x, top + a.y), point(width, top + a.y + line_height), )); let mut y = a.y + line_height; while y < b.y { rects.push(Bounds::from_corners( point(Pixels::ZERO, top + y), point(width, top + y + line_height), )); y += line_height; } rects.push(Bounds::from_corners( point(Pixels::ZERO, top + b.y), point(edge, top + b.y + line_height), )); } } top += line.size(line_height).height; } rects } #[cfg(test)] mod tests { use gpui::{FontWeight, font, hsla, px}; use super::*; #[test] fn a_highlight_sets_weight_slant_and_strike_on_its_runs() { let base = TextRun { len: 3, font: font("Inter"), color: hsla(0., 0., 0., 1.), background_color: None, underline: None, strikethrough: None, }; let bold = Highlight { weight: Some(FontWeight::SEMIBOLD), ..Highlight::new(hsla(0., 0., 0.5, 1.)) }; let slanted = Highlight { italic: true, strike: true, ..Highlight::new(hsla(0., 0., 0.2, 1.)) }; let split = runs( 3, base, vec![(0..2, bold, false), (1..3, slanted, false)], px(1.), ); let styles: Vec<_> = split .iter() .map(|run| { ( run.len, run.font.weight, run.font.style, run.strikethrough.is_some(), ) }) .collect(); assert_eq!( styles, [ (1, FontWeight::SEMIBOLD, FontStyle::Normal, false), (1, FontWeight::SEMIBOLD, FontStyle::Italic, true), (1, FontWeight::NORMAL, FontStyle::Italic, true), ] ); } #[test] fn line_starts_follow_newlines() { assert_eq!(line_starts("ab\ncd\n"), vec![0, 3, 6]); assert_eq!(line_starts(""), vec![0]); } }