.NET CoreとGtkSharpでGUIアプリを作ってみる[GUI作成]

.NET Coreですが、せっかくクロスプラットフォームなのにGUIが無い…orz

…というわけでGtkSharpを使って.NET CoreでGUIアプリを作ってみます。

なお、.NET Coreでは、C#とF#、VBが扱えますが、本記事ではC#を使います。

GtkSharpって?

C言語用のGUIツールキットGTK+C#から使えるようにしたC#ライブラリです。Monoという、クロスプラットフォーム.NET Framework互換環境でGUIツールキットとして利用されてきました。今回使うのは.NET Coreへ対応させたフォークプロジェクト
NuGetのページ)
のものであり、.NET Standard 2.0 を満たす.NET実装から利用できるようで、.NET Core >= 2.0 から利用できるということになります。

Linuxでは一部のGnomeアプリなど(BansheeやGnome-RDPなど)に使われているかと思います。

ただの関数呼び出しの橋渡しではなく、C#らしく使えるようになっており(イベントハンドラ周りや属性を利用したGUIウィジェットの自動割当など)、GTK+をバックエンドとした別物のGUIツールキットと言えるかと思います。

.NET CoreでGtkSharpを使えるようにする

GtkSharpのプロジェクトページに導入方法が記述されています。基本的にはこれに従いますが、Gladeのインストールもついでにしました。(なお、GladeとはGTK+やその各種バインディングで用いられるGUI構築ツールのことです。)

GTK+3とGladeのインストール

最近のFedoraの場合

sudo dnf install gtk3-devel glade

Windowsの場合

MSYS2を利用します。MSYS2は導入済みで、Pathも通してあるものとします。またここでは64bit版を使用します。

pacman -S mingw-w64-x86_64-gtk3 mingw-w64-x86_64-glade

GtkSharpアプリプロジェクトの雛形を追加

dotnet new --install GtkSharp.Template.CSharp

雛形gtkappが使用可能になりました。これにより、以降

dotnet new gtkapp

と、コマンドを打つだけでGtkSharpを使った.NET Coreプロジェクトを作成できます。

また、GtkSharp.Template.FSharp、GtkSharp.Template.VBNetを入れることで、F#VBプロジェクト用の雛形も利用できるようです。

試しにキッチンタイマーでも作ってみる

新規プロジェクト作成

GtkSharpを使った新規.NET Coreプロジェクトを作成します。上記でインストールした雛形gtkappを使います

名前はKitchenTimerとしました。

mkdir KitchenTimer
cd KitchenTimer
dotnet new gtkapp

プロジェクト作成直後の生成物

gtkappプロジェクトを作成すると、4つのファイルが作成されます。次に示すのはls -lの出力結果です。

[geeksinhitachiprovince on KitchenTimer]$ll
合計 16
-rw-rw-r--. 1 geeksinhitachiprovince geeksinhitachiprovince  455  1月 20 02:58 KitchenTimer.csproj
-rw-rw-r--. 1 geeksinhitachiprovince geeksinhitachiprovince  910  1月 20 02:58 MainWindow.cs
-rw-rw-r--. 1 geeksinhitachiprovince geeksinhitachiprovince 1844  1月 20 02:58 MainWindow.glade
-rw-rw-r--. 1 geeksinhitachiprovince geeksinhitachiprovince  484  1月 20 02:58 Program.cs

なお、本記事ではProgram.csは一切いじりません。

ビルドして実行すると

GtkSharpHelloWorld
実行直後の状態

こんな感じのHelloWorldアプリになっています。色が黒っぽいのはFedora26のcinnamonのデフォルトテーマによるものです。

ボタンをクリックすると、

button clicked 5 times
クリック回数がカウントされる

Hello World!」のテキストの後にクリック回数が追記されます。

まずはMainWindow.gladeを編集し、GUIを設計

