6.3 pywebviewを使ったGUI

(1/1)
トランプで遊ぶ人のイラスト(女性)
前回作ったカードゲームの基本プログラム "cardGame1.py" だが、画面に文字を表示し、キーボードから交換するカード番号を入力するという CUI(Character User Interface)は大昔の電子計算機のようで味気ない。せめてブラウザアプリ程度のグラフィックを使った GUI(Graphical User Interface)を導入したい。
そこで、「1.4 ブラウザを使ったGUI」で紹介した外部ライブラリ pywebview を利用することにする。pywebview のインストール方法については、「1.4 ブラウザを使ったGUI」をご覧いただきたい。

目次

サンプル・プログラム

圧縮ファイルの内容
cardGame2.pyサンプル・プログラム:カードをシャフルして配る(GUI版)
cardGame2.pyカードをシャフルして配るプログラムのGUI部。
pahooCardGame.pyクラス・ファイル:カードゲーム基本クラス。
pahooValidate.pyバリデーション・モジュール。
./image/*.pngカード画像データ。

サンプル・プログラムを動かしてみよう

カードをシャフルして配る(GUI版)
まずはダウンロードした圧縮ファイルから、"cardGame2.py", "cardGame2.html", "pahooCardGame.py", "pahooValidate.py" を同じフォルダに解凍し、そのサブフォルダ "./image/" 以下に含まれる画像ファイル(pngファイル)をすべて配置してほしい。
準備ができたら、"cardGame2.py" を実行してみてほしい。上図のようなウィンドウを表示する。
画面にカードをグラフィカルに表示し、キーボードの入力の代わりに、チェックボックスとボタンを使ってカードを交換したり、アプリを終了できるようにした.

pywebviewと JavaScript

じつは、"cardGame2.py" のサブ・プログラムは "cardGame1.py" とまったく同じである。また、バリデーション関数 "pahooValidate.py" や、ユーザー定義クラスであるカード基本操作クラス "pahooCardGame.py" にも手を加えていない。
ユーザー定義関数やクラスをうまく作ることで、いままでに作った資産(プログラム)を新しいアプリケーションに流用できる

pywebviewPython の外部ライブラリだ。pywebview のメソッドは、Python プログラムからみると、何らかのデータを返したり、何らかの作用を行う。
詳しい動きは後述するが、pywebview がブラウザ・モジュールをウィンドウとして表示し、そこにHTML/CSS/JavaScriptを表示する。ここに書いた JavaScriptプログラムと Python プログラムの間では、Apiクラス という名前のユーザー定義クラスを用意することで、データ受渡を行う。

これを JavaScript側からみると下図のように、Apiクラス を介して、Python で作られたクラウドサービスを利用しているように見える。もちろん Python も同じPCにあるから、クラウドサービスではないし、インターネットとも接続する必要がなく、PC内で完結している。
pywebviewの仕組み
なお、JavaScript を使ってデータの受渡やブラウザへの表示処理を行うことになるので、ある程度は JavaScript の知識と経験が必要となる。GUIを作るために他のスクリプトやタグを覚えたり、デザイン設計を学ぶのも大変なので、ここでは他システムへの応用が効く JavaScriptを利用することにした。
JavaScriptによるプログラミングについては、当サイトの「JavaScriptによるプログラミング入門」が参考になるだろう。

Apiクラス を使うことで、"cardGame1.py" とインタフェースがどう変わったかを説明しよう。JavaScriptプログラム(HTMLファイル)とPythonプログラムの関係は上図のようになる。

まず出力の方だが、プログラム "cardGame1.py" では、カードのリストを画面に表示していた。プログラム "cardGame2.py" では、カードのリストを Apiクラスを通じて "cardGame2.html" に書かれたJavaScriptに渡し、JavaScript側で、これを画像ファイル名に変換して画面に表示する。データは、JavaScriptが処理しやすいように JSON形式で渡す。

入力の方は、プログラム "cardGame1.py" では、キーボードからコマンド入力していた。今回は、"cardGame2.html" に書かれた JavaScriptでチェックボックスやボタンの状態をイベント監視し、これをコマンドに変換して Apiクラスを通じて "cardGame2.py" に文字列 command(str) を渡してやる。

こうすることで、Python プログラ "cardGame2.py" は、カードのリストを出力したり、入力されたコマンドを処理するだけで、"cardGame1.py" と処理内容がほとんど同じになる。

カードの初期化

class Api:
	def initGame(self):
		"""初期化する.
		
		Args:
			None
		
		Returns:
			None
		"""
		# 初期化
		try:
			self.cg = pahooCardGame(1)
			self.cg.shuffleStocks()
		except ValueError as errmsg:
			pv.dispErrorMessageAndExit(errmsg)

		self.playerId = 0		# プレイヤーID
		self.cg.stocks2hands(5, self.playerId)
		print(self.cg.hands)

		response = { "hands": self.cg.hands, "message": "開始" }
		return json.dumps(response)
それでは、Python プログラムから見ていこう。
今回用意する Apiクラス は、インスタンス化したときの初期化メソッドは必要ない。
メソッド initGame は、"cardGame1.py" の冒頭で行ったカードの初期化処理をまとめたものである。
処理が終わったら、手札のリストと Python プログラムからのメッセージを下記構造の応答JSONデータにして、JavaScriptに渡す。応答JSONデータの構造は、これ以降のメソッドでも共通である。
応答データ構造(json) hands _0 _0 [スート, ナンバー] _1 [スート, ナンバー] _2 [スート, ナンバー] _3 [スート, ナンバー] _4 [スート, ナンバー] message Pythonプログラムからのメッセージ

コマンドを解釈し実行する

メソッド execCommand のフローも、前回の "cardGame1.py" とほぼ同じである。キーボードの代わりに、JavaScript側から送られてくる command(str) を、ユーザー定義関数 execCommand に渡すようにする。処理が終わったら、応答JSONデータをJavaScriptに渡す。
def execCommand(command, cg, id):
	"""コマンドを解釈し実行する.
	
	Args:
		command(str): コマンド
		cg(cardGame): cardGameオブジェクト

	Returns:
		bool: True 実行成功 / False 処理終了, str: メッセージ

	Note:
		終了コマンドでプログラムを終了する.
	"""
	# 終了コマンド
	if (command == "q"):
		# webviewウィンドウを開いていれば閉じる
		if "window" in globals():
			window.destroy()
		# プログラム終了
		sys.exit()
	# やり直しコマンド
	elif (command == "c"):
		cg.__init__()
		cg.shuffleStocks()
		cg.stocks2hands(5, id)
		return True, "やり直し"

	# 交換する手札番号リストを生成
	numList = [item.strip() for item in command.split(",")]
	maxNum = cg.countHands(id)
	nums = []
	for ss in numList:
		try:
			nums.append(pv.validateNumber(ss, "int", 1, maxNum, label="手札"))
		except ValueError as errmsg:
			return True, errmsg
	# 手札番号リストに重複がないかどうかバリデーション
	if isDuplivacates(nums): return True, "交換する手札番号が重複しています"

	# 手札番号をソートする
	nums.sort()

	# 手札を1枚ずつ交換する
	cnt = 1
	for i in nums:
		cg.hands2layouts(i - cnt, id)
		cnt += 1
		if not cg.stocks2hands(1, id): return True, "山札がなくなりました"

	print("command = " + command)
	print(cg.hands[0])

	return True, f"{nums}を交換しました"

class Api:
	def initGame(self):
		"""初期化する.
		
		Args:
			None
		
		Returns:
			None
		"""
		# 初期化
		try:
			self.cg = pahooCardGame(1)
			self.cg.shuffleStocks()
		except ValueError as errmsg:
			pv.dispErrorMessageAndExit(errmsg)

		self.playerId = 0		# プレイヤーID
		self.cg.stocks2hands(5, self.playerId)
		print(self.cg.hands)

		response = { "hands": self.cg.hands, "message": "開始" }
		return json.dumps(response)

外部ブラウザで指定URLを開く

	def openBrowser(self, url):
		"""外部ブラウザで指定ULを開く.
		
		Args:
			url(str): URL
		
		Returns:
			None
		"""
		webbrowser.open(url)
メソッド openBrowser は、ウィンドウに表示されている参考サイトのリンクをクリックしたとき、何もしないとウィンドウの中身が参考サイトに置き換わってしまうので、外部ブラウザを立ち上げて表示するようにするためのメソッドである。標準ライブラリ webbrowser を使って、デフォルト・ブラウザを呼び出す。

今回用意する Apiクラス として用意したメソッドは、この3つだけだ。

Pythonメイン・プログラム

# メイン・プログラム =======================================================
# 初期値
WIDTH  = 600						# webviewウィンドウの幅(ピクセル)
HEIGHT = 680						# webviewウィンドウの高さ(ピクセル)
HTML_FILE = "./cardGame2.html"		# HTMLファイル名(webview部分)

# pywebviewを使ってウィンドウを表示する.
api = Api()
window = webview.create_window("カードゲーム by Python", HTML_FILE, js_api=api, width=WIDTH, height=HEIGHT)
webview.start(http_server=False, debug=False)
Python メイン・プログラムでは、pywebview を使ってウィンドウを表示するために、まず、Apiクラスをインスタンス化する。
次に、pywebview を importしたことで利用できるようになる webviewオブジェクトにある create_windowメソッドを使ってウィンドウ・オブジェクト Window を用意する。create_window(タイトル, HTMLファイル名, js_api=Apiオブジェクト名, width=ウィンドウの幅, height=ウィンドウの高さ) のようにして使う。
最後に、start メソッドでウィンドウを表示する。start(http_server=HTTPサーバを使うかどうか, debug=デバッグウィンドウを表示するかどうか) のようにして使う。
デスクトップアプリ開発と同じで、start メソッドを実行すると、destroy メソッドを実行するためウィンドウは表示したままになり、Pythonは終了しない。"cardGame1.py" のように繰り返し処理(無限ループ)は必要なくなり、JavaScriptからのイベント駆動により処理を進めていく。

JavaScriptメイン・プログラム

 227: // メイン・プログラム ======================================================
 228: window.onload = function() {
 229:     //タイトル等をセット
 230:     document.title = TITLE;
 231:     document.getElementById('title').innerHTML = TITLE + '&nbsp;<span style="font-size:small;">' + getLastModified() + '版</span>';
 232:     document.getElementById('reference').innerHTML =`
 233: ※参考サイト&nbsp;<span style="color:blue; text-decoration:underline;" onClick="openBrowser('${REFERENCE}')">${REFERENCE}</span>
 234: `;
 235:     //スタイルシートをセットする.
 236:     document.getElementById('help').style.width = WIDTH + 'px';
 237: 
 238:     //ゲーム開始する.
 239:     startGame();
 240: }

次に、JavaScript プログラム("cardGame2.html")を見ていく。
メイン・プログラムは、window.onload メソッドを使って、HTMLファイル読み込み時に実行する処理となる。
前半部分で、基本的なタグ情報やスタイルシートをセットする。
最後に、今回のカードゲームを開始するユーザー関数 startGame を実行する。

ゲーム開始する

  56: /**
  57:  * ゲーム開始する.
  58:  * webviewを起動したときに最初に呼び出すファンクション.
  59:  * @param   なし
  60:  * @return  なし
  61: */
  62: function startGame() {
  63:     //pywebviewが準備できたら...
  64:     if (window.pywebview) {
  65:         initGame();
  66: 
  67:     //100msのポーリング
  68:     } else {
  69:         setTimeout(startGame, 100); 
  70:     }
  71: }

  73: /**
  74:  * カード情報を初期化する.
  75:  * Pythonプログラム initGame() を呼び出す.
  76:  * @param   なし
  77:  * @return  なし
  78: */
  79: async function initGame() {
  80:     //Pythonへコマンドを送る.
  81:     let json = await pywebview.api.initGame();
  82: 
  83:     //Pythonからの応答を取得する.
  84:     let response = JSON.parse(json);
  85:     console.log(response);
  86: 
  87:     //メッセージと手札セットを表示する.
  88:     dispCards(response);
  89: }

