use std::ops::Range;
use std::time::{Duration, Instant};
use std::sync::Arc;
use unicode_segmentation::UnicodeSegmentation as _;
use fluidattacks_designs_domain::inputs::{
State, Validation, CURSOR_BLINK_MS, CURSOR_WIDTH, DEFAULT_ROWS, LINE_HEIGHT, OUTLINE_GAP,
PLACEHOLDER, SELECTION, TEXT, TEXT_SIZE,
};
use fluidattacks_designs_domain::typography::FONT_PRIMARY;
use gpui::prelude::FluentBuilder as _;
use gpui::{
div, fill, point, px, relative, rgb, rgb_to_hsla, rgba, size, App, AvailableSpace, Bounds,
ClipboardItem, Context, CursorStyle, Element, ElementId, ElementInputHandler, Entity,
EntityInputHandler, FocusHandle, Focusable, GlobalElementId, InteractiveElement as _,
IntoElement, KeyBinding, LayoutId, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent,
PaintQuad, ParentElement as _, Pixels, Point, RenderOnce, ScrollHandle, SharedString,
StatefulInteractiveElement as _, Style, Styled as _, TextAlign, TextRun, UTF16Selection,
Window, WrappedLine,
};
use crate::scrollbar::Scrollbar;
use crate::text_input::{
block_field_frame, bottom_row, help_row, label_row, text_runs, validation_icon, x_at,
};
use crate::theme::ActiveTheme as _;
const CONTEXT: &str = "DesignsTextArea";
#[allow(
clippy::derive_partial_eq_without_eq,
reason = "the actions! expansion derives PartialEq without Eq"
)]
mod action_defs {
gpui::actions!(
designs_text_area,
[
Backspace,
Delete,
Left,
Right,
Up,
Down,
SelectLeft,
SelectRight,
SelectUp,
SelectDown,
SelectAll,
Home,
End,
Newline,
ShowCharacterPalette,
Paste,
Cut,
Copy,
]
);
}
use action_defs::{
Backspace, Copy, Cut, Delete, Down, End, Home, Left, Newline, Paste, Right, SelectAll,
SelectDown, SelectLeft, SelectRight, SelectUp, ShowCharacterPalette, Up,
};
pub fn bind_keys(cx: &mut App) {
cx.bind_keys([
KeyBinding::new("backspace", Backspace, Some(CONTEXT)),
KeyBinding::new("delete", Delete, Some(CONTEXT)),
KeyBinding::new("left", Left, Some(CONTEXT)),
KeyBinding::new("right", Right, Some(CONTEXT)),
KeyBinding::new("up", Up, Some(CONTEXT)),
KeyBinding::new("down", Down, Some(CONTEXT)),
KeyBinding::new("shift-left", SelectLeft, Some(CONTEXT)),
KeyBinding::new("shift-right", SelectRight, Some(CONTEXT)),
KeyBinding::new("shift-up", SelectUp, Some(CONTEXT)),
KeyBinding::new("shift-down", SelectDown, Some(CONTEXT)),
KeyBinding::new("cmd-a", SelectAll, Some(CONTEXT)),
KeyBinding::new("enter", Newline, Some(CONTEXT)),
KeyBinding::new("home", Home, Some(CONTEXT)),
KeyBinding::new("cmd-left", Home, Some(CONTEXT)),
KeyBinding::new("end", End, Some(CONTEXT)),
KeyBinding::new("cmd-right", End, Some(CONTEXT)),
KeyBinding::new("ctrl-cmd-space", ShowCharacterPalette, Some(CONTEXT)),
KeyBinding::new("cmd-v", Paste, Some(CONTEXT)),
KeyBinding::new("cmd-c", Copy, Some(CONTEXT)),
KeyBinding::new("cmd-x", Cut, Some(CONTEXT)),
]);
}
fn held_at(caret: f32, window: f32, shown: f32) -> Option<f32> {
let bottom = caret + LINE_HEIGHT;
if caret < shown {
Some(caret.max(0.0))
} else if bottom > shown + window {
Some((bottom - window).max(0.0))
} else {
None
}
}
fn scaled(count: usize, unit: f32) -> f32 {
f32::from(u16::try_from(count).unwrap_or(u16::MAX)) * unit
}
fn text_room(rows: u16) -> f32 {
f32::from(rows.max(1)) * LINE_HEIGHT
}
fn line_starts(text: &str) -> Vec<usize> {
let mut starts = vec![0];
starts.extend(
text.match_indices('\n')
.map(|(index, _)| index.saturating_add(1)),
);
starts
}
#[derive(Default)]
struct Block {
lines: Arc<[WrappedLine]>,
starts: Vec<usize>,
}
impl Block {
fn rows(line: &WrappedLine) -> usize {
line.wrap_boundaries().len().saturating_add(1)
}
fn height(&self, line_height: f32) -> f32 {
scaled(self.lines.iter().map(Self::rows).sum(), line_height)
}
fn width(&self) -> Pixels {
self.lines.iter().fold(px(0.0), |widest, line| {
widest.max(line.size(px(LINE_HEIGHT)).width)
})
}
fn top(&self, index: usize, line_height: f32) -> f32 {
scaled(
self.lines.iter().take(index).map(Self::rows).sum(),
line_height,
)
}
fn locate(&self, offset: usize) -> Option<(usize, usize)> {
let mut found = None;
for (index, start) in self.starts.iter().enumerate() {
if *start > offset {
break;
}
found = Some((index, offset.saturating_sub(*start)));
}
found
}
fn position(&self, offset: usize, line_height: f32) -> Option<Point<Pixels>> {
let (index, within) = self.locate(offset)?;
let line = self.lines.get(index)?;
let local = line.position_for_index(within, px(line_height))?;
Some(point(
local.x,
px(f32::from(local.y) + self.top(index, line_height)),
))
}
fn index_at(&self, position: Point<Pixels>, line_height: f32) -> usize {
let mut top = 0.0_f32;
let last = self.lines.len().saturating_sub(1);
for (index, line) in self.lines.iter().enumerate() {
let rows = Self::rows(line);
let bottom = top + scaled(rows, line_height);
if f32::from(position.y) < bottom || index == last {
let row_top = scaled(rows.saturating_sub(1), line_height);
let local_y = (f32::from(position.y) - top).clamp(0.0, row_top);
let within = line
.closest_index_for_position(point(position.x, px(local_y)), px(line_height))
.unwrap_or_else(|fallback| fallback);
return self
.starts
.get(index)
.copied()
.unwrap_or_default()
.saturating_add(within);
}
top = bottom;
}
0
}
}
pub struct TextAreaState {
focus_handle: FocusHandle,
value: SharedString,
placeholder: SharedString,
rows: u16,
selected_range: Range<usize>,
selection_reversed: bool,
marked_range: Option<Range<usize>>,
block: Block,
last_bounds: Option<Bounds<Pixels>>,
is_selecting: bool,
max_length: Option<usize>,
blink_scheduled: bool,
scroll: ScrollHandle,
held: Option<Instant>,
goal_x: Option<f32>,
caret_epoch: Instant,
}
impl TextAreaState {
pub fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
let focus_handle = cx.focus_handle();
cx.on_focus(&focus_handle, window, |_, _, cx| cx.notify())
.detach();
cx.on_blur(&focus_handle, window, |_, _, cx| cx.notify())
.detach();
Self {
focus_handle,
value: SharedString::default(),
placeholder: SharedString::default(),
rows: DEFAULT_ROWS,
selected_range: 0..0,
selection_reversed: false,
marked_range: None,
block: Block::default(),
last_bounds: None,
is_selecting: false,
max_length: None,
blink_scheduled: false,
scroll: ScrollHandle::new(),
held: None,
goal_x: None,
caret_epoch: Instant::now(),
}
}
#[must_use]
pub const fn rows(mut self, rows: u16) -> Self {
self.rows = rows;
self
}
#[must_use]
pub const fn max_length(mut self, max_length: usize) -> Self {
self.max_length = Some(max_length);
self
}
#[must_use]
pub fn placeholder(mut self, placeholder: impl Into<SharedString>) -> Self {
self.placeholder = placeholder.into();
self
}
#[must_use]
pub fn value(&self) -> &str {
&self.value
}
pub fn set_placeholder(
&mut self,
placeholder: impl Into<SharedString>,
cx: &mut Context<Self>,
) {
self.placeholder = placeholder.into();
cx.notify();
}
pub fn set_value(&mut self, value: impl Into<SharedString>, cx: &mut Context<Self>) {
self.value = value.into();
let end = self.value.len();
self.selected_range = end..end;
self.selection_reversed = false;
self.marked_range = None;
cx.notify();
}
fn left(&mut self, _: &Left, _: &mut Window, cx: &mut Context<Self>) {
if self.selected_range.is_empty() {
self.move_to(self.previous_boundary(self.cursor_offset()), cx);
} else {
self.move_to(self.selected_range.start, cx);
}
}
fn right(&mut self, _: &Right, _: &mut Window, cx: &mut Context<Self>) {
if self.selected_range.is_empty() {
self.move_to(self.next_boundary(self.cursor_offset()), cx);
} else {
self.move_to(self.selected_range.end, cx);
}
}
fn up(&mut self, _: &Up, _: &mut Window, cx: &mut Context<Self>) {
self.walk(false, false, cx);
}
fn down(&mut self, _: &Down, _: &mut Window, cx: &mut Context<Self>) {
self.walk(true, false, cx);
}
fn select_up(&mut self, _: &SelectUp, _: &mut Window, cx: &mut Context<Self>) {
self.walk(false, true, cx);
}
fn select_down(&mut self, _: &SelectDown, _: &mut Window, cx: &mut Context<Self>) {
self.walk(true, true, cx);
}
fn walk(&mut self, downward: bool, select: bool, cx: &mut Context<Self>) {
let Some(position) = self.block.position(self.cursor_offset(), LINE_HEIGHT) else {
return;
};
let column = self.goal_x.unwrap_or_else(|| f32::from(position.x));
let target = if downward {
f32::from(position.y) + LINE_HEIGHT
} else {
f32::from(position.y) - LINE_HEIGHT
};
let offset = if target < 0.0 {
0
} else if target >= self.block.height(LINE_HEIGHT) {
self.value.len()
} else {
self.snapped(
self.block
.index_at(point(px(column), px(target)), LINE_HEIGHT),
)
};
if select {
self.select_to(offset, cx);
} else {
self.move_to(offset, cx);
}
self.goal_x = Some(column);
}
fn select_left(&mut self, _: &SelectLeft, _: &mut Window, cx: &mut Context<Self>) {
self.select_to(self.previous_boundary(self.cursor_offset()), cx);
}
fn select_right(&mut self, _: &SelectRight, _: &mut Window, cx: &mut Context<Self>) {
self.select_to(self.next_boundary(self.cursor_offset()), cx);
}
fn select_all(&mut self, _: &SelectAll, _: &mut Window, cx: &mut Context<Self>) {
self.move_to(0, cx);
self.select_to(self.value.len(), cx);
}
fn home(&mut self, _: &Home, _: &mut Window, cx: &mut Context<Self>) {
self.move_to(self.row_edge(false), cx);
}
fn end(&mut self, _: &End, _: &mut Window, cx: &mut Context<Self>) {
self.move_to(self.row_edge(true), cx);
}
fn row_edge(&self, trailing: bool) -> usize {
let Some(position) = self.block.position(self.cursor_offset(), LINE_HEIGHT) else {
return if trailing { self.value.len() } else { 0 };
};
let x = if trailing {
px(f32::from(self.block.width()) + LINE_HEIGHT)
} else {
px(0.0)
};
self.snapped(self.block.index_at(point(x, position.y), LINE_HEIGHT))
}
fn newline(&mut self, _: &Newline, window: &mut Window, cx: &mut Context<Self>) {
self.replace_text_in_range(None, "\n", window, cx);
}
fn backspace(&mut self, _: &Backspace, window: &mut Window, cx: &mut Context<Self>) {
if self.selected_range.is_empty() {
self.select_to(self.previous_boundary(self.cursor_offset()), cx);
}
self.replace_text_in_range(None, "", window, cx);
}
fn delete(&mut self, _: &Delete, window: &mut Window, cx: &mut Context<Self>) {
if self.selected_range.is_empty() {
self.select_to(self.next_boundary(self.cursor_offset()), cx);
}
self.replace_text_in_range(None, "", window, cx);
}
fn paste(&mut self, _: &Paste, window: &mut Window, cx: &mut Context<Self>) {
if let Some(text) = cx.read_from_clipboard().and_then(|item| item.text()) {
self.replace_text_in_range(None, &text, window, cx);
}
}
fn selected_text(&self) -> &str {
self.value
.get(self.selected_range.clone())
.unwrap_or_default()
}
fn copy(&mut self, _: &Copy, _: &mut Window, cx: &mut Context<Self>) {
if !self.selected_range.is_empty() {
cx.write_to_clipboard(ClipboardItem::new_string(self.selected_text().to_owned()));
}
}
fn cut(&mut self, _: &Cut, window: &mut Window, cx: &mut Context<Self>) {
if !self.selected_range.is_empty() {
cx.write_to_clipboard(ClipboardItem::new_string(self.selected_text().to_owned()));
self.replace_text_in_range(None, "", window, cx);
}
}
fn on_mouse_down(&mut self, event: &MouseDownEvent, _: &mut Window, cx: &mut Context<Self>) {
self.is_selecting = true;
if event.modifiers.shift {
self.select_to(self.index_for_mouse_position(event.position), cx);
} else {
self.move_to(self.index_for_mouse_position(event.position), cx);
}
}
const fn on_mouse_up(&mut self, _: &MouseUpEvent, _: &mut Window, _: &mut Context<Self>) {
self.is_selecting = false;
}
fn on_mouse_move(&mut self, event: &MouseMoveEvent, _: &mut Window, cx: &mut Context<Self>) {
if self.is_selecting {
self.select_to(self.index_for_mouse_position(event.position), cx);
}
}
fn move_to(&mut self, offset: usize, cx: &mut Context<Self>) {
self.selected_range = offset..offset;
self.goal_x = None;
self.caret_epoch = Instant::now();
cx.notify();
}
const fn cursor_offset(&self) -> usize {
if self.selection_reversed {
self.selected_range.start
} else {
self.selected_range.end
}
}
fn index_for_mouse_position(&self, position: Point<Pixels>) -> usize {
if self.value.is_empty() {
return 0;
}
let Some(bounds) = self.last_bounds.as_ref() else {
return 0;
};
let local = point(
px(f32::from(position.x) - f32::from(bounds.left())),
px(f32::from(position.y) - f32::from(bounds.top())),
);
self.snapped(self.block.index_at(local, LINE_HEIGHT))
}
fn snapped(&self, offset: usize) -> usize {
let mut offset = offset.min(self.value.len());
while offset > 0 && !self.value.is_char_boundary(offset) {
offset = offset.saturating_sub(1);
}
offset
}
fn hold_caret_in_view(&self) {
let Some(at) = self.block.position(self.cursor_offset(), LINE_HEIGHT) else {
return;
};
let shown = -f32::from(self.scroll.offset().y);
let Some(moved) = held_at(f32::from(at.y), text_room(self.rows), shown) else {
return;
};
let across = self.scroll.offset().x;
self.scroll.set_offset(point(across, px(-moved)));
}
fn select_to(&mut self, offset: usize, cx: &mut Context<Self>) {
if self.selection_reversed {
self.selected_range.start = offset;
} else {
self.selected_range.end = offset;
}
if self.selected_range.end < self.selected_range.start {
self.selection_reversed = !self.selection_reversed;
self.selected_range = self.selected_range.end..self.selected_range.start;
}
self.goal_x = None;
self.caret_epoch = Instant::now();
cx.notify();
}
fn offset_from_utf16(&self, offset: usize) -> usize {
let mut utf8_offset = 0_usize;
let mut utf16_count = 0_usize;
for ch in self.value.chars() {
if utf16_count >= offset {
break;
}
utf16_count = utf16_count.saturating_add(ch.len_utf16());
utf8_offset = utf8_offset.saturating_add(ch.len_utf8());
}
utf8_offset
}
fn offset_to_utf16(&self, offset: usize) -> usize {
let mut utf16_offset = 0_usize;
let mut utf8_count = 0_usize;
for ch in self.value.chars() {
if utf8_count >= offset {
break;
}
utf8_count = utf8_count.saturating_add(ch.len_utf8());
utf16_offset = utf16_offset.saturating_add(ch.len_utf16());
}
utf16_offset
}
fn range_to_utf16(&self, range: &Range<usize>) -> Range<usize> {
self.offset_to_utf16(range.start)..self.offset_to_utf16(range.end)
}
fn range_from_utf16(&self, range_utf16: &Range<usize>) -> Range<usize> {
self.offset_from_utf16(range_utf16.start)..self.offset_from_utf16(range_utf16.end)
}
fn target_range(&self, range_utf16: Option<&Range<usize>>) -> Range<usize> {
range_utf16
.map(|utf16| self.range_from_utf16(utf16))
.or_else(|| self.marked_range.clone())
.unwrap_or_else(|| self.selected_range.clone())
}
fn previous_boundary(&self, offset: usize) -> usize {
let mut previous = 0;
for (idx, _) in self.value.grapheme_indices(true) {
if idx >= offset {
break;
}
previous = idx;
}
previous
}
fn next_boundary(&self, offset: usize) -> usize {
self.value
.grapheme_indices(true)
.find_map(|(idx, _)| (idx > offset).then_some(idx))
.unwrap_or(self.value.len())
}
fn splice(&mut self, range: &Range<usize>, new_text: &str) -> usize {
let prefix = self.value.get(..range.start).unwrap_or_default();
let suffix = self.value.get(range.end..).unwrap_or_default();
let new_text = self.max_length.map_or(new_text, |max| {
let kept = prefix
.graphemes(true)
.count()
.saturating_add(suffix.graphemes(true).count());
let room = max.saturating_sub(kept);
let end = new_text
.grapheme_indices(true)
.nth(room)
.map_or(new_text.len(), |(idx, _)| idx);
new_text.get(..end).unwrap_or_default()
});
self.value = format!("{prefix}{new_text}{suffix}").into();
self.goal_x = None;
self.caret_epoch = Instant::now();
range.start.saturating_add(new_text.len())
}
}
impl Focusable for TextAreaState {
fn focus_handle(&self, _: &App) -> FocusHandle {
self.focus_handle.clone()
}
}
impl gpui::Render for TextAreaState {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
div()
.w_full()
.h_full()
.key_context(CONTEXT)
.track_focus(&self.focus_handle)
.cursor(CursorStyle::IBeam)
.on_action(cx.listener(Self::backspace))
.on_action(cx.listener(Self::delete))
.on_action(cx.listener(Self::left))
.on_action(cx.listener(Self::right))
.on_action(cx.listener(Self::up))
.on_action(cx.listener(Self::down))
.on_action(cx.listener(Self::select_left))
.on_action(cx.listener(Self::select_right))
.on_action(cx.listener(Self::select_up))
.on_action(cx.listener(Self::select_down))
.on_action(cx.listener(Self::select_all))
.on_action(cx.listener(Self::home))
.on_action(cx.listener(Self::end))
.on_action(cx.listener(Self::newline))
.on_action(|_: &ShowCharacterPalette, window: &mut Window, _| {
window.show_character_palette();
})
.on_action(cx.listener(Self::paste))
.on_action(cx.listener(Self::cut))
.on_action(cx.listener(Self::copy))
.on_mouse_down(MouseButton::Left, cx.listener(Self::on_mouse_down))
.on_mouse_up(MouseButton::Left, cx.listener(Self::on_mouse_up))
.on_mouse_up_out(MouseButton::Left, cx.listener(Self::on_mouse_up))
.on_mouse_move(cx.listener(Self::on_mouse_move))
.child(
div()
.id(("designs-text-area", cx.entity_id()))
.w_full()
.h_full()
.overflow_y_scroll()
.track_scroll(&self.scroll)
.child(BlockElement { state: cx.entity() }),
)
}
}
impl EntityInputHandler for TextAreaState {
fn text_for_range(
&mut self,
range_utf16: Range<usize>,
actual_range: &mut Option<Range<usize>>,
_: &mut Window,
_: &mut Context<Self>,
) -> Option<String> {
let range = self.range_from_utf16(&range_utf16);
actual_range.replace(self.range_to_utf16(&range));
Some(self.value.get(range).unwrap_or_default().to_owned())
}
fn selected_text_range(
&mut self,
_ignore_disabled_input: bool,
_: &mut Window,
_: &mut Context<Self>,
) -> Option<UTF16Selection> {
Some(UTF16Selection {
range: self.range_to_utf16(&self.selected_range),
reversed: self.selection_reversed,
})
}
fn marked_text_range(&self, _: &mut Window, _: &mut Context<Self>) -> Option<Range<usize>> {
self.marked_range
.as_ref()
.map(|range| self.range_to_utf16(range))
}
fn unmark_text(&mut self, _: &mut Window, _: &mut Context<Self>) {
self.marked_range = None;
}
fn replace_text_in_range(
&mut self,
range_utf16: Option<Range<usize>>,
new_text: &str,
_: &mut Window,
cx: &mut Context<Self>,
) {
let range = self.target_range(range_utf16.as_ref());
let caret = self.splice(&range, new_text);
self.selected_range = caret..caret;
self.marked_range.take();
cx.notify();
}
fn replace_and_mark_text_in_range(
&mut self,
range_utf16: Option<Range<usize>>,
new_text: &str,
new_selected_range_utf16: Option<Range<usize>>,
_: &mut Window,
cx: &mut Context<Self>,
) {
let range = self.target_range(range_utf16.as_ref());
let caret = self.splice(&range, new_text);
self.marked_range = if new_text.is_empty() {
None
} else {
Some(range.start..caret)
};
self.selected_range = new_selected_range_utf16
.as_ref()
.map(|utf16| self.range_from_utf16(utf16))
.map(|new_range| {
new_range.start.saturating_add(range.start)
..new_range.end.saturating_add(range.start)
})
.unwrap_or(caret..caret);
cx.notify();
}
fn bounds_for_range(
&mut self,
range_utf16: Range<usize>,
bounds: Bounds<Pixels>,
_: &mut Window,
_: &mut Context<Self>,
) -> Option<Bounds<Pixels>> {
let range = self.range_from_utf16(&range_utf16);
let start = self.block.position(range.start, LINE_HEIGHT)?;
let end = self.block.position(range.end, LINE_HEIGHT)?;
let top = f32::from(bounds.top()) + f32::from(start.y);
Some(Bounds::from_corners(
point(x_at(&bounds, start.x), px(top)),
point(x_at(&bounds, end.x), px(top + LINE_HEIGHT)),
))
}
fn character_index_for_point(
&mut self,
point_at: Point<Pixels>,
_: &mut Window,
_: &mut Context<Self>,
) -> Option<usize> {
let local = self.last_bounds?.localize(&point_at)?;
Some(self.offset_to_utf16(self.snapped(self.block.index_at(local, LINE_HEIGHT))))
}
}
fn row_quad(top: f32, left: Pixels, right: Pixels, tone: u32) -> PaintQuad {
fill(
Bounds::from_corners(point(left, px(top)), point(right, px(top + LINE_HEIGHT))),
rgba(tone << 8 | 0xff),
)
}
fn selection_quads(
block: &Block,
bounds: &Bounds<Pixels>,
range: &Range<usize>,
tone: u32,
) -> Vec<PaintQuad> {
let (Some(start), Some(end)) = (
block.position(range.start, LINE_HEIGHT),
block.position(range.end, LINE_HEIGHT),
) else {
return Vec::new();
};
let left = bounds.left();
let right = px(f32::from(bounds.left()) + f32::from(bounds.size.width));
let top = f32::from(bounds.top());
let (first, last) = (f32::from(start.y), f32::from(end.y));
if last - first < LINE_HEIGHT {
return vec![row_quad(
top + first,
x_at(bounds, start.x),
x_at(bounds, end.x),
tone,
)];
}
let mut quads = vec![row_quad(top + first, x_at(bounds, start.x), right, tone)];
for step in 1..u16::MAX {
let row = f32::from(step).mul_add(LINE_HEIGHT, first);
if row >= last {
break;
}
quads.push(row_quad(top + row, left, right, tone));
}
quads.push(row_quad(top + last, left, x_at(bounds, end.x), tone));
quads
}
fn schedule_blink(state: &Entity<TextAreaState>, until_flip: u64, window: &Window, cx: &mut App) {
state.update(cx, |area, _| area.blink_scheduled = true);
let entity = state.clone();
let view = window.current_view();
window
.spawn(cx, async move |task_cx| {
task_cx
.background_executor()
.timer(Duration::from_millis(until_flip))
.await;
task_cx
.update(|_, app| {
entity.update(app, |area, _| area.blink_scheduled = false);
app.notify(view);
})
.ok();
})
.detach();
}
#[derive(Clone, Copy)]
enum Ask {
At(Pixels),
Narrowest,
Widest,
}
impl Ask {
fn new(known: Option<Pixels>, available: AvailableSpace) -> Self {
known.map_or_else(
|| match available {
AvailableSpace::Definite(width) => Self::At(width),
AvailableSpace::MinContent => Self::Narrowest,
AvailableSpace::MaxContent => Self::Widest,
},
Self::At,
)
}
const fn wrap_width(self) -> Option<Pixels> {
match self {
Self::At(width) => Some(width),
Self::Narrowest | Self::Widest => None,
}
}
const fn settles(self) -> bool {
matches!(self, Self::At(_))
}
fn answer(self, block: &Block) -> Pixels {
match self {
Self::At(width) => width,
Self::Narrowest => px(0.0),
Self::Widest => block.width(),
}
}
}
fn measure_block(
state: &Entity<TextAreaState>,
ask: Ask,
window: &Window,
app: &mut App,
) -> gpui::Size<Pixels> {
let theme = app.theme();
let (body, hint) = (theme.shade(TEXT), theme.shade(PLACEHOLDER));
let area = state.read(app);
let empty = area.value.is_empty();
let text = if empty {
area.placeholder.clone()
} else {
area.value.clone()
};
let marked = area.marked_range.clone();
let style = window.text_style();
let run = TextRun {
len: text.len(),
font: style.font(),
color: rgb_to_hsla(rgb(if empty { hint } else { body })),
background_color: None,
underline: None,
strikethrough: None,
letter_spacing: None,
};
let runs = text_runs(text.len(), marked.as_ref(), &run);
let starts = line_starts(&text);
let font_size = style.font_size.to_pixels(window.rem_size());
let lines: Vec<WrappedLine> = window
.text_system()
.shape_text(text, font_size, &runs, ask.wrap_width(), None)
.map(|shaped| shaped.into_iter().collect())
.unwrap_or_default();
let block = Block {
lines: lines.into(),
starts,
};
let measured = size(
ask.answer(&block),
px(block.height(LINE_HEIGHT).max(LINE_HEIGHT)),
);
if ask.settles() {
state.update(app, |shaped, _| shaped.block = block);
}
measured
}
struct BlockElement {
state: Entity<TextAreaState>,
}
struct BlockPrepaint {
lines: Arc<[WrappedLine]>,
selection: Vec<PaintQuad>,
cursor: Option<PaintQuad>,
}
impl IntoElement for BlockElement {
type Element = Self;
fn into_element(self) -> Self::Element {
self
}
}
impl Element for BlockElement {
type RequestLayoutState = ();
type PrepaintState = BlockPrepaint;
fn id(&self) -> Option<ElementId> {
None
}
fn source_location(&self) -> Option<&'static core::panic::Location<'static>> {
None
}
fn request_layout(
&mut self,
_: Option<&GlobalElementId>,
_: Option<&gpui::InspectorElementId>,
window: &mut Window,
_cx: &mut App,
) -> (LayoutId, Self::RequestLayoutState) {
let mut style = Style::default();
style.size.width = relative(1.0).into();
let state = self.state.clone();
let layout_id =
window.request_measured_layout(style, move |known, available, window, app| {
measure_block(&state, Ask::new(known.width, available.width), window, app)
});
(layout_id, ())
}
fn prepaint(
&mut self,
_: Option<&GlobalElementId>,
_: Option<&gpui::InspectorElementId>,
bounds: Bounds<Pixels>,
&mut (): &mut Self::RequestLayoutState,
_window: &mut Window,
cx: &mut App,
) -> Self::PrepaintState {
let theme = cx.theme();
let (caret_tone, selection_tone) = (theme.shade(TEXT), theme.shade(SELECTION));
let chased = self.state.read(cx).held;
let epoch = self.state.read(cx).caret_epoch;
if chased != Some(epoch) {
self.state.update(cx, |area, _| {
area.hold_caret_in_view();
area.held = Some(epoch);
});
}
let state = self.state.read(cx);
let range = state.selected_range.clone();
let (selection, cursor) = if range.is_empty() {
let caret = state.block.position(range.start, LINE_HEIGHT).map(|at| {
fill(
Bounds::new(
point(
x_at(&bounds, at.x),
px(f32::from(bounds.top()) + f32::from(at.y)),
),
size(px(CURSOR_WIDTH), px(LINE_HEIGHT)),
),
rgb(caret_tone),
)
});
(Vec::new(), caret)
} else {
(
selection_quads(&state.block, &bounds, &range, selection_tone),
None,
)
};
BlockPrepaint {
lines: state.block.lines.clone(),
selection,
cursor,
}
}
fn paint(
&mut self,
_: Option<&GlobalElementId>,
_: Option<&gpui::InspectorElementId>,
bounds: Bounds<Pixels>,
&mut (): &mut Self::RequestLayoutState,
prepaint: &mut Self::PrepaintState,
window: &mut Window,
cx: &mut App,
) {
let (focus_handle, until_flip, blink_visible) = {
let state = self.state.read(cx);
let focus_handle = state.focus_handle.clone();
let elapsed = state.caret_epoch.elapsed().as_millis();
let blink_phase = elapsed
.checked_div(u128::from(CURSOR_BLINK_MS))
.unwrap_or(0);
let blink_visible = blink_phase & 1 == 0;
let into_phase = elapsed
.checked_rem(u128::from(CURSOR_BLINK_MS))
.unwrap_or(0);
let until_flip = CURSOR_BLINK_MS.saturating_sub(u64::try_from(into_phase).unwrap_or(0));
(focus_handle, until_flip, blink_visible)
};
window.handle_input(
&focus_handle,
ElementInputHandler::new(bounds, self.state.clone()),
cx,
);
for quad in prepaint.selection.drain(..) {
window.paint_quad(quad);
}
let line_height = px(LINE_HEIGHT);
let mut top = f32::from(bounds.top());
for line in prepaint.lines.iter() {
line.paint(
point(bounds.left(), px(top)),
line_height,
TextAlign::default(),
Some(bounds),
window,
cx,
)
.ok();
top += scaled(Block::rows(line), LINE_HEIGHT);
}
let focused = focus_handle.is_focused(window);
if focused && !self.state.read(cx).blink_scheduled {
schedule_blink(&self.state, until_flip, window, cx);
}
if let Some(cursor) = prepaint.cursor.take().filter(|_| focused && blink_visible) {
window.paint_quad(cursor);
}
self.state
.update(cx, |area, _| area.last_bounds = Some(bounds));
}
}
#[derive(IntoElement)]
pub struct TextArea {
state: Entity<TextAreaState>,
label: Option<SharedString>,
label_tooltip: Option<(ElementId, SharedString)>,
required: bool,
error: Option<SharedString>,
success: bool,
disabled: bool,
help_text: Option<SharedString>,
help_link: Option<(ElementId, SharedString, SharedString)>,
}
impl TextArea {
#[must_use]
pub fn new(state: &Entity<TextAreaState>) -> Self {
Self {
state: state.clone(),
label: None,
label_tooltip: None,
required: false,
error: None,
success: false,
disabled: false,
help_text: None,
help_link: None,
}
}
#[must_use]
pub fn label(mut self, label: impl Into<SharedString>) -> Self {
self.label = Some(label.into());
self
}
#[must_use]
pub const fn required(mut self, required: bool) -> Self {
self.required = required;
self
}
#[must_use]
pub fn label_tooltip(mut self, id: impl Into<ElementId>, tip: impl Into<SharedString>) -> Self {
self.label_tooltip = Some((id.into(), tip.into()));
self
}
#[must_use]
pub fn help_text(mut self, help_text: impl Into<SharedString>) -> Self {
self.help_text = Some(help_text.into());
self
}
#[must_use]
pub fn help_link(
mut self,
id: impl Into<ElementId>,
label: impl Into<SharedString>,
href: impl Into<SharedString>,
) -> Self {
self.help_link = Some((id.into(), label.into(), href.into()));
self
}
#[must_use]
pub fn error(mut self, error: impl Into<SharedString>) -> Self {
self.error = Some(error.into());
self
}
#[must_use]
pub const fn success(mut self, success: bool) -> Self {
self.success = success;
self
}
#[must_use]
pub const fn disabled(mut self, disabled: bool) -> Self {
self.disabled = disabled;
self
}
fn field(self, state: State, cx: &App) -> gpui::Div {
let theme = cx.theme();
let rows = self.state.read(cx).rows;
block_field_frame(state, rows, theme)
.map(|el| {
if self.disabled {
el.cursor(CursorStyle::OperationNotAllowed)
.child(self.static_text(cx))
} else {
el.child(
div()
.min_w(px(0.0))
.flex_1()
.h_full()
.child(self.state.clone()),
)
}
})
.when_some(
validation_icon(
matches!(state, State::Error),
matches!(state, State::Success),
theme,
),
gpui::ParentElement::child,
)
.child(Scrollbar::vertical(
("designs-text-area-bar", self.state.entity_id()),
&self.state.read(cx).scroll,
))
}
fn static_text(&self, cx: &App) -> gpui::Stateful<gpui::Div> {
let area = self.state.read(cx);
let empty = area.value.is_empty();
let shown = if empty {
area.placeholder.clone()
} else {
area.value.clone()
};
div()
.id(("designs-text-area-locked", self.state.entity_id()))
.min_w(px(0.0))
.flex_1()
.h_full()
.overflow_y_scroll()
.track_scroll(&area.scroll)
.when(empty, |el| {
el.text_color(rgb(cx.theme().shade(PLACEHOLDER)))
})
.child(shown)
}
}
impl RenderOnce for TextArea {
fn render(mut self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let focused = self.state.read(cx).focus_handle.is_focused(window);
let validation = if self.error.is_some() && !self.disabled {
Validation::Error
} else if self.success {
Validation::Success
} else {
Validation::None
};
let state = State::resolve(focused, validation, self.disabled);
let label = self.label.take();
let label_tooltip = self.label_tooltip.take();
let required = self.required;
let error = if self.disabled {
None
} else {
self.error.take()
};
let counter = {
let area = self.state.read(cx);
area.max_length
.map(|max| format!("{}/{max}", area.value.graphemes(true).count()))
};
let help = self.help_text.take();
let help_link = self.help_link.take();
let theme = cx.theme();
let bottom =
(error.is_some() || counter.is_some()).then(|| bottom_row(error, counter, theme));
div()
.flex()
.flex_col()
.items_start()
.gap(px(OUTLINE_GAP))
.w_full()
.font_family(FONT_PRIMARY)
.text_size(px(TEXT_SIZE.px()))
.line_height(px(LINE_HEIGHT))
.when_some(label, |el, label| {
el.child(label_row(label, required, label_tooltip, theme))
})
.child(self.field(state, cx))
.when_some(bottom, gpui::ParentElement::child)
.when(help.is_some() || help_link.is_some(), |el| {
el.child(help_row(help, help_link, theme))
})
}
}
#[cfg(test)]
mod tests {
use super::{held_at, line_starts, scaled, text_room, Ask, Block};
use fluidattacks_designs_domain::inputs::{rows_height, LINE_HEIGHT, PADDING_Y};
use gpui::{px, AvailableSpace};
#[test]
fn the_narrowest_a_block_can_be_is_nothing() {
let block = Block::default();
assert_eq!(Ask::Narrowest.answer(&block), px(0.0));
assert_eq!(Ask::At(px(120.0)).answer(&block), px(120.0));
}
#[test]
fn only_a_settled_width_wraps_the_text() {
assert_eq!(Ask::At(px(120.0)).wrap_width(), Some(px(120.0)));
assert_eq!(Ask::Narrowest.wrap_width(), None);
assert_eq!(Ask::Widest.wrap_width(), None);
}
#[test]
fn only_a_settled_width_leaves_a_block_behind() {
assert!(Ask::At(px(120.0)).settles());
assert!(!Ask::Narrowest.settles());
assert!(!Ask::Widest.settles());
}
#[test]
fn a_known_width_settles_the_ask() {
assert_eq!(
Ask::new(Some(px(80.0)), AvailableSpace::MinContent).wrap_width(),
Some(px(80.0))
);
assert_eq!(
Ask::new(None, AvailableSpace::MinContent).wrap_width(),
None
);
assert_eq!(
Ask::new(None, AvailableSpace::Definite(px(64.0))).wrap_width(),
Some(px(64.0))
);
}
#[test]
fn the_room_is_the_rows_not_the_content() {
assert_eq!(text_room(3).to_bits(), 60.0_f32.to_bits());
assert_eq!(text_room(0).to_bits(), text_room(1).to_bits());
}
#[test]
fn the_room_is_the_domain_box_less_its_padding() {
for rows in [1_u16, 3, 6] {
let expected = rows_height(rows) - PADDING_Y - PADDING_Y;
assert_eq!(text_room(rows).to_bits(), expected.to_bits(), "{rows} rows");
}
}
#[test]
fn a_caret_in_view_holds_the_window_still() {
let window = text_room(3);
assert_eq!(held_at(0.0, window, 0.0), None);
assert_eq!(held_at(40.0, window, 0.0), None);
}
#[test]
fn a_caret_below_the_window_pulls_it_down() {
let window = text_room(3);
assert_eq!(held_at(60.0, window, 0.0), Some(20.0));
assert_eq!(held_at(200.0, window, 0.0), Some(160.0));
}
#[test]
fn a_caret_above_the_window_pulls_it_up() {
let window = text_room(3);
assert_eq!(held_at(20.0, window, 100.0), Some(20.0));
assert_eq!(held_at(0.0, window, 60.0), Some(0.0));
}
#[test]
fn the_window_never_climbs_above_the_block() {
for shown in [0.0_f32, 20.0, 200.0] {
if let Some(moved) = held_at(0.0, text_room(3), shown) {
assert!(moved >= 0.0, "{moved} from {shown}");
}
}
}
#[test]
fn line_starts_follow_the_hard_breaks() {
assert_eq!(line_starts(""), vec![0]);
assert_eq!(line_starts("one line"), vec![0]);
assert_eq!(line_starts("a\nb"), vec![0, 2]);
assert_eq!(line_starts("a\nbb\nc"), vec![0, 2, 5]);
}
#[test]
fn a_trailing_newline_opens_a_line() {
assert_eq!(line_starts("a\n"), vec![0, 2]);
}
#[test]
fn line_starts_are_byte_offsets() {
assert_eq!(line_starts("é\nb"), vec![0, 3]);
}
#[test]
fn a_count_of_rows_scales_to_pixels() {
assert_eq!(scaled(0, LINE_HEIGHT).to_bits(), 0.0_f32.to_bits());
assert_eq!(scaled(3, LINE_HEIGHT).to_bits(), 60.0_f32.to_bits());
}
}