Featured image of post 对Relm4的一次尝试

对Relm4的一次尝试

不~准~说~我~代~码~写~得~烂!

最近看了Rustcc一位大佬写的Rust数据结构与算法,将其中的“递归解汉诺塔问题”稍作修改,改成了一个接收汉诺塔层数后输出需要步骤数而不是输出具体解法的程序。

看起来是一个非常不值一提的简单小程序。然而我突然想到:以前学Rust+GTK开发的时候总是苦于没有对一个功能明确的GUI程序的构想,没法“在用中学”——把默认的counter搞懂就不知道怎么前进了。不如趁此机会,把这个简单的不能再简单的小工具包装成GUI,正好也学习一下Relm4这个库。

Relm4是什么

Relm4是一个深受Elm的思想启发创作的基于GTK4的Rust GUI框架,并完全兼容GTK4和libAdwaita。它使简单而快速地开发优雅的跨平台应用程序成为一种习惯,并使你在短短几个小时内就能提高工作效率。

说句题外话,如果你是一名Gnome用户,Gnome/GTK统一而优雅的观感与使用体验,一定是令你难以忘怀的:流畅的动效,一致的窗口布局,明暗主题的自动切换,简单干净的用户界面。一个字,爽。

分析程序

首先,一个汉诺塔程序应该有这些元素:

  1. 一段提示文本,告诉用户需要做什么
  2. 一个数值的输入框,用来输入塔的层数
  3. 一个用来触发计算事件的按钮。
  4. 一个输出框,可以输出对应的数据

于是,我们可以画出如下草图:

草图

对应的,最后我们将做出这样的界面:

实物图

构建Relm4程序的基本框架

该部分根据Relm4官方教程书写。

一个基本的Relm程序应该由三个部分组成:AppModel、AppMsg和AppWidget。

AppModel

正如一个人需要脑来记忆和处理信息,在Relm程序中,我们也需要一个叫做AppModel的结构,用以存储程序中需要用到的信息和对信息的处理方法。比如,在本程序中,我们需要一个整数类型的变量counter来存储移动汉诺塔需要的步骤数。那么我们将定义如下结构体:

1
2
3
struct AppModel {
	counter:u128 //用于存储移动汉诺塔需要的步骤数
}

AppMsg

因为我以前经常使用Qt,所以我想用Qt中“信号和槽”的概念类比以下AppMsg:

我们将“点击btn_1这个按钮”这个行为作为“CountSteps”信号的触发条件(将点击btn_1和发送“CountSteps”这一信号绑定起来),则当用户点击该按钮时,按钮就会广播这个名为CountSteps的信号。而其他组件对于这个信号的处理方法则会在之后的步骤中在AppModel中编写

1
2
3
enum AppMsg {
	CountSteps, //此信号当按下按钮时将被广播
}

AppWidgets

让我们再贴一次我们程序的效果图:

没想到吧,我是不可能两次用同一个标签的

这一部分的编写最简单:想要什么组件,在里面声明一下就行了。

1
2
3
4
5
6
7
8
struct AppWidgets {
	window: gtk4::ApplicationWindow, //很明显,我们先得有一个窗口
	vbox: gtk4::Box, //使用一个盒状布局来排列窗口上的组件
	counter_button: gtk4::Button, //需要用户点击的按钮
	label: gtk4::Label, //显示提示文字
	ipt_area: gtk4::SpinButton, //获取用户的输入(ipt -> input)
	opt_area: gtk4::Label, //显示计算后的输出值(opt -> output)
}

填入组件

声明窗口

上面的步骤仅仅只是告诉我们的程序“有什么”,现在我们将利用上面写好的组件模板创建一个窗口。我们通过为AppWidgets引入Widgets特征实现这一目的。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
impl Widgets<AppModel, ()> for AppWidgets {
	type Root = gtk4::ApplicationWindow;
	fn init_view(
		model: &AppModel,
		_parent_widgets: &(),
		sender: Sender<AppMsg>
	) -> Self {
	let vbox = gtk4::Box::builder()
		.orientation(gtk4::Orientation::Vertical)
		.spacing(5)
		.build();

	let window = gtk4::ApplicationWindow::builder
		.title("TowerDemo")
		.build();

	window.set_child(Some(&vbox));
	vbox.set_margin_all(5);
}

可以看出,我们首先实现了AppWidgets的关联函数init_view,它新建了一个窗口实例(window)和一个竖排盒状布局(vbox)并分别定义了他们的属性。最后,它使vbox成为了window的子内容。

布局已经规划好了,接下来就开始填组件吧!

声明组件

1
2
3
4
5
6
7
8
9
	window.set_child(Some(&vbox));
	vbox.set_margin_all(5);

+	let counter_button = gtk4::Button::with_label("计算");
+	let ipt_area = gtk4::SpinButton::new(Some(&gtk4::Adjustment::new(1.0, 1.0, 20.0, 1.0, 1.0, 0.0)),0.0, 0);
+	let otp_area = gtk4::Label::new(Some(&format!("需要{}步", model.counter)));
+	let label = gtk4::Label::new(Some("请输入塔的层数"));
+	label.set_margin_all(5);
}

值得一提的是,我在创建SpinButton时,根据编写window与vbox的经验和编译器的智能提示,靠“猜”写出了如下代码:

1
	let ipt_area = gtk4::SpinButton::builder().build();

程序倒是没报错,界面也顺利跑起来了,但SpinButton却处于不可操作状态。

经过痛苦地查阅文档(不得不说,跟Relm的文档比起来Qt的文档显得像诗一样优雅直观,引人入胜)发现,定义SpinButton需要一个关键参数:gtk4::Adjustment “它表示一个值,该值具有关联的下限和上限,以及步长和页面增量以及页面大小。”,说人话就是,这个莫名其妙的结构体用来告诉我们一个控件值的可调节范围和调节时的基本单位。于是,修改代码如下:

1
2
-	let ipt_area = gtk4::SpinButton::builder().build();
+	let ipt_area = gtk4::SpinButton::new(Some(&gtk4::Adjustment::new(1.0, 1.0, 20.0, 1.0, 1.0, 0.0)),0.0, 0);

在本例中,前面四个值分别表示:初始数值(1.0)、最低数值(1.0)、最高数值(20.0)、步长(1.0),后面两个量与SpinButton这个控件关系不大,暂且略过。

最后,我们将这些组件放入vbox中:

1
2
3
4
5
6
	label.set_margin_all(5);

+	vbox.append(&label);
+	vbox.append(&ipt_area);
+	vbox.append(&opt_area);
+	vbox.append(&counter);

好了,一个漂亮的壳子搭好了。

编写事件逻辑

处理信号

现在,我们已经有了一个漂亮的界面,但是,如何实现程序的功能呢?

回到程序本身,我们需要一个用来计算汉诺塔移动次数的函数movetower:

1
2
3
fn movetower(height: u64) -> u128 {
	代码太烂了,无伤大雅,暂且略过
}

那么,我们这一阶段的任务就是:使按钮在被点击的时候广播CountSteps这个信号,而AppModel接收到该信号的时候就会作出对应的响应。

首先,绑定按钮的点击事件和CountSteps信号:

1
2
3
4
5
6
7
8
impl ... for AppWidgets {
...
	vbox.append(&counter);

+	let sender = sender.clone();
+	counter_button.connect_clicked(move |_| {
		send!(sender, AppMsg::CountSteps());
	});

现在,我们已经绑定好了触发事件和信号。我们将回到AppModel,对信号进行处理。为了做到这一点,我们对AppModel引入AppUpdate事件:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
impl AppUpdate for AppModel {
    fn update(
            &mut self,
            msg: AppMsg,
            _components: &(),
            _sender: relm4::Sender<AppMsg>,
        ) -> bool {
        match msg {
            AppMsg::CountSteps() => {
                self.counter = movetower( ??? );
                dbg!(self.counter);
            }
        }
        true
    }
}

可是,movetower需要一个参数啊!我们怎么把属于SpinButton的数据交给AppModel处理呢。

答:把它作为CountSteps的参数广播出来。

我们在AppMsg的定义中修改CountSteps:

1
2
3
4
enum AppMsg {
-	CountSteps(),
+	CountSteps(u64), //生成需要的步数
}

然后在init_view中修改绑定语句:

1
2
3
4
5
-	counter_button.connect_clicked(move |_| {
		send!(sender, AppMsg::CountSteps());
+	let ipt_area_cp = ipt_area.clone();
+	counter_button.connect_clicked(move |_| {
		send!(sender, AppMsg::CountSteps(ipt_area_cp.value_as_int() as u64));

这里我遇到了一个没有找到解决办法的问题。就是对ipt_area的所有权处理。最后只能采用多创建一个变量规避所有权问题的丑陋解法。如果有朋友能解决这里的所有权问题,直接让ipt_area传值给CountSteps,请在评论区告知!谢谢!

不过总得来说,我们至少成功的获取到了ipt_area的值,并将其转换为u64后作为CountSteps的参数一起广播出去。我们的程序逻辑就快完成了!

更新组件

Relm4的组件更新机制

还记得我们前面为AppWidgets引入的Widgets特征吗,现在我们需要为其编写root_widget和view两个方法:

1
2
3
4
5
6
    fn root_widget(&self) -> Self::Root {
        self.window.clone()
    }
    fn view(&mut self, model: &AppModel, sender: Sender<<AppModel as Model>::Msg>) {
        self.opt_area.set_label(&format!("需要{}步",model.counter));
    }

root_widget这个方法返回了程序的根窗口,而view方法则负责根据模型的变化来修改UI的视图功能。

显然,我们通过传入AppModel,成功让程序读到了SpinButton中的值并处理后打印在输出区域。至此,我们程序的基本逻辑完成了。

现在,我们为其添加main函数。

1
2
3
4
5
6
7
fn main (){
    let model = AppModel {
        counter: 0,
    };
    let app = RelmApp::new(model);
    app.run();
}

至此,我们有了一个可运行的,完整的Rust+GTK程序。

Licensed under CC BY-NC-SA 4.0