カードゲームを開始するユーザー定義関数 startGame は、pywebviewオブジェクトの準備ができるまで setTimeoutを使って100ミリ秒のポーリングを行う。準備ができたら、initGame関数を呼び出す。

initGame関数は、前述の Apiクラスに用意した initGameを呼び出す。Apiクラスのメソッドは非同期で実行するので(この点でもクラウドサービスに似ている)、応答が返るまで待機する awaitを指定する。これにともない、initGame関数は asyncで定義する。
前述の通り、Apiクラスの応答は JSON形式なので、parse メソッドを使って配列 response へ代入する。
最後に、応答データをユーザー定義関数 dispCard に渡す。

カードを表示する

 132: /**
 133:  * メッセージと手札セット(5枚)を表示する.
 134:  * @param   Object response Pythonプログラムからの応答
 135:  * @return  なし
 136: */
 137: function dispCards(response) {
 138:     //メッセージを表示
 139:     document.getElementById('message').innerHTML = response.message;
 140: 
 141:     //表示領域をクリア
 142:     document.getElementById('hands').innerHTML = '';
 143: 
 144:     //1枚ずつ表示する
 145:     response.hands[0].forEach(function (card, n) {
 146:         image = card2image(card);
 147:         dispCard(n + 1, image);
 148:     });
 149: }

