Rust GPUI 开发尝试

本文档记录了我在使用 Rust 语言进行图形用户界面(GUI)开发时的一些尝试和经验。由于 GPUI 目前并不成熟且未发布较稳定版本,后续改动可能很大,可能会有内容不完整或错误的地方,欢迎指正和补充。

Get Started Crates.io

引入依赖

[dependencies]
gpui = "0.2.2"

GPUI 中的窗口使用 struct 来声明,内部内容自定义,可以用来修改或用作其他用途。

例如,我接下来创建了一个 struct,我将其作为主窗口。

struct MainUI {
    title: SharedString,
}

定义好了一个 struct 后,对其重写实现 Render trait,就可以将其作为窗口内容进行渲染。

impl Render for MainUI {
    fn render(&mut self, _window: &mut gpui::Window, _cx: &mut gpui::Context
<Self>) -> impl gpui::IntoElement {
        div()
            .text_2xl()
            .child(format!("Welcome to {}", self.title))
    }
}

这个 render() 需要一个返回 impl gpui::IntoElement 的值,表示窗口的内容。窗口内容写法与 HTML 类似,可以使用 div()span() 等标签函数来创建元素,并通过链式调用添加样式和子元素。现仅粗略阐述。

最后使用 Application::new() 创建应用程序实例,然后 run(|cx| { ... }) 启动应用程序。

fn main() {
    Application::new()
        .run(|cx| {
            cx.open_window(
                WindowOptions {
                    ..Default::default()
                }, 
                |_, cx| {
                    cx.new(|_| MainUI {
                        title: "My First GPUI App!".into(),
                    })
                }
            )
            .unwrap();
        });
}

这样,一个简单的 GPUI 应用程序就完成了。

Application::new().run() 需要传入一个 on_finish_launching 闭包,该闭包在应用启动完成后执行,接收一个 &mut gpui::App 类型的 cx 参数。通过 cx 可以调用 open_window() 打开窗口。open_window() 的第一个参数是 WindowOptions,用于配置窗口属性;第二个参数是一个闭包,用于创建窗口内容。这个闭包内部再调用 cx.new(),并传入一个返回实现了 Render trait 的结构体的闭包,从而完成窗口内容的构造。

由此,可以看到 GPUI 为我们生成了一个简单的窗口:

GPUI Window

没有设置背景和文字颜色,默认是透明窗口和黑字,还可以看到 text_2xl() 确实生效了。

可以尝试对窗口进行一些修改:

impl Render for MainUI {
    fn render(&mut self, _window: &mut gpui::Window, _cx: &mut gpui::Context
<Self>) -> impl gpui::IntoElement {
        div()
            .text_2xl()
            .size_full()
            .bg(rgba(0x39C5BB88))
            .child(format!("Welcome to {}", self.title))
            .text_bg(rgb(0x114514))
    }
}

可以看到背景占满了整个窗口,并且设置了半透明背景色和文字背景色:

GPUI Window Modified

可以继续修改 WindowOptions 来对窗口进行更多定义:

把主 Div 的背景色改为 rgba(0x39C5BB11),能够清晰看到效果。

WindowOptions {
    titlebar: Some(TitlebarOptions {
        title: Some("My GPUI App".into()),
        ..Default::default()
    }),
    window_background: WindowBackgroundAppearance::Blurred,
    ..Default::default()
}

效果:

GPUI Window with Titlebar

这样就完美地添加了标题和模糊背景效果。

列表与点击事件

此内容参考 GPUI 示例代码。

在此示例中,创建了一个有 50 个元素的列表,并为每个元素添加了点击事件。

struct MainUI {}

impl Render for MainUI {
    fn render(&mut self, _window: &mut gpui::Window, cx: &mut gpui::Context<Self>) -> impl gpui::IntoElement {
        div()
            .size_full()
            .bg(rgb(0xffffff))
            .child(
                uniform_list(
                    "entries", 
                    50, 
                    cx.processor(|_this, range, _window, _cx| {
                        let mut items = Vec::new();
                        for ix in range {
                            let item = ix + 1;

                            items.push(
                                div()
                                    .id(ix)
                                    .px_2()
                                    .cursor_pointer()
                                    .on_click(move |_event, _window, _cx| {
                                        println!("Clicked item {item:?}");
                                    })
                                    .child(format!("Item {item}"))
                            );
                        }
                        items
                    }),
                )
                .h_full()
            )
    }
}

