diff --git a/Cargo.lock b/Cargo.lock index 5548ebcfaa..cd29eabaf3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2426,6 +2426,9 @@ name = "gpui_elements" version = "0.1.0" dependencies = [ "gpui", + "gpui_platform", + "smallvec", + "unicode-segmentation", ] [[package]] diff --git a/crates/gpui/Cargo.toml b/crates/gpui/Cargo.toml index f0f9ab847b..ef5f5b1c4b 100644 --- a/crates/gpui/Cargo.toml +++ b/crates/gpui/Cargo.toml @@ -256,10 +256,6 @@ path = "examples/legacy/hello_world.rs" name = "image_loading" path = "examples/legacy/image_loading.rs" -[[example]] -name = "input" -path = "examples/legacy/input.rs" - [[example]] name = "layer_shell" path = "examples/legacy/layer_shell.rs" diff --git a/crates/gpui/examples/legacy/input.rs b/crates/gpui/examples/legacy/input.rs deleted file mode 100644 index d9b758add3..0000000000 --- a/crates/gpui/examples/legacy/input.rs +++ /dev/null @@ -1,753 +0,0 @@ -use std::ops::Range; - -use gpui::{ - App, Bounds, ClipboardItem, Context, CursorStyle, ElementId, ElementInputHandler, Entity, - EntityInputHandler, FocusHandle, Focusable, GlobalElementId, KeyBinding, Keystroke, LayoutId, - MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, PaintQuad, Pixels, Point, - ShapedLine, SharedString, Style, TextRun, UTF16Selection, UnderlineStyle, Window, WindowBounds, - WindowOptions, actions, black, div, fill, hsla, opaque_grey, point, prelude::*, px, relative, - rgb, rgba, size, white, yellow, -}; -use unicode_segmentation::*; - -actions!( - text_input, - [ - Backspace, - Delete, - Left, - Right, - SelectLeft, - SelectRight, - SelectAll, - Home, - End, - ShowCharacterPalette, - Paste, - Cut, - Copy, - Quit, - ] -); - -struct TextInput { - focus_handle: FocusHandle, - content: SharedString, - placeholder: SharedString, - selected_range: Range, - selection_reversed: bool, - marked_range: Option>, - last_layout: Option, - last_bounds: Option>, - is_selecting: bool, -} - -impl TextInput { - fn left(&mut self, _: &Left, _: &mut Window, cx: &mut Context) { - 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) { - if self.selected_range.is_empty() { - self.move_to(self.next_boundary(self.selected_range.end), cx); - } else { - self.move_to(self.selected_range.end, cx) - } - } - - fn select_left(&mut self, _: &SelectLeft, _: &mut Window, cx: &mut Context) { - self.select_to(self.previous_boundary(self.cursor_offset()), cx); - } - - fn select_right(&mut self, _: &SelectRight, _: &mut Window, cx: &mut Context) { - self.select_to(self.next_boundary(self.cursor_offset()), cx); - } - - fn select_all(&mut self, _: &SelectAll, _: &mut Window, cx: &mut Context) { - self.move_to(0, cx); - self.select_to(self.content.len(), cx) - } - - fn home(&mut self, _: &Home, _: &mut Window, cx: &mut Context) { - self.move_to(0, cx); - } - - fn end(&mut self, _: &End, _: &mut Window, cx: &mut Context) { - self.move_to(self.content.len(), cx); - } - - fn backspace(&mut self, _: &Backspace, window: &mut Window, cx: &mut Context) { - 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) { - 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 on_mouse_down( - &mut self, - event: &MouseDownEvent, - _window: &mut Window, - cx: &mut Context, - ) { - 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) - } - } - - fn on_mouse_up(&mut self, _: &MouseUpEvent, _window: &mut Window, _: &mut Context) { - self.is_selecting = false; - } - - fn on_mouse_move(&mut self, event: &MouseMoveEvent, _: &mut Window, cx: &mut Context) { - if self.is_selecting { - self.select_to(self.index_for_mouse_position(event.position), cx); - } - } - - fn show_character_palette( - &mut self, - _: &ShowCharacterPalette, - window: &mut Window, - _: &mut Context, - ) { - window.show_character_palette(); - } - - fn paste(&mut self, _: &Paste, window: &mut Window, cx: &mut Context) { - if let Some(text) = cx.read_from_clipboard().and_then(|item| item.text()) { - self.replace_text_in_range(None, &text.replace("\n", " "), window, cx); - } - } - - fn copy(&mut self, _: &Copy, _: &mut Window, cx: &mut Context) { - if !self.selected_range.is_empty() { - cx.write_to_clipboard(ClipboardItem::new_string( - self.content[self.selected_range.clone()].to_string(), - )); - } - } - fn cut(&mut self, _: &Cut, window: &mut Window, cx: &mut Context) { - if !self.selected_range.is_empty() { - cx.write_to_clipboard(ClipboardItem::new_string( - self.content[self.selected_range.clone()].to_string(), - )); - self.replace_text_in_range(None, "", window, cx) - } - } - - fn move_to(&mut self, offset: usize, cx: &mut Context) { - self.selected_range = offset..offset; - cx.notify() - } - - 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) -> usize { - if self.content.is_empty() { - return 0; - } - - let (Some(bounds), Some(line)) = (self.last_bounds.as_ref(), self.last_layout.as_ref()) - else { - return 0; - }; - if position.y < bounds.top() { - return 0; - } - if position.y > bounds.bottom() { - return self.content.len(); - } - line.closest_index_for_x(position.x - bounds.left()) - } - - fn select_to(&mut self, offset: usize, cx: &mut Context) { - 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; - } - cx.notify() - } - - fn offset_from_utf16(&self, offset: usize) -> usize { - let mut utf8_offset = 0; - let mut utf16_count = 0; - - for ch in self.content.chars() { - if utf16_count >= offset { - break; - } - utf16_count += ch.len_utf16(); - utf8_offset += ch.len_utf8(); - } - - utf8_offset - } - - fn offset_to_utf16(&self, offset: usize) -> usize { - let mut utf16_offset = 0; - let mut utf8_count = 0; - - for ch in self.content.chars() { - if utf8_count >= offset { - break; - } - utf8_count += ch.len_utf8(); - utf16_offset += ch.len_utf16(); - } - - utf16_offset - } - - fn range_to_utf16(&self, range: &Range) -> Range { - self.offset_to_utf16(range.start)..self.offset_to_utf16(range.end) - } - - fn range_from_utf16(&self, range_utf16: &Range) -> Range { - self.offset_from_utf16(range_utf16.start)..self.offset_from_utf16(range_utf16.end) - } - - fn previous_boundary(&self, offset: usize) -> usize { - self.content - .grapheme_indices(true) - .rev() - .find_map(|(idx, _)| (idx < offset).then_some(idx)) - .unwrap_or(0) - } - - fn next_boundary(&self, offset: usize) -> usize { - self.content - .grapheme_indices(true) - .find_map(|(idx, _)| (idx > offset).then_some(idx)) - .unwrap_or(self.content.len()) - } - - fn reset(&mut self) { - self.content = "".into(); - self.selected_range = 0..0; - self.selection_reversed = false; - self.marked_range = None; - self.last_layout = None; - self.last_bounds = None; - self.is_selecting = false; - } -} - -impl EntityInputHandler for TextInput { - fn text_for_range( - &mut self, - range_utf16: Range, - actual_range: &mut Option>, - _window: &mut Window, - _cx: &mut Context, - ) -> Option { - let range = self.range_from_utf16(&range_utf16); - actual_range.replace(self.range_to_utf16(&range)); - Some(self.content[range].to_string()) - } - - fn selected_text_range( - &mut self, - _ignore_disabled_input: bool, - _window: &mut Window, - _cx: &mut Context, - ) -> Option { - Some(UTF16Selection { - range: self.range_to_utf16(&self.selected_range), - reversed: self.selection_reversed, - }) - } - - fn marked_text_range( - &self, - _window: &mut Window, - _cx: &mut Context, - ) -> Option> { - self.marked_range - .as_ref() - .map(|range| self.range_to_utf16(range)) - } - - fn unmark_text(&mut self, _window: &mut Window, _cx: &mut Context) { - self.marked_range = None; - } - - fn replace_text_in_range( - &mut self, - range_utf16: Option>, - new_text: &str, - _: &mut Window, - cx: &mut Context, - ) { - let range = range_utf16 - .as_ref() - .map(|range_utf16| self.range_from_utf16(range_utf16)) - .or(self.marked_range.clone()) - .unwrap_or(self.selected_range.clone()); - - self.content = - (self.content[0..range.start].to_owned() + new_text + &self.content[range.end..]) - .into(); - self.selected_range = range.start + new_text.len()..range.start + new_text.len(); - self.marked_range.take(); - cx.notify(); - } - - fn replace_and_mark_text_in_range( - &mut self, - range_utf16: Option>, - new_text: &str, - new_selected_range_utf16: Option>, - _window: &mut Window, - cx: &mut Context, - ) { - let range = range_utf16 - .as_ref() - .map(|range_utf16| self.range_from_utf16(range_utf16)) - .or(self.marked_range.clone()) - .unwrap_or(self.selected_range.clone()); - - self.content = - (self.content[0..range.start].to_owned() + new_text + &self.content[range.end..]) - .into(); - if !new_text.is_empty() { - self.marked_range = Some(range.start..range.start + new_text.len()); - } else { - self.marked_range = None; - } - self.selected_range = new_selected_range_utf16 - .as_ref() - .map(|range_utf16| self.range_from_utf16(range_utf16)) - .map(|new_range| new_range.start + range.start..new_range.end + range.end) - .unwrap_or_else(|| range.start + new_text.len()..range.start + new_text.len()); - - cx.notify(); - } - - fn bounds_for_range( - &mut self, - range_utf16: Range, - bounds: Bounds, - _window: &mut Window, - _cx: &mut Context, - ) -> Option> { - let last_layout = self.last_layout.as_ref()?; - let range = self.range_from_utf16(&range_utf16); - Some(Bounds::from_corners( - point( - bounds.left() + last_layout.x_for_index(range.start), - bounds.top(), - ), - point( - bounds.left() + last_layout.x_for_index(range.end), - bounds.bottom(), - ), - )) - } - - fn character_index_for_point( - &mut self, - point: gpui::Point, - _window: &mut Window, - _cx: &mut Context, - ) -> Option { - let line_point = self.last_bounds?.localize(&point)?; - let last_layout = self.last_layout.as_ref()?; - - assert_eq!(last_layout.text, self.content); - let utf8_index = last_layout.index_for_x(point.x - line_point.x)?; - Some(self.offset_to_utf16(utf8_index)) - } -} - -struct TextElement { - input: Entity, -} - -struct PrepaintState { - line: Option, - cursor: Option, - selection: Option, -} - -impl IntoElement for TextElement { - type Element = Self; - - fn into_element(self) -> Self::Element { - self - } -} - -impl Element for TextElement { - type RequestLayoutState = (); - type PrepaintState = PrepaintState; - - fn id(&self) -> Option { - None - } - - fn source_location(&self) -> Option<&'static core::panic::Location<'static>> { - None - } - - fn request_layout( - &mut self, - _id: Option<&GlobalElementId>, - _inspector_id: Option<&gpui::InspectorElementId>, - window: &mut Window, - cx: &mut App, - ) -> (LayoutId, Self::RequestLayoutState) { - let mut style = Style::default(); - style.size.width = relative(1.).into(); - style.size.height = window.line_height().into(); - (window.request_layout(style, [], cx), ()) - } - - fn prepaint( - &mut self, - _id: Option<&GlobalElementId>, - _inspector_id: Option<&gpui::InspectorElementId>, - bounds: Bounds, - _request_layout: &mut Self::RequestLayoutState, - window: &mut Window, - cx: &mut App, - ) -> Self::PrepaintState { - let input = self.input.read(cx); - let content = input.content.clone(); - let selected_range = input.selected_range.clone(); - let cursor = input.cursor_offset(); - let style = window.text_style(); - - let (display_text, text_color) = if content.is_empty() { - (input.placeholder.clone(), hsla(0., 0., 0., 0.2)) - } else { - (content, style.color) - }; - - let run = TextRun { - len: display_text.len(), - font: style.font(), - color: text_color, - background_color: None, - underline: None, - strikethrough: None, - }; - let runs = if let Some(marked_range) = input.marked_range.as_ref() { - vec![ - TextRun { - len: marked_range.start, - ..run.clone() - }, - TextRun { - len: marked_range.end - marked_range.start, - underline: Some(UnderlineStyle { - color: Some(run.color), - thickness: px(1.0), - wavy: false, - }), - ..run.clone() - }, - TextRun { - len: display_text.len() - marked_range.end, - ..run - }, - ] - .into_iter() - .filter(|run| run.len > 0) - .collect() - } else { - vec![run] - }; - - let font_size = style.font_size.to_pixels(window.rem_size()); - let line = window - .text_system() - .shape_line(display_text, font_size, &runs, None); - - let cursor_pos = line.x_for_index(cursor); - let (selection, cursor) = if selected_range.is_empty() { - ( - None, - Some(fill( - Bounds::new( - point(bounds.left() + cursor_pos, bounds.top()), - size(px(2.), bounds.bottom() - bounds.top()), - ), - gpui::blue(), - )), - ) - } else { - ( - Some(fill( - Bounds::from_corners( - point( - bounds.left() + line.x_for_index(selected_range.start), - bounds.top(), - ), - point( - bounds.left() + line.x_for_index(selected_range.end), - bounds.bottom(), - ), - ), - rgba(0x3311ff30), - )), - None, - ) - }; - PrepaintState { - line: Some(line), - cursor, - selection, - } - } - - fn paint( - &mut self, - _id: Option<&GlobalElementId>, - _inspector_id: Option<&gpui::InspectorElementId>, - bounds: Bounds, - _request_layout: &mut Self::RequestLayoutState, - prepaint: &mut Self::PrepaintState, - window: &mut Window, - cx: &mut App, - ) { - let focus_handle = self.input.read(cx).focus_handle.clone(); - window.handle_input( - &focus_handle, - ElementInputHandler::new(bounds, self.input.clone()), - cx, - ); - if let Some(selection) = prepaint.selection.take() { - window.paint_quad(selection) - } - let line = prepaint.line.take().unwrap(); - line.paint( - bounds.origin, - window.line_height(), - gpui::TextAlign::Left, - None, - window, - cx, - ) - .unwrap(); - - if focus_handle.is_focused(window) - && let Some(cursor) = prepaint.cursor.take() - { - window.paint_quad(cursor); - } - - self.input.update(cx, |input, _cx| { - input.last_layout = Some(line); - input.last_bounds = Some(bounds); - }); - } -} - -impl Render for TextInput { - fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { - div() - .flex() - .key_context("TextInput") - .track_focus(&self.focus_handle(cx)) - .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::select_left)) - .on_action(cx.listener(Self::select_right)) - .on_action(cx.listener(Self::select_all)) - .on_action(cx.listener(Self::home)) - .on_action(cx.listener(Self::end)) - .on_action(cx.listener(Self::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)) - .bg(rgb(0xeeeeee)) - .line_height(px(30.)) - .text_size(px(24.)) - .child( - div() - .h(px(30. + 4. * 2.)) - .w_full() - .p(px(4.)) - .bg(white()) - .child(TextElement { input: cx.entity() }), - ) - } -} - -impl Focusable for TextInput { - fn focus_handle(&self, _: &App) -> FocusHandle { - self.focus_handle.clone() - } -} - -struct InputExample { - text_input: Entity, - recent_keystrokes: Vec, - focus_handle: FocusHandle, -} - -impl Focusable for InputExample { - fn focus_handle(&self, _: &App) -> FocusHandle { - self.focus_handle.clone() - } -} - -impl InputExample { - fn on_reset_click(&mut self, _: &MouseUpEvent, _window: &mut Window, cx: &mut Context) { - self.recent_keystrokes.clear(); - self.text_input - .update(cx, |text_input, _cx| text_input.reset()); - cx.notify(); - } -} - -impl Render for InputExample { - fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { - div() - .bg(rgb(0xaaaaaa)) - .track_focus(&self.focus_handle(cx)) - .flex() - .flex_col() - .size_full() - .child( - div() - .bg(white()) - .border_b_1() - .border_color(black()) - .flex() - .flex_row() - .justify_between() - .child(format!("Keyboard {}", cx.keyboard_layout().name())) - .child( - div() - .border_1() - .border_color(black()) - .px_2() - .bg(yellow()) - .child("Reset") - .hover(|style| { - style - .bg(yellow().blend(opaque_grey(0.5, 0.5))) - .cursor_pointer() - }) - .on_mouse_up(MouseButton::Left, cx.listener(Self::on_reset_click)), - ), - ) - .child(self.text_input.clone()) - .children(self.recent_keystrokes.iter().rev().map(|ks| { - format!( - "{:} {}", - ks.unparse(), - if let Some(key_char) = ks.key_char.as_ref() { - format!("-> {:?}", key_char) - } else { - "".to_owned() - } - ) - })) - } -} - -fn main() { - gpui_platform::application().run(|cx: &mut App| { - let bounds = Bounds::centered(None, size(px(300.0), px(300.0)), cx); - cx.bind_keys([ - KeyBinding::new("backspace", Backspace, None), - KeyBinding::new("delete", Delete, None), - KeyBinding::new("left", Left, None), - KeyBinding::new("right", Right, None), - KeyBinding::new("shift-left", SelectLeft, None), - KeyBinding::new("shift-right", SelectRight, None), - KeyBinding::new("cmd-a", SelectAll, None), - KeyBinding::new("cmd-v", Paste, None), - KeyBinding::new("cmd-c", Copy, None), - KeyBinding::new("cmd-x", Cut, None), - KeyBinding::new("home", Home, None), - KeyBinding::new("end", End, None), - KeyBinding::new("ctrl-cmd-space", ShowCharacterPalette, None), - ]); - - let window = cx - .open_window( - WindowOptions { - window_bounds: Some(WindowBounds::Windowed(bounds)), - ..Default::default() - }, - |_, cx| { - let text_input = cx.new(|cx| TextInput { - focus_handle: cx.focus_handle(), - content: "".into(), - placeholder: "Type here...".into(), - selected_range: 0..0, - selection_reversed: false, - marked_range: None, - last_layout: None, - last_bounds: None, - is_selecting: false, - }); - cx.new(|cx| InputExample { - text_input, - recent_keystrokes: vec![], - focus_handle: cx.focus_handle(), - }) - }, - ) - .unwrap(); - let view = window.update(cx, |_, _, cx| cx.entity()).unwrap(); - cx.observe_keystrokes(move |ev, _, cx| { - view.update(cx, |view, cx| { - view.recent_keystrokes.push(ev.keystroke.clone()); - cx.notify(); - }) - }) - .detach(); - cx.on_keyboard_layout_change({ - move |cx| { - window.update(cx, |_, _, cx| cx.notify()).ok(); - } - }) - .detach(); - - window - .update(cx, |view, window, cx| { - window.focus(&view.text_input.focus_handle(cx), cx); - cx.activate(true); - }) - .unwrap(); - cx.on_action(|_: &Quit, cx| cx.quit()); - cx.bind_keys([KeyBinding::new("cmd-q", Quit, None)]); - }); -} diff --git a/crates/gpui/src/elements/div.rs b/crates/gpui/src/elements/div.rs index 5538d3d92a..a0970015b5 100644 --- a/crates/gpui/src/elements/div.rs +++ b/crates/gpui/src/elements/div.rs @@ -1866,6 +1866,35 @@ pub struct Interactivity { pub(crate) debug_selector: Option, } +impl Interactivity { + /// Assigns the current scroll offset of an element. No-op if the element's + /// style does not have overflow enabled. + /// + /// Should only be called during the `request_layout` phase. + pub fn set_scroll_offset( + &self, + global_id: Option<&GlobalElementId>, + window: &mut Window, + point: Point, + ) { + window.with_optional_element_state::( + global_id, + |element_state, _window| { + let mut element_state = + element_state.map(|element_state| element_state.unwrap_or_default()); + let overflow = &self.base_style.overflow; + if (overflow.x == Some(Overflow::Scroll) || overflow.y == Some(Overflow::Scroll)) + && let Some(element_state) = element_state.as_mut() + { + let scroll_offset = element_state.scroll_offset.get_or_insert_with(Rc::default); + *scroll_offset.borrow_mut() = point; + } + ((), element_state) + }, + ); + } +} + impl Interactivity { /// Layout this element according to this interactivity state's configured styles pub fn request_layout( @@ -2278,7 +2307,7 @@ impl Interactivity { if let Some(text) = window .text_system() .shape_text( - element_id.into(), + &element_id, FONT_SIZE, &[window.text_style().to_run(str_len)], None, diff --git a/crates/gpui/src/elements/text.rs b/crates/gpui/src/elements/text.rs index 82d23c83b7..20b933816c 100644 --- a/crates/gpui/src/elements/text.rs +++ b/crates/gpui/src/elements/text.rs @@ -622,7 +622,121 @@ struct TextLayoutInner { bounds: Option>, } +/// Metadata about how text should be truncated. Generated during text layout via `TextLayout::evaluate_overflow`. +pub struct TextLayoutTruncation { + /// The width that the text can occupy before it is truncated. + pub width: Option, + /// The text to affix to the displayed text if truncating (e.g. an ellipsis `...`). + pub affix: SharedString, + /// What side of the text will be truncated if it does not fit. + pub source: TruncateFrom, +} + +impl TextLayoutTruncation { + /// Creates a truncation by using the overflow as the affix, given the provided width. + fn overflow_width(text_overflow: TextOverflow, width: Option) -> Self { + match text_overflow { + TextOverflow::Truncate(s) => TextLayoutTruncation { + width, + affix: s, + source: TruncateFrom::End, + }, + TextOverflow::TruncateStart(s) => TextLayoutTruncation { + width, + affix: s, + source: TruncateFrom::Start, + }, + } + } +} + impl TextLayout { + /// Evaluates the width to wrap the text at. + pub fn evaluate_wrap_width( + white_space: &WhiteSpace, + known_dimensions: Size>, + available_space: Size, + ) -> Option { + use crate::AvailableSpace::*; + match white_space { + // Text does not wrap, no max width + WhiteSpace::Nowrap => None, + // If the text wraps, return the already calculated width. + WhiteSpace::Normal => known_dimensions.width.or(match available_space.width { + // Otherwise if the available space is a concrete value, then that is the width to wrap to. + Definite(x) => Some(x), + // If the wrapping is content-based, then there is no wrapping of text. + MaxContent | MinContent => None, + }), + } + } + + /// Evaluates how truncation should be applied if the text overflows the available space. + pub fn evaluate_overflow( + text_style: &TextStyle, + known_dimensions: Size>, + available_space: Size, + ) -> TextLayoutTruncation { + match text_style.text_overflow.clone() { + Some(text_overflow) => { + // Calculate the desired width, prioritizing the calculated dimensions, + // falling back on calculating a width from the available space and + // number of lines to clamp to via text style. + let width = known_dimensions.width.or(match available_space.width { + crate::AvailableSpace::Definite(x) => match text_style.line_clamp { + Some(max_lines) => Some(x * max_lines), + None => Some(x), + }, + _ => None, + }); + + TextLayoutTruncation::overflow_width(text_overflow, width) + } + None => TextLayoutTruncation { + width: None, + affix: SharedString::default(), + source: TruncateFrom::End, + }, + } + } + + /// Conditionally applies truncation to some text and outputs how the text should be displayed. + pub fn apply_truncation<'runs>( + text: SharedString, + text_style: &TextStyle, + font_size: Pixels, + wrap_width: Option, + truncation: &TextLayoutTruncation, + runs: &'runs [TextRun], + cx: &mut App, + ) -> (SharedString, Cow<'runs, [TextRun]>) { + let mut line_wrapper = cx.text_system().line_wrapper(text_style.font(), font_size); + if truncation.width.is_some() { + if let Some(max_lines) = text_style.line_clamp + && let Some(wrap_width) = wrap_width + { + line_wrapper.truncate_wrapped_line( + text, + wrap_width, + max_lines, + &truncation.affix, + &runs, + truncation.source, + ) + } else { + line_wrapper.truncate_line( + text, + truncation.width.unwrap_or(Pixels::MAX), + &truncation.affix, + &runs, + truncation.source, + ) + } + } else { + (text, std::borrow::Cow::Borrowed(runs)) + } + } + fn layout( &self, text: SharedString, @@ -647,32 +761,14 @@ impl TextLayout { let element_state = self.clone(); move |known_dimensions, available_space, window, cx| { - let wrap_width = if text_style.white_space == WhiteSpace::Normal { - known_dimensions.width.or(match available_space.width { - crate::AvailableSpace::Definite(x) => Some(x), - _ => None, - }) - } else { - None - }; + let wrap_width = Self::evaluate_wrap_width( + &text_style.white_space, + known_dimensions, + available_space, + ); - let (truncate_width, truncation_affix, truncate_from) = - if let Some(text_overflow) = text_style.text_overflow.clone() { - let width = known_dimensions.width.or(match available_space.width { - crate::AvailableSpace::Definite(x) => match text_style.line_clamp { - Some(max_lines) => Some(x * max_lines), - None => Some(x), - }, - _ => None, - }); - - match text_overflow { - TextOverflow::Truncate(s) => (width, s, TruncateFrom::End), - TextOverflow::TruncateStart(s) => (width, s, TruncateFrom::Start), - } - } else { - (None, "".into(), TruncateFrom::End) - }; + let truncation = + Self::evaluate_overflow(&text_style, known_dimensions, available_space); // Only use cached layout if: // 1. We have a cached size @@ -682,36 +778,20 @@ impl TextLayout { if let Some(text_layout) = element_state.0.borrow().as_ref() && let Some(size) = text_layout.size && (wrap_width.is_none() || wrap_width == text_layout.wrap_width) - && truncate_width.is_none() + && truncation.width.is_none() { return size; } - let mut line_wrapper = cx.text_system().line_wrapper(text_style.font(), font_size); - let (text, runs) = if truncate_width.is_some() { - if let Some(max_lines) = text_style.line_clamp - && let Some(wrap_width) = wrap_width - { - line_wrapper.truncate_wrapped_line( - text.clone(), - wrap_width, - max_lines, - &truncation_affix, - &runs, - truncate_from, - ) - } else { - line_wrapper.truncate_line( - text.clone(), - truncate_width.unwrap_or(Pixels::MAX), - &truncation_affix, - &runs, - truncate_from, - ) - } - } else { - (text.clone(), Cow::Borrowed(&*runs)) - }; + let (text, runs) = Self::apply_truncation( + text.clone(), + &text_style, + font_size, + wrap_width, + &truncation, + &runs, + cx, + ); let len = text.len(); let Some(lines) = window diff --git a/crates/gpui/src/geometry.rs b/crates/gpui/src/geometry.rs index e5951a1296..6b8c19a69d 100644 --- a/crates/gpui/src/geometry.rs +++ b/crates/gpui/src/geometry.rs @@ -218,6 +218,21 @@ impl Point { pub fn magnitude(&self) -> f64 { ((self.x.0.powi(2) + self.y.0.powi(2)) as f64).sqrt() } + + /// Returns true if the difference between self and `other` is less than `epsilon` in both coordinates. + /// + /// # Examples + /// + /// ``` + /// # use gpui::{Pixels, Point}; + /// let p1 = Point { x: Pixels::from(1.0), y: Pixels::from(1.0) }; + /// let p2 = Point { x: Pixels::from(3.0), y: Pixels::from(-2.0) }; + /// assert_eq!(p1.is_nearly_eq(&p2, Pixels::from(4.0)), true); + /// ``` + pub fn is_nearly_eq(&self, other: &Self, epsilon: Pixels) -> bool { + let diff = *self - *other; + diff.x.abs() < epsilon && diff.y.abs() < epsilon + } } impl Point diff --git a/crates/gpui/src/text_system.rs b/crates/gpui/src/text_system.rs index 043d37f679..82adf0c7bb 100644 --- a/crates/gpui/src/text_system.rs +++ b/crates/gpui/src/text_system.rs @@ -504,11 +504,20 @@ impl WindowTextSystem { } /// Shape a multi line string of text, at the given font_size, for painting to the screen. - /// Subsets of the text can be styled independently with the `runs` parameter. + /// Subsets of the text can be styled independently with the `runs` parameter, + /// where each run dictates the length of utf8 characters in `text` that it styles. + /// The length (utf8 characters) of last item in `runs` is semantically ignored as it + /// represents the "rest" of the `text`. + /// /// If `wrap_width` is provided, the line breaks will be adjusted to fit within the given width. - pub fn shape_text( + /// + /// If the text provided is SharedString and does not contain new-lines, + /// it will be used as-is without additional allocations. + /// If the text provided is not a SharedString or contains new-lines, new SharedStrings + /// will be allocated for each substring between new-line characters (minimum of 1). + pub fn shape_text + Into>( &self, - text: SharedString, + text: S, font_size: Pixels, runs: &[TextRun], wrap_width: Option, @@ -598,7 +607,7 @@ impl WindowTextSystem { } }; - let mut split_lines = text.split('\n'); + let mut split_lines = text.as_ref().split('\n'); // Special case single lines to prevent allocating a sharedstring if let Some(first_line) = split_lines.next() @@ -625,8 +634,8 @@ impl WindowTextSystem { ); } } else { - let end = text.len(); - process_line(text, 0, end); + let end = text.as_ref().len(); + process_line(text.into(), 0, end); } self.font_runs_pool.lock().push(font_runs); diff --git a/crates/gpui/src/window.rs b/crates/gpui/src/window.rs index b5a2faab92..5276fb15dc 100644 --- a/crates/gpui/src/window.rs +++ b/crates/gpui/src/window.rs @@ -3357,6 +3357,8 @@ impl Window { } /// Use a piece of state that exists as long this element is being rendered in consecutive frames. + /// + /// This method should only be called during `Render::render`, `RenderOnce::render`, or the drawing functions of `Element`. pub fn use_keyed_state( &mut self, key: impl Into, @@ -3401,7 +3403,9 @@ impl Window { /// Updates or initializes state for an element with the given id that lives across multiple /// frames. If an element with this ID existed in the rendered frame, its state will be passed /// to the given closure. The state returned by the closure will be stored so it can be referenced - /// when drawing the next frame. This method should only be called as part of element drawing. + /// when drawing the next frame. + /// + /// This method should only be called during `Render::render`, `RenderOnce::render`, or the drawing functions of `Element`. pub fn with_element_state( &mut self, global_id: &GlobalElementId, @@ -3531,6 +3535,8 @@ impl Window { /// persist across renders as long as the key remains the same. This is the /// recommended method for most use cases where you want smooth, continuous /// animations. + /// + /// This method should only be called during `Render::render`, `RenderOnce::render`, or the drawing functions of `Element`. pub fn use_keyed_transition( &mut self, key: impl Into, diff --git a/crates/gpui_elements/Cargo.toml b/crates/gpui_elements/Cargo.toml index 2a53df02ef..b0ad429fd7 100644 --- a/crates/gpui_elements/Cargo.toml +++ b/crates/gpui_elements/Cargo.toml @@ -13,6 +13,13 @@ ignored = ["gpui"] [dependencies] gpui.workspace = true +unicode-segmentation.workspace = true +smallvec.workspace = true [dev-dependencies] gpui = { path = "../gpui", features = ["test-support"] } +gpui_platform = { workspace = true, features = ["font-kit", "wayland", "x11"] } + +[[example]] +name = "editable_text" +path = "examples/editable_text.rs" diff --git a/crates/gpui_elements/examples/editable_text.rs b/crates/gpui_elements/examples/editable_text.rs new file mode 100644 index 0000000000..4fcffea1f6 --- /dev/null +++ b/crates/gpui_elements/examples/editable_text.rs @@ -0,0 +1,66 @@ +use gpui::{ + App, Bounds, Context, Hsla, Window, WindowBounds, WindowOptions, div, prelude::*, px, rgb, size, +}; +use gpui_elements::editable_text::{ + actions::{DEFAULT_INPUT_CONTEXT, default_bindings}, + text_area, text_input, +}; + +struct Example; +impl Render for Example { + fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { + div() + .size_full() + .bg(rgb(0x505050)) + .flex() + .flex_col() + .p_2() + .gap_2() + .items_start() + .justify_start() + .child( + text_input("input-field") + .caret_blink_interval_500ms() + .placeholder("some placeholder text") + .border_1() + .rounded_lg() + .border_color(Hsla::white()) // has a border + .p_2() // padding between the text and border + .min_w_10() + .max_w_128() + .min_h_auto() + .max_h_auto() + .whitespace_nowrap(), + ) + .child( + text_area("text-area") + .placeholder("empty text") + .border_1() + .rounded_lg() + .border_color(Hsla::white()) // has a border + .p_2() // padding between the text and border + .w_full() + .min_h_24() + .max_h_128() + .whitespace_normal() // default + .overflow_y_scroll(), + ) + } +} + +fn main() { + gpui_platform::application().run(|cx: &mut App| { + cx.bind_keys(default_bindings().as_keybindings(Some(DEFAULT_INPUT_CONTEXT))); + + let bounds = Bounds::centered(None, size(px(500.), px(500.0)), cx); + cx.open_window( + WindowOptions { + window_bounds: Some(WindowBounds::Windowed(bounds)), + ..Default::default() + }, + |_, cx| cx.new(|_| Example), + ) + .unwrap(); + cx.activate(true); + }); +} diff --git a/crates/gpui_elements/src/editable_text.rs b/crates/gpui_elements/src/editable_text.rs new file mode 100644 index 0000000000..2c05cf5280 --- /dev/null +++ b/crates/gpui_elements/src/editable_text.rs @@ -0,0 +1,175 @@ +//! Implementation for editable-text elements (gpui equivalent of html +//! [``](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/input) and +//! [`