はじめに
GTK4をWindows環境&Pythonでやろうとしたらまとまった資料がなくかなり苦しんだので、備忘録として最低限GUIを作るのに必要な知識をまとめました。
この記事で触れないもの
- UIデザインツールの使い方
- 使っていないので。PythonとXMLを気合いで書いていきます。
- libadwaita (Adw) の使い方
- 後述のとおりWindows環境ではインストールが面倒なので。
- 正しい設計
- わからないので行き当たりばったりで実装します。重い処理を非同期でやらせる話には触れます。
役に立った資料
-
PyGObject
- Tutorialの左のNavigationからチュートリアルに飛べます
- GTK4 Widget Gallery
- GTK4(Python) Tips
- GTK4PythonTutorial
環境構築 (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.dll
とAdw-1.typelib
を拝借すればconda環境にAdwを持ってこれる気がするのだがlibadwaita.dll
が依存先含めlib*
のprefix付いていたりして移植できなかった。。。)
以降はAdwなしで書きます。
Intellisenseを効かせる
Dynamicインポートのせいでこのままでは補完が効かない。以下のライブラリをインストールすると有効になる。
> pip install pygobject-stubs
動作確認
適当なPythonスクリプトで動作確認します
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)
UI定義の読み込み
Gtk.Template
GTKはXML形式で書かれたUI定義ファイルを読み込むことで入り組んだWidgetを作成できる。
C APIではgtk_builder_new_from_file
などをこねこねしているがPythonの場合は@Gtk.Template
デコレータを利用することで簡単に読み込める。
<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>
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!")
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)
入れ子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っぽく書いたものを動的に変換しています。
<interface>
<template class="MainWindow" parent="GtkApplicationWindow">
<child>
<Box orientation="horizontal">
<Box orientation="vertical" widthRequest="400">
...
これが
<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
を使って再帰的に変換できます。
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>
タグで複数埋められます。
<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>
.go {
background-image: none; /* 背景色を変えるときはこれが必須 */
background-color: red;
color: white;
}
.margin-4 {
margin: 4px;
}
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)
テーマカラーの指定
GTKはダークモードがあったりして、テーマカラーが定められています。関係する変数は以下のソースコードで確認できます。
色を指定するときはこのあたりのCSS変数を使った方が後々楽でしょう。
(ところでVSCodeでproperty value expectedの波線が引かれるのを止めるにはどうすればいいんですかね?)
また、特にボタンについては suggested-action
destructive-action
という特殊なクラスがあり、これを付けると色が変わります。
.go {
background-image: none;
background-color: @theme_selected_bg_color;
color: @theme_selected_fg_color;
}
.margin-4 {
margin: 4px;
}
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)
重い処理を別スレッドで回す
tkinterでも同様ですが、重たい処理をそのまま実行してしまうとウィンドウごと固まってしまいます。これはメインスレッドでは軽い処理にとどめて、重い処理を非同期で回せば解決します。
ただし、Gtkの管轄外からUIにアクセスすると壊れるので、(重たい処理を終えた後のコールバックなどで)別スレッドから叩きたい場合はGLib.idle_add()
で続きの処理をGtkに渡します。
実はGLibがasyncio用のloopを作ってくれますが、今のところexperimentalのようなのでいったん新規スレッドで回します。
この新規スレッドの参照をどこで持つのかについてのベストプラクティスは知りません。tkinterとは違ってガベコレも走らないようなので適当なグローバル変数においても問題ないし、Applicationが持っても問題ないと思います。いずれの場合も奥深くのWidgetから簡単に参照することができます。
<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>
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))
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)
ラジオボタン
ラジオボタンという名前のWidgetは存在せず、CheckButtonをグルーピングすることでラジオボタンに変化する。
UIファイルではgroup
プロパティに代表者のidを指定することで作れる。
<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は実体なので、同じ値域だからといって使いまわすと値が一緒に変化してしまう。
<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>
メニューバーの表示
<menu>
タグを使ったUIファイルを作り、Gtk.Application
のset_menubar()
で紐づける。
デフォルトではメニューバーは出てこないのでset_show_menubar(True)
が必要。
クリック時のアクションは、アプリケーションとして行うべきものはUIファイル内のaction nameをapp.hoge
としたうえでGtkApplication
にhoge
というアクションを紐づけ、一方ウィンドウとして行うべきものはaction nameをwin.fuga
としたうえでGtkApplicationWindow
にfuga
というアクションを紐づける。使い分け方はいまいちわからない。
<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>
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)
チェックボックスをメニューに入れ込むこともできる。
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)
別ウィンドウ・ダイアログの表示
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
にする。
class MaskEditorWindow(Gtk.Window):
__gtype_name__ = "MaskEditorWindow"
...
@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のドキュメントから目で翻訳しようとすると沼にはまる。
DropTargetAsync
のdrop
シグナルをリッスンすると、ドラッグアンドドロップしたファイルに関するGdk.Drop
オブジェクトが手に入るので、read_value_async
でファイルの中身を非同期的に取り出す。似たメソッドにgdk_drop_read_async
があるがアプリがクラッシュするなどしてうまく使えなかった。
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
によって描画位置をずらすテクは地味に使います。
@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()
画像 <-> 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()