不难发现,使用了 uniform_list() 来创建一个列表,第一个参数是列表的标识符(id),第二个参数是列表项的数量,第三个参数是一个处理器闭包,用于生成列表项。第三个参数中,cx.processor() 用于创建一个处理器闭包,接收当前范围 range,并返回一个包含列表项的向量。在每个列表项中,使用 on_click() 方法为其添加点击事件处理器,当点击该项时,会打印出被点击的项的编号。processor() 方法接受了一个闭包,该闭包接收四个参数:this(当前组件的引用)、range(当前处理的范围)、window(窗口引用)和 cx(上下文引用)。在闭包内部,遍历 range,为每个索引创建一个 div 元素,并为其添加点击事件处理器。闭包返回值是一个包含所有列表项的向量,此向量被 processor() 方法用来生成列表内容。故此处 items: Vec<impl gpui::IntoElement> 亦即 items: Vec<Stateful<Div>>

h_full() 方法用于设置列表的高度为满高。

items.push(
    div()
        .id(ix)
        .px_2()
        .cursor_pointer()
        .on_click(move |_event, _window, _cx| {
            println!("Clicked item {item:?}");
        })
        .child(format!("Item {item}"))
);

此处声明的是每个列表项的内容。id() 方法填入一个 ix 作为该项的唯一标识符,px_2() 添加了水平内边距,cursor_pointer() 设置鼠标悬停时显示为指针样式,on_click() 方法为该项添加了点击事件处理器。最后,使用 child() 方法添加了显示文本。

on_click() 方法接收一个闭包作为参数,该闭包在点击事件发生时被调用。闭包接收三个参数:event(事件对象)、window(窗口引用)和 cx(上下文引用)。在闭包内部,可以编写处理点击事件的逻辑,例如打印被点击的项的编号。

然后不要忘记在 main() 函数中创建并运行应用程序:

fn main() {
    Application::new().run(|cx| {
        let bounds = Bounds::centered(None, size(px(300.0), px(300.0)), cx);
        cx.open_window(
            WindowOptions {
                window_bounds: Some(WindowBounds::Windowed(bounds)),
                ..Default::default()
            },
            |_, cx| {
                cx.new(|_| MainUI {})
            }
        )
        .unwrap();
    });
}

WindowOptions::window_bounds 用于设置窗口的大小和位置,此处创建了一个 300x300 像素的窗口并居中显示。

最后打开的窗口效果如下:

GPUI List Example

可以发现鼠标指针变成了可操作手势样式,当点击某个元素后,控制台会输出被点击的项的编号:

GPUI List Clicked

动画

此内容参考 GPUI 示例代码。

这个示例展示了如何在 GPUI 中实现简单的动画效果。我们使用一个 SVG 图形,并通过修改其属性来实现动画。

这个示例需要 anyhow 依赖:

anyhow = "1.0.101"

首先,保存一下这个 SVG 图像 到本地,记录一下这个 SVG 的路径:

const ARROW_CIRCLE_SVG: &str = "./arrow_circle.svg";

然后建立一个结构体 Assets 来存储加载的 SVG 资源:

struct Assets {}

对此结构体实现 AssetSource trait,以便在应用启动时加载 SVG 资源:

impl AssetSource for Assets {
    fn load(&self, path: &str) -> gpui::Result<Option<std::borrow::Cow<'static, [u8]>>> {
        std::fs::read(path)
            .map(|i| i.into())
            .map_err(|e| e.into())
            .map(|i| Some(i))
    }

    fn list(&self, path: &str) -> gpui::Result<Vec<gpui::SharedString>> {
        Ok(std::fs::read_dir(path)?
            .filter_map(|entry| {
                Some(SharedString::from(
                    entry.ok()?.path().to_string_lossy().into_owned(),
                ))
            })
            .collect::<Vec<_>>())
    }
}

