Add an input example to gpui (#13534)

Conrad Irwin created

Add a single-line text input example to gpui

(I'm hoping to be able to debug keyboard issues without rebuilding the
whole
app every time)

Release Notes:

- N/A

Change summary

Cargo.lock                             |   1 
crates/editor/src/editor.rs            |   1 
crates/gpui/Cargo.toml                 |   5 
crates/gpui/examples/input.rs          | 489 ++++++++++++++++++++++++++++
crates/gpui/src/platform/mac/window.rs |  10 
5 files changed, 503 insertions(+), 3 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -4919,6 +4919,7 @@ dependencies = [
  "taffy",
  "thiserror",
  "time",
+ "unicode-segmentation",
  "usvg",
  "util",
  "uuid",

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')

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"

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<usize>,
+    selection_reversed: bool,
+    marked_range: Option<Range<usize>>,
+    last_layout: Option<ShapedLine>,
+}
+
+impl TextInput {
+    fn left(&mut self, _: &Left, cx: &mut ViewContext<Self>) {
+        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<Self>) {
+        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>) {
+        self.select_to(self.previous_boundary(self.cursor_offset()), cx);
+    }
+
+    fn select_right(&mut self, _: &SelectRight, cx: &mut ViewContext<Self>) {
+        self.select_to(self.next_boundary(self.cursor_offset()), cx);
+    }
+
+    fn select_all(&mut self, _: &SelectRight, cx: &mut ViewContext<Self>) {
+        self.move_to(0, cx);
+        self.select_to(self.content.len(), cx)
+    }
+
+    fn home(&mut self, _: &Home, cx: &mut ViewContext<Self>) {
+        self.move_to(0, cx);
+    }
+
+    fn end(&mut self, _: &End, cx: &mut ViewContext<Self>) {
+        self.move_to(self.content.len(), cx);
+    }
+
+    fn backspace(&mut self, _: &Backspace, cx: &mut ViewContext<Self>) {
+        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<Self>) {
+        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<Self>) {
+        cx.show_character_palette();
+    }
+
+    fn move_to(&mut self, offset: usize, cx: &mut ViewContext<Self>) {
+        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<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;
+        }
+        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<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 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<usize>,
+        _cx: &mut ViewContext<Self>,
+    ) -> Option<String> {
+        let range = self.range_from_utf16(&range_utf16);
+        Some(self.content[range].to_string())
+    }
+
+    fn selected_text_range(&mut self, _cx: &mut ViewContext<Self>) -> Option<Range<usize>> {
+        Some(self.range_to_utf16(&self.selected_range))
+    }
+
+    fn marked_text_range(&self, _cx: &mut ViewContext<Self>) -> Option<Range<usize>> {
+        self.marked_range
+            .as_ref()
+            .map(|range| self.range_to_utf16(range))
+    }
+
+    fn unmark_text(&mut self, _cx: &mut ViewContext<Self>) {
+        self.marked_range = None;
+    }
+
+    fn replace_text_in_range(
+        &mut self,
+        range_utf16: Option<Range<usize>>,
+        new_text: &str,
+        cx: &mut ViewContext<Self>,
+    ) {
+        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<Range<usize>>,
+        new_text: &str,
+        new_selected_range_utf16: Option<Range<usize>>,
+        cx: &mut ViewContext<Self>,
+    ) {
+        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<usize>,
+        bounds: Bounds<Pixels>,
+        _cx: &mut ViewContext<Self>,
+    ) -> Option<Bounds<Pixels>> {
+        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<TextInput>,
+}
+
+struct PrepaintState {
+    line: Option<ShapedLine>,
+    cursor: Option<PaintQuad>,
+    selection: Option<PaintQuad>,
+}
+
+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<ElementId> {
+        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<Pixels>,
+        _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<Pixels>,
+        _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<Self>) -> 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();
+    });
+}

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()?)