🌃

pywebview on Mojo🔥

に公開

pywebviewMojo🔥でコンパイルする

已前、LinuxでかつPixiを使ってpywebviewが使えることを確認した。

pywebview on linux

それはつまり、Mojo🔥でpywebviewを物する準備が整ったことに外ならない。

インストール

Linuxpywebviewを扱うには、種々のインストールが必要となる。aptすら且つ選択の余地多く煩わしいことを、況やPixiをや。その始終は当の記事に既に述べたものとして、本記事には仔細を省略する。

https://zenn.dev/amenaruya/articles/9d96feaf279bc8

Mojo🔥のインストールと扱いについては、この記事にて簡単に紹介している。

https://zenn.dev/amenaruya/articles/33723fd5465e71

プロジェクト作成

pywebview_mojoの名でプロジェクトを作った。

pixi init pywebview_mojo -c https://conda.modular.com/max-nightly/ -c conda-forge

pixi.tomlはこのようになった。[tasks]の項は本旨と関係ないため、特段気にせずともよい。

pixi.toml
[workspace]
channels = ["https://conda.modular.com/max-nightly", "conda-forge"]
name = "pywebview_mojo"
platforms = ["linux-64"]
version = "0.1.0"

[tasks]
pymain = "python src/python/main.py"
mjtest = "mojo test/test.mojo"
mjmain = "mojo src/mojo/main.mojo"

mkdir = "mkdir -p build"
build = { cmd = "mojo build src/mojo/main.mojo -o build/main", depends-on = ["mkdir"], inputs = ["src/mojo/main.mojo"], outputs = ["build/main"]}
exe = "build/main"
run = [{ task = "build" }, { task = "exe" }]
clean = "rm -rf build"

[dependencies]
# Mojo
modular = ">=25.6.0.dev2025090605,<26"
# Python
python = ">=3.13.7,<3.14"
# pkgs for pywebview
pyqt = ">=5.15.11,<6"
pyqtwebengine = ">=5.15.11,<6"
pyqtwebkit = ">=5.15.11,<6"
pygobject = ">=3.50.0,<4"
gtk3 = ">=3.24.43,<4"

[pypi-dependencies]
# pywebview from PyPI
pywebview = { version = ">=6.0, <7", extras = ["qt"] }

Pythonで動作確認

先ずはPythonで正しく動くことを確かめよう。

ソースコード
main.py
import webview

from MyApi import Api

api = Api()

webview.create_window(
    title = 'App',
    url = 'static/index.html',
    js_api = api
)

webview.start()

MyApi.py
import webview


class Api:
    def __init__(self):
        self._logs = []

    def log(self, value):
        self._logs.append(value)
        print(f"log: {value}")
        return "Logged: " + value

    def get_logs(self):
        print("get_logs: called")
        return self._logs

    def exit(self):
        webview.active_window().destroy()

index.html
<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <title>PyWebView チュートリアル</title>
    <link rel="stylesheet" href="styles.css">
</head>
<body>
    <h1>PyWebView チュートリアル</h1>
    <button onclick="sendLog()" class="main-btn">ログを送信</button>
    <button onclick="showLogs()" class="main-btn">履歴を表示</button>
    <button onclick="exitApp()" class="exit-btn">終了</button>
    <div id="logs"></div>
    <script src="scripts.js"></script>
</body>
</html>

styles.css
body {
    font-family: sans-serif;
    margin: 2em;
}

#logs {
    margin-top: 1em;
    background: #f4f4f4;
    padding: 1em;
    border-radius: 8px;
}

button {
    padding: 0.7em 1.5em;
    margin-right: 0.5em;
    border: none;
    border-radius: 6px;
    font-size: 1em;
    cursor: pointer;
    transition: background 0.2s;
}

.main-btn {
    background: #4caf50;
    color: white;
}

.main-btn:hover {
    background: #388e3c;
}

.exit-btn {
    background: #f44336;
    color: white;
}

.exit-btn:hover {
    background: #c62828;
}

scripts.js
function showResponseTime(start, label) {
    const elapsed = performance.now() - start;
    document.getElementById('logs').innerHTML +=
        `<div><em>${label} 応答時間: ${elapsed.toFixed(3)} ms</em></div>`;
}

function sendLog() {
    const start = performance.now();
    pywebview.api.log("Let's play PyWebView!").then(response => {
        showResponseTime(start, "ログ送信");
        alert(response);
    });
}

function showLogs() {
    const start = performance.now();
    pywebview.api.get_logs().then(logs => {
        document.getElementById('logs').innerHTML =
            "<strong>ログ履歴:</strong><br>" + logs.map(l => `<div>${l}</div>`).join('');
        showResponseTime(start, "履歴表示");
    });
}