可见,我们对 load() 方法进行了实现,从指定路径读取文件内容并返回;list() 方法则列出指定目录下的所有文件。实现了 AssetSource trait 后,我们可以在应用启动时加载 SVG 资源。

Application::new().with_assets(Assets {}).run( ... );

with_assets() 方法用于指定应用程序的资源加载器,这里传入了我们刚刚实现的 Assets 结构体实例。

然后对主界面 AnimationApp 进行定义:

struct AnimationApp {}

实现 Render trait 来定义界面内容:

impl Render for AnimationApp {
    fn render(
        &mut self,
        _window: &mut gpui::Window,
        _cx: &mut gpui::Context
<Self>,
    ) -> impl gpui::IntoElement {
        div()
            .flex()
            .flex_col()
            .size_full()
            .bg(white())
            .text_color(black())
            .justify_around()
            .child(
                div()
                    .flex()
                    .flex_col()
                    .size_full()
                    .justify_around()
                    .child(
                        div()
                            .id("content")
                            .flex()
                            .flex_col()
                            .h(px(150.))
                            .overflow_y_scroll()
                            .w_full()
                            .flex_1()
                            .justify_center()
                            .items_center()
                            .text_xl()
                            .gap_4()
                            .child("Hello Animation!")
                            .child(
                                svg()
                                    .size_20()
                                    .overflow_hidden()
                                    .path(ARROW_CIRCLE_SVG)
                                    .text_color(black())
                                    .with_animation(
                                        "image_circle",
                                        Animation::new(Duration::from_secs(2))
                                            .repeat()
                                            .with_easing(bounce(ease_out_quint())),
                                        |svg, delta| {
                                            svg.with_transformation(Transformation::rotate(
                                                percentage(delta),
                                            ))
                                        },
                                    ),
                            ),
                    )
                    .child(
                        div()
                            .flex()
                            .h(px(64.))
                            .w_full()
                            .p_2()
                            .justify_center()
                            .items_center()
                            .border_t_1()
                            .border_color(black().opacity(0.1))
                            .bg(black().opacity(0.05))
                            .child("Other..."),
                    ),
            )
    }
}

声明的一些东西:

  • flex()flex_col() 用于设置弹性布局和列方向.

    什么是弹性布局? 弹性布局(Flexbox)是一种 CSS 布局模型,允许容器内的子元素根据可用空间动态调整大小和位置。通过设置容器为弹性布局,可以轻松实现响应式设计和复杂的布局结构。

  • justify_around() 用于在主轴上均匀分布子元素,并在每个子元素之间留有间隔。

  • overflow_y_scroll() 用于在垂直方向上启用滚动条(由于内容较少,此声明几乎无效)。

  • items_center() 用于在交叉轴上居中对齐子元素。

  • justify_center() 用于在主轴上居中对齐子元素。

  • svg() 用于创建一个 SVG 元素。

  • path(ARROW_CIRCLE_SVG) 用于指定 SVG 图像的路径。

  • with_animation() 用于为 SVG 元素添加动画效果。

  • Animation::new(Duration::from_secs(2)) 创建一个持续时间为 2 秒的动画。

  • repeat() 方法使动画循环播放。

  • with_easing(bounce(ease_out_quint())) 设置动画的缓动函数,使动画具有弹跳效果。

  • 闭包 |svg, delta| { ... } 用于定义动画的具体效果,此处通过 Transformation::rotate(percentage(delta)) 实现旋转效果。

最后,在 main() 函数中创建并运行应用程序:

fn main() {
    Application::new().with_assets(Assets {}).run(|cx| {
        let options = WindowOptions {
            window_bounds: Some(WindowBounds::Windowed(Bounds::centered(
                None,
                size(px(300.), px(300.)),
                cx,
            ))),
            ..Default::default()
        };
        cx.open_window(options, |_, cx| {
            cx.activate(false);
            cx.new(|_| AnimationApp {})
        })
        .unwrap();
    });
}

可见效果如下:

GPUI Animation Example

侦测输入

此内容参考 GPUI 示例代码。

在这个示例中,我们将展示如何在 GPUI 中侦测键盘和鼠标输入事件。