GUI設計はGladeというツールで行います。GtkSharpではGUI部品はGtk.Widgetの派生クラスとなっており、Gladeで設計したGUIの各部品は、コード上でGtk.Widget派生クラスオブジェクトとして扱えます。gladeファイルはこのツールで生成されるGUI設計を記述したxmlファイルです。

では、GladeでMainWindow.gladeを開いて編集してみます。

最初は、HelloWorldアプリのGUI設計になっています。

glade opening MainWindow.glade
MainWindow.gladeを開いたgladeの画面

なお、最新のGladeの見た目が変わっており、最近のGnomeアプリのようにメニューバーを廃止してタイトルバーに各項目を配置しています。下のスクリーンショットはMSYS2で入れたWindows版のものです。

glade Windows ver
glade on MSYS2 on Windows

キッチンタイマーに必要なウィジェットをつけていき、GUI設計をします。ここでは、分、秒の+/-ボタン、スタート/ストップボタン、表示用のラベルを取り付けました。

editing gui design
GUI設計の編集

Gladeのウィンドウのプロパティ設定部(の全般タブ)にIDという項目があります。このIDがC#ソースコード内でのフィールド名になるので、ちゃんと意味のある名前にします。

id as variable name
IDは変数名になる

ここでは、IDを以下のようにしました。

  • 表示用ラベル:_label1(HelloWorldアプリのものをそのまま流用)
  • 分+ボタン:m_up_buton
  • 分-ボタン:m_down_buton
  • 秒+ボタン:s_up_buton
  • 秒-ボタン:s_down_buton

gladeファイルのC#コード上からの利用

MainWindow.csは最初こんな感じになっていると思います。

using System;
using Gtk;
using UI = Gtk.Builder.ObjectAttribute;

namespace KitchenTimer
{
    class MainWindow : Window
    {
        [UI] private Label _label1 = null;
        [UI] private Button _button1 = null;

        private int _counter;

        public MainWindow() : this(new Builder("MainWindow.glade")) { }

        private MainWindow(Builder builder) : base(builder.GetObject("MainWindow").Handle)
        {
            builder.Autoconnect(this);

            DeleteEvent += Window_DeleteEvent;
            _button1.Clicked += Button1_Clicked;
        }

        private void Window_DeleteEvent(object sender, DeleteEventArgs a)
        {
            Application.Quit();
        }

        private void Button1_Clicked(object sender, EventArgs a)
        {
            _counter++;
            _label1.Text = "Hello World! This button has been clicked " + _counter + " time(s).";
        }
    }
}

ソースコードを見ると、MainWindowのコンストラクタ内でgladeファイルを使ってGtk.Builderオブジェクトを構築しています。そしてGtk.BuiderオブジェクトのAutoconnect()メソッドを呼び出しています。

これだけでGUIの構築は済んでしまいます。

後は構築されたGUIウィジェットオブジェクトをフィールドに代入してもらう方法ですが、ここでフィールド宣言部を見てみます。

        [UI] private Label _label1 = null;
        [UI] private Button _button1 = null;

[UI]属性(using UI = Gtk.Builder.ObjectAttribute;)の付いたフィールドが宣言されていて、そのフィールド名がGladeで設定されているIDと一致していることが確認できると思いまが、Autoconnect()メソッドは、この属性とフィールド名を頼りにしているようです。

というわけで、次の形式でフィールドを宣言します。

[UI] private/public 型(Gtk.Widget派生クラス) フィールド名(Gladeで設定したID) = null

このキッチンタイマーアプリでは、ウィジェット用フィールド宣言部は次のようになります。

/*省略*/
class MainWindow: Window {
	[UI] private Label _label1 = null;
	[UI] private Button m_up_button = null;
	[UI] private Button m_down_button = null;
	[UI] private Button s_up_button = null;
	[UI] private Button s_down_button = null;
	[UI] private Button start_stop_button = null;
/*省略*/
}
/*省略*/

Gladeの画面(右)とソースコード(左)の関係を図示すると

