省流: Rust GUI 架构的圣杯还未被发现。
原文:https://monadical.com/posts/shades-of-rust-gui-library-list.html#The-current-toolkit
译者:GPT-3.5
Rust 中的 GUI 发展速度前所未有地快,Rust GUI 领域的三个月就相当于凡人世界的三年。为了衡量一下,Web 的 GUI 发展得如此之快,以至于它甚至领先于浏览器。这可能有很多原因,其中包括 Rust 的跨平台性质以及 WebAssembly 支持,它提供了更容易的软件分发方式。
在本文中,我将回顾 Rust 中当前的 GUI 工具包,并分享一些构建 WebAssembly bundle 的技巧。由于 Rust GUI 领域的快速发展,下面我所概述的许多工具可能会在不久的将来过时。因此,为了避免您在阅读本文期间就过时了,让我们先深入了解到目前为止我所发现的最新的 Rust GUI 架构。
工具包#
在介绍工具包之前,请注意我在此处多次提到了 Elm Architecture 和 Immediate Mode,尽管它们的定义超出了本篇文章的范围。但是,如果您有 Redux 的经验,可以将 Elm Architecture 大致看作类似于 Redux,而 Immediate Mode 则比 Elm Architecture 更加命令式。
值得一提的是,现在许多库都开始使用 “信号” 响应式语义,您可以直接修改状态变量,这将自动反映在 UI 上,就像我们在 Angular 1 时代一样。
现在来看看工具包。
Dioxus#
Dioxus 具有类似于 React 的接口架构,但是它是基于 Rust 的。对于桌面应用,它使用 Tauri,在 Web 上有重点,具有实验性的 Terminal 支持,并具有用于生成服务器端标记的 SSR 功能。Dioxus 还使用类似于 React 的内部可变性来处理数据,例如使用 use_state。由于它通过类似于 useContext 的 API 进行全局状态管理,因此可以通过它集成潜在的类 Redux 工具(“redux/recoil/mobx on top of context”)。下面是使用 Dioxus 构建的示例小部件:
// main.rs
fn main() {
// launch the web app
dioxus_web::launch(App);
}
fn echo_to_angle(s: &str) -> u32 {
let mut angle = 0;
for c in s.chars() {
angle += c as u32 % 360;
}
angle
}
fn App(cx: Scope) -> Element {
let echo = use_state(&cx, || "Echo".to_string());
let angle = use_state(&cx, || echo_to_angle(&echo.get()));
cx.spawn({
let mut angle = angle.to_owned();
async move {
TimeoutFuture::new(50).await;
angle.with_mut(|a| {
*a += 1;
*a = *a % 360;
});
}
});
cx.render(rsx! {
div {
div {
"Angle: {angle}"
}
div {
transform: "rotateX({angle}deg) rotateY({angle}deg)",
width: "100px",
height: "100px",
transform_style: "preserve-3d",
position: "relative",
div {
position: "absolute",
width: "100%",
height: "100%",
background: "red",
transform: "translateZ(50px)",
}
// more cube sides CSS definitions omitted
div {
position: "absolute",
width: "100%",
height: "100%",
background: "orange",
transform: "rotateY(90deg) translateZ(50px)",
}
}
}
})
}
Tauri#
Tauri 是一个用 Rust 编写的框架,用于创建桌面应用程序(移动平台即将推出),类似于 Electron。Tauri 为应用程序提供了自己的窗口界面,并通过自定义的基于 Promise 的协议让您运行与 Rust 通信的 TS 代码。Tauri 的目的不是为 Web 构建,尽管它利用 Web 技术用于桌面应用。您可以使用许多前端库,如 React(在其初始化脚本中提供),或者只是纯净的。
<!-- index.html -->
<div class="row">
<div>
<input id="greet-input" placeholder="Enter a name..."/>
<button id="greet-button" type="button">Greet</button>
</div>
</div>
// main.ts
import { invoke } from "@tauri-apps/api/tauri";
let greetInputEl: HTMLInputElement | null;
let greetMsgEl: HTMLElement | null;
async function greet() {
if (greetMsgEl && greetInputEl) {
// Learn more about Tauri commands at https://tauri.app/v1/guides/features/command
greetMsgEl.textContent = await invoke("greet", {
name: greetInputEl.value,
});
}
}
window.addEventListener("DOMContentLoaded", () => {
greetInputEl = document.querySelector("#greet-input");
greetMsgEl = document.querySelector("#greet-msg");
document
.querySelector("#greet-button")
?.addEventListener("click", () => greet());
});
//main.rs
#[tauri::command]
fn greet(name: &str) -> String {
format!("Hello, {}! You've been greeted from Rust!", name)
}
fn main() {
tauri::Builder::default()
.invoke_handler(tauri::generate_handler![greet])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
Xilem#
Xilem 是一种实验性但非常有前途的数据优先的 UI 架构,与 Rust 的语言架构非常契合,是 Druid 的继任者。库的作者 Raph Levien 表示,“Xilem 旨在成为 Rust 的首选 UI 库,从 SwiftUI 中吸取了很多灵感,并且严重关注性能。” 请查看他关于Rust UI 架构的完整文章。在我看来,这是目前为止关于这个主题的最好的文章。
基于这篇文章,自然的决定是选择中心化状态,就像 Elm 架构一样。该库使用自己的渲染器,并即将开始大量利用 GPU 功能以实现更快的渲染。该项目目前处于非常早期的实验阶段。它目前针对的是 WebGPU,这在浏览器中运行起来还存在问题,但它有很大的潜力。
Iced#
Iced 是一款 “跨平台 GUI 库,专注于简单性和类型安全性”。它使用 Elm 架构和响应式编程模型。默认情况下,Web 构建使用 DOM 进行渲染,但 Iced 也可以与其他后端一起使用,例如 wgpu 或 glow。目前,该库的 Web 部分似乎有些被忽略了(https://github.com/iced-rs/iced_web),而且已经有一段时间没有更新了。但希望他们能够加速更新。在这里,他们构建的贝塞尔工具使用了 Canvas:
// main.rs
//! This example showcases an interactive `Canvas` for drawing Bézier curves.
use iced::widget::{button, column, text};
use iced::{Alignment, Element, Length, Sandbox, Settings};
pub fn main() -> iced::Result {
Example::run(Settings {
antialiasing: true,
..Settings::default()
})
}
#[derive(Default)]
struct Example {
bezier: bezier::State,
curves: Vec<bezier::Curve>,
}
#[derive(Debug, Clone, Copy)]
enum Message {
AddCurve(bezier::Curve),
Clear,
}
impl Sandbox for Example {
type Message = Message;
fn new() -> Self {
Example::default()
}
fn title(&self) -> String {
String::from("Bezier tool - Iced")
}
fn update(&mut self, message: Message) {
match message {
Message::AddCurve(curve) => {
self.curves.push(curve);
self.bezier.request_redraw();
}
Message::Clear => {
self.bezier = bezier::State::default();
self.curves.clear();
}
}
}
fn view(&self) -> Element<Message> {
column![
text("Bezier tool example").width(Length::Shrink).size(50),
self.bezier.view(&self.curves).map(Message::AddCurve),
button("Clear").padding(8).on_press(Message::Clear),
]
.padding(20)
.spacing(20)
.align_items(Alignment::Center)
.into()
}
}
mod bezier {
use iced::mouse;
use iced::widget::canvas::event::{self, Event};
use iced::widget::canvas::{
self, Canvas, Cursor, Frame, Geometry, Path, Stroke,
};
use iced::{Element, Length, Point, Rectangle, Theme};
#[derive(Default)]
pub struct State {
cache: canvas::Cache,
}
impl State {
pub fn view<'a>(&'a self, curves: &'a [Curve]) -> Element<'a, Curve> {
Canvas::new(Bezier {
state: self,
curves,
})
.width(Length::Fill)
.height(Length::Fill)
.into()
}
pub fn request_redraw(&mut self) {
self.cache.clear()
}
}
struct Bezier<'a> {
state: &'a State,
curves: &'a [Curve],
}
impl<'a> canvas::Program<Curve> for Bezier<'a> {
type State = Option<Pending>;
fn update(
&self,
state: &mut Self::State,
event: Event,
bounds: Rectangle,
cursor: Cursor,
) -> (event::Status, Option<Curve>) {
let cursor_position =
if let Some(position) = cursor.position_in(&bounds) {
position
} else {
return (event::Status::Ignored, None);
};
match event {
Event::Mouse(mouse_event) => {
let message = match mouse_event {
mouse::Event::ButtonPressed(mouse::Button::Left) => {
match *state {
None => {
*state = Some(Pending::One {
from: cursor_position,
});
None
}
Some(Pending::One { from }) => {
*state = Some(Pending::Two {
from,
to: cursor_position,
});
None
}
Some(Pending::Two { from, to }) => {
*state = None;
Some(Curve {
from,
to,
control: cursor_position,
})
}
}
}
_ => None,
};
(event::Status::Captured, message)
}
_ => (event::Status::Ignored, None),
}
}
fn draw(
&self,
state: &Self::State,
_theme: &Theme,
bounds: Rectangle,
cursor: Cursor,
) -> Vec<Geometry> {
let content =
self.state.cache.draw(bounds.size(), |frame: &mut Frame| {
Curve::draw_all(self.curves, frame);
frame.stroke(
&Path::rectangle(Point::ORIGIN, frame.size()),
Stroke::default().with_width(2.0),
);
});
if let Some(pending) = state {
let pending_curve = pending.draw(bounds, cursor);
vec![content, pending_curve]
} else {
vec![content]
}
}
fn mouse_interaction(
&self,
_state: &Self::State,
bounds: Rectangle,
cursor: Cursor,
) -> mouse::Interaction {
if cursor.is_over(&bounds) {
mouse::Interaction::Crosshair
} else {
mouse::Interaction::default()
}
}
}
#[derive(Debug, Clone, Copy)]
pub struct Curve {
from: Point,
to: Point,
control: Point,
}
impl Curve {
fn draw_all(curves: &[Curve], frame: &mut Frame) {
let curves = Path::new(|p| {
for curve in curves {
p.move_to(curve.from);
p.quadratic_curve_to(curve.control, curve.to);
}
});
frame.stroke(&curves, Stroke::default().with_width(2.0));
}
}
#[derive(Debug, Clone, Copy)]
enum Pending {
One { from: Point },
Two { from: Point, to: Point },
}
impl Pending {
fn draw(&self, bounds: Rectangle, cursor: Cursor) -> Geometry {
let mut frame = Frame::new(bounds.size());
if let Some(cursor_position) = cursor.position_in(&bounds) {
match *self {
Pending::One { from } => {
let line = Path::line(from, cursor_position);
frame.stroke(&line, Stroke::default().with_width(2.0));
}
Pending::Two { from, to } => {
let curve = Curve {
from,
to,
control: cursor_position,
};
Curve::draw_all(&[curve], &mut frame);
}
};
}
frame.into_geometry()
}
}
}
Egui#
Egui 是 "最易于使用的 GUI 库"。它使用即时模式 GUI 架构https://en.wikipedia.org/wiki/Immediate_mode_GUI。其目标之一是具备可移植性和易用性。本地外观是特意不作为目标的。例如,它适用于需要快速 GUI 插件库的简单 GUI、游戏或其他应用程序。您可以查看它们的 WebAssembly 示例构建:
// app.rs
// main.rs / lib.rs boilerplate omitted
/// We derive Deserialize/Serialize so we can persist app state on shutdown.
#[derive(serde::Deserialize, serde::Serialize)]
#[serde(default)] // if we add new fields, give them default values when deserializing old state
pub struct TemplateApp {
// Example stuff:
label: String,
// this how you opt-out of serialization of a member
#[serde(skip)]
value: f32,
}
impl Default for TemplateApp {
fn default() -> Self {
Self {
// Example stuff:
label: "Hello World!".to_owned(),
value: 2.7,
}
}
}
impl TemplateApp {
/// Called once before the first frame.
pub fn new(cc: &eframe::CreationContext<'_>) -> Self {
// This is also where you can customize the look and feel of egui using
// `cc.egui_ctx.set_visuals` and `cc.egui_ctx.set_fonts`.
// Load previous app state (if any).
// Note that you must enable the `persistence` feature for this to work.
if let Some(storage) = cc.storage {
return eframe::get_value(storage, eframe::APP_KEY).unwrap_or_default();
}
Default::default()
}
}
impl eframe::App for TemplateApp {
/// Called by the frame work to save state before shutdown.
fn save(&mut self, storage: &mut dyn eframe::Storage) {
eframe::set_value(storage, eframe::APP_KEY, self);
}
/// Called each time the UI needs repainting, which may be many times per second.
/// Put your widgets into a `SidePanel`, `TopPanel`, `CentralPanel`, `Window` or `Area`.
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
let Self { label, value } = self;
// Examples of how to create different panels and windows.
// Pick whichever suits you.
// Tip: a good default choice is to just keep the `CentralPanel`.
// For inspiration and more examples, go to https://emilk.github.io/egui
#[cfg(not(target_arch = "wasm32"))] // no File->Quit on web pages!
egui::TopBottomPanel::top("top_panel").show(ctx, |ui| {
// The top panel is often a good place for a menu bar:
egui::menu::bar(ui, |ui| {
ui.menu_button("File", |ui| {
if ui.button("Quit").clicked() {
_frame.close();
}
});
});
});
egui::SidePanel::left("side_panel").show(ctx, |ui| {
ui.heading("Side Panel");
ui.horizontal(|ui| {
ui.label("Write something: ");
ui.text_edit_singleline(label);
});
ui.add(egui::Slider::new(value, 0.0..=10.0).text("value"));
if ui.button("Increment").clicked() {
*value += 1.0;
}
ui.with_layout(egui::Layout::bottom_up(egui::Align::LEFT), |ui| {
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 0.0;
ui.label("powered by ");
ui.hyperlink_to("egui", "https://github.com/emilk/egui");
ui.label(" and ");
ui.hyperlink_to(
"eframe",
"https://github.com/emilk/egui/tree/master/crates/eframe",
);
ui.label(".");
});
});
});
egui::CentralPanel::default().show(ctx, |ui| {
// The central panel the region left after adding TopPanel's and SidePanel's
ui.heading("Shades of Rust - egui");
ui.hyperlink("https://github.com/Firfi/shades-of-rust/tree/master/shades-egui");
egui::warn_if_debug_build(ui);
});
}
}
注意,官方这里有一个更大的 Demo。
Kas#
Kas 是一个 “高效的保留状态工具包”,它结合了几种 GUI 数据模型。例如,它处理消息时像 Elm 一样,但可以在组件中保留状态。它目前还不能在浏览器环境下工作,至少我没有能够快速构建示例。这是因为它比使用 WebGPU 的浏览器还要先进,而不是相反 。
Slint#
Slint 旨在用于本地应用程序而非 Web 环境。它仅可编译成 WebAssembly 用于演示目的。Slint 的目标是本地外观、嵌入式系统、微控制器和桌面应用程序。它使用自己的脚本语言描述 UI,提供组件状态和外观描述。Slint 允许一些脚本编程,尽管可以在 Rust 中运行最重的代码。总的来说,它是一种命令式的工具。
以下是他们教程的编译代码:
// main.rs
#[cfg_attr(target_arch = "wasm32",
wasm_bindgen::prelude::wasm_bindgen(start))]
pub fn main() {
use slint::Model;
let main_window = MainWindow::new();
// Fetch the tiles from the model
let mut tiles: Vec<TileData> = main_window.get_memory_tiles().iter().collect();
// Duplicate them to ensure that we have pairs
tiles.extend(tiles.clone());
// Randomly mix the tiles
use rand::seq::SliceRandom;
let mut rng = rand::thread_rng();
tiles.shuffle(&mut rng);
// Assign the shuffled Vec to the model property
let tiles_model = std::rc::Rc::new(slint::VecModel::from(tiles));
main_window.set_memory_tiles(tiles_model.clone().into());
let main_window_weak = main_window.as_weak();
main_window.on_check_if_pair_solved(move || {
let mut flipped_tiles =
tiles_model.iter().enumerate().filter(|(_, tile)| tile.image_visible && !tile.solved);
if let (Some((t1_idx, mut t1)), Some((t2_idx, mut t2))) =
(flipped_tiles.next(), flipped_tiles.next())
{
let is_pair_solved = t1 == t2;
if is_pair_solved {
t1.solved = true;
tiles_model.set_row_data(t1_idx, t1);
t2.solved = true;
tiles_model.set_row_data(t2_idx, t2);
} else {
let main_window = main_window_weak.unwrap();
main_window.set_disable_tiles(true);
let tiles_model = tiles_model.clone();
slint::Timer::single_shot(std::time::Duration::from_secs(1), move || {
main_window.set_disable_tiles(false);
t1.image_visible = false;
tiles_model.set_row_data(t1_idx, t1);
t2.image_visible = false;
tiles_model.set_row_data(t2_idx, t2);
});
}
}
});
main_window.run();
}
slint::slint! {
struct TileData := {
image: image,
image_visible: bool,
solved: bool,
}
MemoryTile := Rectangle {
callback clicked;
property <bool> open_curtain;
property <bool> solved;
property <image> icon;
height: 64px;
width: 64px;
background: solved ? #34CE57 : #3960D5;
animate background { duration: 800ms; }
Image {
source: icon;
width: parent.width;
height: parent.height;
}
// Left curtain
Rectangle {
background: #193076;
width: open_curtain ? 0px : (parent.width / 2);
height: parent.height;
animate width { duration: 250ms; easing: ease-in; }
}
// Right curtain
Rectangle {
background: #193076;
x: open_curtain ? parent.width : (parent.width / 2);
width: open_curtain ? 0px : (parent.width / 2);
height: parent.height;
animate width { duration: 250ms; easing: ease-in; }
animate x { duration: 250ms; easing: ease-in; }
}
TouchArea {
clicked => {
// Delegate to the user of this element
root.clicked();
}
}
}
MainWindow := Window {
width: 326px;
height: 326px;
callback check_if_pair_solved();
property <bool> disable_tiles;
property <[TileData]> memory_tiles: [
{ image: @image-url("icons/at.png") },
{ image: @image-url("icons/balance-scale.png") },
{ image: @image-url("icons/bicycle.png") },
{ image: @image-url("icons/bus.png") },
{ image: @image-url("icons/cloud.png") },
{ image: @image-url("icons/cogs.png") },
{ image: @image-url("icons/motorcycle.png") },
{ image: @image-url("icons/video.png") },
];
for tile[i] in memory_tiles : MemoryTile {
x: mod(i, 4) * 74px;
y: floor(i / 4) * 74px;
width: 64px;
height: 64px;
icon: tile.image;
open_curtain: tile.image_visible || tile.solved;
// propagate the solved status from the model to the tile
solved: tile.solved;
clicked => {
if (!root.disable_tiles) {
tile.image_visible = !tile.image_visible;
root.check_if_pair_solved();
}
}
}
}
}
Sycamore#
Sycamore 是一个 “用于创建 Rust 和 WebAssembly Web 应用程序的反应式库”。它具有很好的反应性能是其中的主要特点。它看起来像是 mobx,使用绑定 / 信号语义(与 Angular 或早期的 React 非常相似),提供了良好的开发人员体验:
fn App<G: Html>(cx: Scope) -> View<G> {
let a = create_signal(cx, 0_f64);
view! { cx,
p { "Input value: " (a.get()) }
input(type="number", bind:valueAsNumber=a)
}
}
上面的代码生成 DOM。
默认情况下,Sycamore 是为 Web 而设计的,但它可以轻松地嵌入到其他工具中,比如 Tauri,以创建桌面应用程序:https://github.com/JonasKruckenberg/tauri-sycamore-template。目前,Sycamore 模板还没有被包含在 Tauri 的 create-tauri-app 工具(https://github.com/tauri-apps/create-tauri-app)中,但 Yew 是。
// app.rs
#[derive(Serialize, Deserialize)]
struct GreetArgs<'a> {
name: &'a str,
}
#[component]
pub fn App<G: Html>(cx: Scope) -> View<G> {
let name = create_signal(cx, String::new());
let greet_msg = create_signal(cx, String::new());
let greet = move |_| {
spawn_local_scoped(cx, async move {
// Learn more about Tauri commands at https://tauri.app/v1/guides/features/command
let new_msg =
invoke("greet", to_value(&GreetArgs { name: &name.get() }).unwrap()).await;
log(&new_msg.as_string().unwrap());
greet_msg.set(new_msg.as_string().unwrap());
})
};
view! { cx,
main(class="container") {
div(class="row") {
input(id="greet-input",bind:value=name,placeholder="Enter a name...")
button(type="button",on:click=greet) {
"Greet"
}
}
p {
b {
(greet_msg.get())
}
}
}
}
}
// main.rs
mod app;
use app::App;
#[cfg(all(not(debug_assertions), not(feature = "ssg")))]
fn main() {
sycamore::hydrate(App);
}
#[cfg(all(debug_assertions, not(feature = "ssg")))]
fn main() {
sycamore::render(App);
}
#[cfg(feature = "ssg")]
fn main() {
let out_dir = std::env::args().nth(1).unwrap();
println!("out_dir {}", out_dir);
let template = std::fs::read_to_string(format!("{}/index.html", out_dir)).unwrap();
let html = sycamore::render_to_string(App);
let html = template.replace("<!--app-html-->\n", &html);
let path = format!("{}/index.html", out_dir);
println!("Writing html to file \"{}\"", path);
std::fs::write(path, html).unwrap();
}
Yew#
Yew 现在似乎相当受欢迎。它是专门为 Web 开发的 GUI 框架,具有一些 React 的特点,但也有一些不同之处。例如,组件类似于具有定义消息和对这些消息的反应的状态机。组件在自身中保持状态。可以使用 Yewdux 来实现类似于 Redux 的数据处理。除其他功能外,Yew 还具有水合和服务器端渲染。
// main.rs
use cell::Cellule;
use gloo::timers::callback::Interval;
use rand::Rng;
use yew::html::Scope;
use yew::{classes, html, Component, Context, Html};
mod cell;
pub enum Msg {
Random,
Start,
Step,
Reset,
Stop,
ToggleCellule(usize),
Tick,
}
pub struct App {
active: bool,
cellules: Vec<Cellule>,
cellules_width: usize,
cellules_height: usize,
_interval: Interval,
}
impl App {
pub fn random_mutate(&mut self) {
for cellule in self.cellules.iter_mut() {
if rand::thread_rng().gen() {
cellule.set_alive();
} else {
cellule.set_dead();
}
}
}
fn reset(&mut self) {
for cellule in self.cellules.iter_mut() {
cellule.set_dead();
}
}
fn step(&mut self) {
let mut to_dead = Vec::new();
let mut to_live = Vec::new();
for row in 0..self.cellules_height {
for col in 0..self.cellules_width {
let neighbors = self.neighbors(row as isize, col as isize);
let current_idx = self.row_col_as_idx(row as isize, col as isize);
if self.cellules[current_idx].is_alive() {
if Cellule::alone(&neighbors) || Cellule::overpopulated(&neighbors) {
to_dead.push(current_idx);
}
} else if Cellule::can_be_revived(&neighbors) {
to_live.push(current_idx);
}
}
}
to_dead
.iter()
.for_each(|idx| self.cellules[*idx].set_dead());
to_live
.iter()
.for_each(|idx| self.cellules[*idx].set_alive());
}
fn neighbors(&self, row: isize, col: isize) -> [Cellule; 8] {
[
self.cellules[self.row_col_as_idx(row + 1, col)],
self.cellules[self.row_col_as_idx(row + 1, col + 1)],
self.cellules[self.row_col_as_idx(row + 1, col - 1)],
self.cellules[self.row_col_as_idx(row - 1, col)],
self.cellules[self.row_col_as_idx(row - 1, col + 1)],
self.cellules[self.row_col_as_idx(row - 1, col - 1)],
self.cellules[self.row_col_as_idx(row, col - 1)],
self.cellules[self.row_col_as_idx(row, col + 1)],
]
}
fn row_col_as_idx(&self, row: isize, col: isize) -> usize {
let row = wrap(row, self.cellules_height as isize);
let col = wrap(col, self.cellules_width as isize);
row * self.cellules_width + col
}
fn view_cellule(&self, idx: usize, cellule: &Cellule, link: &Scope<Self>) -> Html {
let cellule_status = {
if cellule.is_alive() {
"cellule-live"
} else {
"cellule-dead"
}
};
html! {
<div key={idx} class={classes!("game-cellule", cellule_status)}
onclick={link.callback(move |_| Msg::ToggleCellule(idx))}>
</div>
}
}
}
impl Component for App {
type Message = Msg;
type Properties = ();
fn create(ctx: &Context<Self>) -> Self {
let callback = ctx.link().callback(|_| Msg::Tick);
let interval = Interval::new(200, move || callback.emit(()));
let (cellules_width, cellules_height) = (53, 40);
Self {
active: false,
cellules: vec![Cellule::new_dead(); cellules_width * cellules_height],
cellules_width,
cellules_height,
_interval: interval,
}
}
fn update(&mut self, _ctx: &Context<Self>, msg: Self::Message) -> bool {
match msg {
Msg::Random => {
self.random_mutate();
log::info!("Random");
true
}
Msg::Start => {
self.active = true;
log::info!("Start");
false
}
Msg::Step => {
self.step();
true
}
Msg::Reset => {
self.reset();
log::info!("Reset");
true
}
Msg::Stop => {
self.active = false;
log::info!("Stop");
false
}
Msg::ToggleCellule(idx) => {
let cellule = self.cellules.get_mut(idx).unwrap();
cellule.toggle();
true
}
Msg::Tick => {
if self.active {
self.step();
true
} else {
false
}
}
}
}
fn view(&self, ctx: &Context<Self>) -> Html {
let cell_rows =
self.cellules
.chunks(self.cellules_width)
.enumerate()
.map(|(y, cellules)| {
let idx_offset = y * self.cellules_width;
let cells = cellules
.iter()
.enumerate()
.map(|(x, cell)| self.view_cellule(idx_offset + x, cell, ctx.link()));
html! {
<div key={y} class="game-row">
{ for cells }
</div>
}
});
html! {
<div>
<section class="game-container">
<section class="game-area">
<div class="game-of-life">
{ for cell_rows }
</div>
<div class="game-buttons">
<button class="game-button" onclick={ctx.link().callback(|_| Msg::Random)}>{ "Random" }</button>
<button class="game-button" onclick={ctx.link().callback(|_| Msg::Step)}>{ "Step" }</button>
<button class="game-button" onclick={ctx.link().callback(|_| Msg::Start)}>{ "Start" }</button>
<button class="game-button" onclick={ctx.link().callback(|_| Msg::Stop)}>{ "Stop" }</button>
<button class="game-button" onclick={ctx.link().callback(|_| Msg::Reset)}>{ "Reset" }</button>
</div>
</section>
</section>
</div>
}
}
}
fn wrap(coord: isize, range: isize) -> usize {
let result = if coord < 0 {
coord + range
} else if coord >= range {
coord - range
} else {
coord
};
result as usize
}
fn main() {
wasm_logger::init(wasm_logger::Config::default());
log::trace!("Initializing yew...");
yew::Renderer::<App>::new().render();
}
Bracket#
Bracket 被重新命名为 rltk(Roguelike Toolkit)。Bracket 由控制台渲染库和几个编写 Roguelike 游戏的辅助工具组成,支持 Web 和桌面平台。请查看这个杀手级别的教程,你可以使用 Rust 编写Roguelike 游戏。
默认情况下,bracket-lib 在 OpenGL 模式下运行(如果检测到您正在编译 wasm32-unknown-unknown,则使用 WebGL)。它可以使用 wgpu 作为后端,在未来将非常方便。这是他们教程中最后一关的 Web 版本:
// main.rs
use bracket_lib::prelude::*;
struct State {}
impl GameState for State {
fn tick(&mut self, ctx: &mut BTerm) {
ctx.print(1, 1, "Hello Bracket World");
}
}
fn main() -> BError {
let context = BTermBuilder::simple80x50()
.with_title("Hello Minimal Bracket World")
.build()?;
let gs: State = State {};
main_loop(context, gs)
}
既然提到了 Bracket,我必须也提一下 Cursive,它是一个用于终端界面的控制台库,尽管目前似乎没有可编译的后端用于浏览器。
![[Pasted image 20230427141944.png]]
下面是一个在桌面上运行的例子:
// main.rs
use cursive::views::TextView;
fn main() {
let mut siv = cursive::default();
siv.add_global_callback('q', |s| s.quit());
siv.add_layer(TextView::new("Hello cursive! Press <q> to quit."));
siv.run();
}
Vizia#
Vizia 是一个声明式的反应式 GUI 框架。它可以在多个平台上使用(Windows、Linux、MacOS、Web),具有类似 Elm 的数据处理方式,并在 GPU 上呈现。
以下是其中一个示例:
// main.rs
use std::str::FromStr;
use vizia::fonts::icons_names::DOWN;
use vizia::prelude::*;
use chrono::{NaiveDate, ParseError};
const STYLE: &str = r#"
/*
* {
border-width: 1px;
border-color: red;
}
*/
textbox.invalid {
background-color: #AA0000;
}
"#;
#[derive(Clone)]
pub struct SimpleDate(NaiveDate);
impl Data for SimpleDate {
fn same(&self, other: &Self) -> bool {
self.0 == other.0
}
}
impl std::fmt::Display for SimpleDate {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0.format("%Y:%m:%d"))
}
}
impl FromStr for SimpleDate {
type Err = ParseError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
NaiveDate::parse_from_str(s, "%Y:%m:%d").map(|date| SimpleDate(date))
}
}
#[derive(Lens)]
pub struct AppData {
options: Vec<&'static str>,
choice: String,
start_date: SimpleDate,
end_date: SimpleDate,
}
pub enum AppEvent {
SetChoice(String),
SetStartDate(SimpleDate),
SetEndDate(SimpleDate),
}
impl Model for AppData {
fn event(&mut self, _: &mut EventContext, event: &mut Event) {
event.map(|app_event, _| match app_event {
AppEvent::SetChoice(choice) => {
self.choice = choice.clone();
}
AppEvent::SetStartDate(date) => {
self.start_date = date.clone();
}
AppEvent::SetEndDate(date) => {
self.end_date = date.clone();
}
});
}
}
impl AppData {
pub fn new() -> Self {
Self {
options: vec!["one-way flight", "return flight"],
choice: "one-way flight".to_string(),
start_date: SimpleDate(NaiveDate::from_ymd_opt(2022, 02, 12).unwrap()),
end_date: SimpleDate(NaiveDate::from_ymd_opt(2022, 02, 26).unwrap()),
}
}
}
fn main() {
Application::new(|cx| {
cx.add_theme(STYLE);
AppData::new().build(cx);
VStack::new(cx, |cx| {
Dropdown::new(
cx,
move |cx|
// A Label and an Icon
HStack::new(cx, move |cx|{
Label::new(cx, AppData::choice)
.width(Stretch(1.0))
.text_wrap(false);
Label::new(cx, DOWN).font("icons").left(Pixels(5.0)).right(Pixels(5.0));
}).width(Stretch(1.0)),
// List of options
move |cx| {
List::new(cx, AppData::options, |cx, _, item| {
Label::new(cx, item)
.width(Stretch(1.0))
.child_top(Stretch(1.0))
.child_bottom(Stretch(1.0))
.bind(AppData::choice, move |handle, choice| {
let selected = item.get(handle.cx) == choice.get(handle.cx);
handle.background_color(if selected {
Color::from("#f8ac14")
} else {
Color::white()
});
})
.on_press(move |cx| {
cx.emit(AppEvent::SetChoice(item.get(cx).to_string().to_owned()));
cx.emit(PopupEvent::Close);
});
});
},
)
.width(Pixels(150.0));
Textbox::new(cx, AppData::start_date)
.on_edit(|cx, text| {
if let Ok(val) = text.parse::<SimpleDate>() {
cx.emit(AppEvent::SetStartDate(val));
cx.toggle_class("invalid", false);
} else {
cx.toggle_class("invalid", true);
}
})
.width(Pixels(150.0));
Textbox::new(cx, AppData::end_date)
.on_edit(|cx, text| {
if let Ok(val) = text.parse::<SimpleDate>() {
cx.emit(AppEvent::SetEndDate(val));
cx.toggle_class("invalid", false);
} else {
cx.toggle_class("invalid", true);
}
})
.width(Pixels(150.0))
.disabled(AppData::choice.map(|choice| choice == "one-way flight"));
Button::new(cx, |_| {}, |cx| Label::new(cx, "Book").width(Stretch(1.0)))
.width(Pixels(150.0));
})
.row_between(Pixels(10.0))
.child_space(Stretch(1.0));
})
.title("Flight Booker")
.inner_size((250, 250))
.run();
}
Leptos#
Leptos 是一个等同于 React 的同构 Web 框架,但它使用 Rust 闭包进行更细粒度的反应式设计。整个渲染函数不会在每次信号更新时执行,而是被视为 “设置” 函数。此外,Leptos 没有虚拟 DOM。该框架还包含了一个在服务器和客户端上都可用的路由器。
以下是 Leptos 路由器示例:
// lib.rs
// main.rs boilerplate omitted
mod api;
use leptos::*;
use leptos_router::*;
use crate::api::{get_contact, get_contacts};
#[component]
pub fn RouterExample(cx: Scope) -> impl IntoView {
log::debug!("rendering <RouterExample/>");
view! { cx,
<Router>
<nav>
// ordinary <a> elements can be used for client-side navigation
// using <A> has two effects:
// 1) ensuring that relative routing works properly for nested routes
// 2) setting the `aria-current` attribute on the current link,
// for a11y and styling purposes
<A exact=true href="/">"Contacts"</A>
<A href="about">"About"</A>
<A href="settings">"Settings"</A>
<A href="redirect-home">"Redirect to Home"</A>
</nav>
<main>
<Routes>
<Route
path=""
view=move |cx| view! { cx, <ContactList/> }
>
<Route
path=":id"
view=move |cx| view! { cx, <Contact/> }
/>
<Route
path="/"
view=move |_| view! { cx, <p>"Select a contact."</p> }
/>
</Route>
<Route
path="about"
view=move |cx| view! { cx, <About/> }
/>
<Route
path="settings"
view=move |cx| view! { cx, <Settings/> }
/>
<Route
path="redirect-home"
view=move |cx| view! { cx, <Redirect path="/"/> }
/>
</Routes>
</main>
</Router>
}
}
#[component]
pub fn ContactList(cx: Scope) -> impl IntoView {
log::debug!("rendering <ContactList/>");
let location = use_location(cx);
let contacts = create_resource(cx, move || location.search.get(), get_contacts);
let contacts = move || {
contacts.read().map(|contacts| {
// this data doesn't change frequently so we can use .map().collect() instead of a keyed <For/>
contacts
.into_iter()
.map(|contact| {
view! { cx,
<li><A href=contact.id.to_string()><span>{&contact.first_name} " " {&contact.last_name}</span></A></li>
}
})
.collect::<Vec<_>>()
})
};
view! { cx,
<div class="contact-list">
<h1>"Contacts"</h1>
<Suspense fallback=move || view! { cx, <p>"Loading contacts..."</p> }>
{move || view! { cx, <ul>{contacts}</ul>}}
</Suspense>
<Outlet/>
</div>
}
}
#[derive(Params, PartialEq, Clone, Debug)]
pub struct ContactParams {
id: usize,
}
#[component]
pub fn Contact(cx: Scope) -> impl IntoView {
log::debug!("rendering <Contact/>");
let params = use_params::<ContactParams>(cx);
let contact = create_resource(
cx,
move || params().map(|params| params.id).ok(),
// any of the following would work (they're identical)
// move |id| async move { get_contact(id).await }
// move |id| get_contact(id),
// get_contact
get_contact,
);
let contact_display = move || match contact.read() {
// None => loading, but will be caught by Suspense fallback
// I'm only doing this explicitly for the example
None => None,
// Some(None) => has loaded and found no contact
Some(None) => Some(view! { cx, <p>"No contact with this ID was found."</p> }.into_any()),
// Some(Some) => has loaded and found a contact
Some(Some(contact)) => Some(
view! { cx,
<section class="card">
<h1>{contact.first_name} " " {contact.last_name}</h1>
<p>{contact.address_1}<br/>{contact.address_2}</p>
</section>
}
.into_any(),
),
};
view! { cx,
<div class="contact">
<Transition fallback=move || view! { cx, <p>"Loading..."</p> }>
{contact_display}
</Transition>
</div>
}
}
#[component]
pub fn About(cx: Scope) -> impl IntoView {
log::debug!("rendering <About/>");
// use_navigate allows you to navigate programmatically by calling a function
let navigate = use_navigate(cx);
view! { cx,
<>
// note: this is just an illustration of how to use `use_navigate`
// <button on:click> to navigate is an *anti-pattern*
// you should ordinarily use a link instead,
// both semantically and so your link will work before WASM loads
<button on:click=move |_| { _ = navigate("/", Default::default()); }>
"Home"
</button>
<h1>"About"</h1>
<p>"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum."</p>
</>
}
}
#[component]
pub fn Settings(cx: Scope) -> impl IntoView {
log::debug!("rendering <Settings/>");
view! { cx,
<>
<h1>"Settings"</h1>
<form>
<fieldset>
<legend>"Name"</legend>
<input type="text" name="first_name" placeholder="First"/>
<input type="text" name="last_name" placeholder="Last"/>
</fieldset>
<pre>"This page is just a placeholder."</pre>
</form>
</>
}
}
// api.rs
use futures::{
channel::oneshot::{self, Canceled},
Future,
};
use leptos::set_timeout;
use serde::{Deserialize, Serialize};
use std::time::Duration;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ContactSummary {
pub id: usize,
pub first_name: String,
pub last_name: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Contact {
pub id: usize,
pub first_name: String,
pub last_name: String,
pub address_1: String,
pub address_2: String,
pub city: String,
pub state: String,
pub zip: String,
pub email: String,
pub phone: String,
}
pub async fn get_contacts(_search: String) -> Vec<ContactSummary> {
// fake an API call with an artificial delay
_ = delay(Duration::from_millis(300)).await;
vec![
ContactSummary {
id: 0,
first_name: "Bill".into(),
last_name: "Smith".into(),
},
ContactSummary {
id: 1,
first_name: "Tim".into(),
last_name: "Jones".into(),
},
ContactSummary {
id: 2,
first_name: "Sally".into(),
last_name: "Stevens".into(),
},
]
}
pub async fn get_contact(id: Option<usize>) -> Option<Contact> {
// fake an API call with an artificial delay
_ = delay(Duration::from_millis(500)).await;
match id {
Some(0) => Some(Contact {
id: 0,
first_name: "Bill".into(),
last_name: "Smith".into(),
address_1: "12 Mulberry Lane".into(),
address_2: "".into(),
city: "Boston".into(),
state: "MA".into(),
zip: "02129".into(),
email: "[email protected]".into(),
phone: "617-121-1221".into(),
}),
Some(1) => Some(Contact {
id: 1,
first_name: "Tim".into(),
last_name: "Jones".into(),
address_1: "56 Main Street".into(),
address_2: "".into(),
city: "Chattanooga".into(),
state: "TN".into(),
zip: "13371".into(),
email: "[email protected]".into(),
phone: "232-123-1337".into(),
}),
Some(2) => Some(Contact {
id: 2,
first_name: "Sally".into(),
last_name: "Stevens".into(),
address_1: "404 E 123rd St".into(),
address_2: "Apt 7E".into(),
city: "New York".into(),
state: "NY".into(),
zip: "10082".into(),
email: "[email protected]".into(),
phone: "242-121-3789".into(),
}),
_ => None,
}
}
fn delay(duration: Duration) -> impl Future<Output = Result<(), Canceled>> {
let (tx, rx) = oneshot::channel();
set_timeout(
move || {
_ = tx.send(());
},
duration,
);
rx
}
Perseus#
Perseus 是一个旨在快速支持每种渲染策略的框架,同时提供出色的开发人员体验。它的范围是 Web,并且使用反应性语义来表示状态变量。它是基于 Sycamore 构建的,但在其之上增加了状态和模板概念。
示例:
// template/index.rs
use crate::global_state::AppStateRx;
use perseus::prelude::*;
use sycamore::prelude::*;
// Note that this template takes no state of its own in this example, but it
// certainly could
fn index_page<G: Html>(cx: Scope) -> View<G> {
// We access the global state through the render context, extracted from
// Sycamore's context system
let global_state = Reactor::<G>::from_cx(cx).get_global_state::<AppStateRx>(cx);
view! { cx,
// The user can change the global state through an input, and the changes they make will be reflected throughout the app
p { (global_state.test.get()) }
input(bind:value = global_state.test)
a(href = "about", id = "about-link") { "About" }
}
}
#[engine_only_fn]
fn head(cx: Scope) -> View<SsrNode> {
view! { cx,
title { "Index Page" }
}
}
pub fn get_template<G: Html>() -> Template<G> {
Template::build("index").view(index_page).head(head).build()
// main.rs
mod global_state;
mod templates;
use perseus::prelude::*;
#[perseus::main(perseus_axum::dflt_server)]
pub fn main<G: Html>() -> PerseusApp<G> {
PerseusApp::new()
.template(crate::templates::index::get_template())
.template(crate::templates::about::get_template())
.error_views(ErrorViews::unlocalized_development_default())
.global_state_creator(crate::global_state::get_global_state_creator())
}
Sauron#
这是关于构建 Web UI 的简单库。它使用类似 React 的 UI 声明和类似 ELM 的状态管理。
// main.rs
use sauron::{jss, prelude::*};
enum Msg {
Increment,
Decrement,
Reset,
}
struct App {
count: i32,
}
impl App {
fn new() -> Self {
App { count: 0 }
}
}
impl Application<Msg> for App {
fn view(&self) -> Node<Msg> {
node! {
<main>
<input type="button"
value="+"
on_click=|_| {
Msg::Increment
}
/>
<button class="count" on_click=|_|{Msg::Reset} >{text(self.count)}</button>
<input type="button"
value="-"
on_click=|_| {
Msg::Decrement
}
/>
</main>
}
}
fn update(&mut self, msg: Msg) -> Cmd<Self, Msg> {
match msg {
Msg::Increment => self.count += 1,
Msg::Decrement => self.count -= 1,
Msg::Reset => self.count = 0,
}
Cmd::none()
}
fn style(&self) -> String {
jss! {
"body":{
font_family: "verdana, arial, monospace",
},
"main":{
width:px(30),
height: px(100),
margin: "auto",
text_align: "center",
},
"input, .count":{
font_size: px(40),
padding: px(30),
margin: px(30),
}
}
}
}
#[wasm_bindgen(start)]
pub fn start() {
Program::mount_to_body(App::new());
}
MoonZoon#
一个前 Seed 维护者的全栈框架。从示例中可以看出,它的前端状态管理使用了 signal 语义。
在这个问题得到解决之前,我无法构建这个示例。
"counter" 示例代码如下:
// main.rs
use zoon::*;
#[static_ref]
fn counter() -> &'static Mutable<i32> {
Mutable::new(0)
}
fn increment() {
counter().update(|counter| counter + 1)
}
fn decrement() {
counter().update(|counter| counter - 1)
}
fn root() -> impl Element {
Column::new()
.item(Button::new().label("-").on_press(decrement))
.item(Text::with_signal(counter().signal()))
.item(Button::new().label("+").on_press(increment))
}
// ------ Alternative ------
fn _root() -> impl Element {
let (counter, counter_signal) = Mutable::new_and_signal(0);
let on_press = move |step: i32| *counter.lock_mut() += step;
Column::new()
.item(
Button::new()
.label("-")
.on_press(clone!((on_press) move || on_press(-1))),
)
.item_signal(counter_signal)
.item(Button::new().label("+").on_press(move || on_press(1)))
}
// ---------- // -----------
fn main() {
start_app("app", root);
}
Relm4#
Relm4 是一款受 Elm 启发、基于 gtk4-rs 的惯用 GUI 库。Relm4 是重新构建的 relm 的一个新版本,与 GTK4 和 libadwaita 兼容。尽管原始的 relm 仍在维护,但这两个库(relm 和 relm4)似乎由不同的人维护。
目前仅支持设备和桌面端。没有 web 可用,可能是因为使用了 gtk4 后端。尽管如此,Relm4 可以利用 CSS,例如在 https://github.com/Relm4/Relm4/blob/53b9a6bf6e514a5cce979c8307a4fefea422bdd7/examples/tracker.rs 中,全局 CSS 用于 UI 渲染。
以下是它们的一个示例在桌面端运行的屏幕截图:
// main.rs
use gtk::glib::clone;
use gtk::prelude::{BoxExt, ButtonExt, GtkWindowExt};
use relm4::{gtk, ComponentParts, ComponentSender, RelmApp, RelmWidgetExt, SimpleComponent};
struct App {
counter: u8,
}
#[derive(Debug)]
enum Msg {
Increment,
Decrement,
}
struct AppWidgets {
// window: gtk::Window,
// vbox: gtk::Box,
// inc_button: gtk::Button,
// dec_button: gtk::Button,
label: gtk::Label,
}
impl SimpleComponent for App {
type Init = u8;
type Input = Msg;
type Output = ();
type Widgets = AppWidgets;
type Root = gtk::Window;
fn init_root() -> Self::Root {
gtk::Window::builder()
.title("Simple app")
.default_width(300)
.default_height(100)
.build()
}
// Initialize the component.
fn init(
counter: Self::Init,
window: &Self::Root,
sender: ComponentSender<Self>,
) -> ComponentParts<Self> {
let model = App { counter };
let vbox = gtk::Box::builder()
.orientation(gtk::Orientation::Vertical)
.spacing(5)
.build();
let inc_button = gtk::Button::with_label("Increment");
let dec_button = gtk::Button::with_label("Decrement");
let label = gtk::Label::new(Some(&format!("Counter: {}", model.counter)));
label.set_margin_all(5);
window.set_child(Some(&vbox));
vbox.set_margin_all(5);
vbox.append(&inc_button);
vbox.append(&dec_button);
vbox.append(&label);
inc_button.connect_clicked(clone!(@strong sender => move |_| {
sender.input(Msg::Increment);
}));
dec_button.connect_clicked(clone!(@strong sender => move |_| {
sender.input(Msg::Decrement);
}));
let widgets = AppWidgets { label };
ComponentParts { model, widgets }
}
fn update(&mut self, msg: Self::Input, _sender: ComponentSender<Self>) {
match msg {
Msg::Increment => {
self.counter = self.counter.wrapping_add(1);
}
Msg::Decrement => {
self.counter = self.counter.wrapping_sub(1);
}
}
}
// Update the view to represent the updated model.
fn update_view(&self, widgets: &mut Self::Widgets, _sender: ComponentSender<Self>) {
widgets
.label
.set_label(&format!("Counter: {}", self.counter));
}
}
fn main() {
let app = RelmApp::new("relm4.example.simple_manual");
app.run::<App>(0);
}
Fltk-rs#
Fltk-rs 是https://www.fltk.org的绑定库。FLTK 目前不支持 Wasm 或 iOS。它对 Android 有实验性支持(因人而异),并专注于桌面应用程序。这是可以被应用程序或 GUI 框架用作图形基础的低级绑定之一。
下面的图片显示了他们的一个示例在桌面上运行的情况:
// main.rs
use fltk::{
app, button, draw, enums::*, frame::Frame, image::SvgImage, prelude::*, valuator::*,
widget::Widget, window::Window,
};
use std::ops::{Deref, DerefMut};
use std::{cell::RefCell, rc::Rc};
const POWER: &str = r#"<?xml version="1.0" encoding="iso-8859-1"?>
<!-- Generator: Adobe Illustrator 19.1.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 315.083 315.083" style="enable-background:new 0 0 315.083 315.083;" xml:space="preserve">
<g id="Layer_1">
<linearGradient id="SVGID_1_" gradientUnits="userSpaceOnUse" x1="157.5417" y1="4.5417" x2="157.5417" y2="310.5417">
<stop offset="0" style="stop-color:#939598"/>
<stop offset="0.25" style="stop-color:#414042"/>
<stop offset="0.5" style="stop-color:#252223"/>
<stop offset="1" style="stop-color:#000000"/>
</linearGradient>
<circle style="fill:url(#SVGID_1_);" cx="157.542" cy="157.542" r="153"/>
</g>
<g id="Layer_2">
<linearGradient id="SVGID_2_" gradientUnits="userSpaceOnUse" x1="157.5417" y1="292.5417" x2="157.5417" y2="22.5417">
<stop offset="0" style="stop-color:#58595B"/>
<stop offset="0.1" style="stop-color:#414042"/>
<stop offset="0.2" style="stop-color:#242122"/>
<stop offset="1" style="stop-color:#000000"/>
</linearGradient>
<circle style="fill:url(#SVGID_2_);stroke:#58595B;stroke-miterlimit:10;" cx="157.542" cy="157.542" r="135"/>
</g>
<g id="Layer_4">
<radialGradient id="SVGID_3_" cx="157.5417" cy="89.9217" r="62.2727" gradientUnits="userSpaceOnUse">
<stop offset="0" style="stop-color:#58595B"/>
<stop offset="0.5" style="stop-color:#414042"/>
<stop offset="1" style="stop-color:#231F20"/>
</radialGradient>
<radialGradient id="SVGID_4_" cx="157.5417" cy="89.9217" r="62.7723" gradientUnits="userSpaceOnUse">
<stop offset="0" style="stop-color:#FFFFFF"/>
<stop offset="0.6561" style="stop-color:#231F20"/>
<stop offset="1" style="stop-color:#000000"/>
</radialGradient>
<ellipse style="fill:url(#SVGID_3_);stroke:url(#SVGID_4_);stroke-miterlimit:10;" cx="157.542" cy="89.922" rx="59.833" ry="64.62"/>
</g>
<g id="Layer_6">
<path style="fill:none;stroke:red;stroke-width:10;stroke-linecap:round;stroke-miterlimit:10;" d="M119.358,119.358
c-9.772,9.772-15.816,23.272-15.816,38.184c0,14.912,6.044,28.412,15.816,38.184s23.272,15.816,38.184,15.816
c14.912,0,28.412-6.044,38.184-15.816s15.816-23.272,15.816-38.184c0-14.912-6.044-28.412-15.816-38.184"/>
<line style="fill:none;stroke:red;stroke-width:10;stroke-linecap:round;stroke-miterlimit:10;" x1="157.542" y1="154.542" x2="157.542" y2="100.542"/>
</g>
</svg>"#;
pub struct FlatButton {
wid: Widget,
}
impl FlatButton {
pub fn new(x: i32, y: i32, w: i32, h: i32, label: &str) -> FlatButton {
let mut x = FlatButton {
wid: Widget::new(x, y, w, h, None).with_label(label),
};
x.draw();
x.handle();
x
}
// Overrides the draw function
fn draw(&mut self) {
self.wid.draw(move |b| {
draw::draw_box(
FrameType::FlatBox,
b.x(),
b.y(),
b.width(),
b.height(),
Color::from_u32(0x304FFE),
);
draw::set_draw_color(Color::White);
draw::set_font(Font::Courier, 24);
draw::draw_text2(
&b.label(),
b.x(),
b.y(),
b.width(),
b.height(),
Align::Center,
);
});
}
// Overrides the handle function.
// Notice the do_callback which allows the set_callback method to work
fn handle(&mut self) {
let mut wid = self.wid.clone();
self.wid.handle(move |_, ev| match ev {
Event::Push => {
wid.do_callback();
true
}
_ => false,
});
}
}
impl Deref for FlatButton {
type Target = Widget;
fn deref(&self) -> &Self::Target {
&self.wid
}
}
impl DerefMut for FlatButton {
fn deref_mut(&mut self) -> &mut Self::Target {
&mut self.wid
}
}
pub struct PowerButton {
frm: Frame,
on: Rc<RefCell<bool>>,
}
impl PowerButton {
pub fn new(x: i32, y: i32, w: i32, h: i32) -> Self {
let mut frm = Frame::new(x, y, w, h, "");
frm.set_frame(FrameType::FlatBox);
frm.set_color(Color::Black);
let on = Rc::from(RefCell::from(false));
frm.draw({
// storing two almost identical images here, in a real application this could be optimized
let on = Rc::clone(&on);
let mut on_svg = SvgImage::from_data(&POWER.to_string().replace("red", "green")).unwrap();
on_svg.scale(frm.width(), frm.height(), true, true);
let mut off_svg = SvgImage::from_data(POWER).unwrap();
off_svg.scale(frm.width(), frm.height(), true, true);
move |f| {
if *on.borrow() {
on_svg.draw(f.x(), f.y(), f.width(), f.height());
} else {
off_svg.draw(f.x(), f.y(), f.width(), f.height());
};
}
});
frm.handle({
let on = on.clone();
move |f, ev| match ev {
Event::Push => {
let prev = *on.borrow();
*on.borrow_mut() = !prev;
f.do_callback();
f.redraw();
true
}
_ => false,
}
});
Self { frm, on }
}
pub fn is_on(&self) -> bool {
*self.on.borrow()
}
}
impl Deref for PowerButton {
type Target = Frame;
fn deref(&self) -> &Self::Target {
&self.frm
}
}
impl DerefMut for PowerButton {
fn deref_mut(&mut self) -> &mut Self::Target {
&mut self.frm
}
}
pub struct FancyHorSlider {
s: Slider,
}
impl FancyHorSlider {
pub fn new(x: i32, y: i32, width: i32, height: i32) -> Self {
let mut s = Slider::new(x, y, width, height, "");
s.set_type(SliderType::Horizontal);
s.set_frame(FrameType::RFlatBox);
s.set_color(Color::from_u32(0x868db1));
s.draw(|s| {
draw::set_draw_color(Color::Blue);
draw::draw_pie(
s.x() - 10 + (s.w() as f64 * s.value()) as i32,
s.y() - 10,
30,
30,
0.,
360.,
);
});
Self { s }
}
}
impl Deref for FancyHorSlider {
type Target = Slider;
fn deref(&self) -> &Self::Target {
&self.s
}
}
impl DerefMut for FancyHorSlider {
fn deref_mut(&mut self) -> &mut Self::Target {
&mut self.s
}
}
fn main() {
let app = app::App::default().with_scheme(app::Scheme::Gtk);
// app::set_visible_focus(false);
let mut wind = Window::default()
.with_size(800, 600)
.with_label("Custom Widgets");
let mut but = FlatButton::new(350, 350, 160, 80, "Increment");
let mut power = PowerButton::new(600, 100, 100, 100);
let mut dial = FillDial::new(100, 100, 200, 200, "0");
let mut frame = Frame::default()
.with_size(160, 80)
.with_label("0")
.above_of(&*but, 20);
let mut fancy_slider = FancyHorSlider::new(100, 550, 500, 10);
let mut toggle = button::ToggleButton::new(650, 400, 80, 35, "@+9circle")
.with_align(Align::Left | Align::Inside);
wind.end();
wind.show();
wind.set_color(Color::Black);
frame.set_label_size(32);
frame.set_label_color(Color::from_u32(0xFFC300));
dial.set_label_color(Color::White);
dial.set_label_font(Font::CourierBold);
dial.set_label_size(24);
dial.set_color(Color::from_u32(0x6D4C41));
dial.set_color(Color::White);
dial.set_selection_color(Color::Red);
toggle.set_frame(FrameType::RFlatBox);
toggle.set_label_color(Color::White);
toggle.set_selection_color(Color::from_u32(0x00008B));
toggle.set_color(Color::from_u32(0x585858));
toggle.clear_visible_focus();
toggle.set_callback(|t| {
if t.is_set() {
t.set_align(Align::Right | Align::Inside);
} else {
t.set_align(Align::Left | Align::Inside);
}
t.parent().unwrap().redraw();
});
dial.draw(|d| {
draw::set_draw_color(Color::Black);
draw::draw_pie(d.x() + 20, d.y() + 20, 160, 160, 0., 360.);
draw::draw_pie(d.x() - 5, d.y() - 5, 210, 210, -135., -45.);
});
dial.set_callback(|d| {
d.set_label(&format!("{}", (d.value() * 100.) as i32));
app::redraw();
});
but.set_callback(move |_| {
frame.set_label(&(frame.label().parse::<i32>().unwrap() + 1).to_string())
});
power.set_callback(move |_| {
println!("power button clicked");
});
fancy_slider.set_callback(|s| s.parent().unwrap().redraw());
app.run().unwrap();
}
在这里我应该提到 https://gtk-rs.org 因为它在提供开发基础设施方面与前面提到的几个库类似。此外,Relm4(请参见本文中的相关工具)是基于它构建的 GUI 库。
Makepad#
Makepad 是一个 UI 框架(支持本地和 Web),并带有自己的 “卫星” IDE:Makepad Studio。
根据框架描述,“使用 Makepad Framework 构建的应用程序可以在本地和 Web 上运行,完全在 GPU 上呈现,并支持一种称为 live design 的新功能。” https://github.com/makepad/makepad/blob/21032963ca1d89fc2449d578faf0a1f17c08ec8c/widgets/README.md.
Makepad Framework 应用程序的样式使用带有 Rust 宏的领域特定语言进行描述。它允许这些模板进行实时重新加载,因为它们直接被喂给正在运行的 Rust 应用程序,所以屏幕可以随时改变。
以下是该框架的一个很好的示例:
请打开示例文件夹直接听取音频,它们无法通过 iframe 播放!
以下是一个更简单的计数器 “hello world” 应用的示例代码:
const wasm = await WasmWebGL.fetch_and_instantiate_wasm(
"/makepad/target/wasm32-unknown-unknown/release/makepad-example-simple.wasm"
);
class MyWasmApp {
constructor(wasm) {
let canvas = document.getElementsByClassName('full_canvas')[0];
this.bridge = new WasmWebGL (wasm, this, canvas);
}
}
let app = new MyWasmApp(wasm);
// main.rs
use makepad_draw_2d::*;
use makepad_widgets;
use makepad_widgets::*;
// The live_design macro generates a function that registers a DSL code block with the global
// context object (`Cx`).
//
// DSL code blocks are used in Makepad to facilitate live design. A DSL code block defines
// structured data that describes the styling of the UI. The Makepad runtime automatically
// initializes widgets from their corresponding DSL objects. Moreover, external programs (such
// as a code editor) can notify the Makepad runtime that a DSL code block has been changed, allowing
// the runtime to automatically update the affected widgets.
live_design! {
import makepad_widgets::button::Button;
import makepad_widgets::label::Label;
// The `{{App}}` syntax is used to inherit a DSL object from a Rust struct. This tells the
// Makepad runtime that our DSL object corresponds to a Rust struct named `App`. Whenever an
// instance of `App` is initialized, the Makepad runtime will obtain its initial values from
// this DSL object.
App = {{App}} {
// The `ui` field on the struct `App` defines a frame widget. Frames are used as containers
// for other widgets. Since the `ui` property on the DSL object `App` corresponds with the
// `ui` field on the Rust struct `App`, the latter will be initialized from the DSL object
// here below.
ui: {
// The `layout` property determines how child widgets are laid out within a frame. In
// this case, child widgets flow downward, with 20 pixels of spacing in between them,
// and centered horizontally with respect to the entire frame.
//
// Because the child widgets flow downward, vertical alignment works somewhat
// differently. In this case, children are centered vertically with respect to the
// remainder of the frame after the previous children have been drawn.
layout: {
flow: Down,
spacing: 20,
align: {
x: 0.5,
y: 0.5
}
},
// The `walk` property determines how the frame widget itself is laid out. In this
// case, the frame widget takes up the entire window.
walk: {
width: Fill,
height: Fill
},
bg: {
shape: Solid
// The `fn pixel(self) -> vec4` syntax is used to define a property named `pixel`,
// the value of which is a shader. We use our own custom DSL to define shaders. It's
// syntax is *mostly* compatible with GLSL, although there are some differences as
// well.
fn pixel(self) -> vec4 {
// Within a shader, the `self.geom_pos` syntax is used to access the `geom_pos`
// attribute of the shader. In this case, the `geom_pos` attribute is built in,
// and ranges from 0 to 1. over x and y of the rendered rectangle
return mix(#7, #3, self.geom_pos.y);
}
}
// The `name:` syntax is used to define fields, i.e. properties for which there are
// corresponding struct fields. In contrast, the `name =` syntax is used to define
// instance properties, i.e. properties for which there are no corresponding struct
// fields. Note that fields and instance properties use different namespaces, so you
// can have both a field and an instance property with the same name.
//
// Widgets can hook into the Makepad runtime with custom code and determine for
// themselves how they want to handle instance properties. In the case of frame widgets,
// they simply iterate over their instance properties, and use them to instantiate their
// child widgets.
// A button to increment the counter.
//
// The `<Button>` syntax is used to inherit a DSL object from another DSL object. This
// tells the Makepad runtime our DSL object has the same properties as the DSL object
// named `Button`, except for the properties defined here below, which override any
// existing values.
button1 = <Button> {
text: "Click to count"
}
// A label to display the counter.
label1 = <Label> {
label: {
color: #f
},
text: "Counter: 0"
}
}
}
}
// This main_app macro generates the code necessary to initialize and run your application.
//
// This code is almost always the same between different applications, so it is convenient to use a
// macro for it. The two main tasks that this code needs to carry out are: initializing both the
// main application struct (`App`) and the global context object (`Cx`), and setting up event
// handling. On desktop, this means creating and running our own event loop from a fn main(). On web, this means
// creating an event handler function that the browser event loop can call into.
main_app!(App);
// The main application struct.
//
// The #[derive(Live, LiveHook)] attribute implements a bunch of traits for this struct that enable
// it to interact with the Makepad runtime. Among other things, this enables the Makepad runtime to
// initialize the struct from a DSL object.
#[derive(Live, LiveHook)]
pub struct App {
// A chromeless window for our application. Used to contain our frame widget.
window: BareWindow,
// A frame widget. Used to contain our button and label.
ui: FrameRef,
// The value for our counter.
//
// The #[rust] attribute here is used to indicate that this field should *not* be initialized
// from a DSL object, even when a corresponding property exists.
#[rust]
counter: usize,
}
impl App {
// This function is used to register any DSL code blocks that you defined in this file. It is
// called automatically by the code we generated with the call to the macro `main_app` above.
// In this function you have to register any dependency crates live design code
pub fn live_design(cx: &mut Cx) {
makepad_widgets::live_design(cx);
}
// This function is used to handle any incoming events from the host system. It is called
// automatically by the code we generated with the call to the macro `main_app` above.
pub fn handle_event(&mut self, cx: &mut Cx, event: &Event) {
if let Event::Draw(event) = event {
// This is a draw event, so create a draw context and use that to draw our application.
let mut draw_cx = Cx2d::new(cx, event);
return self.draw(&mut draw_cx);
}
// Forward the event to the window.
self.window.handle_event(cx, event);
// Forward the event to the frame. In this case, handle_event returns a list of actions.
// Actions are similar to events, except that events are always forwarded downward to child
// widgets, while actions are always returned back upwards to parent widgets.
let actions = self.ui.handle_event(cx, event);
// Get a reference to our button from the frame, and check if one of the actions returned by
// the frame was a notification that the button was clicked.
if self.ui.get_button(id!(button1)).clicked(&actions) {
// Increment the counter.
self.counter += 1;
// Get a reference to our label from the frame, update its text, and schedule a redraw
// for it.
let label = self.ui.get_label(id!(label1));
label.set_text(&format!("Counter: {}", self.counter));
label.redraw(cx);
}
}
// This is the immediate mode draw flow, as called above in response to the Draw event
pub fn draw(&mut self, cx: &mut Cx2d) {
// Indicate that we want to begin drawing to the window.
if self.window.begin(cx).not_redrawing() {
return;
}
// Draw the frame to the window.
let _ = self.ui.draw(cx);
// Indicate that we finished drawing to the window.
self.window.end(cx);
}
}
Rui#
Rui 是一款实验性的 Rust UI 库,灵感来自于 SwiftUI。到目前为止,这个库还处于早期开发阶段。
Rui 是一款立即模式 GUI 库。它是 GPU 渲染的,会在状态更改时以响应式的方式进行更新,并且具有比其他立即模式 UI 更丰富的布局选项。该库使用自己的渲染工具 vger-rs,就像 Druid 会使用 Piet 或 Vellum 一样。其主要目标之一是 “使用类型对 UI 进行编码,以确保稳定的标识性”。有关标识和 id 路径的更多信息,请阅读Xilem 的创建者 UI 架构文章。
最初,Rui 的目的是创作者想将他们自己的应用(一个模块化的音频处理应用)移植到一个更好的平台上(即 Rust)。目前,该库无法在 Web 上正确运行,因为库的默认后端是 WebGPU。然而,我认为它可以在 Web 上运行,尽管需要一些努力。
既然我们已经了解了当代工具包,我想分享一下我如何使用这些库构建 WebAssembly 包。
首先,Trunk 是一个很好的构建 Wasm 并为实时更新提供开发 Web 服务器的工具。在某些情况下,只需运行 trunk serve 即可。在其他情况下,您必须以经典方式构建(cargo build --release --target wasm32-unknown-unknown和wasm-bindgen
),并使用任何静态服务器提供服务,例如 python3 -m http.server。
其次,依赖树中使用的 getrandom 库经常会出现问题,但如果我们启用 “js” 功能,它就可以解决。
第三,任何异步功能都存在 Wasm 构建问题。目前,这可以通过库 gloo-timers 来缓解。应对这些问题的其他方法在我的博客文章《如何为 CHIP-8 模拟器设置 Wasm API》中有所介绍。
第四,一些库 / 框架会自带它们自己的 futures,比如 Sycamore:features=["suspense"]
。
第五,由于这些库目前非常不稳定,我不得不从最新的主干分支运行大多数示例(已发布的库 API 已经过时了)。
第六,尽管标准称为 WebGPU,但以 WebGPU 为后端编写的应用实际上在桌面上的表现更好。但是,当 WebGPU 在浏览器中得到更好的支持时,这种情况将很快改变。
昨日黄花#
还有一些已经被停止开发或不再积极维护的工具也值得一提。这些工具大多是上述当前维护的库的基础和灵感来源。由于 Rust UI 领域非常动荡,我会列出一些已经一年没有更新的仓库。
- Druid 已经被核心开发团队停止维护,他们的工作转移到了 xilem 上。然而,一些支持和更新已经完成,例如最近的 0.8 版本发布。
- Moxie 可能已经死亡或处于非常深的睡眠状态。
- Seed具有类似 Elm 的架构,目前没有维护。
- Conrod 不再维护。
- Orbtk已经经历了它的日落。
结论#
总的来说,Rust GUI 工具的当前状态比较不稳定,你今天选择的工具可能明天就被弃用了。这些工具发展得非常快,甚至领先于浏览器(WebGPU,我在谈论你)。这并不是坏事。
现在的开发动力很大,这将带来处理 GUI 的新的和更好的方法,因为开发人员改进了过去的技术。然而,GUI 本身就有自己的一套复杂问题,所以 Rust GUI 架构的圣杯可能还没有被发现。但是,社区正在寻找最佳解决方案,这意味着有些工具不可避免地会被放弃。很快,这篇文章也将被淘汰并退役到 “昨日黄花” 中。当这必然发生时,我会发布一篇新的文章。你也应该期待自己在探索中发现新的工具。无论好坏如何,作为我们发现 Rust GUI 工具的能力,这是一个有趣的时代!
参考文献
虽然我用了多篇不同的文章来支撑和验证这篇文章,但印象最深的两篇是 Ralph Levien 的博客 “Xilem: an architecture for UI in Rust” 和 Diggory Hardy 的博客 “State of GUI 2022”。
每篇博客都提供了深入、有教育意义的对 Rust GUI 问题和解决方案的分析,特别是在 Rust 中的 GUI。这两篇博客都是由真正的库创建者撰写的。前者来自 Druid/Xilem 的创造者,后者来自 Kas 的创造者。他们确实知道他们的行当。