本示例需要引入 unicode-segmentation 依赖:

unicode-segmentation = "1.12.0"

使用 actions! 宏来定义一个输入事件处理器:

actions!(
    text_input,
    [
        Backspace,
        Delete,
        Left,
        Right,
        SelectLeft,
        SelectRight,
        SelectAll,
        Home,
        End,
        ShowCharacterPalette,
        Paste,
        Cut,
        Copy,
        Quit,
    ]
);

然后定义一个 TextInput 结构体存储当前状态:

struct TextInput {
    focus_handle: FocusHandle,              // 焦点句柄
    content: SharedString,                  // 输入内容
    placeholder: SharedString,              // 占位符文本
    selected_range: Range
<usize>,           // 选择范围
    selection_reversed: bool,               // 选择是否反向
    marked_range: Option<Range<usize>>,     // 标记范围(用于输入法)
    last_layout: Option
<ShapedLine>,        // 上一次的布局信息
    last_bounds: Option<Bounds<Pixels>>,    // 上一次的边界信息
    is_selecting: bool,                     // 是否正在选择文本
}

注意:此处的 `Range

` 来源于 `std::ops::Range`,用于表示文本选择的起始和结束位置。若使用 `std::range::Range` 会出现编译错误。

接着,对这个结构体进行一系列实现:

先实现一个 move_to() 方法,用于移动光标位置:

fn move_to(&mut self, offset: usize, cx: &mut Context
<Self>) {
    self.selected_range = offset..offset;
    cx.notify();
}

其中,notify() 方法用于通知 GPUI 重新渲染界面。

实现一个 select_to() 方法,用于更新选择范围:

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;
    }
    cx.notify()
}

实现一下 previous_boundry()next_boundry() 方法,用于计算文本边界:

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

在此处,我们使用了 unicode-segmentation 提供的 grapheme_indices() 方法来获取文本的字符边界,以便正确处理 Unicode 字符。grapheme_indices(true) 返回一个迭代器,提供文本中每个字符的起始索引和对应的字符。通过 find_map() 方法,我们可以找到第一个满足条件的边界索引,从而实现光标移动和文本选择功能。

实现 reset() 方法,用于重置输入状态:

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;
}

实现 EntityInputHandler trait 来处理输入事件:

impl EntityInputHandler for TextInput {
    fn text_for_range(
        &mut self,
        range_utf16: Range
<usize>,
        actual_range: &mut Option<Range<usize>>,
        _window: &mut Window,
        _cx: &mut Context
<Self>,
    ) -> Option
<String> {
        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
<Self>,
    ) -> Option
<UTF16Selection> {
        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
<Self>,
    ) -> Option<Range<usize>> {
        self.marked_range
            .as_ref()
            .map(|range| self.range_to_utf16(range))
    }

    fn unmark_text(&mut self, _window: &mut Window, _cx: &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 = 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>>,
        _window: &mut Window,
        cx: &mut Context
<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();
        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
<usize>,
        bounds: Bounds
<Pixels>,
        _window: &mut Window,
        _cx: &mut Context
<Self>,
    ) -> Option<Bounds<Pixels>> {
        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
<Pixels>,
        _window: &mut Window,
        _cx: &mut Context
<Self>,
    ) -> Option
<usize> {
        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))
    }
}

实现 copy() cut()paste() 方法,用于处理剪贴板操作:

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.replace("\n", " "), window, cx);
    }
}
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.content[self.selected_range.clone()].to_string(),
        ));
    }
}
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.content[self.selected_range.clone()].to_string(),
        ));
        self.replace_text_in_range(None, "", window, cx)
    }
}

依照这样,将其他事件也补全:

