mirror of
https://github.com/chanzuckerberg/cellxgene.git
synced 2026-10-05 06:58:12 +08:00
GUI UI Elements (#816)
This commit is contained in:
+191
-37
@@ -8,13 +8,17 @@ import threading
|
||||
|
||||
from cefpython3 import cefpython as cef
|
||||
import PySide2
|
||||
from PySide2.QtGui import *
|
||||
from PySide2.QtCore import *
|
||||
from PySide2.QtWidgets import *
|
||||
|
||||
import server.gui.cellxgene_rc
|
||||
from server.gui.browser import CefWidget, CefApplication
|
||||
from server.gui.options_parser import parse_opt_string
|
||||
from server.cli.launch import parse_engine_args
|
||||
from server.gui.workers import Worker, SiteReadyWorker
|
||||
from server.gui.utils import WINDOWS, LINUX, MAC, FileLoadSignals, Emitter, WorkerSignals
|
||||
from server.utils.constants import MODES
|
||||
from server.gui.utils import WINDOWS, LINUX, MAC, FileLoadSignals, Emitter, WorkerSignals, FileChanged
|
||||
from server.utils.errors import OptionsError
|
||||
from server.utils.utils import find_available_port
|
||||
|
||||
if WINDOWS or LINUX:
|
||||
@@ -24,17 +28,19 @@ if WINDOWS or LINUX:
|
||||
|
||||
# Configuration
|
||||
# TODO remember this or calculate it?
|
||||
WIDTH = 1024
|
||||
HEIGHT = 768
|
||||
WIDTH = 1300
|
||||
HEIGHT = 800
|
||||
GUI_PORT = find_available_port("localhost")
|
||||
BROWSER_INDEX = 0
|
||||
LOAD_INDEX = 1
|
||||
|
||||
|
||||
class MainWindow(QMainWindow):
|
||||
def __init__(self):
|
||||
super(MainWindow, self).__init__(None)
|
||||
self.cef_widget = None
|
||||
self.data_widget = None
|
||||
self.stacked_layout = None
|
||||
self.parent_conn, self.child_conn = None, None
|
||||
self.load_emitter = None
|
||||
self.emitter_thread = None
|
||||
@@ -49,6 +55,7 @@ class MainWindow(QMainWindow):
|
||||
|
||||
def showBrowser(self):
|
||||
self.stacked_layout.setCurrentIndex(BROWSER_INDEX)
|
||||
|
||||
def restartOnError(self):
|
||||
self.window().shutdownServer()
|
||||
# close emitter on error/finished
|
||||
@@ -120,8 +127,12 @@ class MainWindow(QMainWindow):
|
||||
file_menu.addAction(load_action)
|
||||
|
||||
def showLoad(self):
|
||||
self.clearMessages()
|
||||
self.stacked_layout.setCurrentIndex(LOAD_INDEX)
|
||||
|
||||
def clearMessages(self):
|
||||
self.data_widget.reset()
|
||||
|
||||
def closeEvent(self, event):
|
||||
# Close browser (force=True) and free CEF reference
|
||||
if self.cef_widget.browser:
|
||||
@@ -147,57 +158,105 @@ class LoadWidget(QFrame):
|
||||
logo_layout = QHBoxLayout()
|
||||
logo_layout.setContentsMargins(0, 0, 0, 20)
|
||||
|
||||
load_layout = QGridLayout()
|
||||
load_layout.setContentsMargins(0, 0, 0, 0)
|
||||
load_layout.setSpacing(0)
|
||||
file_layout = QVBoxLayout()
|
||||
|
||||
message_layout = QHBoxLayout()
|
||||
message_layout.setContentsMargins(0, 0, 0, 0)
|
||||
|
||||
self.serverError = False
|
||||
self.title = ""
|
||||
self.label = QLabel("cellxgene")
|
||||
self.file_name = FilePath()
|
||||
|
||||
self.label = QLabel()
|
||||
logo = QPixmap(":/logo.png")
|
||||
self.label.setPixmap(logo)
|
||||
self.label.setContentsMargins(100, 0, 100, 0)
|
||||
logo_layout.addWidget(self.label)
|
||||
|
||||
# UI section
|
||||
# TODO add load spinner
|
||||
# TODO add cancel button to send back to browser (if available)
|
||||
self.embedding_label = QLabel("embedding: ")
|
||||
load_layout.addWidget(self.embedding_label, 0, 0)
|
||||
self.file_label = QLabel("file: ")
|
||||
load_layout.addWidget(self.file_label, 0, 1)
|
||||
self.embeddings = QComboBox(self)
|
||||
self.embeddings.currentIndexChanged.connect(self.updateEmbedding)
|
||||
self.embeddings.addItems(MODES)
|
||||
self.embedding_selection = [MODES[0]]
|
||||
load_layout.addWidget(self.embeddings, 1, 0)
|
||||
|
||||
self.load = QPushButton("Open...")
|
||||
self.load.clicked.connect(self.onLoad)
|
||||
load_layout.addWidget(self.load, 1, 1)
|
||||
self.file_area = FileArea()
|
||||
self.file_name.signals.changed.connect(self.updatePath)
|
||||
|
||||
self.launch_widget = QPushButton("Launch cellxgene")
|
||||
self.launch_widget.setEnabled(False)
|
||||
self.launch_widget.clicked.connect(self.onLoad)
|
||||
|
||||
self.progress = QProgressBar()
|
||||
self.progress.setTextVisible(False)
|
||||
|
||||
self.advanced_options = CLIOptionsArea(self)
|
||||
|
||||
file_layout.addWidget(self.file_area)
|
||||
file_layout.addWidget(self.advanced_options)
|
||||
self.loading_layout = QStackedLayout()
|
||||
self.loading_layout.addWidget(self.launch_widget)
|
||||
self.loading_layout.addWidget(self.progress)
|
||||
file_layout.addLayout(self.loading_layout)
|
||||
file_layout.setStretch(0, 10)
|
||||
|
||||
# Error section
|
||||
self.error_label = QLabel("")
|
||||
self.error_label.setWordWrap(True)
|
||||
self.error_label.setFixedWidth(self.MAX_CONTENT_WIDTH)
|
||||
message_layout.addWidget(self.error_label, alignment=Qt.AlignTop)
|
||||
message_layout.addWidget(self.error_label)
|
||||
|
||||
# Options Form
|
||||
|
||||
# Layout
|
||||
for l in [logo_layout, load_layout, message_layout ]:
|
||||
for l in [logo_layout, file_layout, message_layout]:
|
||||
load_ui_layout.addLayout(l)
|
||||
|
||||
load_ui_layout.setStretch(2, 10)
|
||||
#TODO remove magic number
|
||||
load_ui_layout.setStretch(1, 10)
|
||||
self.setLayout(load_ui_layout)
|
||||
|
||||
self.timer = QTimer()
|
||||
self.timer.setInterval(100)
|
||||
self.timer.timeout.connect(self.updateProgress)
|
||||
|
||||
self.signals = FileLoadSignals()
|
||||
self.signals.selectedFile.connect(self.createScanpyEngine)
|
||||
self.signals.error.connect(self.onError)
|
||||
|
||||
def updateEmbedding(self, idx):
|
||||
self.embedding_selection = [MODES[idx]]
|
||||
def updatePath(self):
|
||||
file_name = self.file_name.value
|
||||
if file_name:
|
||||
self.file_area.label.setText("File: " + file_name)
|
||||
else:
|
||||
self.file_area.label.setText("")
|
||||
self.launch_widget.setEnabled(bool(file_name))
|
||||
|
||||
def reset(self):
|
||||
self.loading_layout.setCurrentIndex(0)
|
||||
self.timer.stop()
|
||||
self.error_label.setText("")
|
||||
self.file_name.updateValue(None)
|
||||
self.advanced_options.reset()
|
||||
|
||||
def updateProgress(self):
|
||||
curr_val = self.progress.value()
|
||||
next_val = (curr_val + 1) % 100
|
||||
self.progress.setValue(next_val)
|
||||
|
||||
def resetProgress(self):
|
||||
print("here i am")
|
||||
self.progress.setValue(0)
|
||||
self.loading_layout.setCurrentIndex(0)
|
||||
self.timer.stop()
|
||||
|
||||
def createScanpyEngine(self, file_name):
|
||||
try:
|
||||
self.advanced_options.parse()
|
||||
except OptionsError as e:
|
||||
self.signals.error.emit(f"Options Error: {e}")
|
||||
return
|
||||
title = self.advanced_options.title
|
||||
if not title:
|
||||
title = splitext(basename(file_name))[0]
|
||||
self.window().setupServer()
|
||||
worker = Worker(self.window().parent_conn, self.window().child_conn, file_name, self.title, host="127.0.0.1", port=GUI_PORT,
|
||||
layout=self.embedding_selection)
|
||||
worker = Worker(self.window().parent_conn, self.window().child_conn, file_name, host="127.0.0.1",
|
||||
port=GUI_PORT, title=title, engine_options=self.advanced_options.engine_options)
|
||||
self.window().load_emitter.signals.ready.connect(self.onDataReady)
|
||||
self.window().load_emitter.signals.engine_error.connect(self.onServerError)
|
||||
self.window().load_emitter.signals.server_error.connect(self.onServerError)
|
||||
@@ -208,15 +267,14 @@ class LoadWidget(QFrame):
|
||||
self.window().child_conn.close()
|
||||
|
||||
def onLoad(self):
|
||||
options = QFileDialog.Options()
|
||||
# options |= QFileDialog.DontUseNativeDialog
|
||||
file_name, _ = QFileDialog.getOpenFileName(self,
|
||||
"Open H5AD File", "", "H5AD Files (*.h5ad)", options=options)
|
||||
self.title = splitext(basename(file_name))[0]
|
||||
if file_name:
|
||||
self.signals.selectedFile.emit(file_name)
|
||||
if self.file_name.value:
|
||||
# Reset error on reload
|
||||
self.serverError = False
|
||||
self.loading_layout.setCurrentIndex(1)
|
||||
self.timer.start()
|
||||
self.signals.selectedFile.emit(self.file_name.value)
|
||||
else:
|
||||
self.signals.error.emit("Please select a file before launching.")
|
||||
|
||||
def onDataReady(self):
|
||||
self.site_ready_worker = SiteReadyWorker(self.window().url)
|
||||
@@ -228,6 +286,7 @@ class LoadWidget(QFrame):
|
||||
|
||||
def onServerReady(self):
|
||||
if not self.serverError:
|
||||
self.resetProgress()
|
||||
self.window().cef_widget.browser.Navigate(self.window().url)
|
||||
self.window().showBrowser()
|
||||
|
||||
@@ -236,13 +295,105 @@ class LoadWidget(QFrame):
|
||||
if server_error:
|
||||
self.serverError = True
|
||||
# Report error and switch to load screen
|
||||
self.window().shutdownServer()
|
||||
self.window().shutdownServer()
|
||||
self.resetProgress()
|
||||
self.window().stacked_layout.setCurrentIndex(LOAD_INDEX)
|
||||
self.error_label.setText(f"Error: {err}")
|
||||
self.error_label.resize(self.MAX_CONTENT_WIDTH, self.error_label.height())
|
||||
self.window().repaint()
|
||||
|
||||
onServerError = partialmethod(onError, server_error=True)
|
||||
|
||||
class CLIOptionsArea(QFrame):
|
||||
def __init__(self, parent):
|
||||
super(CLIOptionsArea, self).__init__(parent)
|
||||
self.engine_options = {}
|
||||
self.setFixedWidth(500)
|
||||
self.title = None
|
||||
self.form_layout = QFormLayout()
|
||||
self.form_layout.setContentsMargins(0,0,0,0)
|
||||
self.cli_label = QLabel("CLI Options:")
|
||||
self.cli_label.setToolTip("Additional options can be passed as cli parameters. See cellxgene --help for more info")
|
||||
self.cli_widget = QLineEdit()
|
||||
self.form_layout.setFieldGrowthPolicy(QFormLayout.AllNonFixedFieldsGrow)
|
||||
self.form_layout.addRow(self.cli_label, self.cli_widget)
|
||||
self.setLayout(self.form_layout)
|
||||
|
||||
def reset(self):
|
||||
self.cli_widget.setText("")
|
||||
self.engine_options = {}
|
||||
self.title = None
|
||||
|
||||
def parse(self):
|
||||
opts = parse_opt_string(self.cli_widget.text())
|
||||
self.title = opts.pop("title", None)
|
||||
self.engine_options = parse_engine_args(**opts)
|
||||
|
||||
|
||||
class FilePath(QObject):
|
||||
def __init__(self):
|
||||
super(FilePath, self).__init__()
|
||||
self.value = ""
|
||||
self.signals = FileChanged()
|
||||
|
||||
def updateValue(self, path=None):
|
||||
self.value = path
|
||||
self.signals.changed.emit(self.value != path)
|
||||
|
||||
|
||||
class FileArea(QFrame):
|
||||
def __init__(self):
|
||||
super(FileArea, self).__init__()
|
||||
self.setFrameShape(QFrame.Box)
|
||||
self.setMinimumHeight(100)
|
||||
self.setFixedWidth(500)
|
||||
self.setAcceptDrops(True)
|
||||
self.instructions = QLabel(self)
|
||||
self.instructions.setText("Drag & Drop a h5ad file to load or open")
|
||||
self.instructions.setGeometry(10, 10, 500, self.instructions.height())
|
||||
self.loadButton = QPushButton("Open...", parent=self)
|
||||
x_pos = (500 - self.loadButton.width()) / 2
|
||||
self.loadButton.setGeometry(x_pos, 50, self.loadButton.width(), self.loadButton.height())
|
||||
self.loadButton.clicked.connect(self.fileBrowse)
|
||||
self.label = QLabel(self)
|
||||
self.label.setGeometry(10, 75, 500, self.label.height())
|
||||
|
||||
def fileBrowse(self):
|
||||
options = QFileDialog.Options()
|
||||
# options |= QFileDialog.DontUseNativeDialog
|
||||
file_name, _ = QFileDialog.getOpenFileName(self,
|
||||
"Open H5AD File", "", "H5AD Files (*.h5ad)", options=options)
|
||||
if file_name:
|
||||
self.parent().file_name.updateValue(file_name)
|
||||
|
||||
def dragEnterEvent(self, e):
|
||||
if e.mimeData().hasUrls:
|
||||
e.accept()
|
||||
else:
|
||||
e.ignore()
|
||||
|
||||
def dragMoveEvent(self, e):
|
||||
if e.mimeData().hasUrls:
|
||||
e.accept()
|
||||
else:
|
||||
e.ignore()
|
||||
|
||||
def dropEvent(self, e):
|
||||
"""
|
||||
Drop files directly onto the widget
|
||||
File locations are stored in fname
|
||||
:param e:
|
||||
:return:
|
||||
"""
|
||||
if e.mimeData().hasUrls:
|
||||
e.setDropAction(Qt.CopyAction)
|
||||
e.accept()
|
||||
for url in e.mimeData().urls():
|
||||
file_name = str(url.toLocalFile())
|
||||
self.parent().file_name.updateValue(file_name)
|
||||
else:
|
||||
e.ignore()
|
||||
|
||||
|
||||
def main():
|
||||
# This generates an error.log file on error
|
||||
@@ -256,6 +407,9 @@ def main():
|
||||
cef.Initialize(settings)
|
||||
app = CefApplication(sys.argv)
|
||||
main_window = MainWindow()
|
||||
main_window.setWindowTitle("cellxgene")
|
||||
main_window.setUnifiedTitleAndToolBarOnMac(True)
|
||||
main_window.setWindowIcon(QIcon(":icon.png"))
|
||||
main_window.show()
|
||||
main_window.activateWindow()
|
||||
main_window.raise_()
|
||||
|
||||
Reference in New Issue
Block a user