rust-gtk4-p2~> 创建窗口
<~~ 发表日期:2021-11-07 | 本文词数:2175 | 预计阅读时间:11分钟 ~~>

让我们使用 Gtk 创建窗口, 了解下 app_id, 构造者模式, 信号量, handler 等概念吧

同系列传送门: rust-gui
GNOME入坑指南: gnome

背景了解

GTK

GTK, 简单来说是个有名的GUI库, 开源免费, 用C编写
在gtk3后, 由于GI(GObject Introspection) 的应用, 可以轻松地, 创建其他语言的绑定
这使你能利用不同语言编写GUI, 比如Rust, Nim, Vala, Python, Javascript... (不会真的有人用C语言写Gtk吧?)

基本上名气稍微大点的语言, 都有对应的GTK绑定

gtk-rs

目前有个项目, 叫做 gtk-rs, 负责GTK的Rust绑定, 在 crates.io 上搜索 gtk, 所看到的 gtkgtk4, 就属于gtk-rs

gtk4这个crate, 是Rust语言的绑定, 而非同纯C编写的那个gtk4本身
所以进行接下来的步骤前, 请确保环境内, 已有gtk4本身的正确版本, 不然crate会装不上


项目配置

首先, 你需要创建一个新项目, 随后修改 Cargo.toml, 请输入以下命令:

cargo add gtk4@0.5.2 --rename gtk
cargo add libadwaita@0.2 --rename adw

我们将 gtk4(crate), 重命名为 gtk, 方便之后编写, 随后, 请run一下, 安装依赖 (请确保安装了gtk4/libadwaita, 如 p1 中所述)
(adw 这个库之后会提的, 现在先忽略掉即可) 下面就是正式的编码环节了


加速编译

有个非常快速的名为 mold (github.com/rui314/mold) 的链接器, 非常适合加速 gtk 项目的编译, 以便快速调试
直接用你的包管理器下载 mold, 或者根据 github 的指示获取, 随后像下面这样简单的添加前缀即可:

mold -run cargo run

基础知识

一个GTK4应用的创建, 需要用到 gtk::Application
同时还需 use gtk::prelude::*, 原因与 std::prelude 一样

先创建一个应用, 它目前连窗口也没有:

use gtk::prelude::*;
use gtk::Application;

const APP_ID: &str = "xyz.jedsek.myapp";

fn main() {
    let app = Application::builder().application_id(APP_ID).build();
    app.run();
}

很简单吧? 但别急着运行, 先看看下面两处:

  • application_id:
    每个GTK应用, 都带有一个id, 即 application_id, 它必须是全世界唯一的, 一般使用反域名作为id, 如 "org.gnome.gedit"
    app_id 作为App的唯一标识符, 用于App之间的通信与识别, 因此要慎重考虑, 最好第一次就定好, 避免之后的麻烦!

  • Builder Pattern (一种Rust中常见的设计模式):
    我们利用它进行链式构造, 让构造的过程更加清晰, 这里我们在链式中, 指定了程序的id: application_id(id: &str)
    在gtk4中, 它被高频次地使用, 最后调用的 build 会生成比如窗口, 按钮等部件的实例
    当然, 也可以通过编写XML文件, 来构造对应的部件, 指定其布局, 但这是后话


信号量

当你跃跃欲试, mold -run cargo run 之后, 会看到:

GLib-GIO-WARNING : Your application does not implement g_application_activate()
and has no handlers connected to the 'activate' signal.  
It should do one of these.

靠, 这是啥子玩意儿? 实际上, 我们还要添加一个名为 activate 的信号量(Signal):

use gtk::prelude::*;
use gtk::Application;

const APP_ID: &str = "xyz.jedsek.myapp";

fn main() {
    let app = Application::builder().application_id(APP_ID).build();
    app.connect_activate(build_ui);
    app.run();
}

fn build_ui(app: &Application) {
	todo!()
}

当然, 现在还无法执行, 请注意 build_ui 函数中的 todo!(), 我们还没有填充这个函数!
因为我想逐步地讲解一下... 先来讲什么是 signal 吗:

当某件事情发生时, signal 就会被发送, 交由Gtk的全局处理系统, 由它进行调度
比如窗口打开, 按钮被点击, 拖动控件...这些事件都会发送一个 signal 来告诉处理系统, 让它知道有事情发生了
你可以为每个 signal 绑定一个函数, 当处理系统接受到某个 signal, 就可以调用对应的处理函数了

  • 我们把处理函数, 称之为 handler
  • 我们将为 signal 绑定一个 handler 的过程, 称之为 connect

Gtk 的理念是: App只关心在特定的时候需要做的事情
事件发送的时间 , 即 signal 什么时候发送已经定好了, 至于 事情的内容, 即 handler 的具体实现, 由开发者自己负责