impl TextInput {
    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.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>) {
        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.content.len(), cx)
    }

    fn home(&mut self, _: &Home, _: &mut Window, cx: &mut Context
<Self>) {
        self.move_to(0, cx);
    }

    fn end(&mut self, _: &End, _: &mut Window, cx: &mut Context
<Self>) {
        self.move_to(self.content.len(), 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 on_mouse_down(
        &mut self,
        event: &MouseDownEvent,
        _window: &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)
        }
    }

    fn on_mouse_up(&mut self, _: &MouseUpEvent, _window: &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 show_character_palette(
        &mut self,
        _: &ShowCharacterPalette,
        window: &mut Window,
        _: &mut Context
<Self>,
    ) {
        window.show_character_palette();
    }

    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.replace("\n", " "), window, cx);
        }
    }

    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.content[self.selected_range.clone()].to_string(),
            ));
        }
    }
    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.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>) {
        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
<Pixels>) -> 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
<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())
    }

    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;
    }
}

完整的实现如上所示。通过这些方法,我们可以处理各种输入事件,包括键盘输入、鼠标点击等,从而实现一个功能完整的文本输入组件。

接下来,开始构建界面:

我们需要一个输入框,照理也需要建立一个 struct 来把这个对象存储起来:

struct TextElement {
    input: Entity
<TextInput>
}

Entity<TextInput> 是一个 GPUI 实体,表示一个可交互的组件,这里我们将 TextInput 作为其输入处理器。

然后实现 IntoElement trait 来定义界面元素,在此之前要把 ElementTextElement 实现一下:

struct TextElement {
    input: Entity
<TextInput>,
}

impl Element for TextElement {
    type RequestLayoutState = ();
    type PrepaintState = PrepaintState;

    fn id(&self) -> Option
<ElementId> {
        None
    }

    fn source_location(&self) -> Option<&'static std::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
<Pixels>,
        _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
<Pixels>,
        _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(),
            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);
        });
    }
}

如上, Element trait 定义了组件的布局和绘制逻辑。在 request_layout() 方法中,我们设置了组件的大小;在 prepaint() 方法中,我们计算了文本的显示内容、颜色、光标位置等信息;在 paint() 方法中,我们根据预先计算的信息绘制文本、选择区域和光标。

使用到关键词 type 的地方是为了定义关联类型,这些类型在 trait 中被声明,但具体的类型由实现该 trait 的结构体来确定。在 Element trait 中,我们声明了 RequestLayoutStatePrepaintState 这两个关联类型,分别用于表示布局请求的状态和预绘制的状态。在 TextElement 的实现中,我们将 RequestLayoutState 定义为 ()(表示没有特定的状态),而 PrepaintState 则定义为一个包含文本布局、光标和选择信息的结构体。

实现 Render trait 来显示 TextInput

impl Render for TextInput {
    fn render(&mut self, _window: &mut Window, cx: &mut Context
<Self>) -> 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() }),
            )
    }
}

方法 cursor() 用于设置鼠标悬停时的光标样式,这里我们使用了 CursorStyle::IBeam,表示文本输入时常见的竖线光标。

其他常见光标见 GPUI 定义:

/// The style of the cursor (pointer)
#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
pub enum CursorStyle {
    /// The default cursor
    Arrow,

    /// A text input cursor
    /// corresponds to the CSS cursor value `text`
    IBeam,

    /// A crosshair cursor
    /// corresponds to the CSS cursor value `crosshair`
    Crosshair,

    /// A closed hand cursor
    /// corresponds to the CSS cursor value `grabbing`
    ClosedHand,

    /// An open hand cursor
    /// corresponds to the CSS cursor value `grab`
    OpenHand,

    /// A pointing hand cursor
    /// corresponds to the CSS cursor value `pointer`
    PointingHand,

    /// A resize left cursor
    /// corresponds to the CSS cursor value `w-resize`
    ResizeLeft,

    /// A resize right cursor
    /// corresponds to the CSS cursor value `e-resize`
    ResizeRight,

    /// A resize cursor to the left and right
    /// corresponds to the CSS cursor value `ew-resize`
    ResizeLeftRight,

    /// A resize up cursor
    /// corresponds to the CSS cursor value `n-resize`
    ResizeUp,

    /// A resize down cursor
    /// corresponds to the CSS cursor value `s-resize`
    ResizeDown,

    /// A resize cursor directing up and down
    /// corresponds to the CSS cursor value `ns-resize`
    ResizeUpDown,

    /// A resize cursor directing up-left and down-right
    /// corresponds to the CSS cursor value `nesw-resize`
    ResizeUpLeftDownRight,