function exitApp() {
    pywebview.api.exit();
}

実行の様子

Python動作確認
Pythonによる動作

キャプチャーには映っていないが、「ログを送信」ボタンを押した後、毎回次のようなウィンドウが出てきている。

ログ送信結果のウィンドウ

本題:Mojo🔥による実装

Pythonで画面が正常に動作したのを確認したら、Mojo🔥での実装に移行する。

0. APIについて

Mojo🔥でpywebviewを扱うに当たり、APIとしてclassを定めていたことが障る。

main.py
︙
api = Api()

webview.create_window(
    title = 'App',
    url = 'static/index.html',
    js_api = api
)
MyApi.py
class Api:
    def __init__(self):
        self._logs = []

    def log(self, value):

前提知識として、Mojo🔥にclassはなく、structが存在する。とは言え、これ自体は問題の本質ではない。

PythonObject

Mojo🔥にもPythonにも、連想配列である「list」、「dict」、「tuple」が存在する。従ってMojo🔥では、二つの言語の同じ概念が次のような区別のもと共存している。

Mojo🔥 Python
list List python.Python.list
dict Dict python.Python.dict
tuple Tuple python.Python.tuple

なお、Pythonから参照するものの殆どはPythonObjectとして扱われることから、Python側の連想配列の「データの型」は全て一律でPythonObjectとなる。

PythonにはPythonObject

Pythonから参照した関数に引数を与えるとき、基本的にはMojo🔥のオブジェクトが使えない。つまり、webview.create_window()に与えるAPIは、『Pythonで定義されたclassでなければならない』。

なお現在、Python構造体にはclassなどという変数も関数もないようである。

一、Mojo🔥でPythonを記述する

言語によっては、inline assemblyなどと言う機能を持つことがある。Mojo🔥もその一つであり、sys._assembly.inlined_assemblyを使うことでアセンブリーを記述・実行することができる。

これと似て、Pythonも記述することができる。

https://docs.modular.com/mojo/manual/python/types/#mojo-types-in-python

つまり次のように、classを記述することができるのである。

# PythonによるApiクラスの定義
var class_definition: String = """
class Api:
    _hello: str = 'Hello Mojo'

    def hello(self) -> str:
        return self._hello
"""

# ApiClassDefinitionという仮想上のPythonモジュールを作る
# ApiClassDefinition.pyというファイルが存在するような状態(実際には存在しない)
var ApiClassDefinition: PythonObject = Python.evaluate(
    expr = class_definition,
    file = True,
    name = 'ApiClassDefinition'
)

# ApiClassDefinitionモジュールからApiクラスを呼び出す
api = ApiClassDefinition.Api()

# 'Hello Mojo'と表示される
print(api.hello())
動くプログラムの例

動作はこの次に示すものと同じ。但し、こちらの方が起動に時間が掛かった。

from python import Python, PythonObject

fn main():
    var class_definition: String = """
class Api:
    _hello: str = 'Hello Mojo'

    def hello(self) -> str:
        return self._hello
    """

    try:
        var webview: PythonObject = Python.import_module('webview')

        var ApiClassDefinition: PythonObject = Python.evaluate(
            expr = class_definition,
            file = True,
            name = 'ApiClassDefinition'
        )

        api = ApiClassDefinition.Api()

        # print(api.hello())

        webview.create_window(
            title = 'App Mojo',
            html = '''
                <p>pywebview with mojo</p>
                <p><button onclick="pywebview.api.hello().then(response => {document.getElementById('hello').innerHTML = response;})">button</button></p>
                <p id="hello"></p>
            ''',
            js_api = api,
            width = 200,
            height = 200
        )

        webview.start()

    except e:
        print('exception occurred: ', e)

これでも構わないが、端からファイルとして分けた方が整頓の観点では宜しいと感ぜられよう。実際、Pythonの動作確認で既にMyApi.pyというファイルを作っている。

一、.pyファイルを読み込む

.pyファイルを読み込むにあたり、一度確認のためtestというフォルダーの中で行う。

ソースコード
test.mojo
from python import Python, PythonObject

fn main():
    try:
        var webview: PythonObject = Python.import_module('webview')
        var Api: PythonObject = Python.import_module('Api')

        var api: PythonObject = Api.Api()

        webview.create_window(
            title = 'App Mojo',
            html = '''
                <p>pywebview with mojo</p>
                <p><button onclick="pywebview.api.hello().then(response => {document.getElementById('hello').innerHTML = response;})">button</button></p>
                <p id="hello"></p>
            ''',
            js_api = api,
            width = 200,
            height = 200
        )

        webview.start()
    except e:
        print('exception occurred: ', e)

Api.py
class Api:
    _hello: str = 'Hello Mojo'

    def hello(self) -> str:
        return self._hello

