Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: experimental support for running as (qt) app #835

Merged
merged 1 commit into from
Oct 25, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 23 additions & 10 deletions solara/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -261,6 +261,12 @@ def cli():
default=True,
help="Check installed version again pypi version.",
)
@click.option(
"--qt",
is_flag=True,
default=False,
help="Instead of opening a browser, open a Qt window. Will also stop the server when the window is closed. (experimental)",
)
def run(
app,
host,
Expand Down Expand Up @@ -290,6 +296,7 @@ def run(
ssg: bool,
search: bool,
check_version: bool = True,
qt=False,
):
"""Run a Solara app."""
if dev is not None:
Expand Down Expand Up @@ -365,9 +372,16 @@ def open_browser():
while not failed and (server is None or not server.started):
time.sleep(0.1)
if not failed:
webbrowser.open(url)
if qt:
from .server.qt import run_qt

if open:
run_qt(url)
else:
webbrowser.open(url)

# with qt, we open the browser in the main thread (qt wants that)
# otherwise, we open the browser in a separate thread
if open and not qt:
threading.Thread(target=open_browser, daemon=True).start()

rich.print(f"Solara server is starting at {url}")
Expand Down Expand Up @@ -397,7 +411,7 @@ def open_browser():
settings.main.timing = timing
items = (
"theme_variant_user_selectable dark theme_variant theme_loader use_pdb server open_browser open url failed dev tracer"
" timing ssg search check_version production".split()
" timing ssg search check_version production qt".split()
)
for item in items:
del kwargs[item]
Expand Down Expand Up @@ -451,14 +465,13 @@ def ssg_run():

build_index("")

start_server()

# TODO: if we want to use webview, it should be sth like this
# server_thread = threading.Thread(target=start_server)
# server_thread.start()
# if open:
# # open_webview()
# open_browser()
if qt:
server_thread = threading.Thread(target=start_server, daemon=True)
server_thread.start()
open_browser()
else:
start_server()
# server_thread.join()


Expand Down
113 changes: 113 additions & 0 deletions solara/server/qt.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import sys
from typing import List
import webbrowser
from qtpy.QtWidgets import QApplication
from qtpy.QtWebEngineWidgets import QWebEngineView
from qtpy.QtWebChannel import QWebChannel
from qtpy import QtCore, QtGui
import signal
from pathlib import Path

HERE = Path(__file__).parent


# setUrlRequestInterceptor, navigationRequested and acceptNavigationRequest
# all trigger the websocket to disconnect, so we need to block cross origin
# requests on the frontend/browser side by intercepting clicks on links

cross_origin_block_js = """
var script = document.createElement('script');
script.src = 'qrc:///qtwebchannel/qwebchannel.js';
document.head.appendChild(script);
script.onload = function() {
new QWebChannel(qt.webChannelTransport, function(channel) {
let py_callback = channel.objects.py_callback;

document.addEventListener('click', function(event) {
let target = event.target;
while (target && target.tagName !== 'A') {
target = target.parentNode;
}

if (target && target.tagName === 'A') {
const linkOrigin = new URL(target.href).origin;
const currentOrigin = window.location.origin;

if (linkOrigin !== currentOrigin) {
event.preventDefault();
console.log("Blocked cross-origin navigation to:", target.href);
py_callback.open_link(target.href); // Call Python method
}
}
}, true);
});
};
"""


class PyCallback(QtCore.QObject):
@QtCore.Slot(str)
def open_link(self, url):
webbrowser.open(url)


class QWebEngineViewWithPopup(QWebEngineView):
# keep a strong reference to all windows
windows: List = []

def __init__(self):
super().__init__()
self.page().newWindowRequested.connect(self.handle_new_window_request)

# Set up WebChannel and py_callback object
self.py_callback = PyCallback()
self.channel = QWebChannel()
self.channel.registerObject("py_callback", self.py_callback)
self.page().setWebChannel(self.channel)

self.loadFinished.connect(self._inject_javascript)

def _inject_javascript(self, ok):
self.page().runJavaScript(cross_origin_block_js)

def handle_new_window_request(self, info):
webview = QWebEngineViewWithPopup()
geometry = info.requestedGeometry()
webview.resize(geometry.width(), geometry.height())
webview.setUrl(info.requestedUrl())
webview.show()
QWebEngineViewWithPopup.windows.append(webview)
return webview


def run_qt(url):
app = QApplication([])
web = QWebEngineViewWithPopup()
web.setUrl(QtCore.QUrl(url))
web.resize(1024, 1024)
web.show()

app_name = "Solara"
app.setApplicationDisplayName(app_name)
app.setApplicationName(app_name)
web.setWindowTitle(app_name)
app.setWindowIcon(QtGui.QIcon(str(HERE.parent / "website/public/logo.svg")))
if sys.platform.startswith("darwin"):
# Set app name, if PyObjC is installed
# Python 2 has PyObjC preinstalled
# Python 3: pip3 install pyobjc-framework-Cocoa
try:
from Foundation import NSBundle

bundle = NSBundle.mainBundle()
if bundle:
app_info = bundle.localizedInfoDictionary() or bundle.infoDictionary()
if app_info is not None:
app_info["CFBundleName"] = app_name
app_info["CFBundleDisplayName"] = app_name
except ModuleNotFoundError:
pass

# without this, ctrl-c does not work in the terminal
signal.signal(signal.SIGINT, signal.SIG_DFL)
app.exec_()
Original file line number Diff line number Diff line change
Expand Up @@ -87,3 +87,21 @@ In case you forgot how to start a notebook server:
Or the more modern Jupyter lab:

$ jupyter lab


## Run as app (experimental)

You can also run the script as a standalone app. This requires the extra packages `qtpy` and `PySide6` (or `PyQt6`) to be installed.

```bash
$ pip install pip install qtpy PySide6
```

Run from the command line in the same directory where you put your file (`sol.py`):

```bash
$ solara run sol.py --qt
```


<img src="https://dxhl76zpt6fap.cloudfront.net/public/solara-quickstart-app.webp" alt="Markdown Monster icon"/>
Loading