1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

速習 GTK4 on Python on Windows

Posted at

はじめに

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

この記事で触れないもの

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

役に立った資料

環境構築 (Windows)

Condaを使ったインストール(かんたん)

公式ではMSYS2によるインストールを推奨していますが、Conda-forgeからAdwaita以外の主要パッケージをインストールすることができます。Adwaitaの利用を諦めればこれが一番簡単でしょう。

> conda install -c conda-forge gtk4 pygobject pycairo

MSYS2を使ったインストール

MSYS2を導入し、そこにPython環境も含めてGtk環境を構築します。
参考:https://pygobject.gnome.org/getting_started.html#windows-getting-started

MSYS2のインストール

まずMSYS2をインストールします。公式のインストーラを使ってもいいですが、私の環境にはChocolateyがあるのでそれを使います。

> choco install msys2

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

パッケージをインストール

pacmanを利用する。

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

Adwaitaを使いたい場合は

> pacman -S mingw-w64-ucrt-x86_64-libadwaita

(これで手に入れたlibadwaita.dllAdw-1.typelibを拝借すればconda環境にAdwを持ってこれる気がするのだがlibadwaita.dllが依存先含めlib*のprefix付いていたりして移植できなかった。。。)

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

Intellisenseを効かせる

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

> 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

UI定義の読み込み

Gtk.Template

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

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

入れ子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に対応しています。

タグ名は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

テーマカラーの指定

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

色を指定するときはこのあたりの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

重い処理を別スレッドで回す

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

実はGLibがasyncio用のloopを作ってくれますが、今のところ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

ラジオボタン

ラジオボタンという名前の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

メニューバーの表示

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

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

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

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

別ウィンドウ・ダイアログの表示

ApplicationWindowからアラートを出す場合は簡単。

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

ここのshowで親ウィンドウを指定する必要があるので、入れ子で込み入ったWidgetからアラートを出したい場合は自身を管理しているウィンドウを探す必要がある。

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環境においてなじみがあるのでこちらを紹介する。

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のドキュメントから目で翻訳しようとすると沼にはまる。

DropTargetAsyncdropシグナルをリッスンすると、ドラッグアンドドロップしたファイルに関する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に似ています。
以下は読み込んだ画像を中央に表示する機能です。scaletranslateによって描画位置をずらすテクは地味に使います。

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

画像 <-> Cairo

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

@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にダンプしたいときは少しバイナリいじりが必要になる。

    @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で...」みたいな面倒は省略できる。

    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()
1
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?