実行の仕方
pixi.toml
[tasks]mjtest = "mojo test/test.mojo"

pixi.toml中にあるこの箇所により、test.mojoを実行する時は次のように簡略化されている。

pixi run mjtest

このようなタスクを定めない場合、次のように長いコマンドとなる。

pixi run mojo test/test.mojo

画像のような画面が表示される。ボタンを押すと文字が出るという簡素なものである。

before click
before

after click
after

これで、.pyファイルに定義されたclassMojo🔥からでも扱えることが確認できた。同時に、APIはこのようにして定義・利用すればよいことが分かった。

1. Mojo🔥の記述

先の検証で概ね実装方法は判明しているが、「パスの指定」のみ注意を要した。

ここで、srcフォルダーの中にmojoフォルダーを新たに作る。

from python import Python, PythonObject

fn main():
    try:
        # パスを追加しなければ MyApi.py が探されない
        Python.add_to_path("src/python")

        var webview: PythonObject = Python.import_module('webview')
        var MyApi: PythonObject = Python.import_module('MyApi')

        var api: PythonObject = MyApi.Api()

        webview.create_window(
            title = 'App Mojo',
            # pywebview_mojo(プロジェクトルート)から示さなければ探されない
            url = 'pywebview_mojo/src/python/static/index.html',
            js_api = api
        )

        webview.start()
    except e:
        print('exception occurred: ', e)

二箇所でファイルパスを示しているが、その書き方がやや異なることが分かるだろう。私の試した限りでは、この通りでなければエラーになった。

srcから始まるパス

# パスを追加しなければ MyApi.py が探されない
Python.add_to_path("src/python")

今、src/mojo/main.mojosrc/python/MyApi.pyとは異なるフォルダーに存在することから、その位置を示さねばならない。これに用いるのがpython.Python.add_to_pathである。

この場合には、srcから始めることで正しく探すことができるようだった。

プロジェクトルートから始まるパス

webview.create_window(
    title = 'App Mojo',
    # pywebview_mojo(プロジェクトルート)から示さなければ探されない
    url = 'pywebview_mojo/src/python/static/index.html',
    js_api = api
)

これはindex.htmlを基に画面を作る箇所であるため、やはりその位置を正確に示さなければならない。しかし先の箇所とは異なり、プロジェクトルートのフォルダー名であるpywebview_mojoから示さなければ、正しく探されなかった。

実行

pixi.toml
[tasks]mjmain = "mojo src/mojo/main.mojo"

pixi.toml中にあるこの箇所により、main.mojoを実行する時は次のように簡略化されている。

pixi run mjmain

このようなタスクを定めない場合、次のように長いコマンドとなる。

pixi run mojo src/mojo/main.mojo

2. 実行ファイルへのコンパイル

Mojo🔥はコンパイルすることができる。

pixi.toml
[tasks]mkdir = "mkdir -p build"
build = { cmd = "mojo build src/mojo/main.mojo -o build/main", depends-on = ["mkdir"], inputs = ["src/mojo/main.mojo"], outputs = ["build/main"]}
exe = "build/main"
run = [{ task = "build" }, { task = "exe" }]

pixi.toml中にあるこの箇所により、buildフォルダーの作成とコンパイル、実行までを次のコマンドで演じさせることができる。

# pixi run (タスク名)
pixi run run

実行時の注意

此度に限っては、このような実行方法でなければエラーになる。つまり、コンパイルで生成された実行ファイルを直接実行することができないのである。その原因は二つ。

  • pywebviewを探せなくなる
  • QtGTKが探せなくなる

pywebviewPixiでインストールしたものである。Pixiは開発環境をプロジェクト単位で管理するため、その管理を離れると動作が保証されないのである。なおインストールしたものは.pixiという隠しフォルダーに全て収められている。ここからwebviewを探してパスを指定すれば、Python.app_to_pathを使ってエラーを解決することができる。

QtまたはGTKもまたPixiでインストールしたものであるため、実行ファイル単体からは探し出せない。但しaptOS全体にこれがインストールされている場合は、其方が使われるものと憶測する。

実行の様子

mojoによる動作

1ms台の応答時間が多く見られた気がするが、殆ど誤差だろう。コンパイルしたことで歴然の差が生まれるといったことはなさそうである。

今回、Mojo🔥とWebViewの併用を試した。実行速度がさして変わらないのはこれまでの経験から想像通りであり、寧ろ遅くならなかったことに驚いた。しかしQtGTKは仕方ないといえ、pywebviewすら実行ファイルで探せない、即ち外部ファイルに依存したままで内包できないことは想定していなかった。コンパイルできると雖も、その配布には少し気を遣うらしい。

Discussion