    /// A resize cursor directing up-right and down-left
    /// corresponds to the CSS cursor value `nwse-resize`
    ResizeUpRightDownLeft,

    /// A cursor indicating that the item/column can be resized horizontally.
    /// corresponds to the CSS cursor value `col-resize`
    ResizeColumn,

    /// A cursor indicating that the item/row can be resized vertically.
    /// corresponds to the CSS cursor value `row-resize`
    ResizeRow,

    /// A text input cursor for vertical layout
    /// corresponds to the CSS cursor value `vertical-text`
    IBeamCursorForVerticalLayout,

    /// A cursor indicating that the operation is not allowed
    /// corresponds to the CSS cursor value `not-allowed`
    OperationNotAllowed,

    /// A cursor indicating that the operation will result in a link
    /// corresponds to the CSS cursor value `alias`
    DragLink,

    /// A cursor indicating that the operation will result in a copy
    /// corresponds to the CSS cursor value `copy`
    DragCopy,

    /// A cursor indicating that the operation will result in a context menu
    /// corresponds to the CSS cursor value `context-menu`
    ContextualMenu,

    /// Hide the cursor
    None,
}

on_action() 方法用于绑定键盘事件处理器,例如当用户按下 Backspace 键时,会调用 Self::backspace 方法来处理删除操作。参见上述定义,在实际项目中可以通过 actions! 宏来定义这些事件处理器,以便更清晰地组织代码。事件实现细节请参见前文 TextInput 结构体的实现部分。

on_mouse_down()on_mouse_up()on_mouse_move() 方法则用于处理鼠标事件,允许用户通过鼠标点击和拖动来选择文本。

TextInput 实现 Focusable trait 来支持焦点管理:

impl Focusable for TextInput {
    fn focus_handle(&self, _: &App) -> FocusHandle {
        self.focus_handle.clone()
    }
}

定义 TextExample 结构体作为主界面:

struct InputExample {
    text_input: Entity
<TextInput>,
    recent_keystrokes: Vec
<Keystroke>,
    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>) {
        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
<Self>) -> 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()
                    }
                )
            })
        )
    }
}

focus_handle() 方法用于返回组件的焦点句柄,以便 GPUI 能够正确地管理焦点状态。on_reset_click() 方法用于处理重置按钮的点击事件,清除最近的按键记录并重置文本输入状态。用 on_mouse_up() 方法将这个事件处理器绑定到重置按钮上。

最后,在 main() 函数中创建并运行应用程序:

fn main() {
    Application::new().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.activate(true);
            })
            .unwrap();
        cx.on_action(|_: &Quit, cx| cx.quit());
        cx.bind_keys([KeyBinding::new("cmd-q", Quit, None)]);
    });
}

我们发现,这个新建方式和之前的示例有些不同。首先,我们在 main() 函数中创建了一个新的应用程序实例,并在其中打开了一个窗口。窗口的内容由一个闭包定义,在这个闭包中我们创建了 TextInputInputExample 实体,并将它们绑定到窗口上。我们还绑定了一系列键盘事件,以便在用户按下特定键时触发相应的事件处理器。例如,当用户按下 Backspace 键时,会触发 Backspace 事件,从而调用 TextInput 中定义的 backspace() 方法来处理删除操作。这些事件绑定在 bind_keys() 方法中进行设置,确保了用户的输入能够正确地被捕获和处理。此外,我们还设置了一个观察者来监听键盘布局的变化,以便在用户切换键盘布局时能够及时更新界面。这个功能在 on_keyboard_layout_change() 方法中实现,当键盘布局发生变化时,我们调用 window.update() 来通知界面重新渲染,从而确保显示的键盘布局信息是最新的。

在这些之后还有一个 window.update() 调用,用于在窗口创建后立即将焦点设置到文本输入组件上,并激活应用程序,使其准备好接受用户输入。

通过调试发现,其实窗口创建发生于 new() 之后,而窗口内容的创建发生于 run() 之后。这也是为什么我们需要在 run() 中进行窗口内容的定义和事件绑定的原因。

未完待续


Ciallo~(∠・ω< )⌒★