Files
Ely-GPUI-Components/src/forms/text/element.rs
T
ZacharyZhang-NY 8b6bf80adf DB & Dev Tools · Tools and machines: washes paint, strict hex, tested levels
Text fields paint each run's wash before selections and glyphs, so a
Highlight's background shows: the regex tester's matches, a mention's
link and markdown's code. A tile's value gives way and wraps in a narrow
box. Hex and percent escapes take two hex digits and no sign. Tests
cover each WCAG level at its edge, a clean exit's quiet badge, a
stopped container's line without load, and a reading past its limit.
2026-09-27 11:15:50 -04:00

468 lines
16 KiB
Rust

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<TextInput>,
}
impl TextElement {
pub fn new(input: Entity<TextInput>) -> 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<Bounds<Pixels>>,
caret: Option<Bounds<Pixels>>,
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<usize>, Highlight, bool)>,
thickness: Pixels,
) -> Vec<TextRun> {
let mut cuts: Vec<usize> = 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<TextRun>) {
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<usize> {
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<ElementId> {
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<Pixels>,
_: &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<Pixels>,
_: &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<Pixels>,
caret_width: Pixels,
bounds: Bounds<Pixels>,
line_height: Pixels,
) -> Point<Pixels> {
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<Bounds<Pixels>> {
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]);
}
}