import logging
import os
import sys
import threading
from contextlib import redirect_stdout, redirect_stderr
from traceback import print_exc
from typing import List, Optional, Union
from PyQt5.QtCore import QDir, QThread, Qt, QObject, pyqtSignal
from PyQt5.QtGui import QResizeEvent, QKeyEvent, QDropEvent, QDragEnterEvent, QDragMoveEvent
from PyQt5.QtWidgets import QApplication, QWidget, QVBoxLayout, QHBoxLayout, QCheckBox, QPushButton, QProgressBar, \
QLabel, QRadioButton, QSpacerItem, QSizePolicy, QTabWidget, QListWidget, QMainWindow, QFileDialog, QTextEdit, \
QAbstractItemView, QListWidgetItem, QFrame
from SDF.file_io import SDFConverter
[docs]class HLine(QFrame):
def __init__(self):
super().__init__()
self.setFrameShape(QFrame.HLine)
self.setFrameShadow(QFrame.Sunken)
[docs]class SDFConverterGUI(QMainWindow):
def __init__(self):
super().__init__()
# sizes
self.margin = 5
self.bottom_panel_height = 100
self.sidebar_width = 150
# main widgets, must be members, because resizeEvent needs to access them
self.tabs_panel = QWidget(parent=self)
self.bottom_panel = QWidget(parent=self)
self.right_panel = QWidget(parent=self)
# tabs
self.file_list = FileListWidget()
self.output_textbox = QTextEdit()
# output directory
self.output_directory_label = QTextEdit()
self.output_directory_selection_button = QPushButton(text="Select")
self.output_directory_reset_button = QPushButton(text="Reset")
# run panel components
self.progress_bar = QProgressBar()
self.run_button = QPushButton(text="Run")
# ForceSDF options
self.checkbox_mat = QCheckBox(text="Generate matlab\nfile")
self.checkbox_no_force = QCheckBox(text="Don't generate\nForceSDF file")
# verbosity options
self.radiobutton_verbosity_normal = QRadioButton(text="Normal")
self.radiobutton_verbosity_verbose = QRadioButton(text="Verbose")
self.radiobutton_verbosity_debug = QRadioButton(text="Debug")
# file selection
self.file_selection_button = QPushButton(text="Add files")
self.clear_file_list_button = QPushButton(text="Clear file list")
# organize everything
self._init_layout()
self._init_components()
self._init_logging()
# worker thread
self.worker: Optional[WorkerThread] = None
def _init_logging(self):
handler = LoggingHandler()
handler.setFormatter(logging.Formatter("%(levelname)s: %(message)s"))
logging.getLogger().addHandler(handler)
handler.output_stream.output_ready.connect(self.write_output)
def _init_components(self):
# output tab
self.output_textbox.setReadOnly(True)
self.output_textbox.setPlaceholderText("Output")
# output directory label
self.output_directory_label.setReadOnly(True)
self.output_directory_label.setLineWrapMode(QTextEdit.LineWrapMode.NoWrap)
self.output_directory_label.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
self.output_directory_label.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
self.output_directory_label.setPlaceholderText("[Same as input files]")
self.radiobutton_verbosity_normal.setChecked(True)
# connect buttons
self.output_directory_reset_button.clicked.connect(self.output_directory_reset)
self.output_directory_selection_button.clicked.connect(self.output_directory_selection)
self.file_selection_button.clicked.connect(self.select_files)
self.clear_file_list_button.clicked.connect(self.file_list.clear)
self.run_button.clicked.connect(self.run)
def _init_layout(self):
# main window
self.setWindowTitle("SDF Converter")
self.resize(700, 500)
# left panel
left_panel_layout = QVBoxLayout(self.tabs_panel)
tab_container = QTabWidget()
file_tab = QWidget()
file_tab_layout = QVBoxLayout()
file_tab.setLayout(file_tab_layout)
file_tab_layout.addWidget(self.file_list)
output_tab = QWidget()
output_tab_layout = QVBoxLayout()
output_tab.setLayout(output_tab_layout)
output_tab_layout.addWidget(self.output_textbox)
tab_container.addTab(file_tab, "Files")
tab_container.addTab(output_tab, "Output")
left_panel_layout.addWidget(tab_container)
# output directory panel
output_directory_layout = QHBoxLayout()
output_directory_label = QLabel(text="<b>Output directory:</b>")
output_directory_layout.addWidget(output_directory_label)
output_directory_layout.addWidget(self.output_directory_label)
output_directory_layout.addWidget(self.output_directory_selection_button)
output_directory_layout.addWidget(self.output_directory_reset_button)
output_directory_widget = QWidget()
output_directory_widget.setLayout(output_directory_layout)
# run panel
run_layout = QHBoxLayout()
run_layout.addWidget(self.progress_bar)
run_layout.addWidget(self.run_button)
run_widget = QWidget()
run_widget.setLayout(run_layout)
# bottom panel
bottom_panel_layout = QVBoxLayout(self.bottom_panel)
bottom_panel_layout.setSpacing(0)
bottom_panel_layout.addWidget(HLine())
bottom_panel_layout.addWidget(output_directory_widget)
bottom_panel_layout.addWidget(run_widget)
# right panel
right_panel_layout = QVBoxLayout(self.right_panel)
force_sdf_container = QVBoxLayout()
force_sdf_container.addWidget(QLabel(text="<b>ForceSDF options</b>"))
force_sdf_container.addWidget(self.checkbox_mat)
force_sdf_container.addWidget(self.checkbox_no_force)
verbosity_container = QVBoxLayout()
verbosity_container.addWidget(QLabel(text="<b>Verbosity level</b>"))
verbosity_container.addWidget(self.radiobutton_verbosity_normal)
verbosity_container.addWidget(self.radiobutton_verbosity_verbose)
verbosity_container.addWidget(self.radiobutton_verbosity_debug)
file_selection_container = QVBoxLayout()
file_selection_container.addWidget(self.file_selection_button)
file_selection_container.addWidget(self.clear_file_list_button)
right_panel_layout.addLayout(force_sdf_container)
right_panel_layout.addLayout(verbosity_container)
right_panel_layout.addItem(QSpacerItem(2 * self.margin, self.bottom_panel_height, QSizePolicy.Minimum,
QSizePolicy.Expanding))
right_panel_layout.addLayout(file_selection_container)
# set sizes
self.output_directory_selection_button.setFixedWidth(self.sidebar_width - 2*self.margin)
self.output_directory_reset_button.setFixedWidth(self.sidebar_width - 2*self.margin)
self.file_selection_button.setFixedWidth(self.sidebar_width-2*self.margin)
self.clear_file_list_button.setFixedWidth(self.sidebar_width-2*self.margin)
self.output_directory_label.setFixedHeight(25)
output_directory_label.setFixedWidth(self.sidebar_width - 2*self.margin)
# set margins
left_panel_layout.setContentsMargins(self.margin, self.margin, self.margin, self.margin)
output_directory_layout.setContentsMargins(self.margin, self.margin, self.margin, self.margin)
run_layout.setContentsMargins(self.margin, self.margin, self.margin, self.margin)
right_panel_layout.setContentsMargins(self.margin, self.margin, self.margin, self.margin)
[docs] def resizeEvent(self, resize_event: QResizeEvent):
super().resizeEvent(resize_event)
size = resize_event.size()
width, height = size.width(), size.height()
self.tabs_panel.setGeometry(0, 0, width - self.sidebar_width, height - self.bottom_panel_height)
self.bottom_panel.setGeometry(0, height - self.bottom_panel_height, width, self.bottom_panel_height)
self.right_panel.setGeometry(width - self.sidebar_width, 0, self.sidebar_width,
height - self.bottom_panel_height)
[docs] def output_directory_reset(self):
self.output_directory_label.setText("")
[docs] def output_directory_selection(self):
self.output_directory = QFileDialog.getExistingDirectory(self, "Select output directory", QDir.currentPath())
self.output_directory_label.setText(self.output_directory)
[docs] def select_files(self):
filenames, _ = QFileDialog.getOpenFileNames(self, "Select input files", QDir.currentPath())
for filename in filenames:
self.file_list.addItem(filename)
[docs] def run(self):
if not self.file_list.filenames:
return
converter = SDFConverter(
generate_mat=self.checkbox_mat.isChecked(),
generate_force=not self.checkbox_no_force.isChecked(),
output_directory=self.output_directory,
)
filenames = self.file_list.filenames
if self.radiobutton_verbosity_verbose.isChecked():
log_level = logging.INFO
elif self.radiobutton_verbosity_debug.isChecked():
log_level = logging.DEBUG
else:
log_level = logging.WARNING
logging.getLogger().setLevel(log_level)
self.worker = WorkerThread(converter, filenames)
self.worker.finished.connect(self.job_done)
self.worker.progress.connect(self.update_progressbar)
self.worker.output.connect(self.write_output)
# delete worker after job is done
self.worker.finished.connect(self.worker.deleteLater)
# qt deleted the c++ object, but we still have a reference to it (usage leads to RuntimeError)
self.worker.destroyed.connect(self.remove_worker_reference)
self.progress_bar.setMaximum(len(filenames))
self.update_progressbar(value=0)
self.run_button.setText("Abort")
self.run_button.clicked.disconnect()
self.run_button.clicked.connect(self.abort)
self.worker.start()
[docs] def remove_worker_reference(self):
self.worker = None
[docs] def write_output(self, text: str):
scrollbar = self.output_textbox.verticalScrollBar()
autoscroll = scrollbar.value() == scrollbar.maximum()
self.output_textbox.insertHtml(text.replace("\n", "<br>"))
if autoscroll:
scrollbar.setValue(scrollbar.maximum())
[docs] def update_progressbar(self, value: int):
self.progress_bar.setValue(value)
percentage = value / self.progress_bar.maximum() * 100
self.progress_bar.setFormat(f"Converted {value}/{self.progress_bar.maximum()} files ({int(percentage)} %)")
[docs] def job_done(self):
self.run_button.setText("Run")
self.run_button.clicked.disconnect()
self.run_button.clicked.connect(self.run)
[docs] def abort(self):
# request interruption, which currently means finishing the current file and then exiting the loop
if self.worker is not None:
self.run_button.setText("Aborting...")
self.worker.requestInterruption()
# TODO: decide if we want a clean shutdown and how it should look like
# (not handling it means just destroying the qt objects, leading to Python errors)
# def closeEvent(self, close_event: QCloseEvent) -> None:
# # freezes window until abort action succeeded
# self.abort()
# if self.worker is not None:
# self.worker.wait() # blocks and waits for job abortion (else it would crash immediately)
# close_event.accept()
@property
def output_directory(self) -> Optional[str]:
if self.output_directory_label.toPlainText() == "":
return None
return self.output_directory_label.toPlainText()
@output_directory.setter
def output_directory(self, directory: str) -> None:
if directory is None:
self.output_directory_label.setPlainText("")
else:
if os.path.isdir(directory):
self.output_directory_label.setPlainText(directory)
[docs]class WorkerThread(QThread):
# TODO: Python GIL -> shares CPU time with GUI thread. Use QProcess?
# TODO: Find a way to safely close the program without producing errors while this Thread is running
progress = pyqtSignal(int)
output = pyqtSignal(str)
def __init__(self, converter: SDFConverter, files: List[str]):
super().__init__()
self.converter = converter
self.files = files
[docs] def run(self):
stdout_stream = OutputStream(thread_id=threading.get_ident(), replaces=sys.__stdout__)
stderr_stream = OutputStream(thread_id=threading.get_ident(), textcolor_html="#FF0000", replaces=sys.__stderr__)
stdout_stream.output_ready.connect(self.output.emit)
stderr_stream.output_ready.connect(self.output.emit)
with redirect_stdout(stdout_stream), redirect_stderr(stderr_stream):
for i, filename in enumerate(self.files, start=1):
if self.isInterruptionRequested():
print("Interrupted")
break
print(f"<b>Converting file {i}/{len(self.files)}:'{filename}'</b>")
try:
self.converter.convert_file(filename)
except:
print_exc(file=sys.stderr)
self.progress.emit(i)
[docs]class OutputStream(QObject):
output_ready = pyqtSignal(str)
def __init__(self, thread_id: Optional[int] = None, textcolor_html: str = None,
replaces=sys.__stdout__):
"""
To be used as output stream, emits output string as Qt Signal. Uses a buffer to emit signals only
:param thread_id: As returned by threading.get_ident(). If set, only output of this thread is emitted
:param textcolor_html: e.g. '#ff0000', ignored when writing to `replaces`
:param replaces: The stream it replaces. If thread_id is not None and output from another thread is caught,
the output is forwarded to this stream
"""
super().__init__()
self.thread_id = thread_id
self.textcolor_html = textcolor_html # e.g. '#ff0000' for red
self.replaces = replaces
self.buffer = ""
[docs] def write_to_buffer(self, text: str):
self.buffer += text
[docs] def write_from_buffer(self):
text = self.buffer
self.buffer = ""
if self.thread_id is None or threading.get_ident() == self.thread_id:
if self.textcolor_html is not None:
text = f"<font color='{self.textcolor_html}'>{text}</font>"
self.output_ready.emit(text)
else:
self.replaces.write(text)
[docs] def write(self, text: str):
self.write_to_buffer(text)
if self.buffer.endswith("\n"):
self.write_from_buffer()
def __del__(self):
if self.buffer:
self.output_ready.emit(self.buffer)
[docs]class LoggingHandler(logging.Handler):
def __init__(self):
super().__init__()
self.output_stream = OutputStream(replaces=sys.__stderr__)
[docs] def emit(self, record):
record = self.format(record)
self.output_stream.write(f"{record}\n")
[docs]def main():
app = QApplication([])
gui = SDFConverterGUI()
gui.show()
exit(app.exec_())