diff --git a/Cargo.lock b/Cargo.lock index 62486e80a9..f015a0d6dc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4919,6 +4919,7 @@ dependencies = [ "taffy", "thiserror", "time", + "unicode-segmentation", "usvg", "util", "uuid", diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index 7672299112..21e97ac808 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -12390,6 +12390,7 @@ impl ViewInputHandler for Editor { let font_id = cx.text_system().resolve_font(&style.text.font()); let font_size = style.text.font_size.to_pixels(cx.rem_size()); let line_height = style.text.line_height_in_pixels(cx.rem_size()); + let em_width = cx .text_system() .typographic_bounds(font_id, font_size, 'm') diff --git a/crates/gpui/Cargo.toml b/crates/gpui/Cargo.toml index dfab2b9cd2..0781c7cdd6 100644 --- a/crates/gpui/Cargo.toml +++ b/crates/gpui/Cargo.toml @@ -80,6 +80,7 @@ backtrace = "0.3" collections = { workspace = true, features = ["test-support"] } util = { workspace = true, features = ["test-support"] } http = { workspace = true, features = ["test-support"] } +unicode-segmentation.workspace = true [build-dependencies] embed-resource = "2.4" @@ -157,3 +158,7 @@ path = "examples/image/image.rs" [[example]] name = "set_menus" path = "examples/set_menus.rs" + +[[example]] +name = "input" +path = "examples/input.rs" diff --git a/crates/gpui/examples/input.rs b/crates/gpui/examples/input.rs new file mode 100644 index 0000000000..2b30573855 --- /dev/null +++ b/crates/gpui/examples/input.rs @@ -0,0 +1,489 @@ +use std::ops::Range; + +use gpui::*; +use unicode_segmentation::*; + +actions!( + text_input, + [ + Backspace, + Delete, + Left, + Right, + SelectLeft, + SelectRight, + SelectAll, + Home, + End, + ShowCharacterPalette + ] +); + +struct TextInput { + focus_handle: FocusHandle, + content: SharedString, + selected_range: Range, + selection_reversed: bool, + marked_range: Option>, + last_layout: Option, +} + +impl TextInput { + fn left(&mut self, _: &Left, cx: &mut ViewContext) { + if self.selected_range.is_empty() { + self.move_to(self.previous_boundary(self.cursor_offset()), cx); + } else { + self.move_to(self.selected_range.end, cx) + } + } + + fn right(&mut self, _: &Right, cx: &mut ViewContext) { + if self.selected_range.is_empty() { + self.move_to(self.next_boundary(self.selected_range.end), cx); + } else { + self.move_to(self.selected_range.start, cx) + } + } + + fn select_left(&mut self, _: &SelectLeft, cx: &mut ViewContext) { + self.select_to(self.previous_boundary(self.cursor_offset()), cx); + } + + fn select_right(&mut self, _: &SelectRight, cx: &mut ViewContext) { + self.select_to(self.next_boundary(self.cursor_offset()), cx); + } + + fn select_all(&mut self, _: &SelectRight, cx: &mut ViewContext) { + self.move_to(0, cx); + self.select_to(self.content.len(), cx) + } + + fn home(&mut self, _: &Home, cx: &mut ViewContext) { + self.move_to(0, cx); + } + + fn end(&mut self, _: &End, cx: &mut ViewContext) { + self.move_to(self.content.len(), cx); + } + + fn backspace(&mut self, _: &Backspace, cx: &mut ViewContext) { + if self.selected_range.is_empty() { + self.select_to(self.previous_boundary(self.cursor_offset()), cx) + } + self.replace_text_in_range(None, "", cx) + } + + fn delete(&mut self, _: &Delete, cx: &mut ViewContext) { + if self.selected_range.is_empty() { + self.select_to(self.next_boundary(self.cursor_offset()), cx) + } + self.replace_text_in_range(None, "", cx) + } + + fn show_character_palette(&mut self, _: &ShowCharacterPalette, cx: &mut ViewContext) { + cx.show_character_palette(); + } + + fn move_to(&mut self, offset: usize, cx: &mut ViewContext) { + 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 select_to(&mut self, offset: usize, cx: &mut ViewContext) { + 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()) + } +} + +impl ViewInputHandler for TextInput { + fn text_for_range( + &mut self, + range_utf16: Range, + _cx: &mut ViewContext, + ) -> Option { + let range = self.range_from_utf16(&range_utf16); + Some(self.content[range].to_string()) + } + + fn selected_text_range(&mut self, _cx: &mut ViewContext) -> Option> { + Some(self.range_to_utf16(&self.selected_range)) + } + + fn marked_text_range(&self, _cx: &mut ViewContext) -> Option> { + self.marked_range + .as_ref() + .map(|range| self.range_to_utf16(range)) + } + + fn unmark_text(&mut self, _cx: &mut ViewContext) { + self.marked_range = None; + } + + fn replace_text_in_range( + &mut self, + range_utf16: Option>, + new_text: &str, + cx: &mut ViewContext, + ) { + 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>, + cx: &mut ViewContext, + ) { + 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.marked_range = Some(range.start..range.start + new_text.len()); + 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, + _cx: &mut ViewContext, + ) -> Option> { + let Some(last_layout) = self.last_layout.as_ref() else { + return None; + }; + 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(), + ), + )) + } +} + +struct TextElement { + input: View, +} + +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 request_layout( + &mut self, + _id: Option<&GlobalElementId>, + cx: &mut WindowContext, + ) -> (LayoutId, Self::RequestLayoutState) { + let mut style = Style::default(); + style.size.width = relative(1.).into(); + style.size.height = cx.line_height().into(); + (cx.request_layout(style, []), ()) + } + + fn prepaint( + &mut self, + _id: Option<&GlobalElementId>, + bounds: Bounds, + _request_layout: &mut Self::RequestLayoutState, + cx: &mut WindowContext, + ) -> 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 = cx.text_style(); + let run = TextRun { + len: input.content.len(), + font: style.font(), + color: style.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: input.content.len() - marked_range.end, + ..run.clone() + }, + ] + .into_iter() + .filter(|run| run.len > 0) + .collect() + } else { + vec![run] + }; + + let font_size = style.font_size.to_pixels(cx.rem_size()); + let line = cx + .text_system() + .shape_line(content, font_size, &runs) + .unwrap(); + + 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>, + bounds: Bounds, + _request_layout: &mut Self::RequestLayoutState, + prepaint: &mut Self::PrepaintState, + cx: &mut WindowContext, + ) { + let focus_handle = self.input.read(cx).focus_handle.clone(); + cx.handle_input( + &focus_handle, + ElementInputHandler::new(bounds, self.input.clone()), + ); + if let Some(selection) = prepaint.selection.take() { + cx.paint_quad(selection) + } + let line = prepaint.line.take().unwrap(); + line.paint(bounds.origin, cx.line_height(), cx).unwrap(); + + if let Some(cursor) = prepaint.cursor.take() { + cx.paint_quad(cursor); + } + self.input.update(cx, |input, _cx| { + input.last_layout = Some(line); + }); + } +} + +impl Render for TextInput { + fn render(&mut self, cx: &mut ViewContext) -> impl IntoElement { + div() + .flex() + .key_context("TextInput") + .track_focus(&self.focus_handle) + .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)) + .bg(rgb(0xeeeeee)) + .size_full() + .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.view().clone(), + }), + ) + } +} + +fn main() { + App::new().run(|cx: &mut AppContext| { + 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("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| { + cx.new_view(|cx| TextInput { + focus_handle: cx.focus_handle(), + content: "".into(), + selected_range: 0..0, + selection_reversed: false, + marked_range: None, + last_layout: None, + }) + }, + ) + .unwrap(); + window + .update(cx, |view, cx| { + view.focus_handle.focus(cx); + cx.activate(true) + }) + .unwrap(); + }); +} diff --git a/crates/gpui/src/platform/mac/window.rs b/crates/gpui/src/platform/mac/window.rs index 419596140f..5eb9caba2e 100644 --- a/crates/gpui/src/platform/mac/window.rs +++ b/crates/gpui/src/platform/mac/window.rs @@ -1672,9 +1672,13 @@ extern "C" fn first_rect_for_character_range( range: NSRange, _: id, ) -> NSRect { - let frame = unsafe { - let window = get_window_state(this).lock().native_window; - NSView::frame(window) + let frame: NSRect = unsafe { + let state = get_window_state(this); + let lock = state.lock(); + let mut frame = NSWindow::frame(lock.native_window); + let content_layout_rect: CGRect = msg_send![lock.native_window, contentLayoutRect]; + frame.origin.y -= frame.size.height - content_layout_rect.size.height; + frame }; with_input_handler(this, |input_handler| { input_handler.bounds_for_range(range.to_range()?)