connect vars
フィールドの関連付け

こんな感じです。

ボタン操作への反応

Gtk.ButtonにはSystem.EventHandlerデリゲートのイベントプロパティがいくつかあり、そのうちボタン押下への反応に使えるのは、

  • Clicked
  • ButtonPressEvent
  • ButtonReleaseEvent

この3つでしょう。この内の一つにSystem.EventHandlerデリゲートとして使用可能なメソッドを+=で追加するだけです。

/*省略*/
namespace KitchenTimer {
	class MainWindow: Window {
		[UI] private Label _label1 = null;
		[UI] private Button m_up_button = null;
		[UI] private Button m_down_button = null;
		[UI] private Button s_up_button = null;
		[UI] private Button s_down_button = null;
		[UI] private Button start_stop_button = null;
		/*省略*/

		public MainWindow() : this(new Builder("MainWindow.glade")) { }

		private MainWindow(Builder builder) : base(builder.GetObject("MainWindow").Handle) {
			builder.Autoconnect(this);
			/*省略*/
			start_stop_button.ButtonReleaseEvent += startTimer;
			/*省略*/
			m_up_button.ButtonReleaseEvent += incM;
			m_down_button.ButtonReleaseEvent += decM;
			s_up_button.ButtonReleaseEvent += incS;
			s_down_button.ButtonReleaseEvent += decS;
			/*省略*/
		}

		/*省略*/

		void startTimer(object sender, EventArgs e) {
			/*ボタン操作への反応*/
		}

		/*省略*/

		void incM(object sender, EventArgs e) {
			/*ボタン操作への反応*/
		}

		void decM(object sender, EventArgs e) {
			/*ボタン操作への反応*/
		}

		void incS(object sender, EventArgs e) {
			/*ボタン操作への反応*/
		}

		void decS(object sender, EventArgs e) {
			/*ボタン操作への反応*/
		}
	}
}

終了時の後処理

再度生成されたコードを見てみると、

DeleteEvent += Window_DeleteEvent;

という行があります。DeleteEventはGtk.Widgetのイベントフィールドで、ウィンドウが閉じられたときに呼び出されます。よって終了時の後処理をするための雛形はもうできていることになります。追加の後処理がある場合、メソッドWindow_DeleteEventに追加するといいでしょう。

private void Window_DeleteEvent(object sender, DeleteEventArgs a)
{
	/*追加の後処理*/

	Application.Quit();/*この呼び出しがないと終了しない*/
}

GUIオブジェクトをGUIスレッドの外から操作

このキッチンタイマーアプリはGUIスレッドとタイマースレッドに分けて作成しますが、タイマースレッドが一秒刻むごとに表示用ラベル_label1の表示を書き換える必要があります。GtkSharpのプロパティ操作はスレッドセーフではないので、_label1の表示を巡ってタイマースレッドとGUIスレッドが競合します。これを避けるためにタイマースレッドからGUIスレッドに操作を依頼する必要があります。これをしてくれるのが、Gtk.Application.Invoke(System.EventHandler)です。

Application.Invoke ( delegate{
	/*GUIスレッドで実行する操作*/
} );

だいたいこんな感じの使い方になると思います(名前空間Gtkはusingされているので省略しています)。

最終的にこうなった

以上のことを踏まえて、外部スレッドで動くタイマーなどを作成し、出来上がったのがこちらです。

using System;
using Gtk;
using UI = Gtk.Builder.ObjectAttribute;

namespace KitchenTimer {
	class MainWindow: Window {
		[UI] private Label _label1 = null;
		[UI] private Button m_up_button = null;
		[UI] private Button m_down_button = null;
		[UI] private Button s_up_button = null;
		[UI] private Button s_down_button = null;
		[UI] private Button start_stop_button = null;
		TimerMain tm;
		bool counting;

		public MainWindow() : this(new Builder("MainWindow.glade")) { }

		private MainWindow(Builder builder) : base(builder.GetObject("MainWindow").Handle) {
			builder.Autoconnect(this);
			tm = new TimerMain();
			tm.updateLeftTime += updateLabelText;
			tm.setStat += setStat;

			start_stop_button.ButtonReleaseEvent += startTimer;

			DeleteEvent += Window_DeleteEvent;
			counting = false;

			m_up_button.ButtonReleaseEvent += incM;
			m_down_button.ButtonReleaseEvent += decM;
			s_up_button.ButtonReleaseEvent += incS;
			s_down_button.ButtonReleaseEvent += decS;

			updateLabelText();
		}

		private void Window_DeleteEvent(object sender, DeleteEventArgs a) {
			
			tm.exitThread();
			Application.Quit();
		}

		private void updateLabelText() {
			int
				sec = tm.getLeftTime(),
				show_min = sec / 60,
				show_sec = sec - (60 * show_min)
			;

			_label1.Markup = "<span font='32.0' weight='bold'>" + show_min.ToString() + "分\t" + show_sec.ToString() + "秒" + "</span>";
		}

		void startTimer(object sender, EventArgs e) {
			if(counting) {
				tm.pauseCountdown();
			}
			else {
				tm.resumeCountdown();
			}
		}

		void setStat(bool stat) {
			counting = stat;
			if(stat) {
				m_up_button.Sensitive = false;
				m_down_button.Sensitive = false;
				s_up_button.Sensitive = false;
				s_down_button.Sensitive = false;
			}
			else {
				m_up_button.Sensitive = true;
				m_down_button.Sensitive = true;
				s_up_button.Sensitive = true;
				s_down_button.Sensitive = true;
			}
		}

		void incM(object sender, EventArgs e) {
			tm.setLeftTime(tm.getLeftTime() + 60);
			updateLabelText();
		}

		void decM(object sender, EventArgs e) {
			int sec = tm.getLeftTime();

			sec -= 60;
			if(sec < 0) {
				sec += 60;
			}

			tm.setLeftTime(sec);
			updateLabelText();
		}

		void incS(object sender, EventArgs e) {
			tm.setLeftTime(tm.getLeftTime() + 1);
			updateLabelText();
		}

		void decS(object sender, EventArgs e) {
			int sec = tm.getLeftTime();

			sec -= 1;
			if(sec < 0) {
				sec = 0;
			}

			tm.setLeftTime(sec);
			updateLabelText();
		}
	}

	class TimerMain {
		bool exit, pause, sleeping;
		int left_sec;
		System.Threading.Thread th;
		System.Threading.SemaphoreSlim running_lock, pausing_lock, controll_lock;
		public delegate void LeftTimeUpdater();
		public event LeftTimeUpdater updateLeftTime;
		public delegate void CountingStatListener(bool stat);
		public event CountingStatListener setStat;

		void start(Object obj) {
			running_lock.Wait();
			sleeping = false;

			while(!exit) {
				if(pause) {
					pausing_lock.Wait();
					if(setStat != null) {
						Application.Invoke(delegate {
							setStat(false);
						});
					}

					running_lock.Release();

					try {
						sleeping = true;
						System.Threading.Thread.Sleep(System.Threading.Timeout.Infinite);
					}
					catch(System.Threading.ThreadInterruptedException e) {
						sleeping = false;
					}

					running_lock.Wait();

					if(setStat != null) {
						Application.Invoke(delegate {
							setStat(true);
						});
					}
					pausing_lock.Release();
				}

				if(updateLeftTime != null) {
					Application.Invoke(delegate {
						updateLeftTime();
					});
				}

				if(left_sec <= 0) {
					pause = true;
				}
				else {
					System.Threading.Thread.Sleep(999);
					left_sec--;
				}
			}
		}

		public TimerMain() {
			exit = false;
			pause = true;
			th = new System.Threading.Thread(start);
			running_lock = new System.Threading.SemaphoreSlim(1, 1);
			pausing_lock = new System.Threading.SemaphoreSlim(1, 1);
			controll_lock = new System.Threading.SemaphoreSlim(1, 1);

			th.Start();
		}

		public bool setLeftTime(int sec) {
			using(var l1 = new GIHPLib.Locker(controll_lock)) {
				using(var l2 = new GIHPLib.Locker(running_lock, try_lock: true)) {
					if(l2.hasLock()) {
						left_sec = sec;
						return true;
					}

					return false;
				}
			}
		}

		public int getLeftTime() {
			return left_sec;
		}

		public bool resumeCountdown() {
			using(var l1 = new GIHPLib.Locker(controll_lock)) {
				using(var l2 = new GIHPLib.Locker(running_lock, try_lock: true)) {
					if(l2.hasLock()) {
						if(pause) {
							pause = false;
							th.Interrupt();
						}
						return true;
					}

					return false;
				}
			}
		}

		public bool pauseCountdown() {
			using(var l1 = new GIHPLib.Locker(controll_lock)) {
				using(var l2 = new GIHPLib.Locker(pausing_lock, try_lock: true)) {
					if(l2.hasLock()) {
						pause = true;
						return true;
					}

					return false;
				}
			}
		}

		public void exitThread() {
			using(var l = new GIHPLib.Locker(controll_lock)) {
				exit = true;
				System.Threading.Thread.Yield();
				while(!th.Join(0)) {
					if(sleeping) {
						th.Interrupt();
					}
					System.Threading.Thread.Yield();
				}
			}
		}
	}
}

GIHPLib.Lockerという見慣れないクラスがあるかと思いますが、これはC++std::unique_lockみたいなのが欲しかったので作ったものです。一つのSystem.Threading.SemaphoreSlimオブジェクトを管理対象とし、usingステートメントを抜けると管理対象Wait()済みであればRelease()します。nupkg化してあるので、このアプリのプロジェクトには含まれておらず、KitchenTimer.csprojに参照するパッケージとして記述してあります。

namespace GIHPLib {
	public class Locker: System.IDisposable {
		System.Threading.SemaphoreSlim semaphore;
		bool have_lock;

		public Locker(System.Threading.SemaphoreSlim init_semaphore, bool with_lock = true, bool try_lock = false) {
			semaphore = init_semaphore;
			if(with_lock && semaphore != null) {
				if(try_lock) {
					if(semaphore.Wait(0)){
						have_lock = true;
					}
					else{
						have_lock = false;
					}
				}
				else {
					semaphore.Wait();
					have_lock = true;
				}
			}
			else {
				have_lock = false;
			}
		}

		public void getLock(bool try_lock = false) {
			if(!have_lock && semaphore != null) {
				if(try_lock) {
					if(semaphore.Wait(0)){
						have_lock = true;
					}
					else{
						have_lock = false;
					}
				}
				else {
					semaphore.Wait();
					have_lock = true;
				}
			}
		}

		public void releaseLock() {
			if(have_lock && semaphore != null) {
				semaphore.Release();
				have_lock = false;
			}
		}

		public bool hasLock(){
			return have_lock;
		}

		public void Dispose() {
			if(have_lock && semaphore != null) {
				semaphore.Release();
				have_lock = false;
			}
		}
	}
}

実は、C#の経験は、ASP.NET CoreRaspberry Piを制御するための簡単なWebページを作ったことがあるという程度で、故に不慣れな感じのコードになっているかもしれません。

普段はC++QtGUIツールキット)で店頭イベント用の小さなアプリとか作っているのですが、C++std::mutexやstd::unique_lock等の単純で簡単なものが欲しくなりました。

screenshot
5分のカップ麺もOK

次はSDLでアラーム対応化

このアプリ、キッチンタイマーのくせにアラームがなりません。というわけで、次はSDL2_mixerで何かしら鳴らせるようにしてみます。