速習 GTK4 on Python on Windows

# はじめに
GTK4をWindows環境&Pythonでやろうとしたらまとまった資料がなくかなり苦しんだので、備忘録として最低限GUIを作るのに必要な知識をまとめました。

# この記事で触れないもの
* UIデザインツールの使い方
    * 使っていないので。PythonとXMLを気合いで書いていきます。
* libadwaita (Adw) の使い方
    * 後述のとおりWindows環境ではインストールが面倒なので。
* 正しい設計
    * わからないので行き当たりばったりで実装します。重い処理を非同期でやらせる話には触れます。

# 役に立った資料
* [PyGObject](https://pygobject.gnome.org/)
    * [Tutorial](https://pygobject.gnome.org/tutorials/index.html)の左のNavigationからチュートリアルに飛べます
* [GTK4 Widget Gallery](https://docs.gtk.org/gtk4/visual_index.html)
* [GTK4(Python) Tips](https://palepoli.skr.jp/tips/gtk4.php)
* [GTK4PythonTutorial](https://github.com/Taiko2k/GTK4PythonTutorial)

# 環境構築 (Windows)
## Condaを使ったインストール（かんたん）
公式では[MSYS2によるインストールを推奨](https://pygobject.gnome.org/getting_started.html#windows-getting-started)していますが、Conda-forgeからAdwaita以外の主要パッケージをインストールすることができます。Adwaitaの利用を諦めればこれが一番簡単でしょう。

```shell-session
> conda install -c conda-forge gtk4 pygobject pycairo
```

## MSYS2を使ったインストール
MSYS2を導入し、そこにPython環境も含めてGtk環境を構築します。
参考：https://pygobject.gnome.org/getting_started.html#windows-getting-started

### MSYS2のインストール
まずMSYS2をインストールします。[公式](https://www.msys2.org/)のインストーラを使ってもいいですが、私の環境には[Chocolatey](https://community.chocolatey.org/)があるのでそれを使います。

```shell-session
> choco install msys2
```

Windows Terminalに統合したい場合は→https://www.msys2.org/docs/terminals/

### パッケージをインストール
pacmanを利用する。

```bash
> pacman -Suy
> pacman -S mingw-w64-ucrt-x86_64-gtk4 mingw-w64-ucrt-x86_64-python3 mingw-w64-ucrt-x86_64-python3-gobject
```

Adwaitaを使いたい場合は

```bash
> pacman -S mingw-w64-ucrt-x86_64-libadwaita
```
(これで手に入れた`libadwaita.dll`と`Adw-1.typelib`を拝借すればconda環境にAdwを持ってこれる気がするのだが`libadwaita.dll`が依存先含め`lib*`のprefix付いていたりして移植できなかった。。。）

以降はAdwなしで書きます。

# Intellisenseを効かせる
Dynamicインポートのせいでこのままでは補完が効かない。以下のライブラリをインストールすると有効になる。

```shell-session
> pip install pygobject-stubs
```

# 動作確認
適当なPythonスクリプトで動作確認します

```sample.py
import sys
import gi
gi.require_version("GLib", "2.0")
gi.require_version("Gtk", "4.0")
from gi.repository import GLib, Gtk

class MyApplication(Gtk.Application):
    def __init__(self):
        super().__init__(application_id="com.example.MyGtkApplication")
        GLib.set_application_name("My Gtk Application")
        self.connect("activate", self.on_activate)

    def on_activate(self, *args):
        window = Gtk.ApplicationWindow(application=self, title="Hello World")
        window.present()


app = MyApplication()
exit_status = app.run()
sys.exit(exit_status)
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/222311/075c354a-b767-5e25-60dd-30479bf2200d.png)

# UI定義の読み込み
## Gtk.Template
GTKはXML形式で書かれたUI定義ファイルを読み込むことで入り組んだWidgetを作成できる。
C APIでは`gtk_builder_new_from_file`などをこねこねしているがPythonの場合は`@Gtk.Template`デコレータを利用することで簡単に読み込める。

https://pygobject.gnome.org/guide/gtk_template.html

```main_window.ui
<interface>
<template class="MainWindow" parent="GtkApplicationWindow"> <!-- parentで指定したWidgetを継承 -->
  <property name="title">Hello World</property>
  <child>
    <object class="GtkBox">
      <property name="orientation">vertical</property>
      <child>
        <object class="GtkLabel" id="color_label">  <!-- idの使い方は後述 -->
          <property name="label">CLICK ANY BUTTON</property>
        </object>
      </child>
      <child>
        <object class="GtkBox">
          <property name="orientation">horizontal</property>
          <child>
            <object class="GtkButton">
              <property name="label">RED</property>
              <property name="hexpand">true</property>
              <signal name="clicked" handler="on_red_clicked"/> <!-- handlerの使い方は後述 -->
            </object>
          </child>
          <child>
            <object class="GtkButton">
              <property name="label">GREEN</property>
              <property name="hexpand">true</property>
              <signal name="clicked" handler="on_green_clicked"/>
            </object>
          </child>
        </object>
      </child>
    </object>
  </child>
</template>
</interface>
```

```main_window.py
import gi
gi.require_version("Gtk", "4.0")
from gi.repository import Gtk

# Templateデコレータで読み込み。文字列を与えたい場合はstring=**
@Gtk.Template(filename="main_window.ui")
class MainWindow(Gtk.ApplicationWindow):  # 継承元はuiファイル内のparentとそろえる
    __gtype_name__ = "MainWindow"  # ここにuiファイル内のtemplate class名を書く

    # 変数名にuiで指定したidを使うことで対応したWidgetインスタンスを参照できる（Python黒魔術...）
    # 型もつけておくと吉
    color_label: Gtk.Label = Gtk.Template.Child()

    # Callbackデコレータを付けてhandlerと同じ名前の関数を定義するとコールバックを紐づけられる
    @Gtk.Template.Callback()
    def on_red_clicked(self, *args):
        self.color_label.set_label("RED LIGHT!")

    @Gtk.Template.Callback()
    def on_green_clicked(self, *args):
        self.color_label.set_label("GREEN LIGHT!")
```


```sample.py
import sys
import gi
gi.require_version("GLib", "2.0")
gi.require_version("Gtk", "4.0")
from gi.repository import GLib, Gtk

from main_window import MainWindow

class MyApplication(Gtk.Application):
    def __init__(self):
        super().__init__(application_id="com.example.MyGtkApplication")
        GLib.set_application_name("My Gtk Application")
        self.connect("activate", self.on_activate)

    def on_activate(self, *args):
        window = MainWindow(application=self, title="Hello World")
        window.present()


app = MyApplication()
exit_status = app.run()
sys.exit(exit_status)
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/222311/0272bcde-a694-6580-03ae-b9c2f878f274.png)

## 入れ子Widget
UIファイルの中に他で定義した独自Widgetを使うこともできますが、インスタンス生成時点で入れ子になっているWidgetのモジュールもimportされている必要があります。するとPythonからはunused importなので見た目が嫌だという場合は独自Widgetをフォルダにまとめて`__init__.py`にimportしておくと良いかも。

ちなみに入れ子Widgetをimportしていないと以下のようなエラーメッセージが表示されます。
```
Gtk-CRITICAL **: 00:40:57.955: Error building template class 'MainWindow' for an instance of type 'MainWindow': .:0:0 Invalid object type 'MyLabel'
 ```

## トランスパイラについて
UIファイルは一応人間可読ではありますが、大量の`<child>` `<property>`を書かされるので手で書くのは苦痛です。そこで私は以下のようにReactJSっぽく書いたものを動的に変換しています。

```main_window.xml
<interface>
<template class="MainWindow" parent="GtkApplicationWindow">
  <child>
    <Box orientation="horizontal">
      <Box orientation="vertical" widthRequest="400">
...
```
これが
```main_window.ui
<interface>
<template class="MainWindow" parent="GtkApplicationWindow">
  <child>
    <object class="GtkBox">
      <property name="orientation">horizontal</property>
      <child>
        <object class="GtkBox">
          <property name="orientation">vertical</property>
          <property name="width-request">400</property>
...
```
こうなる

どちらもXML準拠なので`xml.etree.ElementTree`を使って再帰的に変換できます。

```transpile.py
from gi.overrides import Gtk
from pathlib import Path
import xml.etree.ElementTree as ET
import re

def _get_class_name(tag: str) -> str:
    if tag.startswith("_"):
        # custom widget
        return tag[1:]
    else:
        # builtin widget
        return "Gtk" + tag

def _transpile(from_element: ET.Element, to_element: ET.Element) -> None:
    if from_element.tag[0].isupper() or from_element.tag[0] == "_":
        # syntax sugar for object tag
        new_element = ET.SubElement(to_element, "object", {"class": _get_class_name(from_element.tag)})
        for key, value in from_element.attrib.items():
            if key == "id":
                new_element.attrib["id"] = from_element.attrib["id"]
            elif key == "className":
                # prepare css classes
                style = ET.SubElement(new_element, "style")
                for name in value.split(" "):
                    ET.SubElement(style, "class", {"name": name})
            elif key.startswith("on-"):
                # prepare signal handler
                ET.SubElement(new_element, "signal", {"name": key[3:], "handler": value})
            else:
                ET.SubElement(new_element, "property", {"name": key}).text = value
        for from_child in from_element:
            to_child = ET.SubElement(new_element, "child")
            _transpile(from_child, to_child)
    else:
        # normal tag
        new_element = ET.SubElement(to_element, from_element.tag, from_element.attrib)
        for from_child in from_element:
            _transpile(from_child, new_element)

def transpile(xmlstr: str, pprint: bool = False) -> str:
    root = ET.fromstring(xmlstr)
    assert root.tag == "interface"

    result_root = ET.Element("interface")
    ET.SubElement(result_root, "requires", {"lib": "gtk", "version": "4.0"})

    for definition in root:
        _transpile(definition, result_root)

    if pprint:
        ET.indent(result_root)
    return ET.tostring(result_root, encoding="utf-8", xml_declaration=True).decode()
```

ただし、あまりGtkに詳しくないうちに完璧なトランスパイラを書こうとすると後で（childタグにattributeが現れたりして）裏切られるのでほどほどにしましょう（1敗）

# Inspectorを出す
Ctrl + Shift + DでInspectorが出せます。

# CSSの導入
GtkはCSSに対応しています。

https://docs.gtk.org/gtk4/css-overview.html

タグ名はprefixの`Gtk`を抜いて小文字にしたもの(GtkScale -> scale)で、idやclass nameも使えます。class nameは`<style>`タグで複数埋められます。

```main_window.ui
<interface>
<template class="MainWindow" parent="GtkApplicationWindow">
  <property name="title">Hello World</property>
  <child>
    <object class="GtkButton">
      <property name="label">Go!</property>
      <style>
        <class name="go"/>
        <class name="margin-4"/>
      </style>
    </object>
  </child>
</template>
</interface>
```

```style.css
.go {
    background-image: none; /* 背景色を変えるときはこれが必須 */
    background-color: red;
    color: white;
}
.margin-4 {
    margin: 4px;
}
```

```sample.py.diff
import sys
import gi
gi.require_version("GLib", "2.0")
gi.require_version("Gtk", "4.0")
gi.require_version("Gdk", "4.0")
from gi.repository import GLib, Gtk, Gdk

from main_window import MainWindow
from my_label import MyLabel

class MyApplication(Gtk.Application):
    def __init__(self):
        super().__init__(application_id="com.example.MyGtkApplication")
        GLib.set_application_name("My Gtk Application")
        self.connect("activate", self.on_activate)

    def on_activate(self, *args):
+        provider = Gtk.CssProvider.new()
+        provider.load_from_path("style.css")
+        Gtk.StyleContext.add_provider_for_display(Gdk.Display.get_default(), provider, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION)
        window = MainWindow(application=self, title="Hello World")
        window.present()


app = MyApplication()
exit_status = app.run()
sys.exit(exit_status)
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/222311/44d8ec73-dda8-0706-dd82-3afe2087b138.png)


## テーマカラーの指定
GTKはダークモードがあったりして、テーマカラーが定められています。関係する変数は以下のソースコードで確認できます。

https://gitlab.gnome.org/GNOME/gtk/-/blob/gtk-4-16/gtk/theme/Default/_colors-public.scss?ref_type=heads

色を指定するときはこのあたりのCSS変数を使った方が後々楽でしょう。
（ところでVSCodeでproperty value expectedの波線が引かれるのを止めるにはどうすればいいんですかね？）

また、特にボタンについては `suggested-action` `destructive-action` という特殊なクラスがあり、これを付けると色が変わります。

```style.css
.go {
    background-image: none;
    background-color: @theme_selected_bg_color;
    color: @theme_selected_fg_color;
}
.margin-4 {
    margin: 4px;
}
```

```sample.py.diff
import sys
import gi
gi.require_version("GLib", "2.0")
gi.require_version("Gtk", "4.0")
gi.require_version("Gdk", "4.0")
from gi.repository import GLib, Gtk, Gdk

from main_window import MainWindow
from my_label import MyLabel

class MyApplication(Gtk.Application):
    def __init__(self):
        super().__init__(application_id="com.example.MyGtkApplication")
        GLib.set_application_name("My Gtk Application")
        self.connect("activate", self.on_activate)
+        # Dark mode設定
+        Gtk.Settings.get_default().set_property("gtk-application-prefer-dark-theme", True)

    def on_activate(self, *args):
        provider = Gtk.CssProvider.new()
        provider.load_from_path("style.css")
        Gtk.StyleContext.add_provider_for_display(Gdk.Display.get_default(), provider, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION)
        window = MainWindow(application=self, title="Hello World")
        window.present()


app = MyApplication()
exit_status = app.run()
sys.exit(exit_status)
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/222311/2c4ca72e-a97a-4c85-797c-25eba1175038.png)

# 重い処理を別スレッドで回す
tkinterでも同様ですが、重たい処理をそのまま実行してしまうとウィンドウごと固まってしまいます。これはメインスレッドでは軽い処理にとどめて、重い処理を非同期で回せば解決します。
ただし、Gtkの管轄外からUIにアクセスすると壊れるので、（重たい処理を終えた後のコールバックなどで）別スレッドから叩きたい場合は`GLib.idle_add()`で続きの処理をGtkに渡します。

https://pygobject.gnome.org/guide/threading.html

実は[GLibがasyncio用のloopを作ってくれます](https://pygobject.gnome.org/guide/asynchronous.html)が、今のところexperimentalのようなのでいったん新規スレッドで回します。

この新規スレッドの参照をどこで持つのかについてのベストプラクティスは知りません。tkinterとは違ってガベコレも走らないようなので適当なグローバル変数においても問題ないし、Applicationが持っても問題ないと思います。いずれの場合も奥深くのWidgetから簡単に参照することができます。

```main_window.ui
<interface>
<template class="MainWindow" parent="GtkApplicationWindow">
  <property name="title">Hello World</property>
  <child>
    <object class="GtkBox">
      <property name="orientation">vertical</property>
      <child>
        <object class="GtkButton" id="go_button">
          <property name="label">Go!</property>
          <signal name="clicked" handler="on_go_clicked"/>
          <style>
            <class name="suggested-action"/>
            <class name="margin-4"/>
          </style>
        </object>
      </child>
      <child>
        <object class="GtkProgressBar" id="progress">
          <property name="hexpand">true</property>
        </object>
      </child>
    </object>
  </child>
</template>
</interface>
```

```main_window.py
import asyncio
import gi
gi.require_version("GLib", "2.0")
gi.require_version("Gtk", "4.0")
from gi.repository import Gtk, GLib

@Gtk.Template(filename="main_window.ui")
class MainWindow(Gtk.ApplicationWindow):
    __gtype_name__ = "MainWindow"

    go_button: Gtk.Button = Gtk.Template.Child()
    progress: Gtk.ProgressBar = Gtk.Template.Child()

    async def long_process(self):
        def update_progress(i):
            self.progress.set_fraction(i / 10)
        GLib.idle_add(lambda: update_progress(0))
        for i in range(10):
            await asyncio.sleep(1)
            GLib.idle_add(lambda: update_progress(i+1))

    def lock_ui(self):
        self.go_button.set_sensitive(False)

    def unlock_ui(self):
        self.go_button.set_sensitive(True)

    @Gtk.Template.Callback()
    def on_go_clicked(self, *args):
        # 自身が所属しているGtkApplicationインスタンスを取得
        application = self.get_ancestor(Gtk.ApplicationWindow).get_application()
        self.lock_ui()
        fut = asyncio.run_coroutine_threadsafe(self.long_process(), application.loop)
        fut.add_done_callback(lambda _: GLib.idle_add(self.unlock_ui))
```

```sample.py
import asyncio
import threading
import sys
import gi
gi.require_version("GLib", "2.0")
gi.require_version("Gtk", "4.0")
gi.require_version("Gdk", "4.0")
from gi.repository import GLib, Gtk, Gdk

from main_window import MainWindow
from my_label import MyLabel

class MyApplication(Gtk.Application):
    def __init__(self):
        super().__init__(application_id="com.example.MyGtkApplication")
        GLib.set_application_name("My Gtk Application")
        self.connect("activate", self.on_activate)
        Gtk.Settings.get_default().set_property("gtk-application-prefer-dark-theme", False)

        # 💡重い処理用のループ
        self.loop = asyncio.new_event_loop()
        self.thread = threading.Thread(target=self.loop.run_forever, daemon=True)
        self.thread.start()

    def on_activate(self, *args):
        provider = Gtk.CssProvider.new()
        provider.load_from_path("style.css")
        Gtk.StyleContext.add_provider_for_display(Gdk.Display.get_default(), provider, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION)
        window = MainWindow(application=self, title="Hello World")
        window.present()


app = MyApplication()
exit_status = app.run()
sys.exit(exit_status)
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/222311/8919231e-9c65-9859-af35-2c53a788a827.png)

# ラジオボタン
ラジオボタンという名前のWidgetは存在せず、CheckButtonをグルーピングすることでラジオボタンに変化する。
UIファイルでは`group`プロパティに代表者のidを指定することで作れる。

```radio.ui
<object class="GtkBox">
  <property name="orientation">vertical</property>
  <child>
    <object class="GtkCheckButton" id="radio1">
      <property name="label">one</property>
    </object>
  </child>
  <child>
    <object class="GtkCheckButton" id="radio2">
      <property name="active">true</property>
      <property name="label">two</property>
      <property name="group">radio1</property>
    </object>
  </child>
  <child>
    <object class="GtkCheckButton" id="radio3">
      <property name="label">three</property>
      <property name="group">radio1</property>
    </object>
  </child>
</object>
```

# 数値を入れるやつ (Scale, SpinButton) と Adjustment
スピナーはステップサイズや値域などをGtkAdjustmentで指定する。UIファイルでは以下のように`<interface>`直下にGtkAdjustmentのオブジェクトを定義して、スピナーなどからはそのidを参照する。
注意点として、定義したGtkAdjustmentは実体なので、同じ値域だからといって使いまわすと値が一緒に変化してしまう。

```main_window.ui
<interface>
<object class="GtkAdjustment" id="scale_adjustment">
  <property name="lower">0.0</property>
  <property name="upper">10.0</property>
  <property name="step-increment">0.1</property>
  <property name="page-increment">1.0</property>  <!-- PageUp/PageDownの変化量 -->
  <property name="value">5.0</property>  <!-- 初期値 -->
</object>
<template class="MainWindow" parent="GtkApplicationWindow">
  <property name="title">Hello World</property>
  <child>
    <object class="GtkBox">
      <property name="orientation">vertical</property>
      <child>
        <object class="GtkSpinButton">
          <property name="adjustment">scale_adjustment</property>
          <property name="digits">1</property>  <!-- 表示する小数点以下の桁数 -->
        </object>
      </child>
    </object>
  </child>
</template>
</interface>
```
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/222311/a387b45b-f948-5052-758d-be71ca251a18.png)

# メニューバーの表示
`<menu>`タグを使ったUIファイルを作り、`Gtk.Application`の`set_menubar()`で紐づける。
デフォルトではメニューバーは出てこないので`set_show_menubar(True)`が必要。
クリック時のアクションは、アプリケーションとして行うべきものはUIファイル内のaction nameを`app.hoge`としたうえで`GtkApplication`に`hoge`というアクションを紐づけ、一方ウィンドウとして行うべきものはaction nameを`win.fuga`としたうえで`GtkApplicationWindow`に`fuga`というアクションを紐づける。使い分け方はいまいちわからない。

```menu.ui
<interface>
<menu id="menu">
    <submenu>
        <attribute name="label">File</attribute>  <!-- menubarの見出し -->
        <section>
            <item>
                <attribute name="label">Open File...</attribute>
                <attribute name="action">app.open_file</attribute>
            </item>
            <item>
                <attribute name="label">Open Folder...</attribute>
                <attribute name="action">app.open_folder</attribute>
            </item>
        </section>  <!-- sectionで分けると表示上で仕切り線が現れる -->
        <section>
            <item>
                <attribute name="label">Print...</attribute>
                <attribute name="action">app.print</attribute>
            </item>
        </section>
    </submenu>
    <submenu>
        <attribute name="label">View</attribute>
        <section>
            <item>
                <attribute name="label">Show Grid</attribute>
                <attribute name="action">win.show_grid</attribute>
            </item>
        </section>
    </submenu>
</menu>
</interface>
```

```sample.py
import sys
import gi
gi.require_version("GLib", "2.0")
gi.require_version("Gtk", "4.0")
gi.require_version("Gdk", "4.0")
gi.require_version("Gio", "2.0")
from gi.repository import GLib, Gtk, Gdk, Gio

from main_window import MainWindow

class MyApplication(Gtk.Application):
    def __init__(self):
        super().__init__(application_id="com.example.MyGtkApplication")
        GLib.set_application_name("My Gtk Application")
        self.connect("activate", self.on_activate)
        self.connect("startup", self.on_startup)

    def on_startup(self, *args):
        menu = Gtk.Builder.new_from_file("./menu.ui").get_object("menu")
        self.set_menubar(menu)

    def on_activate(self, *args):
        provider = Gtk.CssProvider.new()
        provider.load_from_path("style.css")
        Gtk.StyleContext.add_provider_for_display(Gdk.Display.get_default(), provider, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION)
        window = MainWindow(application=self, title="Hello World")
        window.set_show_menubar(True)
        window.present()

        # app.open_file
        open_file_action = Gio.SimpleAction.new("open_file", None)
        open_file_action.connect("activate", self.on_open_file)
        self.add_action(open_file_action)
        # app.open_folder
        open_folder_action = Gio.SimpleAction.new("open_folder", None)
        open_folder_action.connect("activate", self.on_open_folder)
        self.add_action(open_folder_action)
        # 紐づいていないActionはdisable表示になる

    def on_open_file(self, *args):
        print("Open file")
    
    def on_open_folder(self, *args):
        print("Open folder")

app = MyApplication()
exit_status = app.run()
sys.exit(exit_status)
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/222311/9019bb17-dd88-1212-aed7-88647914d6a0.png)

チェックボックスをメニューに入れ込むこともできる。

```main_window.py
import gi
gi.require_version("GLib", "2.0")
gi.require_version("Gtk", "4.0")
gi.require_version("Gio", "2.0")
from gi.repository import GLib, Gtk, Gio

@Gtk.Template(filename="main_window.ui")
class MainWindow(Gtk.ApplicationWindow):
    __gtype_name__ = "MainWindow"
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        # win.show_grid
        self.show_grid = Gio.SimpleAction.new_stateful("show_grid", None, GLib.Variant.new_boolean(False))
        self.show_grid.connect("change-state", self.on_show_grid)
        self.add_action(self.show_grid)
    
    def on_show_grid(self, action, value):
        print("Show grid:", value.get_boolean())
        self.show_grid.set_state(value)
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/222311/451d67fa-a080-8295-7b3e-f07afdda26fc.png)


# 別ウィンドウ・ダイアログの表示
`ApplicationWindow`からアラートを出す場合は簡単。
```python
Gtk.AlertDialog(message=f"Notice", detail="Only one file will be opened.").show(self)
```
ここの`show`で親ウィンドウを指定する必要があるので、入れ子で込み入ったWidgetからアラートを出したい場合は自身を管理しているウィンドウを探す必要がある。

```python
window = self.get_ancestor(Gtk.ApplicationWindow)
Gtk.AlertDialog(message=f"Notice", detail="Only one file will be opened.").show(window)
```

サブウィンドウを作る時は`Gtk.Window`を継承したクラスを作成する。モーダル（閉じるまで他ウィンドウをいじれない）にしたい場合はサブウィンドウの`modal`プロパティを`true`にする。

```mask_editor_window.py
class MaskEditorWindow(Gtk.Window):
    __gtype_name__ = "MaskEditorWindow"
    ...
```

```main_window.py
    @Gtk.Template.Callback()
    def on_open_mask_button_clicked(self, button):
        if self.mask_window is None:
            self.mask_window = MaskEditorWindow()
            # 開いたり閉じたりで参照の管理が面倒なときは、閉じるボタンを押しても隠すだけにする。
            self.mask_window.set_hide_on_close(True)
        self.mask_window.present()  # このウィンドウを手前に持ってくる
```

# 「ファイルを開く」
ファイル選択ダイアログを表示する。Gtkによる実装の`Gtk.FileDialog`とOSが提供する`Gtk.FileChooserNative`があり、後者はdeprecatedであるものの、Windows環境においてなじみがあるのでこちらを紹介する。

```python
from PIL import Image
from functools import partial
def load_image_with_dialog(window: Gtk.ApplicationWindow, resolve: Callable[[Image.Image], None]):
    def callback(resolve, dialog, response):
        if response == Gtk.ResponseType.ACCEPT:
            try:
                file = window.open_dialog.get_file()
                if file is not None:
                    print(f"File path is {file.get_path()}")
                    resolve(Image.open(file.get_path()))
            except GLib.Error as error:
                print(error)
                Gtk.AlertDialog(message=f"Error opening file", detail=error.message).show(window)
            except Exception as error:
                Gtk.AlertDialog(message=f"Unknown Error", detail=f"Unexpected {error=}").show(window)

    # 生成したインスタンスはGtkのオブジェクトにぶら下げる必要が（おそらく）ある
    window.open_dialog = Gtk.FileChooserNative.new(title="Open File", parent=window, action=Gtk.FileChooserAction.OPEN)
    window.open_dialog.set_modal(True)
    window.open_dialog.set_transient_for(window)
    window.open_dialog.connect("response", partial(callback, resolve))
    window.open_dialog.show()
```


# アプリケーション外部からのドラッグアンドドロップ（非同期処理）
同期版はこちら→ https://pygobject.gnome.org/tutorials/gtk4/drag-and-drop.html
C APIのドキュメントから目で翻訳しようとすると沼にはまる。

`DropTargetAsync`の`drop`シグナルをリッスンすると、ドラッグアンドドロップしたファイルに関する`Gdk.Drop`オブジェクトが手に入るので、`read_value_async`でファイルの中身を非同期的に取り出す。似たメソッドに`gdk_drop_read_async`があるがアプリがクラッシュするなどしてうまく使えなかった。

```window.py
class Window(Gtk.ApplicationWindow):
    __gtype_name__ = "Window"
    widget = Gtk.Template.Child()

    def __init__(self):
        super().__init__()
        drop = Gtk.DropTargetAsync(
            actions=Gdk.DragAction.COPY,
            formats=Gdk.ContentFormats.new(["text/uri-list"])  # D&Dで実際に飛んでくるmimetype
        )
        self.widget.add_controller(drop)  # D&Dを有効化したいウィジェットに紐づける
        drop.connect("drop", self.on_drop_file)

    def on_drop_file(self, widget, drop, x, y):
        # D&Dは単一ファイルを放り込んだ場合でもファイルリストの形で飛んでくる
        drop.read_value_async(
            Gdk.FileList,
            GLib.PRIORITY_DEFAULT,
            None,
            self.drop_file_callback,
        )
        drop.finish(Gdk.DragAction.COPY)

    def drop_file_callback(self, drop, result):
        files = drop.read_value_finish(result).get_files()
        if len(files) > 1:
            print("Warning: Only one file will be opened.")
        file = files[0]
        print("Opening", file.get_path(), "...")
```


# GtkDrawingAreaとCairo
Javascriptでいうところのcanvasと2D contextです。描画APIの使用感もcanvasに似ています。
以下は読み込んだ画像を中央に表示する機能です。`scale`や`translate`によって描画位置をずらすテクは地味に使います。

```main_window.py
@Gtk.Template(filename="main_window.ui")
class MainWindow(Gtk.ApplicationWindow):
    __gtype_name__ = "MainWindow"
    canvas: Gtk.DrawingArea = Gtk.Template.Child()

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.canvas.set_draw_func(self.on_draw, None)
        image = Image.open("sample.png").convert("RGB")
        data = image.tobytes()
        w, h = image.size
        data = GLib.Bytes.new(data)
        self.pixbuf = GdkPixbuf.Pixbuf.new_from_bytes(data, GdkPixbuf.Colorspace.RGB, False, 8, w, h, w * 3)
        self.canvas.queue_draw()  # DrawingAreaの描画を実行する

    def on_draw(self, widget, cr: cairo.Context, width, height, data):
        cr.set_source_rgba(0, 0, 0, 0)  # transparent color
        cr.paint()
        if self.pixbuf:
            buf_width = self.pixbuf.get_width()
            buf_height = self.pixbuf.get_height()
            scale_on_fitting_height = height / buf_height
            scale_on_fitting_width = width / buf_width
            cr.save()
            if scale_on_fitting_height < scale_on_fitting_width:
                # fit height and centering width
                scale = scale_on_fitting_height
                cr.translate((width - buf_width * scale) / 2, 0)
                cr.scale(scale, scale)
            else:
                # fit width and centering height
                scale = scale_on_fitting_width
                cr.translate(0, (height - buf_height * scale) / 2)
                cr.scale(scale, scale)
            Gdk.cairo_set_source_pixbuf(cr, self.pixbuf, 0, 0)
            cr.paint()
            cr.restore()
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/222311/dc2ff284-7bb9-9a4c-b5ab-86296fdc16bb.png)

## 画像 <-> Cairo
画像をCairoに描画するときは上のように`GdkPixbuf.Pixbuf`を経由すればよい。
逆の場合は`cairo.ImageSurface`上に移してからpngにダンプする。(もっといい方法があるかも...)
`cairo.Surface`やそのContextの生成は他にもオフスクリーンレンダリングなどにも活用できる。


```diff
@Gtk.Template(filename="main_window.ui")
class MainWindow(Gtk.ApplicationWindow):
    __gtype_name__ = "MainWindow"
    canvas: Gtk.DrawingArea = Gtk.Template.Child()

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
+        self.rgb_surface = None
        self.canvas.set_draw_func(self.on_draw, None)
        image = Image.open("sample.png").convert("RGB")
        data = image.tobytes()
        w, h = image.size
        data = GLib.Bytes.new(data)
        self.pixbuf = GdkPixbuf.Pixbuf.new_from_bytes(data, GdkPixbuf.Colorspace.RGB, False, 8, w, h, w * 3)
        self.canvas.queue_draw()  # DrawingAreaの描画を実行する

    def on_draw(self, widget, cr: cairo.Context, width, height, data):
        # cr.set_source_rgba(0, 0, 0, 0)  # transparent color
        cr.set_source_rgba(0, 0, 0)  # black color
        cr.paint()
        if self.pixbuf:
            buf_width = self.pixbuf.get_width()
            buf_height = self.pixbuf.get_height()
            scale_on_fitting_height = height / buf_height
            scale_on_fitting_width = width / buf_width
            cr.save()
            if scale_on_fitting_height < scale_on_fitting_width:
                # fit height and centering width
                scale = scale_on_fitting_height
                cr.translate((width - buf_width * scale) / 2, 0)
                cr.scale(scale, scale)
            else:
                # fit width and centering height
                scale = scale_on_fitting_width
                cr.translate(0, (height - buf_height * scale) / 2)
                cr.scale(scale, scale)
            Gdk.cairo_set_source_pixbuf(cr, self.pixbuf, 0, 0)
            cr.paint()
            cr.restore()
+        if self.rgb_surface is not None:
+            self.rgb_surface.finish()
+        self.rgb_surface = cairo.ImageSurface(cairo.Format.RGB24, width, height)
+        rgb_cr = cairo.Context(self.rgb_surface)
+        rgb_cr.set_source_surface(cr.get_target())
+        rgb_cr.paint()
        
+    @Gtk.Template.Callback()
+    def on_save(self, button):
+        if self.rgb_surface is not None:
+            self.rgb_surface.write_to_png("output.png")
+            print("Saved to output.png")
```

pngではなくPIL.Imageにダンプしたいときは少しバイナリいじりが必要になる。

```python
    @Gtk.Template.Callback()
    def on_save(self, button):
        if self.rgb_surface is not None:
            height = self.rgb_surface.get_height()
            width = self.rgb_surface.get_width()
            # https://pycairo.readthedocs.io/en/latest/reference/enums.html#cairo.Format.RGB24
            # each pixel is a 32-bit quantity, with the upper 8 bits unused. 1 Red, Green, and Blue are stored in the remaining 24 bits in that order.
            arr = np.frombuffer(self.rgb_surface.get_data().tobytes(), dtype=np.int32).reshape(height, width).copy()
            # convert to RGB
            arr = np.stack([
                arr >> 16 & 0xff,
                arr >> 8 & 0xff,
                arr & 0xff
            ], axis=-1)
            arr = arr.astype(np.uint8)
            Image.fromarray(arr).save("output.png")
            print("Saved to output.png")
```

## マウスイベント
DrawingAreaにお絵描きしたいときに利用する。「Widget内でドラッグ」というシグナルが存在するので「onMouseDownでフラグを立ててonMouseMoveで...」みたいな面倒は省略できる。

```python
    def __init__(self, *args, **kwargs):
        ...
        # mouse move
        self.cursor_point = (0, 0)
        evk = Gtk.EventControllerMotion.new()
        evk.connect("motion", self.on_mouse_motion)
        self.canvas.add_controller(evk)
        # mouse drag event
        evk = Gtk.GestureDrag.new()
        evk.connect("drag-begin", self.on_drag_begin)
        evk.connect("drag-update", self.on_drag_update)
        evk.connect("drag-end", self.on_drag_end)
        self.canvas.add_controller(evk)

    def on_mouse_motion(self, motion, x, y):
        self.cursor_point = (x, y)
        self.canvas.queue_draw()
        
    def on_drag_begin(self, drag, x, y):
        # ドラッグ開始点 (x, y)
        self.canvas.queue_draw()
    
    def on_drag_update(self, drag, x, y):
        # ドラッグ開始点からのオフセット (x, y)
        self.canvas.queue_draw()
    
    def on_drag_end(self, drag, x, y):
        # ドラッグの終了
        self.canvas.queue_draw()
```