Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Select an option

  • Save promto-c/18bc58ddb9263f365bdc2b60b8d3d51a to your computer and use it in GitHub Desktop.

Select an option

Save promto-c/18bc58ddb9263f365bdc2b60b8d3d51a to your computer and use it in GitHub Desktop.
PyQt + React splitscreen desktop demo using QWebEngineView and QWebChannel, showing bidirectional communication between native PyQt widgets and a React + Tailwind UI.
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Copyright (C) 2026 promto-c
Permission Notice:
- You are free to use, copy, modify, and distribute this software for any purpose.
- No restrictions are imposed on its use.
- Credit is appreciated but not required.
- Use at your own risk; this software is provided "AS IS", without any warranty — express or implied — including, but not limited to, warranties of merchantability or fitness for a particular purpose.
- This notice does not apply to any third-party libraries or dependencies; those are subject to their respective licenses.
"""
"""Splitscreen demo: PyQt native UI on the left, React + Tailwind app on the right.
Showcases bidirectional communication:
- PyQt → React via QWebChannel signal
- React → PyQt via QWebChannel slot
"""
from PyQt5 import QtCore, QtGui, QtWidgets
from PyQt5 import QtWebEngineWidgets, QtWebChannel
# HTML for the React + Tailwind app
# ---------------------------------
HTML = r"""
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>PyQt ⇄ React Bridge</title>
<!-- Tailwind CDN -->
<script src="https://cdn.tailwindcss.com"></script>
<!-- React 18 UMD -->
<script crossorigin src="https://unpkg.com/react@18/umd/react.development.js"></script>
<script crossorigin src="https://unpkg.com/react-dom@18/umd/react-dom.development.js"></script>
<!-- Qt WebChannel -->
<script src="qrc:///qtwebchannel/qwebchannel.js"></script>
<meta name="viewport" content="width=device-width, initial-scale=1" />
</head>
<body class="bg-slate-900 text-slate-100">
<div id="root" class="min-h-screen flex items-center justify-center"></div>
<script type="text/javascript">
// Global backend reference (PyQt bridge object)
window.backend = null;
// Setup QWebChannel for JS ↔ PyQt bridge
new QWebChannel(qt.webChannelTransport, function (channel) {
window.backend = channel.objects.backend;
console.log("WebChannel ready:", window.backend);
// Listen for messages from PyQt (signal: messageFromQt(str))
backend.messageFromQt.connect(function (text) {
console.log("From PyQt:", text);
// Dispatch custom DOM event so React can subscribe without Qt-specific code.
window.dispatchEvent(new CustomEvent("qt-message", { detail: text }));
});
});
function App() {
const [input, setInput] = React.useState("");
const [fromQt, setFromQt] = React.useState("(none)");
const [status, setStatus] = React.useState("Idle");
// Listen for custom DOM event fired when PyQt sends a message
React.useEffect(() => {
function handleQtMessage(ev) {
setFromQt(ev.detail);
}
window.addEventListener("qt-message", handleQtMessage);
return () => window.removeEventListener("qt-message", handleQtMessage);
}, []);
const sendToPyQt = () => {
if (!window.backend) {
setStatus("Backend not ready");
return;
}
setStatus("Sending to PyQt…");
window.backend.receiveFromReact(input);
};
return React.createElement(
"div",
{
className:
"max-w-lg w-full bg-slate-800/80 rounded-2xl shadow-xl " +
"p-6 border border-slate-700 space-y-4",
},
[
React.createElement(
"h1",
{ key: "title", className: "text-xl font-semibold" },
"React side (in QWebEngineView)"
),
React.createElement(
"p",
{
key: "desc",
className: "text-slate-300 text-sm",
},
"This UI is React + Tailwind running inside QWebEngineView. " +
"It can send and receive messages to the PyQt side."
),
React.createElement(
"label",
{
key: "labelReactInput",
className: "block text-xs font-medium text-slate-400 uppercase tracking-wide",
},
"Message to PyQt"
),
React.createElement("input", {
key: "input",
className:
"w-full px-3 py-2 rounded-lg bg-slate-900/70 " +
"border border-slate-600 focus:outline-none focus:ring " +
"focus:ring-emerald-500/60 text-sm",
placeholder: "Type here and click Send to PyQt…",
value: input,
onChange: (e) => setInput(e.target.value),
onKeyDown: (e) => {
if (e.key === "Enter") {
sendToPyQt();
}
},
}),
React.createElement(
"button",
{
key: "btnReactToQt",
className:
"inline-flex items-center px-4 py-2 rounded-lg " +
"bg-emerald-500 hover:bg-emerald-400 text-slate-900 " +
"font-medium text-sm transition-colors",
onClick: sendToPyQt,
},
"Send to PyQt"
),
React.createElement(
"div",
{ key: "fromQt", className: "text-sm text-slate-200 pt-2" },
"Last message from PyQt: ",
React.createElement("span", { className: "font-mono" }, fromQt)
),
React.createElement(
"div",
{ key: "status", className: "text-xs text-slate-400 pt-1" },
"Status: " + status
),
]
);
}
const rootElement = document.getElementById("root");
const root = ReactDOM.createRoot(rootElement);
root.render(React.createElement(App));
</script>
</body>
</html>
"""
# Bridge object (PyQt ↔ JS)
# --------------------------
class WebBridge(QtCore.QObject):
"""Bridge object exposed to JavaScript via QWebChannel.
Signals:
messageFromQt: Emitted when PyQt sends a message to React.
messageFromReact: Emitted when React sends a message to PyQt.
"""
messageFromQt = QtCore.pyqtSignal(str)
messageFromReact = QtCore.pyqtSignal(str)
@QtCore.pyqtSlot(str)
def receiveFromReact(self, text: str):
"""Receive a message from React/JS.
Called from JS: backend.receiveFromReact(text).
Args:
text: Text sent from React.
"""
self.messageFromReact.emit(text)
# Main widget: splitscreen PyQt (left) + React (right)
# ----------------------------------------------------
class SplitScreenBridgeDemo(QtWidgets.QWidget):
"""Splitscreen demo with PyQt UI on the left and React app on the right.
"""
STYLE_SHEET = """
QWidget {
background-color: #020617; /* slate-950 */
color: #e5e7eb; /* gray-200 */
font-size: 13px;
}
#TitleLabel {
font-size: 16px;
font-weight: 600;
}
#FieldLabel {
font-size: 11px;
text-transform: uppercase;
color: #9ca3af;
}
QLineEdit {
background-color: #020617;
border: 1px solid #4b5563;
border-radius: 6px;
padding: 6px 8px;
}
QLineEdit:focus {
border-color: #10b981;
}
QPushButton {
background-color: #10b981;
color: #020617;
border-radius: 6px;
padding: 6px 12px;
font-weight: 500;
}
QPushButton:hover {
background-color: #34d399;
}
QPlainTextEdit {
background-color: #020617;
border: 1px solid #1f2933;
border-radius: 6px;
}
"""
# Initialization and Setup
# ------------------------
def __init__(self, parent=None):
"""Initialize the splitscreen demo.
"""
super().__init__(
parent,
windowTitle="PyQt ⇄ React Bridge - Splitscreen Demo",
styleSheet=self.STYLE_SHEET,
)
self._bridge = WebBridge(self)
self._channel = QtWebChannel.QWebChannel(self)
self._channel.registerObject("backend", self._bridge)
self.__init_ui()
def __init_ui(self):
"""Initialize the UI layout and widgets.
"""
self.resize(1200, 700)
# Create Widgets
# --------------
# Left: PyQt panel
title_label = QtWidgets.QLabel(
self,
text="PyQt side",
objectName="TitleLabel",
)
desc_label = QtWidgets.QLabel(
self,
text="Native PyQt widgets. Type below and click <b>Send to React</b> to update the React app on the right.",
wordWrap=True,
)
input_label = QtWidgets.QLabel(
self,
text="Message to React",
objectName="FieldLabel",
)
self._qt_input = QtWidgets.QLineEdit(
self,
placeholderText="Type here and press Enter or click Send to React…",
)
self._send_to_react_button = QtWidgets.QPushButton(
self,
text="Send to React",
)
self._last_from_react_label = QtWidgets.QLabel(
self,
text="Last message from React: (none)",
)
log_label = QtWidgets.QLabel(
self,
text="Log (PyQt side)",
objectName="FieldLabel",
)
self._log = QtWidgets.QPlainTextEdit(
self,
readOnly=True,
)
# Right: QWebEngineView (React)
self._view = QtWebEngineWidgets.QWebEngineView(self)
self._view.page().setWebChannel(self._channel)
base_url = QtCore.QUrl("https://local.pyqt-react-splitscreen.test/")
self._view.setHtml(HTML, base_url)
# Add Widgets to Layouts
# ----------------------
left_frame = QtWidgets.QFrame(self)
left_layout = QtWidgets.QVBoxLayout(left_frame)
left_layout.setContentsMargins(12, 12, 12, 12)
left_layout.setSpacing(8)
left_layout.addWidget(title_label)
left_layout.addWidget(desc_label)
left_layout.addSpacing(8)
left_layout.addWidget(input_label)
left_layout.addWidget(self._qt_input)
left_layout.addWidget(self._send_to_react_button)
left_layout.addSpacing(8)
left_layout.addWidget(self._last_from_react_label)
left_layout.addSpacing(8)
left_layout.addWidget(log_label)
left_layout.addWidget(self._log, 1)
splitter = QtWidgets.QSplitter(self)
splitter.setOrientation(QtCore.Qt.Orientation.Horizontal)
splitter.addWidget(left_frame)
splitter.addWidget(self._view)
splitter.setStretchFactor(0, 0)
splitter.setStretchFactor(1, 1)
splitter.setSizes([400, 800])
main_layout = QtWidgets.QHBoxLayout(self)
main_layout.setContentsMargins(8, 8, 8, 8)
main_layout.setSpacing(8)
main_layout.addWidget(splitter)
# Connect Signals
# ---------------
self._send_to_react_button.clicked.connect(self.send_to_react)
self._qt_input.returnPressed.connect(self.send_to_react)
self._bridge.messageFromReact.connect(self.send_to_pyqt)
# Public Methods
# --------------
def send_to_react(self, text: str):
"""Send the current PyQt input text to React via the bridge."""
text = text or self._qt_input.text()
self._append_log(f"PyQt → React: {text}")
self._bridge.messageFromQt.emit(text)
def send_to_pyqt(self, text: str):
"""Handle messages coming from React/JS."""
self._last_from_react_label.setText(f"Last message from React: {text!r}")
self._append_log(f"React → PyQt: {text}")
# Private Methods
# ---------------
def _append_log(self, line):
"""Append a line of text to the log."""
self._log.appendPlainText(line)
# Main entry point
# ----------------
def main():
"""Create the application, and show the splitscreen demo.
"""
import sys
app = QtWidgets.QApplication(sys.argv)
widget = SplitScreenBridgeDemo()
widget.show()
sys.exit(app.exec_())
if __name__ == "__main__":
main()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment