Files
oak-gpui/crates/gpui_elements/src/input/paint.rs
T

640 lines
22 KiB
Rust

use crate::input::{Input, InputColors, InputLayoutData, InputLogicalLine, InputState};
use gpui::{
Along, App, Axis, Bounds, ContentMask, CursorStyle, DispatchPhase, Element, ElementId,
ElementInputHandler, Entity, Focusable, GlobalElementId, Hitbox, HitboxBehavior, Hsla,
InspectorElementId, LayoutId, Length, MouseButton, MouseDownEvent, MouseMoveEvent,
MouseUpEvent, Pixels, Point, ScrollWheelEvent, SharedString, Style, TextAlign, TextRun,
TextStyle, Window, fill, point, px, relative, size,
};
use std::ops::Range;
const CURSOR_WIDTH: f32 = 2.0;
const MARKED_TEXT_UNDERLINE_THICKNESS: f32 = 2.0;
pub struct InputLayoutState {
text_style: TextStyle,
}
pub struct InputPrepaintState {
hitbox: Option<Hitbox>,
}
impl Element for Input {
type RequestLayoutState = InputLayoutState;
type PrepaintState = InputPrepaintState;
fn id(&self) -> Option<ElementId> {
self.interactivity.element_id.clone()
}
fn source_location(&self) -> Option<&'static std::panic::Location<'static>> {
self.interactivity.source_location()
}
fn request_layout(
&mut self,
global_id: Option<&GlobalElementId>,
inspector_id: Option<&InspectorElementId>,
window: &mut Window,
cx: &mut App,
) -> (LayoutId, Self::RequestLayoutState) {
let mut resolved_text_style = None;
let layout_id = self.interactivity.request_layout(
global_id,
inspector_id,
window,
cx,
|element_style, window, cx| {
let layout = self.input.read(cx).layout_style();
window.with_text_style(element_style.text_style().cloned(), |window| {
resolved_text_style = Some(window.text_style());
let mut layout_style = element_style.clone();
if matches!(layout, super::InputLayoutStyle::MultiLine) {
if let Length::Auto = layout_style.size.width {
layout_style.size.width = relative(1.).into();
}
if let Length::Auto = layout_style.size.height {
layout_style.size.height = relative(1.).into();
}
}
window.request_layout(layout_style, None, cx)
})
},
);
let layout_state = InputLayoutState {
text_style: resolved_text_style.unwrap_or_else(|| window.text_style()),
};
(layout_id, layout_state)
}
fn prepaint(
&mut self,
global_id: Option<&GlobalElementId>,
inspector_id: Option<&InspectorElementId>,
bounds: Bounds<Pixels>,
layout_state: &mut Self::RequestLayoutState,
window: &mut Window,
cx: &mut App,
) -> Self::PrepaintState {
let line_height = layout_state
.text_style
.line_height_in_pixels(window.rem_size());
let wrap_width = match self.input.read(cx).layout_style() {
super::InputLayoutStyle::SingleLine => None,
super::InputLayoutStyle::MultiLine => Some(bounds.size.width),
};
self.input.update(cx, |input, _cx| {
let dirty = input.layout_data.dirty
|| input.layout_data.wrap_width != wrap_width
|| input.layout_data.text_style != layout_state.text_style;
let layout_data = InputLayoutData {
text_style: layout_state.text_style.clone(),
line_height,
wrap_width,
available_size: bounds.size,
dirty: false,
};
input.layout_data = layout_data;
if dirty {
input.logical_lines =
InputState::build_logical_lines(input.content(), window, &input.layout_data);
input.scroll_to_cursor();
}
});
let hitbox = self.interactivity.prepaint(
global_id,
inspector_id,
bounds,
bounds.size,
window,
cx,
|_style, _point, hitbox, window, _cx| {
hitbox.or_else(|| Some(window.insert_hitbox(bounds, HitboxBehavior::Normal)))
},
);
InputPrepaintState { hitbox }
}
fn paint(
&mut self,
global_id: Option<&GlobalElementId>,
inspector_id: Option<&InspectorElementId>,
bounds: Bounds<Pixels>,
layout_state: &mut Self::RequestLayoutState,
prepaint_state: &mut Self::PrepaintState,
window: &mut Window,
cx: &mut App,
) {
let focus_handle = self.input.focus_handle(cx);
if let Some(hitbox) = &prepaint_state.hitbox {
window.set_cursor_style(CursorStyle::IBeam, hitbox);
}
window.handle_input(
&focus_handle,
ElementInputHandler::new(bounds, self.input.clone()),
cx,
);
let snapshot = InputStateSnapshot::new(&self.input, cx);
let placeholder = self.placeholder.clone();
let text_style = layout_state.text_style.clone();
let is_focused = focus_handle.is_focused(window);
let colors = self.colors;
let is_cursor_visible = self.input.update(cx, |input, cx| {
input.toggle_cursor_on_focus_change(is_focused, cx)
});
let perform_paint = |_style: &Style, window: &mut Window, cx: &mut App| {
let context = PaintContext {
snapshot,
is_focused,
bounds,
text_style: &text_style,
placeholder: placeholder.as_ref(),
colors: &colors,
cursor_visible: is_cursor_visible,
};
context.process_mouse_events(&self.input, window, cx);
window.with_content_mask(Some(ContentMask { bounds }), |window| {
context.paint(window, cx);
});
};
self.interactivity.paint(
global_id,
inspector_id,
bounds,
prepaint_state.hitbox.as_ref(),
window,
cx,
perform_paint,
);
}
}
/// A minimal copy of InputState that is used during paint operations without needing to read from the entity in App multiple times in a single paint.
/// Ideally this struct is quite small.
struct InputStateSnapshot {
layout_axis: Axis,
should_center_placeholder: bool,
show_placeholder: bool,
selected_range: Range<usize>,
marked_range: Option<Range<usize>>,
cursor_position: usize,
logical_lines: Vec<InputLogicalLine>,
scroll_distance: Pixels,
line_height: Pixels,
}
impl InputStateSnapshot {
fn new(entity: &Entity<InputState>, cx: &App) -> Self {
let input_state = entity.read(cx);
let selected_range = input_state.selected_range().clone();
let marked_range = input_state.marked_range().cloned();
let cursor_position = input_state.cursor_position();
let logical_lines = input_state.logical_lines.clone();
let scroll_distance = input_state.distance_from_top();
let line_height = input_state.line_height();
let layout_axis = input_state.layout_style().axis();
let should_center_placeholder = matches!(
input_state.layout_style(),
super::InputLayoutStyle::SingleLine
);
Self {
layout_axis,
should_center_placeholder,
show_placeholder: input_state.content().is_empty(),
selected_range,
marked_range,
cursor_position,
logical_lines,
scroll_distance,
line_height,
}
}
}
struct PaintContext<'app> {
snapshot: InputStateSnapshot,
is_focused: bool,
bounds: Bounds<Pixels>,
text_style: &'app TextStyle,
placeholder: Option<&'app SharedString>,
colors: &'app InputColors,
cursor_visible: bool,
}
impl<'app> PaintContext<'app> {
pub fn process_mouse_events(
&self,
entity: &Entity<InputState>,
window: &mut Window,
cx: &mut App,
) {
let axis = self.snapshot.layout_axis;
let bounds = self.bounds;
let scroll_distance = self.snapshot.scroll_distance;
window.on_mouse_event({
let input = entity.clone();
move |event: &MouseDownEvent, phase, window, cx| {
if phase != DispatchPhase::Bubble {
return;
}
if !bounds.contains(&event.position) {
return;
}
if event.button != MouseButton::Left {
return;
}
input.update(cx, |input, cx| {
// Converts a screen position to a position relative to the text area origin, adjusted for scroll offset.
let text_position = (event.position - bounds.origin)
.apply_along(axis, |pos| pos + scroll_distance);
input.on_mouse_down(
text_position,
event.click_count,
event.modifiers.shift,
window,
cx,
);
});
}
});
window.on_mouse_event({
let input = entity.clone();
move |event: &MouseUpEvent, phase, _window, cx| {
if phase != DispatchPhase::Bubble {
return;
}
if event.button != MouseButton::Left {
return;
}
input.update(cx, |input, cx| {
input.on_mouse_up(cx);
});
}
});
window.on_mouse_event({
let input = entity.clone();
move |event: &MouseMoveEvent, phase, _window, cx| {
if phase != DispatchPhase::Bubble {
return;
}
input.update(cx, |input, cx| {
// Converts a screen position to a position relative to the text area origin, adjusted for scroll offset.
let text_position = (event.position - bounds.origin)
.apply_along(axis, |pos| pos + scroll_distance);
input.on_mouse_move(text_position, cx);
});
}
});
window.on_mouse_event({
let input = entity.clone();
let content_size = match axis {
gpui::Axis::Horizontal => {
let state = input.read(cx);
let line = state.logical_lines.first();
let line = line.and_then(|l| l.wrapped_line.as_ref());
line.map(|w| w.width()).unwrap_or(px(0.))
}
gpui::Axis::Vertical => input.read(cx).total_content_height(),
};
let max_scroll = (content_size - bounds.size.along(axis)).max(px(0.));
move |event: &ScrollWheelEvent, phase, _window, cx| {
if phase != DispatchPhase::Bubble {
return;
}
if !bounds.contains(&event.position) {
return;
}
let pixel_delta = event.delta.pixel_delta(px(20.));
input.update(cx, |input, cx| {
let delta = match axis {
gpui::Axis::Horizontal => pixel_delta.y,
gpui::Axis::Vertical => {
if pixel_delta.x.abs() > pixel_delta.y.abs() {
pixel_delta.x
} else {
pixel_delta.y
}
}
};
input.apply_scroll_delta(delta, max_scroll);
cx.notify();
});
}
});
}
fn paint_bounds_quad(
&self,
window: &mut Window,
color: Hsla,
offset_start: Point<Pixels>,
offset_end: Point<Pixels>,
) {
let top_left = point(self.bounds.left(), self.bounds.top());
window.paint_quad(fill(
Bounds::from_corners(top_left + offset_start, top_left + offset_end),
color,
));
}
pub fn paint(&self, window: &mut Window, cx: &mut App) {
if !self.snapshot.selected_range.is_empty() {
self.paint_selection(window);
}
if self.snapshot.show_placeholder {
self.paint_placeholder(window, cx);
} else {
self.paint_text(window, cx);
}
self.paint_marked_underline(window);
if self.is_focused && self.snapshot.selected_range.is_empty() && self.cursor_visible {
self.paint_cursor(window);
}
}
fn paint_selection(&self, window: &mut Window) {
let one_line = self.snapshot.logical_lines.len() == 1;
for line in &self.snapshot.logical_lines {
let line_y = line.y_offset - self.snapshot.scroll_distance;
if !one_line {
if !self.is_line_visible(line) {
continue;
}
if !line_intersects_range(&line.text_range, &self.snapshot.selected_range) {
continue;
}
}
if line.text_range.is_empty() {
const EMPTY_LINE_SELECTION_WIDTH: Pixels = px(6.);
self.paint_bounds_quad(
window,
self.colors.selection,
point(px(0.), line_y),
point(
EMPTY_LINE_SELECTION_WIDTH,
line_y + self.snapshot.line_height,
),
);
} else {
self.paint_line_range(
window,
line,
&self.snapshot.selected_range,
self.colors.selection,
px(0.),
);
}
}
}
fn paint_placeholder(&self, window: &mut Window, cx: &mut App) {
let Some(placeholder) = self.placeholder else {
return;
};
if placeholder.is_empty() {
return;
}
let run = TextRun {
len: placeholder.len(),
font: self.text_style.font(),
color: self.colors.placeholder,
background_color: None,
underline: None,
strikethrough: None,
};
let font_size = self.text_style.font_size.to_pixels(window.rem_size());
let shaped_line =
window
.text_system()
.shape_line(placeholder.clone(), font_size, &[run], None);
let line_height = self.text_style.line_height_in_pixels(window.rem_size());
let mut paint_origin = self.bounds.origin;
if self.snapshot.should_center_placeholder {
let y_offset = (self.bounds.size.height - line_height).max(px(0.)) / 2.0;
paint_origin.y += y_offset;
}
let _ = shaped_line.paint(paint_origin, line_height, TextAlign::Left, None, window, cx);
}
fn paint_text(&self, window: &mut Window, cx: &mut App) {
for line_layout in &self.snapshot.logical_lines {
let line_y = line_layout.y_offset - self.snapshot.scroll_distance;
if !self.is_line_visible(line_layout) {
continue;
}
let Some(wrapped) = &line_layout.wrapped_line else {
continue;
};
let paint_pos = point(self.bounds.left(), self.bounds.top() + line_y);
let _ = wrapped.paint(
paint_pos,
self.snapshot.line_height,
TextAlign::Left,
Some(self.bounds),
window,
cx,
);
}
}
fn paint_marked_underline(&self, window: &mut Window) {
let Some(marked_range) = &self.snapshot.marked_range else {
return;
};
if marked_range.is_empty() {
return;
}
let underline_thickness = px(MARKED_TEXT_UNDERLINE_THICKNESS);
let underline_offset = self.snapshot.line_height - underline_thickness;
for line in &self.snapshot.logical_lines {
if !self.is_line_visible(line) {
continue;
}
if !line_intersects_range(&line.text_range, marked_range) {
continue;
}
if line.text_range.is_empty() {
continue;
}
self.paint_line_range(
window,
line,
marked_range,
self.colors.marked,
underline_offset,
);
}
}
fn find_cursor_position_in_layouts(&self) -> Point<Pixels> {
for line in &self.snapshot.logical_lines {
let line_y = line.y_offset - self.snapshot.scroll_distance;
if !self.is_line_visible(line) {
continue;
}
// Since range is non-inclusive of the end value we need to check for it explicitly
let is_cursor_in_line = if line.text_range.is_empty() {
self.snapshot.cursor_position == line.text_range.start
} else {
line.text_range.contains(&self.snapshot.cursor_position)
|| self.snapshot.cursor_position == line.text_range.end
};
if !is_cursor_in_line {
continue;
}
let Some(wrapped) = &line.wrapped_line else {
return Point::default();
};
let local_offset = self
.snapshot
.cursor_position
.saturating_sub(line.text_range.start);
let cursor_pos = wrapped
.position_for_index(local_offset, self.snapshot.line_height)
.unwrap_or_default();
return cursor_pos + point(px(0.), line_y);
}
Point::default()
}
fn paint_cursor(&self, window: &mut Window) {
let cursor_pos = self.find_cursor_position_in_layouts();
window.paint_quad(fill(
Bounds::new(
point(self.bounds.left(), self.bounds.top()) + cursor_pos,
size(px(CURSOR_WIDTH), self.snapshot.line_height),
),
self.colors.cursor,
));
}
fn is_line_visible(&self, line: &InputLogicalLine) -> bool {
let line_y = line.y_offset - self.snapshot.scroll_distance;
let line_bottom = line_y + self.snapshot.line_height * line.visual_line_count as f32;
line_bottom >= px(0.) && line_y <= self.bounds.size.height
}
fn compute_visual_line_index(&self, y: Pixels) -> usize {
(y / self.snapshot.line_height).floor() as usize
}
fn paint_line_range(
&self,
window: &mut Window,
line: &InputLogicalLine,
subrange: &Range<usize>,
color: Hsla,
quad_offset_y: Pixels,
) {
let Some(wrapped) = &line.wrapped_line else {
return;
};
let line_y = line.y_offset - self.snapshot.scroll_distance;
let line_start = line.text_range.start;
let line_end = line.text_range.end;
let subrange_start = subrange.start.max(line_start) - line_start;
let subrange_end = subrange.end.min(line_end) - line_start;
let start_pos = wrapped
.position_for_index(subrange_start, self.snapshot.line_height)
.unwrap_or_default();
let end_pos = wrapped
.position_for_index(subrange_end, self.snapshot.line_height)
.unwrap_or_else(|| {
let last_line_y = self.snapshot.line_height * (line.visual_line_count - 1) as f32;
point(wrapped.width(), last_line_y)
});
let start_visual_line = self.compute_visual_line_index(start_pos.y);
let end_visual_line = self.compute_visual_line_index(end_pos.y);
if start_visual_line == end_visual_line {
self.paint_bounds_quad(
window,
color,
point(start_pos.x, line_y + start_pos.y + quad_offset_y),
point(end_pos.x, line_y + start_pos.y + self.snapshot.line_height),
);
} else {
let line_width = wrapped.width();
// First visual line
self.paint_bounds_quad(
window,
color,
point(start_pos.x, line_y + start_pos.y + quad_offset_y),
point(line_width, line_y + start_pos.y + self.snapshot.line_height),
);
// Middle visual lines
for visual_line in (start_visual_line + 1)..end_visual_line {
let y = self.snapshot.line_height * visual_line as f32;
self.paint_bounds_quad(
window,
color,
point(px(0.), line_y + y + quad_offset_y),
point(line_width, line_y + y + self.snapshot.line_height),
);
}
// Last visual line
self.paint_bounds_quad(
window,
color,
point(px(0.), line_y + end_pos.y + quad_offset_y),
point(end_pos.x, line_y + end_pos.y + self.snapshot.line_height),
);
}
}
}
fn line_intersects_range(
text_range: &std::ops::Range<usize>,
selected_range: &std::ops::Range<usize>,
) -> bool {
if text_range.is_empty() {
selected_range.start <= text_range.start && selected_range.end > text_range.start
} else {
selected_range.end > text_range.start && selected_range.start < text_range.end
}
}