#ui #renderer #interface #renderer-agnostic #agnostic #gamedev

raui-derive

渲染器无关用户界面宏

60 个版本 (32 个重大更改)

0.63.0 2024 年 1 月 26 日
0.61.2 2024 年 1 月 17 日
0.57.0 2023 年 12 月 31 日
0.55.6 2023 年 11 月 24 日
0.38.2 2021 年 7 月 18 日

#19 in #agnostic

Download history 27/week @ 2024-04-07 36/week @ 2024-04-14 47/week @ 2024-04-21 32/week @ 2024-04-28 30/week @ 2024-05-05 37/week @ 2024-05-12 59/week @ 2024-05-19 63/week @ 2024-05-26 68/week @ 2024-06-02 38/week @ 2024-06-09 38/week @ 2024-06-16 30/week @ 2024-06-23 8/week @ 2024-06-30 107/week @ 2024-07-07 54/week @ 2024-07-14 26/week @ 2024-07-21

每月下载量 196
26 个crate中使用(通过 raui-core

MIT/Apache

28KB
165

RAUI Crates.ioDocs.rs

关于

RAUI 是一个渲染器无关的 UI 系统,它深受 React 的声明式 UI 组合和 UE4 Slate 小部件组件系统的影响。

🗣 发音: RAUI 发音为 "ra"(埃及神)+ "oui"(法语中为 "yes") — 音频示例

RAUI 架构背后的主要思想是将 UI 视为另一种数据源,您将其转换为您的首选渲染引擎使用的目标可渲染数据格式。

架构

应用程序

Application 是用户关注的中心点。它执行整个 UI 处理逻辑。在那里,您应用要处理的小部件树,从主机应用程序向小部件发送消息,并接收从小部件发送到主机应用程序的信号。

// Coords mapping tell RAUI renderers how to convert coordinates
// between virtual-space and ui-space.
let mapping = CoordsMapping::new(Rect {
    left: 0.0,
    right: 1024.0,
    top: 0.0,
    bottom: 576.0,
});

// Application is UI host.
let mut application = Application::default();
// we use setup functions to register component and props mappings for serialization.
application.setup(setup);
// we can also register them at any time one by one.
application.register_component("app", FnWidget::pointer(app));

// Widget tree is simply a set of nested widget nodes.
let tree = make_widget!(app)
    .named_slot("title", make_widget!(title_bar).with_props("Hello".to_owned()))
    .named_slot("content", make_widget!(vertical_box)
        .listed_slot(make_widget!(text_button).key("hi").with_props("Say hi!".to_owned()))
        .listed_slot(make_widget!(text_button).key("exit").with_props("Exit!".to_owned()))
    );

// some dummy widget tree renderer.
// it reads widget unit tree and transforms it into target format.
let mut renderer = JsonRenderer::default();

// `apply()` sets new widget tree.
application.apply(tree);

// `render()` calls renderer to perform transformations on processed application widget tree.
// by default application won't process widget tree if nothing was changed.
// "change" is either any widget state change, or new message sent to any widget (messages
// can be sent from application host, for example a mouse click, or from another widget).
application.forced_process();
if let Ok(output) = application.render::<JsonRenderer, String, _>(&mapping, &mut renderer) {
    println!("* OUTPUT:\n{}", output);
}

小部件

小部件分为三个类别

  • WidgetNode - 用于源 UI 树(可以是组件、单元或无的变体)
let tree = make_widget!(app)
    .named_slot("title", make_widget!(title_bar).with_props("Hello".to_owned()))
    .named_slot("content", make_widget!(vertical_box)
        .listed_slot(make_widget!(text_button).key("hi").with_props("Say hi!".to_owned()))
        .listed_slot(make_widget!(text_button).key("exit").with_props("Exit!".to_owned()))
    );
  • WidgetComponent - 您可以将其视为虚拟 DOM 节点,它们存储
    • 指向 组件函数 的指针(该函数处理其数据)
    • 唯一的 (它是小部件 ID 的一部分,并将用于告诉系统是否应将其 状态 带到下一次处理运行)
    • boxed 可复制的 属性 数据
    • 列出的槽(简单地说:小部件子项)
    • 命名的槽(类似于列出的槽:小部件子项,但这些子项被分配了名称,因此您可以通过名称而不是通过索引来访问它们)
  • WidgetUnit - 渲染器使用的基本元素,将其转换为特定渲染引擎的目标可渲染数据格式。
    # use raui::prelude::*;
    TextBoxNode {
        text: "Hello World".to_owned(),
        ..Default::default()
    };
    

组件函数

组件函数是静态函数,将输入数据(属性、状态或两者皆非)转换为输出小部件树(通常用于将其他组件树简单封装在一个组件中,其中某个最简单的组件返回最终的 WidgetUnit)。它们共同作为转换链工作 - 根组件使用其自己的属性或状态中的数据将其属性应用到子组件中。

#[derive(PropsData, Debug, Default, Copy, Clone, Serialize, Deserialize)]
struct AppProps {
    #[serde(default)]
    pub index: usize,
}
fn app(context: WidgetContext) -> WidgetNode {
    let WidgetContext {
        props, named_slots, ..
    } = context;
    // easy way to get widgets from named slots.
    unpack_named_slots!(named_slots => { title, content });
    let index = props.read::<AppProps>().map(|p| p.index).unwrap_or(0);

    // we always return new widgets tree.
    make_widget!(vertical_box)
        .key(index)
        .listed_slot(title)
        .listed_slot(content)
        .into()
}

状态

这可能会引发一个疑问:"如果我只使用函数而不使用对象来描述如何可视化UI,我如何在每次渲染运行之间保持一些数据?" 。为了这个目的,你使用 状态。状态是存储在每次处理调用之间,只要给定的部件是活动状态(这意味着:只要部件ID在两次处理调用之间保持相同,为了确保你的部件保持相同,你使用键 - 如果未分配键,系统将为你的部件生成一个键,但这将使其可能在任何时间死亡,例如,如果在你共同的父组件中部件子项的数量发生变化,你的部件将在未分配键的情况下更改其ID)。一些额外的说明:当你使用 属性 向下传递信息和使用 状态 在处理调用之间存储小部件数据时,你可以通过消息和信号与其他小部件和宿主应用程序进行通信!此外,你可以使用钩子来监听小部件的生命周期并在那里执行操作。值得注意的是,状态使用 属性 来存储其数据,因此你可以通过这种方式附加多个钩子,每个钩子使用不同的数据类型作为小部件状态,这为结合在相同小部件上操作的不同钩子提供了非常丰富的可能性。

#[derive(PropsData, Debug, Default, Copy, Clone, Serialize, Deserialize)]
struct ButtonState {
    #[serde(default)]
    pub pressed: bool,
}

钩子

钩子用于将常用的小部件逻辑放入可以链式调用的小部件和另一个钩子中的单独函数(你可以使用它构建可重用的依赖链逻辑)。通常,它用于监听生命周期事件,如挂载、更改和卸载,此外,你可以链式调用钩子,以便按照它们在组件和另一个钩子中的链式调用顺序顺序处理。

#[derive(MessageData, Debug, Copy, Clone, PartialEq, Eq)]
enum ButtonAction {
    Pressed,
    Released,
}

fn use_empty(context: &mut WidgetContext) {
    context.life_cycle.mount(|_| {
        println!("* EMPTY MOUNTED");
    });

    context.life_cycle.change(|_| {
        println!("* EMPTY CHANGED");
    });

    context.life_cycle.unmount(|_| {
        println!("* EMPTY UNMOUNTED");
    });
}

// you use life cycle hooks for storing closures that will be called when widget will be
// mounted/changed/unmounted. they exists for you to be able to reuse some common logic across
// multiple components. each closure provides arguments such as:
// - widget id
// - widget state
// - message sender (this one is used to message other widgets you know about)
// - signal sender (this one is used to message application host)
// although this hook uses only life cycle, you can make different hooks that use many
// arguments, even use context you got from the component!
#[pre_hooks(use_empty)]
fn use_button(context: &mut WidgetContext) {
    context.life_cycle.mount(|context| {
        println!("* BUTTON MOUNTED: {}", context.id.key());
        let _ = context.state.write(ButtonState { pressed: false });
    });

    context.life_cycle.change(|context| {
        println!("* BUTTON CHANGED: {}", context.id.key());
        for msg in context.messenger.messages {
            if let Some(msg) = msg.as_any().downcast_ref::<ButtonAction>() {
                let pressed = match msg {
                    ButtonAction::Pressed => true,
                    ButtonAction::Released => false,
                };
                println!("* BUTTON ACTION: {:?}", msg);
                let _ = context.state.write(ButtonState { pressed });
                let _ = context.signals.write(*msg);
            }
        }
    });

    context.life_cycle.unmount(|context| {
        println!("* BUTTON UNMOUNTED: {}", context.id.key());
    });
}

#[pre_hooks(use_button)]
fn button(mut context: WidgetContext) -> WidgetNode {
    let WidgetContext { key, props, .. } = context;
    println!("* PROCESS BUTTON: {}", key);

    make_widget!(text_box).key(key).merge_props(props.clone()).into()
}

幕后发生的事情

  • 应用程序在节点上调用 button
    • button 调用 use_button 钩子
      • use_button 调用 use_empty 钩子
    • use_button 逻辑执行
  • button 逻辑执行

布局

RAUI公开了 Application::layout() API,允许使用虚拟到实坐标映射和自定义布局引擎来执行小部件树定位数据,该数据随后由自定义UI渲染器用于指定给定小部件应放置的框。每次执行布局都会在应用程序中存储布局数据,你可以在任何时间访问这些数据。有一个 DefaultLayoutEngine 可以以通用的方式执行此操作。如果你发现其管道中的某些部分的工作方式与你的预期不同,请随时创建你自己的自定义布局引擎!

let mut application = Application::default();
let mut layout_engine = DefaultLayoutEngine;
application.apply(tree);
application.forced_process();
println!(
    "* TREE INSPECTION:\n{:#?}",
    application.rendered_tree().inspect()
);
if application.layout(&mapping, &mut layout_engine).is_ok() {
    println!("* LAYOUT:\n{:#?}", application.layout_data());
}

交互性

RAUI 允许您通过交互引擎简化并自动化与 UI 的交互 - 这只是一个实现 perform_interactions 方法的结构体,该方法与应用程序相关联,您在该方法中应该发送与用户输入相关的消息到小部件。存在一个 DefaultInteractionsEngine,它涵盖了小部件导航、按钮和输入字段 - 来自鼠标(或任何单指针)、键盘和游戏手柄等输入设备的操作。当涉及到 UI 导航时,您可以向默认交互引擎发送原始的 NavSignal 消息,尽管您可以选择/取消选择小部件,但您还有典型的导航动作:上、下、左、右、上一标签/屏幕、下一标签/屏幕,还可以聚焦文本输入并将文本输入更改发送到聚焦的输入小部件。RAUI 提供的所有交互式小部件组件都在它们的钩子中处理所有 NavSignal 动作,因此所有用户需要做的就是激活它们的导航功能(使用 NavItemActive 单位属性)。想要仅使用默认交互引擎的 RAUI 集成应使用它们中组合的此结构体,并使用有关输入更改的信息调用其 interact 方法。RAUI App crate(AppInteractionsEngine 结构体)中有此功能的示例。

注意:交互引擎应使用布局来处理指针事件,因此在进行交互之前请确保重新构建布局!

let mut application = Application::default();
// default interactions engine covers typical pointer + keyboard + gamepad navigation/interactions.
let mut interactions = DefaultInteractionsEngine::default();
// we interact with UI by sending interaction messages to the engine.
interactions.interact(Interaction::PointerMove(Vec2 { x: 200.0, y: 100.0 }));
interactions.interact(Interaction::PointerDown(
    PointerButton::Trigger,
    Vec2 { x: 200.0, y: 100.0 },
));
// navigation/interactions works only if we have navigable items (such as `button`) registered
// in some navigable container (usually containers with `nav_` prefix).
let tree = make_widget!(nav_content_box)
    .key("app")
    .listed_slot(make_widget!(button)
        .key("button")
        .with_props(NavItemActive)
        .named_slot("content", make_widget!(image_box).key("icon"))
    );
application.apply(tree);
application.process();
let mapping = CoordsMapping::new(Rect {
    left: 0.0,
    right: 1024.0,
    top: 0.0,
    bottom: 576.0,
});
application
    .layout(&mapping, &mut DefaultLayoutEngine)
    .unwrap();
// Since interactions engines require constructed layout to process interactions we have to
// process interactions after we layout the UI.
application.interact(&mut interactions).unwrap();

媒体

  • RAUI + Spitfire In-Game RAUI 与自定义材料主题结合使用的 In-Game 集成示例,使用 Spitfire 作为渲染器。

    RAUI + Spitfire In-Game

  • RAUI Todo App 具有暗色主题材料组件库的 TODO 应用示例。

    RAUI Todo App

贡献

任何提高 RAUI 工具集质量的贡献都备受赞赏。

  • 如果您有功能请求,请创建一个 Issue 贴文,并解释该功能的目的是什么以及为什么需要它以及其优缺点。
  • 如果您想要创建 PR,请从 next 分支创建功能分支,以便在获得批准后可以简单地使用 GitHub 合并按钮进行合并。
  • 所有更改都存入 next 分支,新版本由其提交生成,master 被认为是稳定/发布分支。
  • 更改应通过测试,您可以使用:cargo test --all --features all.
  • 此说明文件是从 lib.rs 文档生成的,可以使用 cargo readme 重新生成。

里程碑

RAUI 仍处于早期开发阶段,因此请为这些变化做好准备,直到 v1.0

  • 将 RAUI 集成到一个公共开源 Rust 游戏中。
  • 编写文档。
  • 编写关于如何正确使用 RAUI 以及如何使 UI 效率化的 MD 书籍。
  • 实现 VDOM 差分算法以优化树重建。
  • 找到一种解决方案(或将其作为功能)将特性对象数据转换为强类型数据,用于属性和状态。

目前已完成的事项

  • 添加布局支持。
  • 添加交互支持(用户输入)。
  • 为GGEZ游戏框架创建渲染器。
  • 创建基本用户组件。
  • 创建基本的Hello World示例应用程序。
  • 将共享属性从属性中分离出来(不要合并它们,将共享属性放在上下文中)。
  • 创建TODO应用程序作为示例。
  • 创建游戏内应用程序作为示例。
  • 为Oxygengine游戏引擎创建渲染器。
  • 添加复杂的导航系统。
  • 创建滚动框小部件。
  • 添加“即时模式UI”构建器,以提供基于宏的声明性模式UI构建的替代方案(无开销,相当于默认使用的声明性宏,即时模式和声明性模式的小部件可以无障碍地互相通信)。
  • 添加数据绑定属性类型,以便轻松从应用程序外部修改数据。
  • 创建将生成顶点 + 索引 + 批处理缓冲区的细分渲染器,以便用于网格渲染器。
  • widget_component!widget_hook!宏规则从pre_hookspost_hooks函数属性中移动。
  • 添加PropsDataMessageData过程宏,以逐步替代调用implement_props_data!implement_message_data!宏的需求。
  • 添加对门户的支持——一种将子树“传送”到另一个树节点的简单方法(对模态框和拖放很有用)。
  • 添加对View-Model的支持,以便在宿主应用程序和UI之间共享数据。

依赖关系

~1.5MB
~36K SLoC