下面是四个待响应的信号, 也对应着四个待实现的虚函数/handler:

  • startup: 在App第一次启动时被调用, 用于与UI显示无关的初始化任务
  • shutdown: 在App结束时调用, 清理资源, 进行善后, 不过这好像对Rust没啥用? Drop能自动清理, 但毕竟源码是C
  • activate: GtkApp总得有至少一个窗口, 该函数决定如何显示/绘制窗口
  • open 当App需要打开某个文件时被执行, 文件会在新窗口显示, 比如浏览器打开了pdf文件

正如上面第三个信号所说, 一个GtkApp至少得有一个窗口
当某个App试图打开第二个窗口, Gtk全局系统, 会发送 signal(activate/open) 给第一个窗口
所有的初始化工作都应在startup中完成, 哪怕是第二个窗口相关的初始化

这些只是Gtk给我们的 signal 中最为常见的几个, 实际上还有很多信号, 帮你自定义设计App, 本节后面, 会再帮你加深下印象


创建空白窗口

回到之前的代码, 如下:

use gtk::prelude::*;
use gtk::Application;

const APP_ID: &str = "xyz.jedsek.myapp";

fn main() {
    let app = Application::builder().application_id(APP_ID).build();
    app.connect_activate(build_ui);
    app.run();
}

fn build_ui(app: &Application) {
	todo!()
}
Your application does not implement g_application_activate()
and has no handlers connected to the 'activate' signal. 

现在你应该明白, 为什么之前会报出这个警告了吧? connect_xxxx, 代表着连接某个 signal , 即实现对应的虚函数/handler

connect_activate, 要求传入一个具有静态lifetime的函数作为参数, 且被传入函数只有唯一的参数, 其类型是 &Application
我们创建的这个函数, 名为 build_ui, 正是为了消除该警告, 真正显示窗口, 毕竟一个GUI程序若无任何显示, 这...啧, 不太聪明?

直接上 build_ui 的代码, 注意 use 那边多了东西:

use gtk::prelude::*;
use gtk::{Application, ApplicationWindow};

const APP_ID: &str = "xyz.jedsek.myapp";

fn main() {
    let app = Application::builder().application_id(APP_ID).build();
    app.connect_activate(build_ui);
    app.run();
}

fn build_ui(app: &Application) {
    let win = ApplicationWindow::builder()
        .application(app)
        .title("My App")
        .build();
    win.present();
}

我们用builder模式, 指定了窗口对应的App是谁, 标题是啥, 并在最后显示它
如果你处于GNOME-DE, 并启用了相关主题, 将会看见一个具有对应主题的空白窗口, 标题是 My App

在App启动之后的某时刻, activate 信号对应的虚函数被调用, 之前因为没有实现它, 一个警告出现
但现在, 我们已经将它覆盖成自己的 build_ui 了, 因此警告不再出现


新增按钮

让我们再创建一个按钮, 提示信息是 Press me!, 在按下之后, 变成显示 Hello World!:

use gtk::{prelude::*, Button};
use gtk::{Application, ApplicationWindow};

const APP_ID: &str = "xyz.jedsek.myapp";

fn main() {
    let app = Application::builder().application_id(APP_ID).build();
    app.connect_activate(build_ui);
    app.run();
}

fn build_ui(app: &Application) {
    let btn = Button::builder().label("Press me!").build();
    btn.connect_clicked(|btn| btn.set_label("Hello World!"));

    let win = ApplicationWindow::builder()
        .application(app)
        .title("My App")
        .child(&btn)
        .build();

    win.present();
}

我在这里使用Button, 而不是Label, 单纯是为了复习先前学过的概念, 仅此而已

之前说过, 我们可以 connect 的 signal 远不止那四个, 比如这里的 connect_clicked, 就代表按钮被点击后的handler
点击事情发生时, 本来就能啥也不做, 因此即使不调用 connect_clicked 也不会出现警告, 不像 activate 强制要求你处理

然后, 我们在使用 Builder Pattern 构造窗口时, 调用child, 将该按钮的引用传入, 让按钮也出现在窗口中
注意: 这里会使得按钮的强引用数量+1, 因为Button是GObject的子类, 而GObject具有引用计数的特点, 下节会讲


常见设置

稍微再水一点字数

  • 设置间距:
    运行先前的代码, 你会发现按钮占满了整个窗口
    我们可以在链式构造一个按钮时, 顺带着设置它与四周的间距, 代码很简单, 如下:
let btn = Button::builder()
    .label("Press me!")
    .margin_top(12)
    .margin_bottom(12)
    .margin_start(12)
    .margin_end(12)
    .build();
  • 透明设置:
let win = ApplicationWindow::builder()
    .application(app)
    .title("My Gtk App")
    .opacity(0.8)         // 窗口不透明度, 越低越透明, 越高越不透明
    .build();
  • 默认高宽:
let win = ApplicationWindow::builder()
    .application(app)
    .title("My Gtk App")
    .default_height(100)
    .default_width(200)
    .build();

至此, 本小节结束, 又要愉快地鸽鸽鸽了呢 :)