use std::ops::Range;
use std::time::Duration;
use gpui::prelude::*;
use gpui::{
fill, point, px, size, App, Bounds, ClipboardItem, Context, ElementInputHandler, Entity,
FocusHandle, GlobalElementId, Hsla, KeyDownEvent, LayoutId, MouseDownEvent, MouseMoveEvent,
MouseUpEvent, PaintQuad, Pixels, Point, ShapedLine, SharedString, Style, TextRun, UnderlineStyle,
Window,
};
use super::edit::TextEdit;
use super::{apply_nav, KeyOutcome};
use crate::theme::theme;
const MASK: char = '\u{2022}';
const MASK_STR: &str = "\u{2022}";
const BLINK: Duration = Duration::from_millis(530);
const SCROLL_PAD: f32 = 2.0;
#[derive(Debug, Default)]
pub struct LineState {
pub(crate) shaped: Option<ShapedLine>,
pub(crate) bounds: Option<Bounds<Pixels>>,
pub(crate) scroll: Pixels,
pub(crate) marked: Option<Range<usize>>,
pub(crate) selecting: bool,
pub(crate) masked: bool,
pub(crate) empty: bool,
pub(crate) focused: bool,
pub(crate) caret_on: bool,
pub(crate) blinking: bool,
}
impl LineState {
pub fn new() -> Self {
LineState {
caret_on: true,
..Default::default()
}
}
fn shaped_byte(&self, edit: &TextEdit, index: usize) -> usize {
if self.masked {
index.min(edit.len()) * MASK.len_utf8()
} else {
edit.byte_of(index)
}
}
fn char_index(&self, edit: &TextEdit, byte: usize) -> usize {
if self.masked {
(byte / MASK.len_utf8()).min(edit.len())
} else {
edit.char_of(byte)
}
}
pub(crate) fn index_at(&self, edit: &TextEdit, position: Point<Pixels>) -> Option<usize> {
let (bounds, shaped) = (self.bounds?, self.shaped.as_ref()?);
if self.empty {
return Some(0);
}
let x = position.x - bounds.left() + self.scroll;
Some(self.char_index(edit, shaped.closest_index_for_x(x)))
}
fn wake(&mut self) {
self.caret_on = true;
}
}
pub trait LineEditor: 'static + Sized + gpui::EntityInputHandler {
fn edit(&self) -> &TextEdit;
fn edit_mut(&mut self) -> &mut TextEdit;
fn line(&self) -> &LineState;
fn line_mut(&mut self) -> &mut LineState;
fn line_focus(&self) -> &FocusHandle;
fn line_masked(&self) -> bool {
false
}
fn line_read_only(&self) -> bool {
false
}
fn line_max_length(&self) -> Option<usize> {
None
}
fn line_filter(&self, text: String) -> String {
text
}
fn line_changed(&mut self, cx: &mut Context<Self>);
}
macro_rules! line_input_handler {
($ty:ty) => {
impl ::gpui::EntityInputHandler for $ty {
fn text_for_range(
&mut self,
range_utf16: ::std::ops::Range<usize>,
actual: &mut Option<::std::ops::Range<usize>>,
_window: &mut ::gpui::Window,
_cx: &mut ::gpui::Context<Self>,
) -> Option<String> {
let range = $crate::input::line::from_utf16(self.edit(), &range_utf16);
actual.replace($crate::input::line::to_utf16(self.edit(), &range));
Some($crate::input::line::slice(self.edit(), &range))
}
fn selected_text_range(
&mut self,
_ignore_disabled: bool,
_window: &mut ::gpui::Window,
_cx: &mut ::gpui::Context<Self>,
) -> Option<::gpui::UTF16Selection> {
Some($crate::input::line::utf16_selection(self.edit()))
}
fn marked_text_range(
&self,
_window: &mut ::gpui::Window,
_cx: &mut ::gpui::Context<Self>,
) -> Option<::std::ops::Range<usize>> {
let marked = self.line().marked.clone()?;
Some($crate::input::line::to_utf16(self.edit(), &marked))
}
fn unmark_text(&mut self, _window: &mut ::gpui::Window, _cx: &mut ::gpui::Context<Self>) {
self.line_mut().marked = None;
}
fn replace_text_in_range(
&mut self,
range_utf16: Option<::std::ops::Range<usize>>,
text: &str,
_window: &mut ::gpui::Window,
cx: &mut ::gpui::Context<Self>,
) {
$crate::input::line::replace(self, range_utf16, text, None, cx);
}
fn replace_and_mark_text_in_range(
&mut self,
range_utf16: Option<::std::ops::Range<usize>>,
text: &str,
selected_utf16: Option<::std::ops::Range<usize>>,
_window: &mut ::gpui::Window,
cx: &mut ::gpui::Context<Self>,
) {
$crate::input::line::replace(self, range_utf16, text, Some(selected_utf16), cx);
}
fn bounds_for_range(
&mut self,
range_utf16: ::std::ops::Range<usize>,
bounds: ::gpui::Bounds<::gpui::Pixels>,
_window: &mut ::gpui::Window,
_cx: &mut ::gpui::Context<Self>,
) -> Option<::gpui::Bounds<::gpui::Pixels>> {
$crate::input::line::range_bounds(self, range_utf16, bounds)
}
fn character_index_for_point(
&mut self,
point: ::gpui::Point<::gpui::Pixels>,
_window: &mut ::gpui::Window,
_cx: &mut ::gpui::Context<Self>,
) -> Option<usize> {
let index = self.line().index_at(self.edit(), point)?;
Some($crate::input::line::to_utf16(self.edit(), &(0..index)).end)
}
}
};
}
pub(crate) use line_input_handler;
pub(crate) fn to_utf16(edit: &TextEdit, range: &Range<usize>) -> Range<usize> {
let buffer = edit.chars();
let units = |chars: usize| {
buffer[..chars.min(buffer.len())]
.iter()
.map(|c| c.len_utf16())
.sum::<usize>()
};
units(range.start)..units(range.end)
}
pub(crate) fn from_utf16(edit: &TextEdit, range: &Range<usize>) -> Range<usize> {
let buffer = edit.chars();
let chars = |units: usize| {
let mut seen = 0;
for (index, c) in buffer.iter().enumerate() {
if seen >= units {
return index;
}
seen += c.len_utf16();
}
buffer.len()
};
chars(range.start)..chars(range.end)
}
pub(crate) fn slice(edit: &TextEdit, range: &Range<usize>) -> String {
let buffer = edit.chars();
let start = range.start.min(buffer.len());
let end = range.end.clamp(start, buffer.len());
buffer[start..end].iter().collect()
}
pub(crate) fn utf16_selection(edit: &TextEdit) -> gpui::UTF16Selection {
let (start, end) = edit.selection().unwrap_or((edit.cursor(), edit.cursor()));
let reversed = edit.cursor() == start && start != end;
gpui::UTF16Selection {
range: to_utf16(edit, &(start..end)),
reversed,
}
}
fn flatten(text: &str) -> String {
text
.chars()
.map(|c| if c == '\n' || c == '\r' { ' ' } else { c })
.filter(|c| !c.is_control())
.collect()
}
pub(crate) fn replace<V: LineEditor>(
this: &mut V,
range_utf16: Option<Range<usize>>,
text: &str,
marking: Option<Option<Range<usize>>>,
cx: &mut Context<V>,
) {
if this.line_read_only() {
return;
}
let text = this.line_filter(flatten(text));
let range = range_utf16
.map(|r| from_utf16(this.edit(), &r))
.or_else(|| this.line().marked.clone())
.unwrap_or_else(|| {
this
.edit()
.selection()
.map(|(s, e)| s..e)
.unwrap_or_else(|| this.edit().cursor()..this.edit().cursor())
});
let text = match this.line_max_length() {
Some(max) => {
let kept = this.edit().len() - range.len().min(this.edit().len());
text.chars().take(max.saturating_sub(kept)).collect()
}
None => text,
};
let start = range.start;
this.edit_mut().replace_range(range, &text);
match marking {
Some(selection) => {
let end = start + text.chars().count();
this.line_mut().marked = (!text.is_empty()).then_some(start..end);
if let Some(selection) = selection {
let selection = from_utf16(this.edit(), &selection);
this
.edit_mut()
.set_selection(start + selection.start, start + selection.end);
}
}
None => this.line_mut().marked = None,
}
this.line_mut().wake();
this.line_changed(cx);
}
pub(crate) fn range_bounds<V: LineEditor>(
this: &V,
range_utf16: Range<usize>,
bounds: Bounds<Pixels>,
) -> Option<Bounds<Pixels>> {
let shaped = this.line().shaped.as_ref()?;
let range = from_utf16(this.edit(), &range_utf16);
let x = |index: usize| {
bounds.left() + shaped.x_for_index(this.line().shaped_byte(this.edit(), index))
- this.line().scroll
};
Some(Bounds::from_corners(
point(x(range.start), bounds.top()),
point(x(range.end), bounds.bottom()),
))
}
pub(crate) fn mouse_down<V: LineEditor>(
this: &mut V,
event: &MouseDownEvent,
window: &mut Window,
cx: &mut Context<V>,
) {
window.focus(this.line_focus());
let Some(index) = this.line().index_at(this.edit(), event.position) else {
cx.notify();
return;
};
match event.click_count {
1 if event.modifiers.shift => this.edit_mut().extend_to(index),
1 => this.edit_mut().set_cursor(index),
2 => {
let (start, end) = this.edit().word_at(index);
this.edit_mut().set_selection(start, end);
}
_ => this.edit_mut().select_all(),
}
this.line_mut().selecting = true;
this.line_mut().wake();
cx.notify();
}
pub(crate) fn mouse_move<V: LineEditor>(
this: &mut V,
event: &MouseMoveEvent,
_window: &mut Window,
cx: &mut Context<V>,
) {
if !this.line().selecting {
return;
}
if let Some(index) = this.line().index_at(this.edit(), event.position) {
this.edit_mut().extend_to(index);
cx.notify();
}
}
pub(crate) fn mouse_up<V: LineEditor>(
this: &mut V,
_event: &MouseUpEvent,
_window: &mut Window,
cx: &mut Context<V>,
) {
if this.line().selecting {
this.line_mut().selecting = false;
cx.notify();
}
}
pub(crate) fn wire<V: LineEditor>(
element: gpui::Stateful<gpui::Div>,
focus: &FocusHandle,
cx: &mut Context<V>,
) -> gpui::Stateful<gpui::Div> {
element
.track_focus(focus)
.cursor(gpui::CursorStyle::IBeam)
.on_mouse_down(gpui::MouseButton::Left, cx.listener(mouse_down))
.on_mouse_move(cx.listener(mouse_move))
.on_mouse_up(gpui::MouseButton::Left, cx.listener(mouse_up))
.on_mouse_up_out(gpui::MouseButton::Left, cx.listener(mouse_up))
}
macro_rules! line_focus_builders {
($ty:ty) => {
impl $ty {
pub fn tab_index(mut self, index: isize) -> Self {
self.focus = self.focus.clone().tab_index(index);
self
}
pub fn tab_stop(mut self, tab_stop: bool) -> Self {
self.focus = self.focus.clone().tab_stop(tab_stop);
self
}
pub fn focus_handle(&self) -> ::gpui::FocusHandle {
self.focus.clone()
}
}
};
}
pub(crate) use line_focus_builders;
pub(crate) fn keys<V: LineEditor>(
this: &mut V,
event: &KeyDownEvent,
window: &mut Window,
cx: &mut Context<V>,
) -> KeyOutcome {
let ks = &event.keystroke;
let m = &ks.modifiers;
if ks.key == "tab" && !m.platform && !m.control {
if m.shift {
window.focus_prev();
} else {
window.focus_next();
}
cx.stop_propagation();
return KeyOutcome::Edited;
}
if m.platform && !m.alt && !m.control {
match ks.key.as_str() {
"c" => return copy(this, cx),
"x" => return cut(this, cx),
"v" => return paste(this, cx),
"z" if m.shift => return history(this, false, cx),
"z" => return history(this, true, cx),
"y" => return history(this, false, cx),
_ => {}
}
}
if this.line_read_only() && mutates(ks.key.as_str()) {
return KeyOutcome::Pass;
}
let outcome = apply_nav(this.edit_mut(), ks);
if outcome == KeyOutcome::Edited {
this.line_mut().wake();
}
outcome
}
fn mutates(key: &str) -> bool {
matches!(key, "backspace" | "delete" | "k")
}
fn copy<V: LineEditor>(this: &mut V, cx: &mut Context<V>) -> KeyOutcome {
if !this.line_masked() {
if let Some(text) = this.edit().selected_text() {
cx.write_to_clipboard(ClipboardItem::new_string(text));
}
}
cx.stop_propagation();
KeyOutcome::Pass
}
fn cut<V: LineEditor>(this: &mut V, cx: &mut Context<V>) -> KeyOutcome {
if this.line_masked() || this.line_read_only() {
cx.stop_propagation();
return KeyOutcome::Pass;
}
let Some(text) = this.edit().selected_text() else {
cx.stop_propagation();
return KeyOutcome::Pass;
};
cx.write_to_clipboard(ClipboardItem::new_string(text));
this.edit_mut().delete_selection();
this.line_mut().wake();
cx.stop_propagation();
KeyOutcome::Edited
}
fn paste<V: LineEditor>(this: &mut V, cx: &mut Context<V>) -> KeyOutcome {
if this.line_read_only() {
cx.stop_propagation();
return KeyOutcome::Pass;
}
let Some(text) = cx.read_from_clipboard().and_then(|item| item.text()) else {
cx.stop_propagation();
return KeyOutcome::Pass;
};
let text = this.line_filter(flatten(&text));
let text = match this.line_max_length() {
Some(max) => {
let selected = this.edit().selection().map_or(0, |(s, e)| e - s);
let room = max.saturating_sub(this.edit().len() - selected);
text.chars().take(room).collect()
}
None => text,
};
this.edit_mut().break_undo();
this.edit_mut().insert(&text);
this.edit_mut().break_undo();
this.line_mut().wake();
cx.stop_propagation();
KeyOutcome::Edited
}
fn history<V: LineEditor>(this: &mut V, undo: bool, cx: &mut Context<V>) -> KeyOutcome {
if this.line_read_only() {
cx.stop_propagation();
return KeyOutcome::Pass;
}
let changed = if undo {
this.edit_mut().undo()
} else {
this.edit_mut().redo()
};
this.line_mut().wake();
cx.stop_propagation();
if changed {
KeyOutcome::Edited
} else {
KeyOutcome::Pass
}
}
pub struct Line<V: LineEditor> {
field: Entity<V>,
placeholder: SharedString,
placeholder_color: Option<Hsla>,
}
impl<V: LineEditor> Line<V> {
pub fn new(field: Entity<V>) -> Self {
Line {
field,
placeholder: SharedString::default(),
placeholder_color: None,
}
}
pub fn placeholder(mut self, placeholder: impl Into<SharedString>, color: Hsla) -> Self {
self.placeholder = placeholder.into();
self.placeholder_color = Some(color);
self
}
}
pub struct LinePrepaint {
shaped: Option<ShapedLine>,
caret: Option<PaintQuad>,
selection: Option<PaintQuad>,
scroll: Pixels,
}
impl<V: LineEditor> IntoElement for Line<V> {
type Element = Self;
fn into_element(self) -> Self::Element {
self
}
}
impl<V: LineEditor> Element for Line<V> {
type RequestLayoutState = ();
type PrepaintState = LinePrepaint;
fn id(&self) -> Option<gpui::ElementId> {
None
}
fn source_location(&self) -> Option<&'static std::panic::Location<'static>> {
None
}
fn request_layout(
&mut self,
_id: Option<&GlobalElementId>,
_inspector: Option<&gpui::InspectorElementId>,
window: &mut Window,
cx: &mut App,
) -> (LayoutId, ()) {
let mut style = Style::default();
style.size.width = gpui::relative(1.0).into();
style.size.height = window.line_height().into();
(window.request_layout(style, [], cx), ())
}
fn prepaint(
&mut self,
_id: Option<&GlobalElementId>,
_inspector: Option<&gpui::InspectorElementId>,
bounds: Bounds<Pixels>,
_layout: &mut (),
window: &mut Window,
cx: &mut App,
) -> LinePrepaint {
let t = theme(cx);
let text_color = t.text().hsla();
let caret_color = t.primary().hsla();
let selection_color = t.selection();
let dimmed = t.dimmed().hsla();
let focused = self.field.read(cx).line_focus().is_focused(window);
let field = self.field.read(cx);
let masked = field.line_masked();
let empty = field.edit().is_empty();
let cursor = field.edit().cursor();
let selection = field.edit().selection();
let marked = field.line().marked.clone();
let chars = field.edit().len();
let caret_on = field.line().caret_on;
let display: SharedString = if empty {
self.placeholder.clone()
} else if masked {
SharedString::from(MASK_STR.repeat(chars))
} else {
SharedString::from(field.edit().text())
};
let style = window.text_style();
let font_size = style.font_size.to_pixels(window.rem_size());
let color = if empty {
self.placeholder_color.unwrap_or(dimmed)
} else {
text_color
};
let run = TextRun {
len: display.len(),
font: style.font(),
color,
background_color: None,
underline: None,
strikethrough: None,
};
let runs = match marked.filter(|_| !empty) {
Some(marked) => {
let limit = display.len();
let byte = |index: usize| {
if masked {
index.saturating_mul(MASK.len_utf8())
} else {
byte_of(&display, index)
}
.min(limit)
};
let start = byte(marked.start);
let end = byte(marked.end).max(start);
vec![
TextRun {
len: start,
..run.clone()
},
TextRun {
len: end.saturating_sub(start),
underline: Some(UnderlineStyle {
color: Some(color),
thickness: px(1.0),
wavy: false,
}),
..run.clone()
},
TextRun {
len: display.len().saturating_sub(end),
..run
},
]
.into_iter()
.filter(|run| run.len > 0)
.collect()
}
None => vec![run],
};
let shaped = window
.text_system()
.shape_line(display.clone(), font_size, &runs, None);
let byte = |index: usize| {
if masked {
index.saturating_mul(MASK.len_utf8())
} else {
byte_of(&display, index)
}
};
let caret_x = if empty {
px(0.0)
} else {
shaped.x_for_index(byte(cursor))
};
let width = bounds.size.width;
let pad = px(SCROLL_PAD);
let mut scroll = self.field.read(cx).line().scroll;
scroll = scroll.min((shaped.width - width + pad).max(px(0.0)));
if caret_x - scroll > width - pad {
scroll = caret_x - width + pad;
}
if caret_x - scroll < px(0.0) {
scroll = caret_x;
}
scroll = scroll.max(px(0.0));
let quads = if focused && !empty {
match selection {
Some((start, end)) => (
None,
Some(fill(
Bounds::from_corners(
point(
bounds.left() + shaped.x_for_index(byte(start)) - scroll,
bounds.top(),
),
point(
bounds.left() + shaped.x_for_index(byte(end)) - scroll,
bounds.bottom(),
),
),
selection_color,
)),
),
None => (caret_quad(bounds, caret_x - scroll, caret_color), None),
}
} else if focused {
(caret_quad(bounds, caret_x - scroll, caret_color), None)
} else {
(None, None)
};
self.field.update(cx, |field, cx| {
let state = field.line_mut();
state.scroll = scroll;
state.masked = masked;
state.empty = empty;
if state.focused != focused {
state.focused = focused;
state.caret_on = true;
}
if focused && !state.blinking {
state.blinking = true;
blink(cx);
}
});
LinePrepaint {
shaped: Some(shaped),
caret: quads.0.filter(|_| caret_on),
selection: quads.1,
scroll,
}
}
fn paint(
&mut self,
_id: Option<&GlobalElementId>,
_inspector: Option<&gpui::InspectorElementId>,
bounds: Bounds<Pixels>,
_layout: &mut (),
prepaint: &mut LinePrepaint,
window: &mut Window,
cx: &mut App,
) {
let focus = self.field.read(cx).line_focus().clone();
window.handle_input(
&focus,
ElementInputHandler::new(bounds, self.field.clone()),
cx,
);
let shaped = prepaint.shaped.take().unwrap_or_default();
let origin = point(bounds.origin.x - prepaint.scroll, bounds.origin.y);
let parent_mask = window.content_mask().bounds;
let mask = Bounds::from_corners(
point(bounds.left(), parent_mask.top()),
point(bounds.right(), parent_mask.bottom()),
);
window.with_content_mask(Some(gpui::ContentMask { bounds: mask }), |window| {
if let Some(selection) = prepaint.selection.take() {
window.paint_quad(selection);
}
shaped.paint(origin, window.line_height(), window, cx).ok();
if let Some(caret) = prepaint.caret.take() {
window.paint_quad(caret);
}
});
self.field.update(cx, |field, _| {
let state = field.line_mut();
state.shaped = Some(shaped);
state.bounds = Some(bounds);
});
}
}
fn caret_quad(bounds: Bounds<Pixels>, x: Pixels, color: Hsla) -> Option<PaintQuad> {
Some(fill(
Bounds::new(
point(bounds.left() + x, bounds.top()),
size(px(1.0), bounds.size.height),
),
color,
))
}
fn byte_of(text: &str, index: usize) -> usize {
text
.char_indices()
.nth(index)
.map(|(byte, _)| byte)
.unwrap_or(text.len())
}
fn blink<V: LineEditor>(cx: &mut Context<V>) {
cx.spawn(async move |field, cx| loop {
cx.background_executor().timer(BLINK).await;
let running = field
.update(cx, |field, cx| {
let state = field.line_mut();
if !state.focused {
state.blinking = false;
state.caret_on = true;
cx.notify();
return false;
}
state.caret_on = !state.caret_on;
cx.notify();
true
})
.unwrap_or(false);
if !running {
break;
}
})
.detach();
}