ユーザー定義関数 dispCards は、ウィンドウにメッセージと手札を表示する。
手札は5枚が1セットの配列になっており、forEachメソッドを使って、1枚ずつ表示するユーザー定義関数 dispCards を呼び出す。

 104: /**
 105:  * 手札を1枚表示する.
 106:  * @param   Integer n   手札番号(1~5)
 107:  * @param   String img  カードの画像ファイル名
 108:  * @return  なし
 109: */
 110: function dispCard(n, img) {
 111:     let id = 'hand' + ('00' + n).substr(-2)                 //オブジェクトID
 112:     let scale = WIDTH / ((WIDTH_CARD + 10* NUM_CARDS);    //画像表示倍率
 113:     let height = Math.floor(HEIGHT_CARD * scale);           //表示画像の高さ
 114:     let width  = Math.floor(WIDTH_CARD * scale);            //表示画像の幅
 115: 
 116:     //上からの相対位置(固定;ピクセル)
 117:     let top = 0;
 118:     //左からの相対位置(ピクセル)
 119:     let left = 16;
 120: 
 121:     let html = `
 122: <div style="position:relative; top:${top}px; left:${left}px;">
 123: <img src="${img}" style="width:${width}px; height:${height}px;">
 124: <div style="text-align:center;">
 125: <input id="${id}" type="checkbox" value="${n}">
 126: </div>
 127: </div>
 128: `;
 129:     document.getElementById('hands').innerHTML +html;
 130: }

ユーザー定義関数 dispCards は、カード1枚分の情報(スート, ナンバー)をもとに、対応する画像ファイル名を導きだし、表示位置を計算し、HTMLタグにしてウィンドウに表示する。

  91: /**
  92:  * カード情報配列に対応する画像ファイル名を取得する.
  93:  * @param   Array card カード情報 [スート,ナンバー]
  94:  * @return  String 画像ファイル名
  95: */
  96: function card2image(card) {
  97:     let suit = card[0];
  98:     let num  = card[1];
  99:     let image = IMAGE_FOLDER + suit + '_' + ('00' + num).substr(-2+ '.png';
 100: 
 101:     return image;
 102: }

ユーザー定義関数 card2image は、カード1枚分の情報(スート, ナンバー)をもとに、対応する画像ファイル名を返す。

次回予告

次回は、今回はつくったクラスを継承し、ポーカーの役判定を行うメソッドを追加する。pywebview を利用することで、グラフィカルな画面操作ができるようにする。
最後に、関数やメソッドを別の関数やメソッドで上書き(オーバーライド)する目的と、その方法について学ぶ。

コラム:3層アーキテクチャ、再び

ユーザーインターフェース
今回、pywebview を使い、前回のCUIプログラムを拡張する形でGUIを実装した。この拡張ができたのは、じつは、上図のようにユーザーインターフェースを拡張する目論見があったからである。

Python プログラムを1つの巨大な関数と考えてみてほしい。この関数には、入力(引数)が必要で、出力(戻り値)が発生する。
cardGame1.py では、入力(command)にキーボードを、出力(response)に画面を割り当てた。キーボードも画面もCUI=文字列ベースで動作するから、いずれもstr型のデータを入出力すればよい。
cardGame2.py では、入出力をブラウザベースに切り替えた。入力(command)はApiクラスのメソッドの引数となるデータだから、str型のままでよい。出力(response)もstr型のままでもよかったのだが、これを解釈して画像ファイルを呼び出す処理が必要だったので、JavaScriptが解釈しやすいJSON形式に変更した。

このシリーズでは取り上げないが、ここまで出来ていれば、Pythonプログラムをクラウドサービスとしてリリースし、ブラウザアプリとしてカードゲームを楽しむ cardGameX.py を作ることができるだろう。このとき、入出力はhttp通信になるので、入力(command)はPOST渡しするXMLかJSON形式に変更した方がいいだろう。
3層アーキテクチャ
コラム:3層アーキテクチャ」で、システム開発は、プレゼンテーション層アプリケーション層データ層の3つを別々のファイルに分離し、場合によっては別々の言語で実装すると書いた。今回の場合、データ層はないのだが、プレゼンテーション層は HTML+CSS+JavaScript で、アプリケーション層は Python で書いた。
このように3層を別々に開発することで、開発管理がしやすくなるだけでなく、拡張性ももたせることができる。
(この項おわり)
header