Created
January 9, 2026 20:12
-
-
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/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