前往
大廳
主題

【程式】如何建一個視窗—GTK篇

Shark | 2023-07-14 02:29:34 | 巴幣 2002 | 人氣 545

X Window或Wayland是Linux視窗系統的底層,但它們只有在畫面上開一個矩形區域,沒有提供按鈕、文字框、滑塊等元件(這些GUI元件稱為widget),這些留給比較高階的函式庫實作。使用最普遍的是GTK和QT這兩個,寫Linux視窗程式基本上要在兩者裡面選一個。其他還有FLTK、Motif等函式庫存在。

GTK的歷史就不介紹了,本篇只介紹如何寫程式。目前有在用的有2、3、4三個版本,各版本沒有向後相容,本篇介紹的是GTK4。

GTK的特徵是雖然它用純C寫成,但是有物件繼承多型的概念。但為了要用C模擬出物件,程式碼會很冗長,而且需要一些物件導向的知識。
(確切來說,GTK是用GObject函式庫做到物件導向,其他依存GObject的函式庫也有這個特性,例如GdkPixbuf和gstreamer。)

Shark的程式教學目錄



雖然GTK可以跨平台,但是筆者沒興趣在Windows用GTK,本篇的程式是在Linux寫的。
首先安裝開發用header和library,Fedora是這個套件:gtk4-devel,Ubuntu和Mint是libgtk-4-dev,其他發行版就請自己找。

方法1:使用GtkApplication和g_application_run()。
參考:Gtk-4.0: Getting Started with GTK
#include<gtk/gtk.h>

static void appStart(GtkApplication* app, gpointer userData){
  GtkWidget* window= gtk_application_window_new(app);
  gtk_window_set_title(GTK_WINDOW(window), "title");
  gtk_window_set_default_size(GTK_WINDOW(window), 200, 200);
  gtk_window_present(GTK_WINDOW(window));
}

int main(int argc, char** argv){
  GtkApplication* app=gtk_application_new("org.shark0r.simplewindow",
    G_APPLICATION_DEFAULT_FLAGS);
  g_signal_connect(app,"activate", G_CALLBACK(appStart),NULL);
  int status=g_application_run(G_APPLICATION(app), argc, argv);
  g_object_unref(app);
  return status;
}

先從int main()開始看。第一行建立一個GtkApplication物件。第二行g_signal_connect()是設定事件(在GTK裡稱為signal,有的函式庫把它稱為event)和callback,讓GTK在特定時機呼叫我們指定的函式。這裡activate事件是在程式開始時觸發一次。第三行g_application_run()執行訊息迴圈。程式會在此暫停,裡面會間接執行剛才註冊的callback,直到使用者關閉視窗才繼續。第四行刪除GtkApplication物件,方法是「讀取圖檔的方法-Linux篇」裡提到的reference count。g_application_run()傳回值必須作為main()的傳回值。

再來看callback函式appStart()。建立視窗的程式碼基本上要放在activate callback裡面。建立一個GtkApplicationWindow物件;呼叫函式設定標題和初始尺寸;由於widget剛建立時都是隱藏的,要呼叫gtk_window_present()讓它顯示在螢幕上。
(官方範例用的是gtk_widget_show(),但我自己用的時候會跳deprecated的警告,它建議用gtk_widget_set_visible()或gtk_window_present())
視窗物件在關閉時就會刪除,不需要g_object_unref()。

如果檔名叫simplewindow.c,用這個指令build
gcc simplewindow.c -o simplewindow -Os -s `pkg-config --cflags --libs gtk4`
包住pkg-config的`是鍵盤左上角1左邊的鍵。直接執行「pkg-config --cflags --libs gtk4」在筆者的電腦上會出現這些。
-I/usr/include/gtk-4.0 -I/usr/include/pango-1.0 -I/usr/include/glib-2.0 -I/usr/lib64/glib-2.0/include -I/usr/include/sysprof-4 -I/usr/include/harfbuzz -I/usr/include/freetype2 -I/usr/include/libpng16 -I/usr/include/libmount -I/usr/include/blkid -I/usr/include/fribidi -I/usr/include/libxml2 -I/usr/include/cairo -I/usr/include/pixman-1 -I/usr/include/gdk-pixbuf-2.0 -I/usr/include/graphene-1.0 -I/usr/lib64/graphene-1.0/include -mfpmath=sse -msse -msse2 -I/usr/include/gio-unix-2.0 -pthread -lgtk-4 -lpangocairo-1.0 -lpango-1.0 -lharfbuzz -lgdk_pixbuf-2.0 -lcairo-gobject -lcairo -lgraphene-1.0 -lgio-2.0 -lgobject-2.0 -lglib-2.0
這段會嵌入gcc的指令裡面,設定header檔路徑和要連結的library。

用命令列打./simplewindow,或是在檔案管理器用滑鼠點執行

視窗工作區一開始是200×200,但這個視窗可以改變大小或最大、最小化。
邊框和標題列跟「如何建一個視窗(X Window)」不一樣是因為桌面環境不一樣(隔了這麼久有換過發行版),不是X Window和GTK的差異。


這程式看起來很短,但每一行都大有學問。

第一行GtkApplication是class名稱,C++建立物件是用new保留字寫成「new GtkApplication(...)」,GTK則要用「class名稱+_new」的函式。
class GtkApplication說明  gtk_application_new()說明
gtk_application_new()第一參數是application ID,為軟體取一個唯一的名稱,在一些情況有用處。本篇的程式沒用到所以先不解釋,有興趣可以看這篇說明。
GNOME Developer Documentation: Application ID
第二參數flags看這篇,本程式用預設值即可。
Gio.ApplicationFlags說明

第二行g_signal_connect()的說明不在GTK裡面,要找GObject的說明文件。
第一參數是要設定的物件,第二參數用一個字串代表事件種類。翻class GtkApplication說明找Signals的段落,然後找activate這一項,會發現它在「Signals inherited from GApplication」裡面。

第三參數是user_data,GTK內部不會用到,會原封不動傳給callback,可以利用它在主程式和callback之間傳遞資料。它的型態gpointer是跟指標一樣大小的整數,等同void*。

第三行g_application_run()是g_application開頭而不是gtk_application,代表它屬於GApplication的成員函式而不是GtkApplication的。

第二和第三行都用到class繼承的概念。翻GtkApplication的文件可以看到這張圖,顯示物件繼承關係。

因為GtkApplication繼承GApplication,所以GtkApplication也可以使用GApplication的函式和事件。activate signal和g_application_run()必須查class GApplication的說明才找得到,可以在Ancestors點選連結過去。
Gio.Application說明
在左邊選Signal→activate可看到此事件的說明。它會在g_application_activate()被呼叫時觸發,本篇g_application_run()會呼叫一次此函式所以不用自己呼叫。
在左邊選run可看到g_application_run()的說明。它會幫你做很多初始化工作,包括必要和非必要的。

第三行剛好是GTK為了用C做出物件導向,讓程式變得冗長的例子。C++呼叫member function時寫法是「app->run(...)」;GTK則要把app放在第一參數,且函式名帶有class名稱「g_application_」避免跟其他class的同名函式衝突。此外如果第一參數只填app,編譯時會跳型態不符的警告,要加一個轉型用的macro G_APPLICATION();相對地C++可以分辨class的繼承關係,下層class可以呼叫上層class的成員函式。
//C++寫法
int status = app->run(argc, argv);
//GTK寫法
int status = g_application_run(G_APPLICATION(app), argc, argv);
本篇還沒有自己寫class繼承,寫class繼承會更麻煩。

再來看appStart()。說明文件會寫每個signal的callback要宣告成什麼格式。查activate signal的說明會看到這一段。

appStart()的參數和傳回值要照這樣宣告,然後把函式指標放在g_signal_connect()的第二參數。為了避免跳型態不符的警告,要加一個轉型用macro G_CALLBACK()。
appStart()第一參數是產生這個事件的widget;第二參數user_data是先前填在g_signal_connect()的user_data。

此函式裡建立一個GtkApplicationWindow物件再用member function修改屬性。GTK多數class都是不透明的,只能呼叫函式操作而不能直接存取成員變數。
Gtk.ApplicationWindow說明
建構子gtk_application_window_new()傳回的是GtkWidget型態而不是GtkApplicationWindow型態。GtkWidget是所有widget的基底型態,所有widget建構子傳回的都是GtkWidget,需要時才用macro轉成其他型態。筆者不知道這是因為向後相容還是什麼原因。

這裡再一次用到繼承的概念。從gtk_window_set_title()等函式名稱可看出是從class GtkWindow繼承的,所以查它們的說明必須查GtkWindow的頁面。
Gtk.Window說明

或許有人注意到本程式有用到命令列參數。g_application_run()第二和第三參數是把程式的命令列參數填進去。如果用「./simplewindow --help-all」執行會出現這個訊息。

GTK內部只會用到「--gapplication-service」一個,這個我也沒用過不知道用途。主要是GApplication可以幫你初步處理命令列參數,只要用g_application_add_main_option()或類似函式登錄參數,就有打--help時顯示說明、資料型態轉換等功能。



方法2:使用gtk_init()和g_main_context_iteration()
參考:Gtk-4.0: Initialization
#include<gtk/gtk.h>

static void windowClose(GtkWidget* window, int* isEndPtr){
  *isEndPtr=1;
}

int main(){
  gtk_init();
  GtkWidget* window=gtk_window_new();
  gtk_window_set_title(GTK_WINDOW(window), "title");
  gtk_window_set_default_size(GTK_WINDOW(window), 200, 200);
  gtk_window_present(GTK_WINDOW(window));
  int isEnd=0;
  g_signal_connect(window, "destroy", G_CALLBACK(windowClose), &isEnd);

  while(!isEnd){
    g_main_context_iteration(NULL,TRUE);
  }
  return 0;
}
這是比較底層的方法,不像GtkApplication會自動幫我們做一些事。第一行呼叫gtk_init()把系統初始化。方法1的GtkApplication內部會呼叫此所以不用自己呼叫。
這個函式不屬於任何class,要查global的文件。
GTK global說明  Gtk.init說明

2~5行跟方法1一樣建立視窗。這裡建立的class是GtkWindow而不是GtkApplicationWindow。官方說明是如果使用GtkApplication,視窗用GtkApplicationWindow比較容易跟它配合,如果沒使用GtkApplication就要把視窗建成GtkWindow。

方法1的g_application_run()會處理訊息迴圈,但方法2要自己寫迴圈。如同「如何建一個視窗(Windows API)」和「如何建一個視窗(X Window)」,程式會在一個地方停下來,直到發生事件才繼續。g_main_context_iteration()便是做這個工作,會判斷事件屬於哪個widget並執行我們設定的callback。第一參數目前可以填NULL,第二參數是要不要暫停,如果填FALSE則即使沒有事件程式仍然會繼續。
g_main_context_iteration()說明

即使按╳按鈕關閉視窗也不會自動跳出迴圈,這樣畫面上看不到東西了但process仍然殘留在電腦裡,故程式要能偵測視窗關閉的事件。用g_signal_connect()註冊destroy事件的callback。destroy事件放在class GtkWidget,代表所有widget都有這個事件,會在widget被刪除時觸發。
destroy signal說明
我們的callback:windowClose()的傳回值和參數要照它的說明寫。

然後用一個變數isEnd做記號。這裡示範一下user_data的用法:把isEnd的指標放在g_signal_connect()第四參數,GTK呼叫callback時會把這個指標填在user_data參數,從位址取變數就能修改isEnd。
(C語言要做到call by reference必須用指標,如果只填isEnd不取指標那只會把數字0傳給callback,callback裡無法修改isEnd)

除了上面提到的以外,application ID和命令列參數也是GtkApplication會自動處理,但方法2要自己處理的。

編譯方法一樣是這個指令
gcc simplewindow.c -o simplewindow -Os -s `pkg-config --cflags --libs gtk4`
執行的樣子肉眼看起來跟方法1一樣。



GTK不是一個大的整體而是分成很多子函式庫,這篇把它們全部列出來。
GTK Documentation
本篇的程式已經用到一些GLib、GObject和GIO的東西了。個人認為分成多個函式庫的好處是寫程式可以只挑需要的用,不用一次把全部都包進來,像是如果只要讀取圖檔就可以只用GdkPixbuf。

其實還有第三種寫法:使用GtkApplication但自己寫訊息迴圈,本篇不介紹,但可以參考這篇。
https://stackoverflow.com/questions/46910038/how-to-avoid-blocking-function-g-application-run
要先呼叫g_application_register()和g_application_activate()才能跑訊息迴圈,本來是g_application_run()內部呼叫這兩個函式。



用過Windows API和X Window之後其實我不太喜歡用GTK。第一是覺得它裡面是不知道在做什麼的黑盒子,Windows API和X Window雖然程式長一點,但能清楚看出每一步在做什麼。第二是從GTK2~GTK4每出新版就有不少變更,一部分程式要重寫,以後GTK5出來又不知道要改多少地方。但是大家都在用這個東西,我不配合也不行。

很久以前用過GTK2,本篇的程式用GTK2要這樣寫。
#include<gtk/gtk.h>

int main(int argc, char** argv){
  gtk_init(&argc, &argv);
  GtkWidget* window=gtk_window_new(GTK_WINDOW_TOPLEVEL);
  gtk_window_set_title(GTK_WINDOW(window), "title");
  gtk_widget_set_size_request(window,200,200);
  g_signal_connect(G_OBJECT(window), "delete_event", gtk_main_quit, NULL);

  gtk_widget_show_all(window);
  gtk_main();
  return 0;
}
筆者不介紹GTK2了,有興趣自己查資料吧。

由於現在還有很多程式是用GTK2或GTK3寫的,一般Linux發行版會同時裝有GTK2、GTK3和GTK4的函式庫。例如GTK3是在2011年發布,但是GIMP為了移植到GTK3花了很多年,目前最新穩定版2.10.34還在用GTK2,使用GTK3的GIMP 3還在開發中。

創作回應

更多創作