From 98d74df045ee22cd5d598b669969d691db9b38cb Mon Sep 17 00:00:00 2001 From: "Jesse M. Barr" Date: Thu, 5 May 2022 13:08:36 -0500 Subject: [PATCH] ACEstatGUI --- .gitattributes | 1 + .gitignore | 8 + ACEstatGUI.spec | 34 ++ LICENSE | 4 + compile.bat | 4 + requirements.txt | 6 + resources/icons/dark/arrow_down.png | 3 + resources/icons/dark/arrow_down_disabled.png | 3 + resources/icons/dark/arrow_left.png | 3 + resources/icons/dark/arrow_left_disabled.png | 3 + resources/icons/dark/arrow_right.png | 3 + resources/icons/dark/arrow_right_disabled.png | 3 + resources/icons/dark/arrow_up.png | 3 + resources/icons/dark/arrow_up_disabled.png | 3 + resources/icons/light/arrow_down.png | 3 + resources/icons/light/filesave.png | 3 + resources/icons/light/home.png | 3 + resources/icons/light/minus.png | 3 + resources/icons/light/move.png | 3 + resources/icons/light/next.png | 3 + resources/icons/light/plus.png | 3 + resources/icons/light/prev.png | 3 + resources/icons/light/zoom_to_rect.png | 3 + resources/icons/usace_logo.ico | Bin 0 -> 99678 bytes resources/resources.qrc | 28 + resources/styles/ElegantDark.qss | 421 ++++++++++++++ src/ACEstatGUI.py | 534 ++++++++++++++++++ src/Exporters/ExportDialog.py | 75 +++ src/Exporters/Exporter.py | 210 +++++++ src/Exporters/ImageExporter.py | 115 ++++ src/Exporters/MPLExporter.py | 117 ++++ src/Exporters/__init__.py | 6 + src/Main.py | 27 + src/Settings/Settings.py | 134 +++++ src/Settings/__init__.py | 1 + src/Utilities/Signals.py | 63 +++ src/Utilities/__init__.py | 2 + src/Utilities/_utils.py | 10 + src/Widgets/CollapsibleBox.py | 187 ++++++ src/Widgets/Formatting.py | 18 + src/Widgets/FuncComboBox.py | 59 ++ src/Widgets/GroupComboBox.py | 72 +++ src/Widgets/Images.py | 30 + src/Widgets/PlotCanvas.py | 333 +++++++++++ src/Widgets/QueuePanel.py | 212 +++++++ src/Widgets/ResultPanel.py | 218 +++++++ src/Widgets/ResultTable.py | 49 ++ src/Widgets/TestForm.py | 332 +++++++++++ src/Widgets/__init__.py | 10 + 49 files changed, 3371 insertions(+) create mode 100644 .gitattributes create mode 100644 .gitignore create mode 100644 ACEstatGUI.spec create mode 100644 compile.bat create mode 100644 requirements.txt create mode 100644 resources/icons/dark/arrow_down.png create mode 100644 resources/icons/dark/arrow_down_disabled.png create mode 100644 resources/icons/dark/arrow_left.png create mode 100644 resources/icons/dark/arrow_left_disabled.png create mode 100644 resources/icons/dark/arrow_right.png create mode 100644 resources/icons/dark/arrow_right_disabled.png create mode 100644 resources/icons/dark/arrow_up.png create mode 100644 resources/icons/dark/arrow_up_disabled.png create mode 100644 resources/icons/light/arrow_down.png create mode 100644 resources/icons/light/filesave.png create mode 100644 resources/icons/light/home.png create mode 100644 resources/icons/light/minus.png create mode 100644 resources/icons/light/move.png create mode 100644 resources/icons/light/next.png create mode 100644 resources/icons/light/plus.png create mode 100644 resources/icons/light/prev.png create mode 100644 resources/icons/light/zoom_to_rect.png create mode 100644 resources/icons/usace_logo.ico create mode 100644 resources/resources.qrc create mode 100644 resources/styles/ElegantDark.qss create mode 100644 src/ACEstatGUI.py create mode 100644 src/Exporters/ExportDialog.py create mode 100644 src/Exporters/Exporter.py create mode 100644 src/Exporters/ImageExporter.py create mode 100644 src/Exporters/MPLExporter.py create mode 100644 src/Exporters/__init__.py create mode 100644 src/Main.py create mode 100644 src/Settings/Settings.py create mode 100644 src/Settings/__init__.py create mode 100644 src/Utilities/Signals.py create mode 100644 src/Utilities/__init__.py create mode 100644 src/Utilities/_utils.py create mode 100644 src/Widgets/CollapsibleBox.py create mode 100644 src/Widgets/Formatting.py create mode 100644 src/Widgets/FuncComboBox.py create mode 100644 src/Widgets/GroupComboBox.py create mode 100644 src/Widgets/Images.py create mode 100644 src/Widgets/PlotCanvas.py create mode 100644 src/Widgets/QueuePanel.py create mode 100644 src/Widgets/ResultPanel.py create mode 100644 src/Widgets/ResultTable.py create mode 100644 src/Widgets/TestForm.py create mode 100644 src/Widgets/__init__.py diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..24a8e87 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +*.png filter=lfs diff=lfs merge=lfs -text diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d9b26ad --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +build +dist +__pycache__ +MANIFEST +cmd.bat +/venv +*.ini +*.json diff --git a/ACEstatGUI.spec b/ACEstatGUI.spec new file mode 100644 index 0000000..fbc1dbb --- /dev/null +++ b/ACEstatGUI.spec @@ -0,0 +1,34 @@ +# -*- mode: python -*- + +from PyInstaller.utils.hooks import collect_data_files + +block_cipher = None + +a = Analysis(['./src/Main.py'], + pathex=[], + binaries=[], + datas=[ + ('./resources', 'resources'), + *collect_data_files('acestatpy') + ], + hiddenimports=[], + hookspath=[], + runtime_hooks=[], + excludes=[], + win_no_prefer_redirects=False, + win_private_assemblies=False, + cipher=block_cipher) + +pyz = PYZ(a.pure, a.zipped_data, + cipher=block_cipher) +exe = EXE(pyz, + a.scripts, + a.binaries, + a.zipfiles, + a.datas, + name='ACEstatGUI', + debug=False, + strip=False, + upx=True, + console=False, + icon='resources/icons/usace_logo.ico' ) diff --git a/LICENSE b/LICENSE index 0e259d4..dfb0c2a 100644 --- a/LICENSE +++ b/LICENSE @@ -1,3 +1,7 @@ +USACE ERDC ACESTAT +Copyright (c) 2021 U.S. Army Corps of Engineers Engineering Research and Development Center. All rights reserved. + + Creative Commons Legal Code CC0 1.0 Universal diff --git a/compile.bat b/compile.bat new file mode 100644 index 0000000..09a31a3 --- /dev/null +++ b/compile.bat @@ -0,0 +1,4 @@ +:: Python 3 +CALL venv\Scripts\activate.bat +python -m PyInstaller ACEstatGUI.spec +pause diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..67aab2e --- /dev/null +++ b/requirements.txt @@ -0,0 +1,6 @@ +#acestatpy==2022.4.28.1 # Must be installed from file +matplotlib==3.5.1 +PyQt5==5.15.6 +pyqtgraph==0.12.3 +#PyQt6==6.0.3 +#PySide6==6.0.3 diff --git a/resources/icons/dark/arrow_down.png b/resources/icons/dark/arrow_down.png new file mode 100644 index 0000000..3cce707 --- /dev/null +++ b/resources/icons/dark/arrow_down.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:cebaf80baa47b11c4652a77461f8dc4d47c74f5f4988fb0df0c65688910cea6e +size 525 diff --git a/resources/icons/dark/arrow_down_disabled.png b/resources/icons/dark/arrow_down_disabled.png new file mode 100644 index 0000000..9911d8d --- /dev/null +++ b/resources/icons/dark/arrow_down_disabled.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:29771e23a5d29fcc6ffea5fffd3513874386f4849c32522b323d2b3c81c15b6e +size 547 diff --git a/resources/icons/dark/arrow_left.png b/resources/icons/dark/arrow_left.png new file mode 100644 index 0000000..3e5b7c5 --- /dev/null +++ b/resources/icons/dark/arrow_left.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:97256e2348439e86d60edbc646f55fe55bc97526fe20a198e343eb08334a34ca +size 546 diff --git a/resources/icons/dark/arrow_left_disabled.png b/resources/icons/dark/arrow_left_disabled.png new file mode 100644 index 0000000..7807701 --- /dev/null +++ b/resources/icons/dark/arrow_left_disabled.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ac054ef9c996505557362d229186e582e170b4544dab2d29ef2d745eea0de016 +size 569 diff --git a/resources/icons/dark/arrow_right.png b/resources/icons/dark/arrow_right.png new file mode 100644 index 0000000..1aeea95 --- /dev/null +++ b/resources/icons/dark/arrow_right.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e4555c63b16a2b2fc235d406313e524a798b7f7f4628612d813aaa832364288c +size 518 diff --git a/resources/icons/dark/arrow_right_disabled.png b/resources/icons/dark/arrow_right_disabled.png new file mode 100644 index 0000000..75179ef --- /dev/null +++ b/resources/icons/dark/arrow_right_disabled.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d52b6b3995491fb2895a5326c9e9a95f8798465dd76f68eaa9e962da2331c8f9 +size 553 diff --git a/resources/icons/dark/arrow_up.png b/resources/icons/dark/arrow_up.png new file mode 100644 index 0000000..448741c --- /dev/null +++ b/resources/icons/dark/arrow_up.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0f265a562fec70454766696c1e2e5317d556b358be48f957f9bde55fc44f568b +size 512 diff --git a/resources/icons/dark/arrow_up_disabled.png b/resources/icons/dark/arrow_up_disabled.png new file mode 100644 index 0000000..244a57a --- /dev/null +++ b/resources/icons/dark/arrow_up_disabled.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ea474a77bdfcdbe3f55f0ee518fd67817c1b0d56af466eb80af283ed6d2bebf5 +size 538 diff --git a/resources/icons/light/arrow_down.png b/resources/icons/light/arrow_down.png new file mode 100644 index 0000000..cf671a3 --- /dev/null +++ b/resources/icons/light/arrow_down.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a55f467b1ef75434b58f5fd866b4617d64295de8e766be1697e764c6c5885968 +size 223 diff --git a/resources/icons/light/filesave.png b/resources/icons/light/filesave.png new file mode 100644 index 0000000..9a24121 --- /dev/null +++ b/resources/icons/light/filesave.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:085687c000b84550e78176edcb9e04ad40e9653a47e8bf35526ca3240b006ff2 +size 178 diff --git a/resources/icons/light/home.png b/resources/icons/light/home.png new file mode 100644 index 0000000..7f80b29 --- /dev/null +++ b/resources/icons/light/home.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a459bffe91e1e0c68a210775311b56456d3320ce6437c46a4012bed84a9b347d +size 207 diff --git a/resources/icons/light/minus.png b/resources/icons/light/minus.png new file mode 100644 index 0000000..d6d04a0 --- /dev/null +++ b/resources/icons/light/minus.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:62c1d7e287683ae956b39529537da6f623ba771609b29bd6e5fed6bf13474af0 +size 107 diff --git a/resources/icons/light/move.png b/resources/icons/light/move.png new file mode 100644 index 0000000..679e71e --- /dev/null +++ b/resources/icons/light/move.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d2cc7177ede2643ded663d910c4b1b70fa93cfa051efb828a91cc3c042ab0a5d +size 321 diff --git a/resources/icons/light/next.png b/resources/icons/light/next.png new file mode 100644 index 0000000..5e4c6f4 --- /dev/null +++ b/resources/icons/light/next.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b380fdc2fdaad3162ec50859b5f754a038bf91104b4709a03d7f314a75f67bdb +size 317 diff --git a/resources/icons/light/plus.png b/resources/icons/light/plus.png new file mode 100644 index 0000000..ea4683d --- /dev/null +++ b/resources/icons/light/plus.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:336dfaf797fa46a83ecae13db51a693789e7dba8764e243280a21513e38a8f7d +size 143 diff --git a/resources/icons/light/prev.png b/resources/icons/light/prev.png new file mode 100644 index 0000000..ff704dc --- /dev/null +++ b/resources/icons/light/prev.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:471ad329b2cb0842c704cf33941256ccbf0a1a82de59fbce8375dd657e0194ce +size 293 diff --git a/resources/icons/light/zoom_to_rect.png b/resources/icons/light/zoom_to_rect.png new file mode 100644 index 0000000..38d1f2e --- /dev/null +++ b/resources/icons/light/zoom_to_rect.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:515926a17a79a1afca5d711a9fccbc1f2c61a19bb3d74d3e4850f974c40ede59 +size 767 diff --git a/resources/icons/usace_logo.ico b/resources/icons/usace_logo.ico new file mode 100644 index 0000000000000000000000000000000000000000..ace117b52e388208db5597e17e69f5e02bb624f8 GIT binary patch literal 99678 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 af#IqHmEZyg0tW&I0tW&I0tb4I1OE>@#mlY$ literal 0 HcmV?d00001 diff --git a/resources/resources.qrc b/resources/resources.qrc new file mode 100644 index 0000000..1f83079 --- /dev/null +++ b/resources/resources.qrc @@ -0,0 +1,28 @@ + + + icons/usace_logo.ico + styles/ElegantDark.qss + Tests.xml + + + icons/dark/arrow_down.png + icons/dark/arrow_down_disabled.png + icons/dark/arrow_up.png + icons/dark/arrow_up_disabled.png + icons/dark/arrow_right.png + icons/dark/arrow_right_disabled.png + icons/dark/arrow_left.png + icons/dark/arrow_left_disabled.png + + + icons/light/arrow_down.png + icons/light/plus.png + icons/light/minus.png + icons/light/filesave.png + icons/light/home.png + icons/light/move.png + icons/light/next.png + icons/light/prev.png + icons/light/zoom_to_rect.png + + diff --git a/resources/styles/ElegantDark.qss b/resources/styles/ElegantDark.qss new file mode 100644 index 0000000..4448419 --- /dev/null +++ b/resources/styles/ElegantDark.qss @@ -0,0 +1,421 @@ +/* +ElegantDark Style Sheet for QT Applications +Author: Jaime A. Quiroga P. +Company: GTRONICK +Last updated: 17/04/2018 +Available at: https://github.com/GTRONICK/QSS/blob/master/ElegantDark.qss + +Modified by: Jesse M. Barr +Date: 2020-04-10 +*/ + +* { + font-family: Helvetica; +} + +QDialog, +QMainWindow { + background-color: rgb(82, 82, 82); +} + +QGroupBox { + color: #fff; +} + +QTabWidget { + color: #000; + background-color: rgb(247,246,246); +} + +QTabWidget::pane{ + border-color: rgb(77,77,77); + background-color: rgb(101,101,101); + border-style: solid; + border-width: 0; + border-top-left-radius: 0; + border-top-right-radius: 6px; + border-bottom-right-radius: 6px; + border-bottom-left-radius: 6px; +} + +QAbstractScrollArea, +QAbstractScrollArea #TestStaging, +QAbstractScrollArea #TestQueue, +QAbstractScrollArea #ResultPanel { + color: #fff; + background-color: rgb(101,101,101); +} + +QScrollBar:horizontal { + height: 16px; + margin: 2px 16px; + border: 1px solid #32414B; + border-radius: 4px; + background-color: #19232D; +} + +QScrollBar:vertical { + background-color: #19232D; + width: 16px; + margin: 16px 2px; + border: 1px solid #32414B; + border-radius: 4px; +} + +QScrollBar::handle:horizontal { + background-color: #787878; + border: 1px solid #32414B; + border-radius: 4px; + min-width: 8px; +} + +QScrollBar::handle:horizontal:hover { + background-color: #148CD2; + border: 1px solid #148CD2; + border-radius: 4px; + min-width: 8px; +} + +QScrollBar::handle:horizontal:focus { + border: 1px solid #1464A0; +} + +QScrollBar::handle:vertical { + background-color: #787878; + border: 1px solid #32414B; + min-height: 8px; + border-radius: 4px; +} + +QScrollBar::handle:vertical:hover { + background-color: #148CD2; + border: 1px solid #148CD2; + border-radius: 4px; + min-height: 8px; +} + +QScrollBar::handle:vertical:focus { + border: 1px solid #1464A0; +} + +QScrollBar::add-line:horizontal { + margin: 0px 0px 0px 0px; + border-image: url('dark:arrow_right_disabled.png'); + height: 12px; + width: 12px; + subcontrol-position: right; + subcontrol-origin: margin; +} + +QScrollBar::add-line:horizontal:hover, +QScrollBar::add-line:horizontal:on { + border-image: url('dark:arrow_right.png'); + height: 12px; + width: 12px; + subcontrol-position: right; + subcontrol-origin: margin; +} + +QScrollBar::add-line:vertical { + margin: 3px 0px 3px 0px; + border-image: url('dark:arrow_down_disabled.png'); + height: 12px; + width: 12px; + subcontrol-position: bottom; + subcontrol-origin: margin; +} + +QScrollBar::add-line:vertical:hover, +QScrollBar::add-line:vertical:on { + border-image: url('dark:arrow_down.png'); + height: 12px; + width: 12px; + subcontrol-position: bottom; + subcontrol-origin: margin; +} + +QScrollBar::sub-line:horizontal { + margin: 0px 3px 0px 3px; + border-image: url('dark:arrow_left_disabled.png'); + height: 12px; + width: 12px; + subcontrol-position: left; + subcontrol-origin: margin; +} + +QScrollBar::sub-line:horizontal:hover, +QScrollBar::sub-line:horizontal:on { + border-image: url('dark:arrow_left.png'); + height: 12px; + width: 12px; + subcontrol-position: left; + subcontrol-origin: margin; +} + +QScrollBar::sub-line:vertical { + margin: 3px 0px 3px 0px; + border-image: url('dark:arrow_up_disabled.png'); + height: 12px; + width: 12px; + subcontrol-position: top; + subcontrol-origin: margin; +} + +QScrollBar::sub-line:vertical:hover, +QScrollBar::sub-line:vertical:on { + border-image: url('dark:arrow_up.png'); + height: 12px; + width: 12px; + subcontrol-position: top; + subcontrol-origin: margin; +} + +QScrollBar::up-arrow:horizontal, +QScrollBar::down-arrow:horizontal { + background: none; +} + +QScrollBar::up-arrow:vertical, +QScrollBar::down-arrow:vertical { + background: none; +} + +QScrollBar::add-page:horizontal, +QScrollBar::sub-page:horizontal { + background: none; +} + +QScrollBar::add-page:vertical, +QScrollBar::sub-page:vertical { + background: none; +} + +QTabBar::tab { + padding: 2px; + color: rgb(250,250,250); + background-color: qlineargradient(spread:pad, x1:0.5, y1:1, x2:0.5, y2:0, stop:0 rgba(77, 77, 77, 255), stop:1 rgba(97, 97, 97, 255)); + border-style: solid; + border-width: 2px; + border-top-right-radius: 4px; + border-top-left-radius: 4px; + border-bottom-right-radius: 0; + border-bottom-left-radius: 0; + border-top-color: qlineargradient(spread:pad, x1:0.5, y1:0.6, x2:0.5, y2:0.4, stop:0 rgba(115, 115, 115, 255), stop:1 rgba(95, 92, 93, 255)); + border-right-color: qlineargradient(spread:pad, x1:0.4, y1:0.5, x2:0.6, y2:0.5, stop:0 rgba(115, 115, 115, 255), stop:1 rgba(95, 92, 93, 255)); + border-left-color: qlineargradient(spread:pad, x1:0.6, y1:0.5, x2:0.4, y2:0.5, stop:0 rgba(115, 115, 115, 255), stop:1 rgba(95, 92, 93, 255)); + border-bottom-color: rgb(101,101,101); +} + +QTabBar::tab:hover, +QTabBar::tab:last:selected, +QTabBar::tab:selected { + background-color: rgb(101,101,101); + margin-left: 0; + margin-right: 1px; +} + +QTabBar::tab:!selected { + margin-top: 1px; + margin-right: 1px; +} + +QPlainTextEdit { + background-color: #2a2a2a; + color: rgb(0, 255, 0); +} + +QPushButton { + border-style: outset; + border-width: 2px; + border-top-color: qlineargradient(spread:pad, x1:0.5, y1:0.6, x2:0.5, y2:0.4, stop:0 rgba(115, 115, 115, 255), stop:1 rgba(62, 62, 62, 255)); + border-right-color: qlineargradient(spread:pad, x1:0.4, y1:0.5, x2:0.6, y2:0.5, stop:0 rgba(115, 115, 115, 255), stop:1 rgba(62, 62, 62, 255)); + border-left-color: qlineargradient(spread:pad, x1:0.6, y1:0.5, x2:0.4, y2:0.5, stop:0 rgba(115, 115, 115, 255), stop:1 rgba(62, 62, 62, 255)); + border-bottom-color: rgb(58, 58, 58); + border-bottom-width: 1px; + border-style: solid; + color: #fff; + padding: 2px; + background-color: qlineargradient(spread:pad, x1:0.5, y1:1, x2:0.5, y2:0, stop:0 rgba(77, 77, 77, 255), stop:1 rgba(97, 97, 97, 255)); +} + +QPushButton:hover { + border-top-color: qlineargradient(spread:pad, x1:0.5, y1:0.6, x2:0.5, y2:0.4, stop:0 rgba(180, 180, 180, 255), stop:1 rgba(110, 110, 110, 255)); + border-right-color: qlineargradient(spread:pad, x1:0.4, y1:0.5, x2:0.6, y2:0.5, stop:0 rgba(180, 180, 180, 255), stop:1 rgba(110, 110, 110, 255)); + border-left-color: qlineargradient(spread:pad, x1:0.6, y1:0.5, x2:0.4, y2:0.5, stop:0 rgba(180, 180, 180, 255), stop:1 rgba(110, 110, 110, 255)); + border-bottom-color: rgb(115, 115, 115); + background-color: qlineargradient(spread:pad, x1:0.5, y1:1, x2:0.5, y2:0, stop:0 rgba(107, 107, 107, 255), stop:1 rgba(157, 157, 157, 255)); +} + +QPushButton:pressed { + border-top-color: qlineargradient(spread:pad, x1:0.5, y1:0.6, x2:0.5, y2:0.4, stop:0 rgba(62, 62, 62, 255), stop:1 rgba(22, 22, 22, 255)); +} + +QPushButton:disabled { + color: #000; + background-color: qlineargradient(spread:pad, x1:0.5, y1:1, x2:0.5, y2:0, stop:0 rgba(57, 57, 57, 255), stop:1 rgba(77, 77, 77, 255)); +} + +QLineEdit { + border-width: 1px; + border-radius: 4px; + border-color: rgb(58, 58, 58); + border-style: inset; + padding: 0 8px; + color: #fff; + background: rgb(100, 100, 100); + selection-background-color: rgb(187, 187, 187); + selection-color: rgb(60, 63, 65); +} + +QLabel { + color: #fff; +} + +QLabel:disabled { + color: #7f7f7f; +} + +QComboBox { + padding-right: 0; + height: 20px; + padding-left: 3px; +} + +QComboBox QListView { + color: #000; + selection-color: #000; + background-color: #fff; +} + +QComboBox#testSelect { + height: 35px; +} + +QComboBox#testSelect QListView::item { + height: 30px; +} + +QComboBox::drop-down { + subcontrol-origin: margin; + border-left-width: 1px; + border-left-color: darkgray; + border-left-style: solid; + border-top-right-radius: 2px; + border-bottom-right-radius: 2px; + width: 20px; +} + +QComboBox::drop-down:hover { + background-color: qlineargradient(spread:pad, x1:0.5, y1:1, x2:0.5, y2:0, stop:0 rgba(107, 107, 107, 255), stop:1 rgba(157, 157, 157, 255)); + border-top-color: qlineargradient(spread:pad, x1:0.5, y1:0.6, x2:0.5, y2:0.4, stop:0 rgba(62, 62, 62, 255), stop:1 rgba(22, 22, 22, 255)); + border-right-color: qlineargradient(spread:pad, x1:0.4, y1:0.5, x2:0.6, y2:0.5, stop:0 rgba(115, 115, 115, 255), stop:1 rgba(62, 62, 62, 255)); + border-left-color: qlineargradient(spread:pad, x1:0.6, y1:0.5, x2:0.4, y2:0.5, stop:0 rgba(115, 115, 115, 255), stop:1 rgba(62, 62, 62, 255)); +} + +QComboBox::down-arrow { + image: url("light:arrow_down.png"); + width: 12px; + height: 12px; +} + +QPushButton#btnTestHelp { + color: #fff; + background-color: #4472db; + font-weight: bold; +} + +QPushButton:checked { + background-color: qlineargradient(spread:pad, x1:0.5, y1:1, x2:0.5, y2:0, stop:0 rgba(107, 107, 107, 255), stop:1 rgba(157, 157, 157, 255)); +} + +QProgressBar { + text-align: center; + color: rgb(240, 240, 240); + border-width: 1px; + border-radius: 10px; + border-color: rgb(58, 58, 58); + border-style: inset; + background-color: rgb(77,77,77); +} + +QProgressBar::chunk { + background-color: qlineargradient(spread:pad, x1:0.5, y1:0.7, x2:0.5, y2:0.3, stop:0 rgba(87, 97, 106, 255), stop:1 rgba(93, 103, 113, 255)); + border-radius: 5px; +} + +QMenuBar { + background: rgb(82, 82, 82); +} + +QMenuBar::item { + color: #dfdbd2; + spacing: 3px; + padding: 1px 4px; + background: transparent; +} + +QMenuBar::item:selected { + background: rgb(115, 115, 115); +} + +QMenu::item:selected { + color: #fff; + border-width: 2px; + border-style: solid; + padding: 2px 8px 3px 18px; + background: qlineargradient(spread:pad, x1:0.5, y1:0.7, x2:0.5, y2:0.3, stop:0 rgba(87, 97, 106, 255), stop:1 rgba(93, 103, 113, 255)); + border-top-color: qlineargradient(spread:pad, x1:0.5, y1:0.6, x2:0.5, y2:0.4, stop:0 rgba(115, 115, 115, 255), stop:1 rgba(62, 62, 62, 255)); + border-right-color: qlineargradient(spread:pad, x1:0.4, y1:0.5, x2:0.6, y2:0.5, stop:0 rgba(115, 115, 115, 255), stop:1 rgba(62, 62, 62, 255)); + border-left-color: qlineargradient(spread:pad, x1:0.6, y1:0.5, x2:0.4, y2:0.5, stop:0 rgba(115, 115, 115, 255), stop:1 rgba(62, 62, 62, 255)); + border-bottom-color: rgb(58, 58, 58); + border-bottom-width: 1px; +} + +QMenu::item { + color: #dfdbd2; + background-color: #4e4e4e; + padding: 4px 10px 4px 20px; +} + +QMenu { + background-color: #4e4e4e; +} + +QCheckBox { + color: #fff; + padding: 2px; +} + +QCheckBox:hover { + border-radius: 4px; + border-style: solid; + padding: 1px; + border-width: 1px; + border-color: #57616a; + background-color: qlineargradient(spread:pad, x1:0.5, y1:0.7, x2:0.5, y2:0.3, stop:0 rgba(87, 97, 106, 150), stop:1 rgba(93, 103, 113, 150)); +} + +QCheckBox::indicator, +QGroupBox::indicator { + border-radius: 4px; + border-style: solid; + border-width: 2px; + border-color: #fff; +} + +QCheckBox::indicator:checked, +QGroupBox::indicator:checked { + background-color: #000; +} +QCheckBox::indicator:unchecked, +QGroupBox::indicator:unchecked { + background-color:#fff; +} + +QStatusBar { + color: #f0f0f0; +} + +#TestQueue QWidget, #ResultPanel QWidget { + background-color: #525252; +} diff --git a/src/ACEstatGUI.py b/src/ACEstatGUI.py new file mode 100644 index 0000000..a1d6aad --- /dev/null +++ b/src/ACEstatGUI.py @@ -0,0 +1,534 @@ +''' +Last Modified: 2021-08-20 + +@author: Jesse M. Barr + +Contains: + -ACEstatGUI + +Changes: + -2021-08-20: + -Added table to display matrix results. + -2021-04-28: + -Change widgets in response to acestatpy events rather than assuming. + -Added countdown timer. + +ToDo: + +''' +# PyQt +from PyQt5.QtCore import Qt, QDir, QFile, QTextStream, QTimer +from PyQt5.QtGui import QIcon +from PyQt5.QtWidgets import (QMainWindow, QWidget, QHBoxLayout, QVBoxLayout, + QSplitter, QTabWidget, QPushButton, QLineEdit, + QGroupBox, QComboBox, QLabel, QPlainTextEdit, + QMessageBox, QStyleFactory, QFileDialog) +# Installed +from acestatpy import ACEstatPy +# Local +from Settings import LoadConfig, SettingsDialog +from Utilities import SignalTranslator, resource_path +from Widgets import (FuncComboBox, PlotCanvas, QueuePanel, ResultPanel, + ResultTable, TestForm) + +QDir.addSearchPath('icons', resource_path("icons")) +QDir.addSearchPath('styles', resource_path("styles")) +QDir.addSearchPath('dark', resource_path("icons", "dark")) +QDir.addSearchPath('light', resource_path("icons", "light")) + +# Temporary, for testing only +# import random + +# Global Variables +DEFAULT_BAUD = 9600 +CONFIGFILE = 'config.ini' +CONFIG = LoadConfig(CONFIGFILE) + + +class ACEstatGUI(QMainWindow): + + def __init__(self): + super().__init__() + self.title = "ACEstatGUI" + self.acestat = ACEstatPy() + xScale = self.logicalDpiX() / 96.0 + yScale = self.logicalDpiY() / 96.0 + + # Setting this closes all child windows when this window is closed. + self.setAttribute(Qt.WA_DeleteOnClose) + # self.setStyleSheet(qdarkstyle.load_stylesheet(qt_api='pyqt5')) + self.setStyle(QStyleFactory.create("Fusion")) + # self.setStyleSheet(pkg_resources.resource_string("resources", 'styles/ElegantDark.qss').decode("utf-8") ) + styles = QFile("styles:ElegantDark.qss") + if styles.open(styles.ReadOnly | styles.Text): + self.setStyleSheet(QTextStream(styles).readAll()) + styles.close() + self.initUI() + + self.setWindowIcon(QIcon("icons:usace_logo.ico")) + self.setWindowTitle(self.title) + # self.adjustSize() + self.resize(int(875 * xScale), int(550 * yScale)) + + def messageDialog(self, message, title, icon=None, detailed=None, informative=None): + msg = QMessageBox() + msg.setText(message) + if icon: + msg.setIcon(icon) + if informative: + msg.setInformativeText(informative) + msg.setWindowTitle(title) + if detailed: + msg.setDetailedText(detailed) + msg.exec_() + + def closeEvent(self, event): + try: + with open(CONFIGFILE, 'w') as configfile: + CONFIG.write(configfile) + except: + pass + event.accept() + + def initUI(self): + ####################### + ### LOCAL FUNCTIONS ### + ####################### + def updateCountdown(): + rem = self.acestat.currentTest.estimateRemaining() + if rem is None: + testStatus.setText("Running {0}...".format(self.acestat.currentTest.id)) + return + else: + testStatus.setText("Running {0}: ~{1}s".format( + self.acestat.currentTest.id, round(rem))) + if rem == 0: + _timer.stop() + + def updatePreference(section, option, value): + if section == "console": + if option == "show_console": + CONFIG.set(section, option, str(value).lower()) + value = CONFIG.getboolean(section, option) + mToggleConsole.setChecked(value) + right_bottom.setVisible(value) + if not value: + handleConsoleClear() + elif option == "console_lines": + CONFIG.set(section, option, str(value)) + value = CONFIG.getint(section, option) + consoleTE.setMaximumBlockCount(value) + elif section == "results": + if option == "result_sort": + CONFIG.set(section, option, str(value)) + value = CONFIG.getint(section, option) + resultList.applySort(value) + elif option == "result_limit": + CONFIG.set(section, option, str(value)) + value = CONFIG.getint(section, option) + resultList.setLimit(value) + + def handleFileAction(action): + if action == mLoadTests: + fpath = QFileDialog.getOpenFileName( + self, "Import Test Definitions", filter="XML (*.xml)") + if fpath[0]: + try: + self.acestat.Definitions.importTests(fpath[0]) + testForm.initUI() + self.acestat.clearQueue() + testQueue.updateQueue() + resultList.clear() + except: + self.messageDialog( + "Error encountered when importing: {0}.".format(fpath[0]), + "Import Error", + QMessageBox.Warning) + elif action == mExportTests: + fpath = QFileDialog.getSaveFileName( + self, "Export Default Test Definitions", filter="XML (*.xml)") + if fpath[0]: + self.acestat.Definitions.copyDefaultTests(fpath[0]) + elif action == mPreferences: + dlg = SettingsDialog(self, config=CONFIG) + if dlg.exec_(): + for i in dlg.Changes.items(): + updatePreference(*i[0], i[1]) + elif action == mQuit: + self.close() + + def handleToolAction(action): + if action == mReset: + self.acestat.sendCancel() + elif action == mSavePreset: + fpath = QFileDialog.getSaveFileName( + self, "Save Preset", filter="JSON (*.json)") + if fpath[0]: + params = testForm.CurrentParameters + self.acestat.Definitions.saveCustomPreset( + params[0], params[1], fpath[0]) + elif action == mLoadPreset: + fpaths = QFileDialog.getOpenFileNames( + self, "Import Custom Presets", filter="JSON (*.json)") + for p in fpaths[0]: + try: + self.acestat.Definitions.loadCustomPreset(p) + except Exception as e: + self.messageDialog( + "Error encountered when importing: {0}.".format(p), + "Import Error", + QMessageBox.Warning, + "{0}".format(e)) + + def handleViewAction(action): + if action == mToggleConsole: + updatePreference("console", "show_console", action.isChecked()) + + def validateConnection(): + if portCB.currentText() and baudCB.currentText() \ + and (not serialComms.isOpen() or portCB.currentText() != serialComms.port + or baudCB.currentText() != str(serialComms.baud)): + btnConnect.setEnabled(True) + else: + btnConnect.setEnabled(False) + + def Connect(): + self.acestat.disconnect() + try: + self.acestat.connect(portCB.currentText(), int(baudCB.currentText())) + except Exception as e: + self.messageDialog( + "Connection to {0} was unsuccessful.".format(portCB.currentText()), + "Connection Error", + QMessageBox.Warning, + "{0}".format(e)) + + def addToQueue(*args, **kwargs): + self.acestat.addToQueue(*args, **kwargs) + testQueue.updateQueue() + + def handlePause(): + self.acestat.togglePause() + testQueue.setPause(self.acestat.paused) + + def handleCancel(idx): + self.acestat.removeFromQueue(idx, force=True) + testQueue.updateQueue() + + def plotResult(id, test): + pltLayout.removeWidget(self.rDisplay) + self.rDisplay.close() + self.rDisplay = PlotCanvas(self) + pltLayout.addWidget(self.rDisplay) + pltLayout.update() + # self.rDisplay.clear() + labels = {} + axes = {} + info = test.info.plots[id] + + title = info.title + self.rDisplay.setLabel("x", label=info.x_label) + self.rDisplay.setLabel("y", label=info.y_label) + + for s in info.series: + self.rDisplay.plot({ + "x": test.results[s.x.output][s.x.field], + "y": test.results[s.y.output][s.y.field], + }) + self.rDisplay.setEnabled(True) + + def showTable(id, test): + # print(id) + pltLayout.removeWidget(self.rDisplay) + self.rDisplay.close() + + info = test.info.outputs[id] + # print([f.label for f in info.fields]) + + self.rDisplay = ResultTable([ + f"{f.label}{f' ({f.units})' if hasattr(f, 'units') else ''}" + for f in info.fields], list(zip(*reversed([ + test.results[id][f.label] for f in info.fields + ]))), self) + pltLayout.addWidget(self.rDisplay) + pltLayout.update() + self.rDisplay.setEnabled(True) + + def handleSend(): + if inputLE.text(): + try: + serialComms.send(inputLE.text()) + inputLE.clear() + except Exception as e: + self.messageDialog( + "Message to {0} was unsuccessful.".format(portCB.currentText()), + "Error Sending Message", + QMessageBox.Warning, + "{0}".format(e)) + + def handleConsoleClear(): + # Draw new plot, used for testing + # self.rDisplay.plot([random.uniform(-1, 1) for i in range(10)]) + consoleTE.clear() + + def onError(err): + # On error signal + testStatus.setText("{0}".format(err)) + self.messageDialog( + "Error Encountered", + "Error Encountered", + QMessageBox.Warning, + "{0}".format(err)) + + def onResult(id): + # On result signal + print(id) + + def onReady(ready): + # On ready signal + if ready: + _timer.stop() + testStatus.setText("Ready") + testQueue.updateQueue() + + def onStart(test, *args, **kwargs): + # On test start signal + updateCountdown() + _timer.start(1000) + + def onEnd(test, *args, **kwargs): + # On test end signal + _timer.stop() + resultList.append(test) + + def sendingData(msg): + # On serial sending signal + if right_bottom.isVisible(): + consoleTE.ensureCursorVisible() + consoleTE.insertPlainText("{0}\n".format(msg)) + + def receiveData(msg): + # On serial received signal + if right_bottom.isVisible(): + consoleTE.insertPlainText(msg) + consoleTE.ensureCursorVisible() + if self.acestat.running: + testStatus.setText("Receiving data...") + + def onConnected(): + # On serial connected signal + testStatus.setText("Waiting for board response...") + btnConnect.setEnabled(False) + connectionStatus.setText( + 'Port: {0} Baud: {1}'.format(serialComms.port, serialComms.baud)) + # inputBtn.setEnabled(True) + # inputLE.setEnabled(True) + disconnectBtn.setEnabled(True) + + def onDisconnect(err=None): + # On serial disconnected signal + _timer.stop() + # inputBtn.setEnabled(False) + # inputLE.setEnabled(False) + disconnectBtn.setEnabled(False) + connectionStatus.setText("Disconnected") + testStatus.setText("") + portCB.clear() + if err: + self.messageDialog( + "Connection lost!", + "Connection Error", + QMessageBox.Critical, + "{0}".format(err)) + portCB.refreshItems() + validateConnection() + + ################# + ### INTERFACE ### + ################# + _timer = QTimer(self) + _timer.timeout.connect(updateCountdown) + + menuBar = self.menuBar() + + fileMenu = menuBar.addMenu("&File") + mLoadTests = fileMenu.addAction("Import Tests") + mExportTests = fileMenu.addAction("Export Default Tests") + fileMenu.addSeparator() + mPreferences = fileMenu.addAction("Preferences") + fileMenu.addSeparator() + mQuit = fileMenu.addAction("Quit") + fileMenu.triggered.connect(handleFileAction) + + toolMenu = menuBar.addMenu("&Tools") + mReset = toolMenu.addAction("Send Reset") + mLoadPreset = toolMenu.addAction("Import Presets") + mSavePreset = toolMenu.addAction("Save Preset") + toolMenu.triggered.connect(handleToolAction) + + viewMenu = menuBar.addMenu("&View") + mToggleConsole = viewMenu.addAction("Show Console") + mToggleConsole.setCheckable(True) + viewMenu.triggered.connect(handleViewAction) + + serialComms = self.acestat.Serial + + vLayout = QVBoxLayout() + + ''' Start Connection bar ''' + connLayout = QHBoxLayout() + connLayout.addWidget(QLabel("Port:")) + + portCB = FuncComboBox(self.acestat.Serial.PORTS) + portCB.currentIndexChanged.connect(validateConnection) + connLayout.addWidget(portCB) + + connLayout.addWidget(QLabel("Baud:")) + baudCB = QComboBox() + baudCB.addItems([str(b) for b in self.acestat.Serial.BAUDRATES()]) + baudCB.setCurrentText(str(DEFAULT_BAUD)) + baudCB.setSizeAdjustPolicy(QComboBox.AdjustToContentsOnFirstShow) + baudCB.currentIndexChanged.connect(validateConnection) + connLayout.addWidget(baudCB) + + btnConnect = QPushButton("Connect") + connLayout.addWidget(btnConnect) + btnConnect.clicked.connect(Connect) + + disconnectBtn = QPushButton("Disconnect") + connLayout.addWidget(disconnectBtn) + disconnectBtn.clicked.connect(self.acestat.disconnect) + disconnectBtn.setEnabled(False) + + connLayout.addStretch(1) + vLayout.addLayout(connLayout) + ''' End Connection bar ''' + + ''' Start Horizontal panes ''' + hPanes = QSplitter(Qt.Horizontal, handleWidth=5) + hPanes.setChildrenCollapsible(False) + + ''' Start Left pane ''' + leftPane = QTabWidget() + + ''' Start Test tab ''' + testForm = TestForm(self.acestat.Definitions) + SignalTranslator(testForm.sigSubmit, addToQueue) + leftPane.addTab(testForm, "Test") + ''' End Test tab ''' + + ''' Start Queue tab ''' + testQueue = QueuePanel(self.acestat.paused, self.acestat.queue) + testQueue.onPause(handlePause) + testQueue.onCancelItem(handleCancel) + leftPane.addTab(testQueue, "Queue") + ''' End Queue tab ''' + + ''' Start Multiplexer tab ''' + # multTab = QWidget() + # leftPane.addTab(multTab, "Multiplexer") + ''' End Multiplexer tab ''' + + hPanes.addWidget(leftPane) + hPanes.setStretchFactor(0, 0) + ''' End Left ''' + + ''' Start Middle pane ''' + middlePane = QGroupBox("Result Display") + + pltLayout = QVBoxLayout() + pltLayout.setContentsMargins(0, 0, 0, 0) + # self.rDisplay = PlotCanvas(self) + self.rDisplay = QLabel("No results to display") + self.rDisplay.setAlignment(Qt.AlignHCenter | Qt.AlignVCenter) + pltLayout.addWidget(self.rDisplay) + # self.rDisplay.plot([random.uniform(-1, 1) for i in range(10)]) + middlePane.setLayout(pltLayout) + self.rDisplay.setEnabled(False) + + hPanes.addWidget(middlePane) + hPanes.setStretchFactor(1, 1) + ''' End Middle pane ''' + + ''' Start Right pane ''' + rightPane = QSplitter(Qt.Vertical, handleWidth=5) + rightPane.setChildrenCollapsible(False) + + ''' Start Right Top pane ''' + right_top = QGroupBox("Results") + rtLayout = QVBoxLayout() + rtLayout.setContentsMargins(0, 0, 0, 0) + resultList = ResultPanel() + resultList.sigPlot.connect(plotResult) + resultList.sigTable.connect(showTable) + rtLayout.addWidget(resultList) + right_top.setLayout(rtLayout) + rightPane.addWidget(right_top) + ''' End Right Top pane ''' + + ''' Start Right Bottom pane ''' + right_bottom = QGroupBox("Console") + rbLayout = QVBoxLayout() + rbLayout.setContentsMargins(0, 0, 0, 0) + + consoleTE = QPlainTextEdit() + consoleTE.setReadOnly(True) + rbLayout.addWidget(consoleTE) + + rbInputLayout = QHBoxLayout() + # inputLE = QLineEdit() + # inputLE.setEnabled(False) + # inputLE.returnPressed.connect(handleSend) + # + # inputBtn = QPushButton("Send") + # inputBtn.clicked.connect(handleSend) + # inputBtn.setEnabled(False) + + btnClear = QPushButton("Clear") + btnClear.clicked.connect(handleConsoleClear) + # rbInputLayout.addWidget(inputLE) + # rbInputLayout.addWidget(inputBtn) + rbInputLayout.addWidget(btnClear) + rbLayout.addLayout(rbInputLayout) + + right_bottom.setLayout(rbLayout) + rightPane.addWidget(right_bottom) + ''' End Right Bottom pane ''' + + rightPane.setSizes([1, 9]) + hPanes.addWidget(rightPane) + hPanes.setStretchFactor(2, 0) + ''' End Right pane ''' + + hPanes.setSizes([215, 1, 200]) + # hPanes.setSizes([215 * self.__xScale, 1, 200 * self.__xScale]) + ''' End Horizontal panes ''' + + vLayout.addWidget(hPanes) + + ###################### + ### ACEstatPy Signals ### + ###################### + SignalTranslator(self.acestat.sigReady, onReady) + SignalTranslator(self.acestat.sigTestStart, onStart) + SignalTranslator(self.acestat.sigTestEnd, onEnd) + SignalTranslator(self.acestat.sigError, onError) + # SignalTranslator(self.acestat.sigResult, onResult) + SignalTranslator(serialComms.sigSendMessage, sendingData) + SignalTranslator(serialComms.sigMessageReceived, receiveData) + SignalTranslator(serialComms.sigConnected, onConnected) + SignalTranslator(serialComms.sigDisconnected, onDisconnect) + + validateConnection() + connectionStatus = QLabel("Disconnected") + self.statusBar().addPermanentWidget(connectionStatus, 1) + testStatus = QLabel("") + self.statusBar().addPermanentWidget(testStatus, 3) + + for s in CONFIG.sections(): + for o in CONFIG.items(s): + updatePreference(s, *o) + + ui = QWidget() + ui.setLayout(vLayout) + self.setCentralWidget(ui) diff --git a/src/Exporters/ExportDialog.py b/src/Exporters/ExportDialog.py new file mode 100644 index 0000000..7c83288 --- /dev/null +++ b/src/Exporters/ExportDialog.py @@ -0,0 +1,75 @@ +''' +Last Modified: 2020-04-10 + +@author: Jesse M. Barr + +Contains: + -ExportDialog + +Description: + +ToDo: + +''' +# PyQt5 +from PyQt5.QtCore import Qt +from PyQt5.QtWidgets import (QVBoxLayout, QFileDialog, QStackedLayout, + QComboBox, QWidget, QMainWindow) +# import qdarkstyle +# PyQtGraph +from pyqtgraph import PlotWidget +# Local +from . import Exporter, ImageExporter, MPLExporter + + +class ExportDialog(QMainWindow): + ExporterClasses = [ImageExporter, MPLExporter] + + def __init__(self, plot, *args, **kwargs): + if not isinstance(plot, PlotWidget): + raise Exception("Expected PlotWidget") + super().__init__() + # self.setStyleSheet(qdarkstyle.load_stylesheet(qt_api='pyqt5')) + + self.plot = plot + self.exporters = {} + + self.setWindowFlags(Qt.Dialog) + self.setWindowTitle("Export") + self.resize(250, 375) + self.initUI() + + def initUI(self): + vLayout = QVBoxLayout() + + self.selection = QComboBox() + vLayout.addWidget(self.selection) + + self.exporterWidgets = QStackedLayout() + + for e in self.ExporterClasses: + self.selection.addItem(e.Name) + self.exporters[e.Name] = self.exporterWidgets.addWidget(e(self.plot, self)) + + self.selection.currentTextChanged.connect(self.showTool) + + vLayout.addLayout(self.exporterWidgets) + + ui = QWidget() + ui.setLayout(vLayout) + self.setCentralWidget(ui) + + def setTool(self, exp): + if self.selection.currentText() == exp: + return self.showTool(exp) + self.selection.setCurrentText(exp) + + def showTool(self, exp): + self.exporterWidgets.setCurrentIndex(self.exporters[exp]) + self.show() + self.setWindowState(self.windowState() & ~Qt.WindowMinimized) + self.raise_() + self.activateWindow() + + def show(self, item=None): + super().show() diff --git a/src/Exporters/Exporter.py b/src/Exporters/Exporter.py new file mode 100644 index 0000000..5e176c0 --- /dev/null +++ b/src/Exporters/Exporter.py @@ -0,0 +1,210 @@ +''' +Last Modified: 2020-04-10 + +@author: Jesse M. Barr + +Contains: + -Exporter + +Description: + +ToDo: + +''' +# PyQt5 +from PyQt5.QtCore import Qt +# from PyQt5.QtGui import +from PyQt5.QtWidgets import (QVBoxLayout, QHBoxLayout, QFileDialog, QLabel, + QTreeWidget, QPushButton, QAbstractScrollArea, + QWidget, QTreeWidgetItem, QGraphicsRectItem) +# PyQtGraph +from pyqtgraph import PlotWidget, functions as fn +from pyqtgraph.graphicsItems.ViewBox import ViewBox +from pyqtgraph.graphicsItems.PlotItem import PlotItem +from pyqtgraph.GraphicsScene import GraphicsScene + + +class Exporter(QWidget): + allowCopy = False + TypeRestrictions = (GraphicsScene, PlotItem, ViewBox) + exporter = None + + def __init__(self, plot, *args, **kwargs): + if not isinstance(plot, PlotWidget): + raise Exception("Expected PlotWidget") + super().__init__(*args, **kwargs) + self.plot = plot + self.scene = plot.scene() + + self.selectBox = QGraphicsRectItem() + self.selectBox.setPen(fn.mkPen('y', width=3, style=Qt.DashLine)) + self.selectBox.hide() + self.scene.addItem(self.selectBox) + + Exporter.initUI(self) + + def initUI(self): + vLayout = QVBoxLayout() + vLayout.setContentsMargins(0, 0, 0, 0) + + vLayout.addWidget(QLabel("Item to export:")) + + self.itemTree = QTreeWidget() + self.itemTree.setSizeAdjustPolicy(QAbstractScrollArea.AdjustToContents) + + self.itemTree.headerItem().setText(0, "1") + self.itemTree.header().setVisible(False) + vLayout.addWidget(self.itemTree) + + self.updateItemList() + + btnLayout = QHBoxLayout() + if self.allowCopy: + self.copyBtn = QPushButton("Copy") + self.copyBtn.clicked.connect(self.copyClicked) + btnLayout.addWidget(self.copyBtn, 1) + else: + btnLayout.addStretch(1) + self.exportBtn = QPushButton("Export") + self.exportBtn.clicked.connect(self.exportClicked) + btnLayout.addWidget(self.exportBtn, 1) + vLayout.addLayout(btnLayout) + + self.setLayout(vLayout) + + self.itemTree.currentItemChanged.connect(self.exportItemChanged) + + def plotResized(ev): + self.updateSelectBox() + + self.plot.sigAfterResize.connect(plotResized) + + def updatedPlot(): + item = self.itemTree.currentItem() + if item is None: + self.updateItemList() + self.itemTree.setCurrentItem(self.itemTree.topLevelItem(0)) + else: + self.updateItemList(item.gitem) + + self.plot.sigPlotChanged.connect(updatedPlot) + + def exportItemChanged(self, item): + if item is None: + return + if self.exporter is not None: + self.exporter.item = item.gitem + self.updateSelectBox(item) + if self.isVisible(): + self.selectBox.show() + + def updateSelectBox(self, item=None): + if item is None and self.itemTree.currentItem() is None: + return + else: + item = self.itemTree.currentItem() + if item.gitem is self.scene: + gview = self.scene.getViewWidget() + newBounds = gview.mapToScene(gview.viewport().geometry()).boundingRect() + else: + newBounds = item.gitem.sceneBoundingRect() + self.selectBox.setRect(newBounds) + + def updateItemList(self, select=None): + self.itemTree.clear() + si = None + if isinstance(self.scene, self.TypeRestrictions): + # Use whole scene + si = QTreeWidgetItem(["Entire Scene"]) + si.gitem = self.scene + self.itemTree.addTopLevelItem(si) + si.setExpanded(True) + + for child in self.scene.items(): + if child.parentItem() is None: + self.updateItemTree(child, si, select=select) + + def updateItemTree(self, item, treeItem=None, select=None): + si = None + if isinstance(item, ViewBox): + si = QTreeWidgetItem(['ViewBox']) + elif isinstance(item, PlotItem): + if item.titleLabel.text: + title = item.titleLabel.text + else: + title = "Plot" + si = QTreeWidgetItem([title]) + + if isinstance(item, self.TypeRestrictions): + si.gitem = item + if treeItem is None: + self.itemTree.addTopLevelItem(si) + else: + treeItem.addChild(si) + treeItem = si + if si.gitem is select: + self.itemTree.setCurrentItem(si) + + for ch in item.childItems(): + self.updateItemTree(ch, treeItem, select=select) + + def showEvent(self, event): + self.updateSelectBox() + self.selectBox.show() + super().showEvent(event) + + def hideEvent(self, event): + try: + self.selectBox.setVisible(False) + super().hideEvent(event) + except: + pass + + def getScene(self): + return self.scene + + def fileSaveDialog(self, filter=None): + dialog = QFileDialog() + dialog.setFileMode(QFileDialog.AnyFile) + dialog.setAcceptMode(QFileDialog.AcceptSave) + if filter is not None: + if isinstance(filter, str): + dialog.setNameFilter(filter) + elif isinstance(filter, list): + dialog.setNameFilters(filter) + if dialog.exec_(): + fpath = dialog.selectedFiles()[0] + ext = dialog.selectedNameFilter().split('.')[-1].lower() + f_ext = fpath.split('.')[-1].lower() + if ext and ext != f_ext: + fpath = "{0}.{1}".format(fpath, ext) + return fpath + + def getSourceRect(self): + item = self.itemTree.currentItem() + if item is None: + return + if isinstance(item.gitem, GraphicsScene): + w = item.gitem.getViewWidget() + return w.viewportTransform().inverted()[0].mapRect(w.rect()) + else: + return item.gitem.sceneBoundingRect() + + def getTargetRect(self): + item = self.itemTree.currentItem() + if item is None: + return + if isinstance(item.gitem, GraphicsScene): + return item.gitem.getViewWidget().rect() + else: + return item.gitem.mapRectToDevice(item.gitem.boundingRect()) + + def exportClicked(self): + self.selectBox.hide() + self.export() + self.selectBox.show() + + def copyClicked(self): + self.selectBox.hide() + self.export(copy=True) + self.selectBox.show() diff --git a/src/Exporters/ImageExporter.py b/src/Exporters/ImageExporter.py new file mode 100644 index 0000000..a018edd --- /dev/null +++ b/src/Exporters/ImageExporter.py @@ -0,0 +1,115 @@ +''' +Last Modified: 2020-04-10 + +@author: Jesse M. Barr + +Contains: + -ImageExporter + +Description: + +ToDo: + +''' +# PyQt5 +from PyQt5.QtGui import QImageWriter +from PyQt5.QtWidgets import QLabel, QMessageBox +# PyQtGraph +from pyqtgraph.parametertree import ParameterTree +from pyqtgraph.exporters import ImageExporter as pyqtgraphImageExporter +# Local +from .Exporter import Exporter + +IMGFormats = ["*."+f.data().decode('utf-8') for f in QImageWriter.supportedImageFormats()] +preferred = ['*.png', '*.jpg'] +for p in preferred[::-1]: + if p in IMGFormats: + IMGFormats.remove(p) + IMGFormats.insert(0, p) + + +class ImageExporter(Exporter): + Name = "Image File (PNG, TIF, JPG, ...)" + allowCopy = True + Formats = IMGFormats + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.exporter = pyqtgraphImageExporter(self.scene) + # Use a class boolean to control when auto-resizing should occur + self.blockUpdates = False + # Disconnect the default signals + self.exporter.params.param('width').sigValueChanged.disconnect(self.exporter.widthChanged) + self.exporter.params.param('height').sigValueChanged.disconnect(self.exporter.heightChanged) + self.exporter.params.param('width').sigValueChanged.connect(self.widthChanged) + self.exporter.params.param('height').sigValueChanged.connect(self.heightChanged) + self.exporter.params.addChild({'name': 'showXAxis', 'type': 'bool', 'value': True}) + self.exporter.params.addChild({'name': 'showYAxis', 'type': 'bool', 'value': True}) + + self.itemTree.currentItemChanged.connect(self.updateDefaultSize) + self.plot.sigAfterResize.connect(self.updateDefaultSize) + + # Need to maintain aspect ratio for width/height + + self.initUI() + + def updateDefaultSize(self): + sr = self.getTargetRect() + if sr is None: + return + self.blockUpdates = True + self.exporter.params.param('height').setDefault(int(sr.height())) + self.exporter.params.param('width').setDefault(int(sr.width())) + self.exporter.params.param('height').setToDefault() + self.exporter.params.param('width').setToDefault() + self.blockUpdates = False + + def widthChanged(self): + if self.blockUpdates: + return + sr = self.getSourceRect() + ar = float(sr.height()) / sr.width() + self.blockUpdates = True + self.exporter.params.param('height').setValue(int(self.exporter.params['width'] * ar)) + self.blockUpdates = False + + def heightChanged(self): + if self.blockUpdates: + return + sr = self.getSourceRect() + ar = float(sr.width()) / sr.height() + self.blockUpdates = True + self.exporter.params.param('width').setValue(int(self.exporter.params['height'] * ar)) + self.blockUpdates = False + + def initUI(self): + vLayout = self.layout() + vLayout.insertWidget(2, QLabel("Export options:")) + self.paramTree = ParameterTree() + + self.paramTree.headerItem().setText(0, "1") + self.paramTree.header().setVisible(False) + self.paramTree.setParameters(self.exporter.params) + vLayout.insertWidget(3, self.paramTree, 1) + + def export(self, filename=None, toBytes=False, copy=False): + if not toBytes and not copy and filename is None: + fname = self.fileSaveDialog(filter=self.Formats) + if fname is not None: + self.export(fname) + return + if not self.exporter.params['showXAxis']: + self.plot.getPlotItem().hideAxis('bottom') + if not self.exporter.params['showYAxis']: + self.plot.getPlotItem().hideAxis('left') + try: + self.exporter.export(filename, toBytes, copy) + except Exception as e: + msg = QMessageBox() + msg.setIcon(QMessageBox.Critical) + msg.setText("{0}".format(e)) + msg.setWindowTitle("Error") + msg.exec_() + finally: + self.plot.getPlotItem().showAxis('bottom') + self.plot.getPlotItem().showAxis('left') diff --git a/src/Exporters/MPLExporter.py b/src/Exporters/MPLExporter.py new file mode 100644 index 0000000..4740d9e --- /dev/null +++ b/src/Exporters/MPLExporter.py @@ -0,0 +1,117 @@ +''' +Last Modified: 2020-04-10 + +@author: Jesse M. Barr + +Contains: + -MatplotWindow + -MPLExporter + +Description: + +ToDo: + +''' +# PyQt5 +from PyQt5.QtCore import Qt +from PyQt5.QtWidgets import QVBoxLayout, QWidget, QMainWindow +# PyQtGraph +from pyqtgraph.graphicsItems.PlotItem import PlotItem +# Matplotlib +from matplotlib.backends.backend_qt5agg import (FigureCanvas, NavigationToolbar2QT as NavigationToolbar) +from matplotlib.figure import Figure +# Local +from .Exporter import Exporter + + +class MatplotWindow(QMainWindow): + + def __init__(self, *args, **kwargs): + # Initializing with a parent parameter causes the window to be tied to + # the parent. No icon on the taskbar, and minimizes to a small frame + # at the bottom of the screen. + super().__init__() + self.bgUpdate = False # background update + self.initUI() + + def initUI(self): + ui = QWidget() + self.fig = Figure() + self.canvas = FigureCanvas(self.fig) + toolbar = NavigationToolbar(self.canvas, ui) + + vLayout = QVBoxLayout() + vLayout.setContentsMargins(0, 0, 0, 0) + vLayout.addWidget(toolbar) + vLayout.addWidget(self.canvas) + ui.setLayout(vLayout) + self.setCentralWidget(ui) + + @property + def item(self): + return self._item + + @item.setter + def item(self, value): + if not isinstance(value, PlotItem): + raise Exception("Item must be PlotItem") + self._item = value + # Whenever self.item is set, update the plot + self.updatePlot() + + def updatePlot(self): + self.clear() + item = self.item + axes = self.fig.add_subplot(111) + # get labels from the plot + title = item.titleLabel.text + self.setWindowTitle(title) + axes.set_title(title) + + xlabel = item.axes['bottom']['item'].label.toPlainText() + axes.set_xlabel(xlabel) + + ylabel = item.axes['left']['item'].label.toPlainText() + axes.set_ylabel(ylabel) + + # Let matplotlib handle plot colors + for i in item.curves: + x, y = i.getData() + axes.plot(x, y) + + if self.isVisible() and not self.isMinimized(): + # Do not re-draw the plot if it isn't visible. + self.canvas.draw() + else: + self.bgUpdate = True + + def clear(self, redraw=False): + self.fig.clear() + if redraw: + self.canvas.draw() + + def showEvent(self, event): + if self.bgUpdate: + self.canvas.draw() + self.bgUpdate = False + super().showEvent(event) + + +class MPLExporter(Exporter): + Name = "Matplotlib Window" + TypeRestrictions = (PlotItem) + + def __init__(self, plot, *args, **kwargs): + super().__init__(plot) + self.exporter = MatplotWindow() + self.initUI() + + def initUI(self): + vLayout = self.layout() + vLayout.setStretch(1, 1) + + def export(self): + self.exporter.show() + self.exporter.setWindowState(self.exporter.windowState() & ~Qt.WindowMinimized) + self.exporter.raise_() + self.exporter.activateWindow() diff --git a/src/Exporters/__init__.py b/src/Exporters/__init__.py new file mode 100644 index 0000000..3e380fa --- /dev/null +++ b/src/Exporters/__init__.py @@ -0,0 +1,6 @@ +from .Exporter import Exporter +from .MPLExporter import MPLExporter +from .ImageExporter import ImageExporter +# from .SVGExporter import SVGExporter +# from .CSVExporter import CSVExporter +from .ExportDialog import ExportDialog diff --git a/src/Main.py b/src/Main.py new file mode 100644 index 0000000..d5a3331 --- /dev/null +++ b/src/Main.py @@ -0,0 +1,27 @@ +''' +Last Modified: 2020-06-30 + +@author: Jesse M. Barr + +Runs ACEstatGUI application. + +''' +import sys +from PyQt5.QtCore import Qt +from PyQt5.QtWidgets import QApplication, QStyleFactory + +if hasattr(Qt, 'AA_EnableHighDpiScaling'): + QApplication.setAttribute(Qt.AA_EnableHighDpiScaling, True) + +if hasattr(Qt, 'AA_UseHighDpiPixmaps'): + QApplication.setAttribute(Qt.AA_UseHighDpiPixmaps, True) + + +if __name__ == "__main__": + app = QApplication(sys.argv) + # Import the main GUI after initializing app. This allows using message + # dialogs if any errors occur. + from ACEstatGUI import ACEstatGUI + ex = ACEstatGUI() + ex.show() + sys.exit(app.exec_()) diff --git a/src/Settings/Settings.py b/src/Settings/Settings.py new file mode 100644 index 0000000..3646ad4 --- /dev/null +++ b/src/Settings/Settings.py @@ -0,0 +1,134 @@ +''' +Last Modified: 2021-05-07 + +@author: Jesse M. Barr + +Contains: + -PreferenceDialog + +Changes: + +ToDo: + +''' +# PyQt +from PyQt5.QtWidgets import ( + QComboBox, QDialog, QDialogButtonBox, QLabel, + QSpinBox, QVBoxLayout, QGridLayout, QCheckBox +) +# Installed +from configparser import SafeConfigParser + + +def LoadConfig(fPath): + config = SafeConfigParser() + config.read(fPath) + + if not config.has_section("console"): + config.add_section("console") + + try: config.getboolean('console', 'show_console') + except: config.set('console', 'show_console', 'false') + try: config.getint('console', 'console_lines') + except: config.set('console', 'console_lines', '500') + + config.set('console', 'console_lines', str(min(config.getint('console', 'console_lines'), 100000))) + config.set('console', 'console_lines', str(max(config.getint('console', 'console_lines'), 1))) + + if not config.has_section("results"): + config.add_section("results") + + try: config.getint('results', 'result_limit') + except: config.set('results', 'result_limit', '10') + try: config.getint('results', 'result_sort') + except: config.set('results', 'result_sort', '-1') + + config.set("results", "result_limit", str(min(config.getint("results", "result_limit"), 100))) + config.set("results", "result_limit", str(max(config.getint("results", "result_limit"), 1))) + + return config + + +class SettingsDialog(QDialog): + def __init__(self, *args, **kwargs): + config = kwargs.pop("config") + self.__changes = {} + super().__init__(*args, **kwargs) + + self.setWindowTitle("Preferences") + + QBtn = QDialogButtonBox.Ok | QDialogButtonBox.Cancel + + buttonBox = QDialogButtonBox(QBtn) + buttonBox.button(QDialogButtonBox.Ok).setText("Apply") + buttonBox.accepted.connect(self.accept) + buttonBox.rejected.connect(self.reject) + + layout = QVBoxLayout() + + ''' CONSOLE ''' + layout.addWidget(QLabel("Console:")) + + consoleLayout = QGridLayout() + consoleLayout.setContentsMargins(15, 0, 0, 5) + + consoleLayout.addWidget(QLabel("Show Console:"), consoleLayout.rowCount(), 0) + showConsole = QCheckBox() + showConsole.setChecked(config.getboolean("console", "show_console")) + consoleLayout.addWidget(showConsole, consoleLayout.rowCount()-1, 1) + + def setShowConsole(show): + self.__changes[('console', 'show_console')] = show + + showConsole.toggled.connect(setShowConsole) + + consoleLayout.addWidget(QLabel("Console Line Limit:"), consoleLayout.rowCount(), 0) + consoleLimit = QSpinBox() + consoleLimit.setRange(1, 100000) + consoleLimit.setValue(config.getint("console", "console_lines")) + consoleLayout.addWidget(consoleLimit, consoleLayout.rowCount()-1, 1) + + def setConsoleLimit(lim): + self.__changes[('console', 'console_lines')] = lim + + consoleLimit.valueChanged.connect(setConsoleLimit) + + layout.addLayout(consoleLayout) + + ''' RESULTS ''' + layout.addWidget(QLabel("Results:")) + + resultsLayout = QGridLayout() + resultsLayout.setContentsMargins(15, 0, 0, 5) + + resultsLayout.addWidget(QLabel("Sort Results:"), resultsLayout.rowCount(), 0) + resultSort = QComboBox() + resultSort.addItems(["Newest to Oldest", "Oldest to Newest"]) + resultSort.setCurrentIndex(int(config.getint("results", "result_sort") >= 0)) + resultsLayout.addWidget(resultSort, resultsLayout.rowCount()-1, 1) + + def setResultSort(sort): + if sort == 0: + sort = -1 + self.__changes[('results', 'result_sort')] = sort + + resultSort.currentIndexChanged.connect(setResultSort) + + resultsLayout.addWidget(QLabel("Result Limit:"), resultsLayout.rowCount(), 0) + resultLimit = QSpinBox() + resultLimit.setRange(1, 100) + resultLimit.setValue(config.getint("results", "result_limit")) + resultsLayout.addWidget(resultLimit, resultsLayout.rowCount()-1, 1) + + def setResultLimit(lim): + self.__changes[('results', 'result_limit')] = lim + + resultLimit.valueChanged.connect(setResultLimit) + + layout.addLayout(resultsLayout) + layout.addWidget(buttonBox) + self.setLayout(layout) + + @property + def Changes(self): + return self.__changes diff --git a/src/Settings/__init__.py b/src/Settings/__init__.py new file mode 100644 index 0000000..1d09e9f --- /dev/null +++ b/src/Settings/__init__.py @@ -0,0 +1 @@ +from .Settings import LoadConfig, SettingsDialog diff --git a/src/Utilities/Signals.py b/src/Utilities/Signals.py new file mode 100644 index 0000000..1b539f0 --- /dev/null +++ b/src/Utilities/Signals.py @@ -0,0 +1,63 @@ +''' + Last Modified: 2021-04-27 + + @author: Jesse M. Barr + + Contains: + -SignalTranslator + + This is a helper class to translate between pydispatch and PyQt. + + Why? + PyQt objects can not be called from another thread, but pyqtSignals work + around that problem. + + This also works with args and kwargs, where pyqtSignal by itself can only + use args. +''' +from PyQt5.QtCore import QObject, pyqtSignal as Signal +from acestatpy import Signal as ACEstatSignal + + +class SignalTranslator(QObject): + __signal = Signal(object) + __acestatSig = None + __callback = None + + def __init__(self, acestatSig, callback): + if not isinstance(acestatSig, ACEstatSignal): + raise Exception("Expected a ACEstatPy Signal object.") + elif not callable(callback): + raise Exception("callback must be a function.") + super().__init__() + + self.__acestatSig = acestatSig + self.__callback = callback + + self.__signal.connect(self.__fnCall) + self.__acestatSig.connect(self.__fnSignal, weak=False) + + def __fnCall(self, data): + if not self.__callback: + return + return self.__callback(*data["args"], **data["kwargs"]) + + def __fnSignal(self, *args, **kwargs): + # ACEstatPy signals include the signal name and sender, which we don't + # need. + kwargs.pop("sender", None) + kwargs.pop("signal", None) + self.__signal.emit({ + "args": args, + "kwargs": kwargs + }) + + def disconnect(self): + self.__acestatSig.disconnect(self.__fnSignal) + self.__signal.disconnect(self.__fnCall) + + def __del__(self): + try: + self.disconnect() + except: + pass diff --git a/src/Utilities/__init__.py b/src/Utilities/__init__.py new file mode 100644 index 0000000..17e5c3b --- /dev/null +++ b/src/Utilities/__init__.py @@ -0,0 +1,2 @@ +from .Signals import SignalTranslator +from ._utils import resource_path diff --git a/src/Utilities/_utils.py b/src/Utilities/_utils.py new file mode 100644 index 0000000..56a04a0 --- /dev/null +++ b/src/Utilities/_utils.py @@ -0,0 +1,10 @@ +import sys +from os.path import join, dirname, realpath + +def resource_path(*args): + fpath = "resources" + if len(args): + fpath = join(fpath, *args) + if hasattr(sys, '_MEIPASS'): + return join(sys._MEIPASS, fpath) + return join(dirname(realpath(__file__)), '..', '..', fpath) diff --git a/src/Widgets/CollapsibleBox.py b/src/Widgets/CollapsibleBox.py new file mode 100644 index 0000000..b77e229 --- /dev/null +++ b/src/Widgets/CollapsibleBox.py @@ -0,0 +1,187 @@ +''' +Last Modified: 2021-04-30 + +@author: Jesse M. Barr + +Contains: + -CollapsibleBox + +Changes: + -2021-04-27: + -Using the QToolButton expansion flag to determine whether the widget + should be expanded or not frequently did not function correctly. + Solved by adding a simple class boolean flag. +ToDo: + +''' +from PyQt5.QtCore import ( + Qt, QEvent, QParallelAnimationGroup, QAbstractAnimation, QPropertyAnimation, + pyqtSignal as Signal +) +from PyQt5.QtGui import QColor, QFont, QTransform +from PyQt5.QtWidgets import ( + QWidget, QHBoxLayout, QVBoxLayout, QToolButton, QScrollArea, QFrame, + QSizePolicy, QLabel, QStyle +) +# Local +from .Images import Pixmap +from .Formatting import ElideLabel + + +class ScrollArea(QScrollArea): + resized = Signal() + + def resizeEvent(self, e): + self.resized.emit() + return super(ScrollArea, self).resizeEvent(e) + + +class CollapsibleBox(QWidget): + + def __init__(self, title, parent=None): + super().__init__(parent) + self.__imgExpand = Pixmap("light:plus.png", QColor("white")) + self.__imgCollapse = Pixmap("light:minus.png", QColor("white")) + + self.__header = QToolButton() + self.__header.setSizePolicy( + QSizePolicy.Expanding, QSizePolicy.Fixed + ) + self.__header.setStyleSheet("border: none;") + self.__header.setCheckable(True) + self.__header.setChecked(False) + + btnLayout = QHBoxLayout() + btnLayout.setContentsMargins(0, 0, 0, 0) + + self.arrow = QLabel() + self.arrow.setPixmap(self.__imgExpand) + btnLayout.addWidget(self.arrow) + + lbl = QLabel() + lbl.static = title + lbl.installEventFilter(self) + btnLayout.addWidget(lbl, 1) + + self.__header.setLayout(btnLayout) + + self.toggle_animation = QParallelAnimationGroup(self) + + self.content_area = ScrollArea(maximumHeight=0, minimumHeight=0) + self.content_area.setSizePolicy( + QSizePolicy.Expanding, QSizePolicy.Fixed + ) + self.content_area.setFrameShape(QFrame.NoFrame) + self.content_area.resized.connect(self.updateContentLayout) + + lay = QVBoxLayout() + lay.setSpacing(0) + lay.setContentsMargins(0, 0, 0, 0) + lay.addWidget(self.__header) + lay.addWidget(self.content_area) + self.setLayout(lay) + + self.toggle_animation.addAnimation( + QPropertyAnimation(self, b"minimumHeight") + ) + self.toggle_animation.addAnimation( + QPropertyAnimation(self, b"maximumHeight") + ) + self.toggle_animation.addAnimation( + QPropertyAnimation(self.content_area, b"maximumHeight") + ) + + def start_animation(checked): + self.arrow.setPixmap( + self.__imgCollapse + if checked + else self.__imgExpand + ) + self.toggle_animation.setDirection( + QAbstractAnimation.Forward + if checked + else QAbstractAnimation.Backward + ) + self.toggle_animation.start() + + self.__header.toggled.connect(start_animation) + + def header(self): + return self.__header + + def setContentLayout(self, layout): + lay = self.content_area.layout() + del lay + self.content_area.setLayout(layout) + margins = layout.getContentsMargins() + collapsed_height = self.__header.layout().sizeHint().height() + margins[3] + content_height = layout.sizeHint().height() + for i in range(self.toggle_animation.animationCount()): + animation = self.toggle_animation.animationAt(i) + animation.setDuration(100) + animation.setStartValue(collapsed_height) + animation.setEndValue(collapsed_height + content_height) + + content_animation = self.toggle_animation.animationAt( + self.toggle_animation.animationCount() - 1 + ) + content_animation.setDuration(100) + content_animation.setStartValue(0) + content_animation.setEndValue(content_height) + + def updateContentLayout(self): + if self.__header.isChecked() and self.toggle_animation.state() != self.toggle_animation.Running: + margins = self.content_area.layout().getContentsMargins() + collapsed_height = self.__header.layout().sizeHint().height() + margins[3] + content_height = self.content_area.layout().sizeHint().height() + self.setMinimumHeight(collapsed_height + content_height) + self.setMaximumHeight(collapsed_height + content_height) + self.content_area.setMaximumHeight(content_height) + self.updateGeometry() + p = self.parent() + if isinstance(p, ScrollArea): + p.resized.emit() + + def eventFilter(self, obj, event): + if (event.type() == QEvent.Resize): + if hasattr(obj, "static"): + ElideLabel(obj, obj.static) + return super().eventFilter(obj, event) + + +if __name__ == "__main__": + import sys + import random + from PyQt5.QtGui import QColor + from PyQt5.QtWidgets import QLabel, QDockWidget, QMainWindow, QApplication + + app = QApplication(sys.argv) + + w = QMainWindow() + w.setCentralWidget(QWidget()) + dock = QDockWidget("Collapsible Demo") + w.addDockWidget(Qt.LeftDockWidgetArea, dock) + scroll = QScrollArea() + dock.setWidget(scroll) + content = QWidget() + scroll.setWidget(content) + scroll.setWidgetResizable(True) + vlay = QVBoxLayout(content) + for i in range(10): + box = CollapsibleBox("Collapsible Box Header-{}".format(i)) + vlay.addWidget(box) + lay = QVBoxLayout() + for j in range(8): + label = QLabel("{}".format(j)) + color = QColor(*[random.randint(0, 255) for _ in range(3)]) + label.setStyleSheet( + "background-color: {}; color : white;".format(color.name()) + ) + label.setAlignment(Qt.AlignCenter) + lay.addWidget(label) + + box.setContentLayout(lay) + vlay.addStretch() + w.resize(640, 480) + w.show() + sys.exit(app.exec_()) diff --git a/src/Widgets/Formatting.py b/src/Widgets/Formatting.py new file mode 100644 index 0000000..d960bec --- /dev/null +++ b/src/Widgets/Formatting.py @@ -0,0 +1,18 @@ +''' +Last Modified: 2020-05-12 + +@author: Jesse M. Barr + +Contains: + -ElideLabel + +ToDo: + +''' +from PyQt5.QtCore import Qt, QEvent +from PyQt5.QtGui import QFontMetrics + +def ElideLabel(obj, text): + metrics = QFontMetrics(obj.font()) + obj.setText(metrics.elidedText(text, Qt.ElideRight, obj.width()-2)) + obj.setToolTip(text) diff --git a/src/Widgets/FuncComboBox.py b/src/Widgets/FuncComboBox.py new file mode 100644 index 0000000..0c1a387 --- /dev/null +++ b/src/Widgets/FuncComboBox.py @@ -0,0 +1,59 @@ +''' +Last Modified: 2021-05-06 + +@author: Jesse M. Barr + +Contains: + -FuncComboBox + +Changes: +-2021-05-06: + -Allow a list or a function. + -Block signals while updating the list, to prevent triggering selection + change events. +-2021-04-27: + -Changed from PortComboBox to FuncComboBox. Now the list is refreshed + through a provided function. + +ToDo: + +''' +# PyQt5 +from PyQt5.QtWidgets import QComboBox + + +class FuncComboBox(QComboBox): + def __init__(self, items, autoSelect=True, *args, **kwargs): + super(QComboBox, self).__init__(*args, **kwargs) + if not callable(items) and not isinstance(items, list): + raise Exception("Items must be a function or a list.") + self.setMinimumContentsLength(10) + self.__items = items + self.__autoSelect = autoSelect + self.refreshItems() + + def refreshItems(self): + if callable(self.__items): + return self.updateList(self.__items()) + return self.updateList(self.__items) + + def updateList(self, new_list): + idx = self.currentIndex() + sel = self.currentText() + if not self.__autoSelect: + self.blockSignals(True) + self.clear() + self.addItems(new_list) + self.blockSignals(False) + if idx == -1 and not self.__autoSelect: + self.setCurrentIndex(idx) + else: + self.setCurrentText(sel) + + def keyReleaseEvent(self, event): + self.refreshItems() + super(FuncComboBox, self).keyReleaseEvent(event) + + def mousePressEvent(self, event): + self.refreshItems() + super(FuncComboBox, self).mousePressEvent(event) diff --git a/src/Widgets/GroupComboBox.py b/src/Widgets/GroupComboBox.py new file mode 100644 index 0000000..1fe6ba3 --- /dev/null +++ b/src/Widgets/GroupComboBox.py @@ -0,0 +1,72 @@ +''' +Last Modified: 2021-04-27 + +@author: Jesse M. Barr + +Contains: + -GroupDelegate + -GroupItem + -GroupComboBox + +Changes: + -2021-04-27 + -GroupComboBox now keeps track of groups, allowing the user to recall + them. + +ToDo: + +''' +# PyQt5 +from PyQt5.QtCore import Qt +from PyQt5.QtWidgets import (QComboBox, QStyledItemDelegate) +from PyQt5.QtGui import (QStandardItem, QStandardItemModel) + + +class GroupDelegate(QStyledItemDelegate): + def initStyleOption(self, option, index): + super(GroupDelegate, self).initStyleOption(option, index) + if index.data(Qt.UserRole): + option.font.setBold(True) + else: + option.text = " " + option.text + + +class GroupItem(QStandardItem): + def __init__(self, text): + super(GroupItem, self).__init__(text) + self.setData(True, Qt.UserRole) + self._number_of_childrens = 0 + self.setFlags(self.flags() & ~Qt.ItemIsSelectable & ~Qt.ItemIsEnabled) + + def addChild(self, text, tooltip=None): + it = QStandardItem(text) + it.setData(False, Qt.UserRole) + if tooltip is not None: + it.setToolTip(tooltip) + self._number_of_childrens += 1 + self.model().insertRow(self.row() + self._number_of_childrens, it) + return it + + +class GroupComboBox(QComboBox): + def __init__(self, parent=None): + super(GroupComboBox, self).__init__(parent) + self.setModel(QStandardItemModel(self)) + delegate = GroupDelegate(self) + self.setItemDelegate(delegate) + self.__groups = {} + + def Group(self, text): + if text in self.__groups: + return self.__groups[text] + it = GroupItem(text) + self.__groups[text] = it + self.model().appendRow(it) + return it + + def addChild(self, text, tooltip=None): + it = QStandardItem(text) + it.setData(True, Qt.UserRole) + if tooltip is not None: + it.setToolTip(tooltip) + self.model().appendRow(it) diff --git a/src/Widgets/Images.py b/src/Widgets/Images.py new file mode 100644 index 0000000..221fef5 --- /dev/null +++ b/src/Widgets/Images.py @@ -0,0 +1,30 @@ +from PyQt5.QtCore import Qt +from PyQt5.QtGui import (QIcon, QPixmap, QColor) + + +def Icon(resource_path, color=None, disabled_color=None): + pm = QPixmap(resource_path) + icon = QIcon(pm) + if color is not None: + mask = pm.createMaskFromColor(QColor('black'), + Qt.MaskOutColor) + pm.fill(color) + pm.setMask(mask) + icon.addPixmap(pm, QIcon.Normal) + + if disabled_color is not None: + pm.fill(disabled_color) + pm.setMask(mask) + icon.addPixmap(pm, QIcon.Disabled) + return icon + + +def Pixmap(resource_path, color=None): + pm = QPixmap(resource_path) + if color is not None: + mask = pm.createMaskFromColor(QColor('black'), + Qt.MaskOutColor) + pm.fill(color) + pm.setMask(mask) + + return pm diff --git a/src/Widgets/PlotCanvas.py b/src/Widgets/PlotCanvas.py new file mode 100644 index 0000000..d6e0ec0 --- /dev/null +++ b/src/Widgets/PlotCanvas.py @@ -0,0 +1,333 @@ +''' +Last Modified: 2021-05-03 + +@author: Jesse M. Barr + +Contains: + -PlotHistory + -PlotToolbar + -PlotWidget + -PlotCanvas + +ToDo: + -Potentially customize the context menu. + -It can unfortunately override some settings and cause things to not + work properly. + -The built-in export tool causes a memory leak. + -It is disabled entirely for now. + -Add plot customizations to the toolbar. + -Include color settings for the plot. + -Include tools from the context menu. + +''' +# PyQt5 +from PyQt5.QtCore import Qt, pyqtSignal, QRectF, QEvent +from PyQt5.QtWidgets import (QWidget, QLabel, QSizePolicy, QVBoxLayout, QMenu, + QToolBar, QToolButton) +from PyQt5.QtGui import QColor +# PyQtGraph +from pyqtgraph import PlotWidget as PW # , SignalProxy +from pyqtgraph.graphicsItems.ViewBox import ViewBox +# Local +from Exporters import ExportDialog +from .Images import Icon + + +class PlotHistory(object): + previous = None + next = None + plotRange = None + + def __init__(self, plotRange): + self.plotRange = plotRange + + def addItem(self, item): + item.previous = self + self.next = item + return self.next + + def __eq__(self, plotRange): + c1 = self.plotRange + if isinstance(plotRange, PlotHistory): + c2 = plotRange.plotRange + elif isinstance(plotRange, QRectF): + c2 = plotRange + return c1.left() == c2.left() and c1.right() == c2.right() and \ + c1.top() == c2.top() and c1.bottom() == c2.bottom() + + def range(self): + return self.plotRange + + +class PlotToolbar(QToolBar): + def __init__(self, plot, *args, **kwargs): + if not isinstance(plot, PW): + raise Exception("Expected PlotWidget") + super().__init__(*args, **kwargs) + self.setObjectName("PlotToolbar") + self.plot = plot + self.plotHistory = None + # Qt objects are not deleted on close, therefore, we will re-use them. + self.exporter = ExportDialog(self.plot) + + self.initUI() + + def initUI(self): + # Reversed because of dark theme + # background_color = self.palette().color(self.backgroundRole()) + # foreground_color = self.palette().color(self.foregroundRole()) + # print(foreground_color, background_color) + # icon_color = (foreground_color + # if background_color.value() < 128 else None) + icon_color = QColor("white") + disabled_color = QColor("gray") + + def resetView(): + self.plot.plotItem.vb.setRange(self.homeView, padding=0.0) + self.addHistory() + + btnHome = self.addAction(Icon("light:home.png", icon_color, + disabled_color), 'Reset', resetView) + + def rangeChanged(): + if self.plot.mouseHeld: + return + self.addHistory() + + self.plot.plotItem.vb.sigRangeChangedManually.connect(rangeChanged) + + def plotChange(): + self.plotHistory = None + self.plot.plotItem.vb.autoRange() + self.homeView = self.plot.plotItem.vb.viewRect() + self.btnUndo.setEnabled(False) + self.btnRedo.setEnabled(False) + resetView() + + self.plot.sigPlotChanged.connect(plotChange) + + def undo(): + self.plotHistory = self.plotHistory.previous + self.plot.plotItem.vb.setRange(self.plotHistory.range(), padding=0.0) + self.btnRedo.setEnabled(True) + if self.plotHistory.previous is None: + self.btnUndo.setEnabled(False) + + self.btnUndo = self.addAction( + Icon("light:prev.png", icon_color, disabled_color), "Undo", undo) + + def redo(): + self.plotHistory = self.plotHistory.next + self.plot.plotItem.vb.setRange(self.plotHistory.range(), padding=0.0) + self.btnUndo.setEnabled(True) + if self.plotHistory.next is None: + self.btnRedo.setEnabled(False) + + self.btnRedo = self.addAction( + Icon("light:next.png", icon_color, disabled_color), "Redo", redo) + + self.addSeparator() + + def togglePan(): + if btnPan.isChecked(): + btnZoom.setChecked(False) + self.plot.setPanEnabled(True) + else: + self.plot.setPanEnabled(False) + setLabel() + + btnPan = self.addAction(Icon("light:move.png", icon_color, + disabled_color), "Pan", togglePan) + btnPan.setCheckable(True) + + def toggleZoom(): + if btnZoom.isChecked(): + btnPan.setChecked(False) + self.plot.setZoomEnabled(True) + else: + self.plot.setZoomEnabled(False) + setLabel() + + btnZoom = self.addAction(Icon("light:zoom_to_rect.png", icon_color, + disabled_color), "Zoom", toggleZoom) + btnZoom.setCheckable(True) + + self.addSeparator() + + saveButton = QToolButton(self) + saveButton.setText("Export") + saveButton.setToolTip("Export") + saveButton.setIcon(Icon("light:filesave.png", icon_color, disabled_color)) + saveButton.setPopupMode(QToolButton.InstantPopup) + saveMenu = QMenu(saveButton) + for e in self.exporter.ExporterClasses: + saveMenu.addAction(e.Name, + lambda item=e.Name: self.exporter.setTool(item)) + saveButton.setMenu(saveMenu) + self.addWidget(saveButton) + + lblInfo = QLabel("", self) + lblInfo.setAlignment(Qt.AlignRight | Qt.AlignTop) + lblInfo.setSizePolicy(QSizePolicy(QSizePolicy.Expanding, + QSizePolicy.Ignored)) + labelAction = self.addWidget(lblInfo) + + def setLabel(s=""): + if self.plot.mode and s: + lblInfo.setText("{0}, {1}".format(self.plot.mode, s)) + elif self.plot.mode: + lblInfo.setText("{0}".format(self.plot.mode)) + else: + lblInfo.setText("{0}".format(s)) + + def mouseMoved(ev): + if self.plot.mouseHeld and btnPan.isChecked(): + return + coords = self.plot.plotItem.vb.mapSceneToView(ev) + coords = "x={0:<12g} y={1:<12g}".format(coords.x(), coords.y()) + setLabel(coords) + + # proxy = SignalProxy(self.plot.scene().sigMouseMoved, rateLimit=60, slot=mouseMoved) + self.plot.scene().sigMouseMoved.connect(mouseMoved) + + def mouseLeft(ev): + setLabel() + + self.plot.sigMouseLeave.connect(mouseLeft) + + def addHistory(self): + currRange = self.plot.plotItem.vb.viewRect() + if self.plotHistory is None: + self.plotHistory = PlotHistory(currRange) + elif currRange != self.plotHistory: + self.plotHistory = self.plotHistory.addItem(PlotHistory(currRange)) + self.btnRedo.setEnabled(False) + self.btnUndo.setEnabled(True) + + +class PlotWidget(PW): + # Custom PlotWidget for custom event handling + # # Might be able to use an event filter in PlotToolbar + sigMousePressed = pyqtSignal(object) + sigPlotChanged = pyqtSignal() + sigMouseEnter = pyqtSignal(object) + sigMouseLeave = pyqtSignal(object) + sigAfterResize = pyqtSignal(object) + mouseHeld = None + myPlot = None + mode = None + + def __init__(self, *args, **kwargs): + super(PlotWidget, self).__init__(*args, **kwargs) + self.scene().installEventFilter(self) + self.mouseHeld = False + + def mouseReleaseEvent(self, ev): + self.mouseHeld = False + self.sigMouseReleased.emit(ev) + super(PlotWidget, self).mouseReleaseEvent(ev) + + def mousePressEvent(self, ev): + self.mouseHeld = True + self.sigMousePressed.emit(ev) + super(PlotWidget, self).mousePressEvent(ev) + + def mouseMoveEvent(self, ev): + super(PlotWidget, self).mouseMoveEvent(ev) + + def resizeEvent(self, ev): + super().resizeEvent(ev) + self.sigAfterResize.emit(ev) + + def setPanEnabled(self, enabled=True): + if enabled: + self.plotItem.setMouseEnabled(True, True) + self.plotItem.vb.setMouseMode(ViewBox.PanMode) + self.mode = "pan/zoom" + self.setCursor(Qt.CursorShape.SizeAllCursor) + else: + self.plotItem.setMouseEnabled(False, False) + self.plotItem.vb.setMouseMode(ViewBox.PanMode) + self.mode = None + self.unsetCursor() + + def setZoomEnabled(self, enabled=True): + if enabled: + self.mode = "zoom rect" + self.plotItem.setMouseEnabled(True, True) + self.plotItem.vb.setMouseMode(ViewBox.RectMode) + self.setCursor(Qt.CursorShape.CrossCursor) + else: + self.plotItem.setMouseEnabled(False, False) + self.plotItem.vb.setMouseMode(ViewBox.PanMode) + self.mode = None + self.unsetCursor() + + def eventFilter(self, src, ev): + # https://doc.qt.io/qt-5/qevent.html + if ev.type() == QEvent.Enter: + self.sigMouseEnter.emit(ev) + elif ev.type() == QEvent.Leave: + self.sigMouseLeave.emit(ev) + # Can capture much more here, especially if more EventFilters are added + return super(PlotWidget, self).eventFilter(src, ev) + + def clear(self): + self.plotItem.clear() + self.sigPlotChanged.emit() + + def plot(self, *args, **kwargs): + self.plotItem.plot(*args, **kwargs) + # if self.myPlot is None: + # self.myPlot = self.plotItem.plot(*args, **kwargs) + # else: + # self.myPlot.setData(*args, **kwargs) + self.sigPlotChanged.emit() + + def setLabel(self, axis=None, label=None, units=None): + if axis == 'x': + self.plotItem.setLabel('bottom', label, units) + elif axis == 'y': + self.plotItem.setLabel('left', label, units) + + +class PlotCanvas(QWidget): + # Container to hold the plot and toolbar + def __init__(self, *args, **kwargs): + super(PlotCanvas, self).__init__(*args, **kwargs) + self.initUI() + + def initUI(self): + self.__plot = PlotWidget() + self.setLabel = self.__plot.setLabel + # Built-in exportDialog causes a memory leak + # self.__plot.scene().exportDialog = ExportDialog(self.__plot) + self.__plot.plotItem.vb.setMenuEnabled(False) # disable entire context menu + # self.__plot.plotItem.vb.menu.clear() # disable context menu above 'Plot Options' + # self.__plot.plotItem.ctrlMenu = None # disable context menu 'Plot Options' + # self.__plot.scene().contextMenu = None # disable context menu 'Export' + self.__plot.plotItem.hideButtons() + self.__plot.plotItem.setMouseEnabled(False, False) + # self.__plot.plotItem.setMenuEnabled(True) + vLayout = QVBoxLayout() + vLayout.setContentsMargins(0, 0, 0, 0) + + vLayout.addWidget(PlotToolbar(self.__plot)) + vLayout.addWidget(self.__plot) + self.setLayout(vLayout) + + def plot(self, *args, **kwargs): + self.__plot.plot(*args, **kwargs) + + # @property + # def plot(self): + # return self.__plot + + def clear(self): + self.__plot.clear() + + def removePlot(self, plt): + self.__plot.removeItem(plt) + + def closeEvent(self, event): + super(PlotCanvas, self).closeEvent(event) diff --git a/src/Widgets/QueuePanel.py b/src/Widgets/QueuePanel.py new file mode 100644 index 0000000..f62adf9 --- /dev/null +++ b/src/Widgets/QueuePanel.py @@ -0,0 +1,212 @@ +''' +Last Modified: 2021-05-07 + +@author: Jesse M. Barr + +Contains: + -QueueItem + -QueuePanel + +Changes: +-2021-05-07: + -Cleaner list widget. + -Fixed bug with list updating too quickly. +-2021-04-24: + -Updated to work with acestatpy library. +-2020-05-21: + -Added preset handling. +-2020-06-17: + -Completely cancel running test. + +ToDo: + +''' +from PyQt5.QtCore import pyqtSignal as Signal, QEvent +from PyQt5.QtGui import QColor +from PyQt5.QtWidgets import ( + QWidget, QFrame, QLabel, QPushButton, QScrollArea, QGridLayout, QVBoxLayout, + QHBoxLayout, QSizePolicy, QMenu, QGridLayout +) +from threading import Lock +# Local +from Widgets import CollapsibleBox, ElideLabel + + +class QueueItem(QWidget): + sigCancel = Signal() + + def __init__(self, test): + super().__init__() + self.test = test + self.initUI() + + def initUI(self): + relTest = self.test.info + qItem = CollapsibleBox("{0}".format(relTest.name)) + hLayout = qItem.header().layout() + hLayout.addStretch() + + if self.test.run_forever: + self.__count1 = QLabel(u"\N{INFINITY}") + else: + self.__count1 = QLabel("{0}".format(self.test.iterations)) + self.__count1.setStyleSheet("padding-right: 3px;") + hLayout.addWidget(self.__count1) + + boxLayout = QVBoxLayout() + boxLayout.setContentsMargins(15, 3, 3, 3) + + paramLayout = QGridLayout() + paramLayout.setContentsMargins(0, 0, 0, 0) + for p in self.test.parameters: + paramInfo = relTest.parameters[p] + paramLayout.addWidget(QLabel("{0}:".format( + paramInfo.name)), paramLayout.rowCount(), 0) + + if hasattr(paramInfo, "options"): + value = paramInfo.options[self.test.parameters[p]] + else: + value = self.test.parameters[p] + if hasattr(paramInfo, "units"): + value = "{0}{1}".format(value, paramInfo.units) + else: + value = "{0}".format(value) + paramLayout.addWidget(QLabel("{0}".format(value)), paramLayout.rowCount()-1, 1) + + paramLayout.addWidget(QLabel("Start delay:"), paramLayout.rowCount(), 0) + paramLayout.addWidget(QLabel("{0}s".format( + self.test.start_delay)), paramLayout.rowCount()-1, 1) + + paramLayout.addWidget(QLabel("Remaining:"), paramLayout.rowCount(), 0) + if self.test.run_forever: + self.__count2 = QLabel(u"\N{INFINITY}") + else: + self.__count2 = QLabel("{0}".format(self.test.iterations)) + paramLayout.addWidget(self.__count2, paramLayout.rowCount()-1, 1) + + paramLayout.addWidget(QLabel("Iteration delay:"), paramLayout.rowCount(), 0) + paramLayout.addWidget(QLabel("{0}s".format( + self.test.inner_delay)), paramLayout.rowCount()-1, 1) + + paramLayout.addWidget(QLabel("Export:"), paramLayout.rowCount(), 0) + lbl = QLabel() + lbl.static = "{0}".format(self.test.export) + lbl.installEventFilter(self) + paramLayout.addWidget(lbl, paramLayout.rowCount()-1, 1) + + paramLayout.setColumnStretch(0, 0) + paramLayout.setColumnStretch(1, 1) + + boxLayout.addLayout(paramLayout) + + hl = QHBoxLayout() + btnRemove = QPushButton("Cancel") + btnRemove.setStyleSheet("padding:2px 15px;") + btnRemove.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed) + btnRemove.clicked.connect(self.sigCancel.emit) + hl.addWidget(btnRemove) + boxLayout.addLayout(hl) + qItem.setContentLayout(boxLayout) + + l = QVBoxLayout() + l.setContentsMargins(0, 0, 0, 0) + l.addWidget(qItem) + self.setLayout(l) + + def updateCount(self): + # Count only needs updated if not run_forever + if not self.test.run_forever: + self.__count1.setText("{0}".format(self.test.iterations)) + self.__count2.setText("{0}".format(self.test.iterations)) + + def contextMenuEvent(self, event): + menu = QMenu(self) + cancel = menu.addAction("Cancel") + action = menu.exec_(self.mapToGlobal(event.pos())) + if action == cancel: + self.sigCancel.emit() + + def eventFilter(self, obj, event): + if (event.type() == QEvent.Resize): + if hasattr(obj, "static"): + ElideLabel(obj, obj.static) + return super().eventFilter(obj, event) + + +class QueuePanel(QWidget): + sigPause = Signal() + sigCancel = Signal(int) + + def __init__(self, paused, queue, *args, **kwargs): + super().__init__(*args, **kwargs) + self.__lock = Lock() + self.__queue = queue # Hold reference to queue + self.initUI() + self.setPause(paused) + self.updateQueue() + + def initUI(self): + ui = QWidget(self) + ui.setObjectName("TestQueue") + + testScroll = QScrollArea() + testScroll.setFrameStyle(QFrame.NoFrame | QFrame.Plain) + testScroll.setWidget(ui) + testScroll.setWidgetResizable(True) + + listLayout = QVBoxLayout() + + self.__list = QVBoxLayout() + listLayout.addLayout(self.__list) + listLayout.addStretch(1) + ui.setLayout(listLayout) + + mainLayout = QVBoxLayout() + mainLayout.setContentsMargins(0, 0, 0, 0) + hLayout = QHBoxLayout() + hLayout.setContentsMargins(0, 0, 0, 0) + self.__pauseButton = QPushButton() + self.__pauseButton.setStyleSheet("margin: 5px;") + self.__pauseButton.clicked.connect(self.sigPause.emit) + hLayout.addWidget(self.__pauseButton) + mainLayout.addLayout(hLayout) + mainLayout.addWidget(testScroll) + self.setLayout(mainLayout) + + def updateQueue(self): + with self.__lock: + queue = self.__queue + for i in reversed(range(self.__list.count())): + w = self.__list.itemAt(i).widget() + if not hasattr(w, "test"): + # deleteLater() has been called, but has not completed yet + continue + elif w.test in queue: + # If the test is still in the queue, just update it + w.updateCount() + else: + # print("Deleting:", i) + w.deleteLater() + # Add new items. + # Note: + # If we ever make it possible to rearrange tests, this will not work! + for i in queue[self.__list.count():]: + item = QueueItem(i) + item.sigCancel.connect(self.cancelTest) + self.__list.insertWidget(self.__list.count(), item) + + def cancelTest(self): + self.sigCancel.emit(self.__list.indexOf(self.sender())) + + def setPause(self, pause): + self.__pauseButton.setText("Resume Queue" if pause else "Pause Queue") + + def onCancelItem(self, fn): + if not callable(fn): + return + self.sigCancel.connect(fn) + + def onPause(self, fn): + if not callable(fn): + return + self.sigPause.connect(fn) diff --git a/src/Widgets/ResultPanel.py b/src/Widgets/ResultPanel.py new file mode 100644 index 0000000..999d5d3 --- /dev/null +++ b/src/Widgets/ResultPanel.py @@ -0,0 +1,218 @@ +''' +Last Modified: 2021-08-19 + +@author: Jesse M. Barr + +Contains: + -ResultItem + -ResultPanel + +Changes: + +ToDo: + +''' +from PyQt5.QtCore import pyqtSignal as Signal, QEvent, Qt +from PyQt5.QtGui import QColor +from PyQt5.QtWidgets import ( + QWidget, QFrame, QLabel, QPushButton, QScrollArea, QGridLayout, QVBoxLayout, + QHBoxLayout, QSizePolicy, QGridLayout, QMenu, QFileDialog +) +from datetime import datetime +# Local +from Widgets import CollapsibleBox, ElideLabel + + +class ResultItem(QWidget): + sigPlot = Signal(str, object) + sigTable = Signal(str, object) + + def __init__(self, test): + super().__init__() + self.test = test + self.initUI() + + def initUI(self): + relTest = self.test.info + name = "{0} {1}".format(datetime.fromtimestamp( + self.test.startTime).strftime('%Y%m%d-%H%M%S'), self.test.info.name) + rItem = CollapsibleBox("{0}".format(name)) + hLayout = rItem.header().layout() + hLayout.addStretch() + + boxLayout = QVBoxLayout() + boxLayout.setContentsMargins(15, 0, 3, 3) + + params = CollapsibleBox("Parameters") + + paramLayout = QGridLayout() + paramLayout.setContentsMargins(15, 3, 3, 3) + for p in self.test.parameters: + paramInfo = relTest.parameters[p] + paramLayout.addWidget(QLabel("{0}:".format( + paramInfo.name)), paramLayout.rowCount(), 0) + + if hasattr(paramInfo, "options"): + value = paramInfo.options[self.test.parameters[p]] + else: + value = self.test.parameters[p] + if hasattr(paramInfo, "units"): + value = "{0}{1}".format(value, paramInfo.units) + else: + value = "{0}".format(value) + paramLayout.addWidget(QLabel("{0}".format(value)), paramLayout.rowCount()-1, 1) + + paramLayout.setColumnStretch(0, 0) + paramLayout.setColumnStretch(1, 1) + params.setContentLayout(paramLayout) + boxLayout.addWidget(params) + + results = CollapsibleBox("Results") + resultLayout = QGridLayout() + resultLayout.setContentsMargins(15, 3, 3, 3) + + def createSignalTable(id): + def signalTable(): + self.sigTable.emit(id, self.test) + return signalTable + + for p in relTest.outputs: + rInfo = relTest.outputs[p] + + if rInfo.type in ["field", "list"]: + for f in rInfo.fields: + resultLayout.addWidget(QLabel(f"{p}.{f.label}:"), resultLayout.rowCount(), 0) + resultLayout.addWidget( + QLabel(f"{self.test.results[p][f.label]}{getattr(f, 'units', '')}"), resultLayout.rowCount()-1, 1) + elif rInfo.type == "matrix": + resultLayout.addWidget(QLabel(f"{p}:"), resultLayout.rowCount(), 0) + btnTable = QPushButton("View") + btnTable.clicked.connect(createSignalTable(p)) + resultLayout.addWidget(btnTable, resultLayout.rowCount()-1, 1) + else: + pass + + resultLayout.setColumnStretch(0, 0) + resultLayout.setColumnStretch(1, 1) + results.setContentLayout(resultLayout) + boxLayout.addWidget(results) + + plots = CollapsibleBox("Plots") + plotLayout = QGridLayout() + plotLayout.setContentsMargins(15, 3, 3, 3) + + def createSignalPlot(id): + def signalPlot(): + self.sigPlot.emit(id, self.test) + return signalPlot + + for p in relTest.plots: + pInfo = relTest.plots[p] + + plotLayout.addWidget(QLabel(f"{p}:"), plotLayout.rowCount(), 0) + btnPlot = QPushButton("Plot") + btnPlot.clicked.connect(createSignalPlot(p)) + plotLayout.addWidget(btnPlot, plotLayout.rowCount()-1, 1) + + plotLayout.setColumnStretch(0, 0) + plotLayout.setColumnStretch(1, 1) + plots.setContentLayout(plotLayout) + boxLayout.addWidget(plots) + + rItem.setContentLayout(boxLayout) + + l = QVBoxLayout() + l.setContentsMargins(0, 0, 0, 0) + l.addWidget(rItem) + self.setLayout(l) + + def contextMenuEvent(self, event): + menu = QMenu(self) + saveResults = menu.addAction("Save Results") + savePreset = menu.addAction("Save as Preset") + action = menu.exec_(self.mapToGlobal(event.pos())) + if action == saveResults: + path = QFileDialog.getExistingDirectory( + self, "Choose Directory") + if path: + self.test.export(path) + elif action == savePreset: + path = QFileDialog.getSaveFileName( + self, "Save Preset", filter="JSON (*.json)") + if path[0]: + self.test.parameters.export(path[0]) + + def eventFilter(self, obj, event): + if (event.type() == QEvent.Resize): + if hasattr(obj, "static"): + ElideLabel(obj, obj.static) + return super().eventFilter(obj, event) + + +class ResultPanel(QWidget): + sigPlot = Signal(str, object) + sigTable = Signal(str, object) + + def __init__(self, *args, **kwargs): + self.__limit = kwargs.pop("limit", 5) + self.__direction = kwargs.pop("direction", 0) + super().__init__(*args, **kwargs) + self.__tests = [] + self.initUI() + + def initUI(self): + ui = QWidget(self) + ui.setObjectName("ResultPanel") + # self.ui.setStyleSheet("background-color: #0000FF") + + testScroll = QScrollArea() + testScroll.setFrameStyle(QFrame.NoFrame | QFrame.Plain) + testScroll.setWidget(ui) + testScroll.setWidgetResizable(True) + + listLayout = QVBoxLayout() + + self.__list = QVBoxLayout() + self.applySort(self.__direction) + listLayout.addLayout(self.__list) + listLayout.addStretch(1) + ui.setLayout(listLayout) + + mainLayout = QVBoxLayout() + mainLayout.setContentsMargins(0, 0, 0, 0) + hLayout = QHBoxLayout() + hLayout.setContentsMargins(0, 0, 0, 0) + mainLayout.addLayout(hLayout) + mainLayout.addWidget(testScroll) + self.setLayout(mainLayout) + + def setLimit(self, limit=None): + if limit is None: + pass + elif limit < 1: + raise Exception("Result limit must be 1 or more.") + else: + self.__limit = limit + while len(self.__tests) > self.__limit: + self.__tests[0].deleteLater() + del self.__tests[0] + + def applySort(self, direction): + # For now, anything other than 0 reverses order + if direction >= 0: + self.__list.setDirection(self.__list.BottomToTop) + else: + self.__list.setDirection(self.__list.TopToBottom) + + def append(self, test): + item = ResultItem(test) + item.sigPlot.connect(self.sigPlot.emit) + item.sigTable.connect(self.sigTable.emit) + self.__tests.append(item) + self.setLimit() + self.__list.insertWidget(0, item) + + def clear(self): + for i in self.__tests: + i.deleteLater() + self.__tests.clear() diff --git a/src/Widgets/ResultTable.py b/src/Widgets/ResultTable.py new file mode 100644 index 0000000..5628e6a --- /dev/null +++ b/src/Widgets/ResultTable.py @@ -0,0 +1,49 @@ +''' +Last Modified: 2021-08-20 + +@author: Jesse M. Barr + +Contains: + -ResultTable + +Changes: + +ToDo: + +''' +from PyQt5.QtCore import Qt +from PyQt5.QtWidgets import (QWidget, QTableWidget, QTableWidgetItem, + QVBoxLayout, QHeaderView) + + +class ResultTable(QWidget): + + def __init__(self, labels, data, *args, **kwargs): + super().__init__(*args, **kwargs) + self._columnHeaders = labels + self._data = data + self.setObjectName("ResultTable") + self.initUI() + + def initUI(self): + + tableWidget = QTableWidget() + tableWidget.setRowCount(len(self._data)) + tableWidget.setColumnCount(len(self._columnHeaders)) + for i in range(0, len(self._columnHeaders)): + tableWidget.setHorizontalHeaderItem( + i, QTableWidgetItem(self._columnHeaders[i])) + + for r in range(0, len(self._data)): + for c in range(0, len(self._data[r])): + item = QTableWidgetItem(str(self._data[r][c])) + item.setTextAlignment(Qt.AlignCenter) + tableWidget.setItem(r, c, item) + tableWidget.move(0, 0) + + tableWidget.horizontalHeader().setTextElideMode(Qt.ElideRight) + tableWidget.horizontalHeader().setSectionResizeMode(QHeaderView.Interactive) + + layout = QVBoxLayout() + layout.addWidget(tableWidget) + self.setLayout(layout) diff --git a/src/Widgets/TestForm.py b/src/Widgets/TestForm.py new file mode 100644 index 0000000..bb4ad4e --- /dev/null +++ b/src/Widgets/TestForm.py @@ -0,0 +1,332 @@ +''' +Last Modified: 2021-05-27 + +@author: Jesse M. Barr + +Contains: + -TestParameter + -TestPanel + -TestForm + +Changes: + -2021-05-05: + -Hide repeat/export if unchecked + -2021-04-27: + -Updated to work with acestatpy + +ToDo: + +''' +from os import getcwd +from acestatpy import Signal as ACEstatSignal + +from PyQt5.QtCore import QEvent, pyqtSignal as Signal +from PyQt5.QtWidgets import ( + QWidget, QFrame, QComboBox, QLabel, QRadioButton, QPushButton, QSpinBox, + QScrollArea, QGridLayout, QVBoxLayout, QHBoxLayout, QSizePolicy, QCheckBox, + QStackedWidget, QFileDialog +) + + +# Local +from . import ElideLabel, FuncComboBox, GroupComboBox + + +class TestParameter(QWidget): + sigChanged = Signal(object) + + def __init__(self, param, *args, **kwargs): + super(TestParameter, self).__init__(*args, **kwargs) + hLayout = QHBoxLayout() + hLayout.setContentsMargins(0, 0, 0, 0) + self.param = param + + if param.type == "int": + self.input = QSpinBox() + self.input.setRange(param.min, param.max) + self.input.valueChanged.connect(self.valueChanged) + elif param.type == "select": + self.input = QComboBox() + self.input.setSizeAdjustPolicy(QComboBox.AdjustToMinimumContentsLength) + self.input.currentIndexChanged.connect(self.valueChanged) + self.input.addItems(param.options.values()) + else: + raise Exception("Unrecognized parameter type") + + hLayout.addWidget(self.input) + self.setLayout(hLayout) + + def valueChanged(self, val): + if isinstance(self.input, QComboBox): + self.input.setToolTip(self.input.currentText()) + self.sigChanged.emit(self.getValue()) + + def setValue(self, value): + if isinstance(self.input, QSpinBox): + self.input.setValue(int(value)) + elif isinstance(self.input, QComboBox): + self.input.setCurrentText(self.param.options[value]) + + def getValue(self): + value = None + # Check widget type + if isinstance(self.input, QSpinBox): + value = str(self.input.value()) + elif isinstance(self.input, QComboBox): + value = list(self.param.options.keys())[self.input.currentIndex()] + return value + + +class TestPanel(QWidget): + name = None + parameters = None + + def __init__(self, test, *args, **kwargs): + super(TestPanel, self).__init__(*args, **kwargs) + self.test = test + self.name = test.name + self.parameters = {} + self.initUI() + + def initUI(self): + layout = QGridLayout() + layout.setContentsMargins(0, 0, 0, 0) + layout.addWidget(QLabel("Presets:"), 0, 0) + def refreshPresets(): + return list(self.test.presets.keys()) + presets = FuncComboBox(refreshPresets, autoSelect=False) + layout.addWidget(presets, 0, 1, 1, 2) + + def resetPreset(): + presets.setCurrentIndex(-1) + + for id in self.test.parameters: + p = self.test.parameters[id] + layout.addWidget(QLabel("{0}:".format(p.name)), layout.rowCount(), 0) + self.parameters[id] = TestParameter(p) + if hasattr(p, "units"): + layout.addWidget(self.parameters[id], layout.rowCount() - 1, 1) + layout.addWidget(QLabel(p.units), layout.rowCount() - 1, 2) + else: + layout.addWidget(self.parameters[id], layout.rowCount() - 1, 1, 1, 2) + self.parameters[id].sigChanged.connect(resetPreset) + layout.setColumnStretch(0, 0) + layout.setColumnStretch(1, 1) + layout.setColumnStretch(2, 0) + + self.setLayout(layout) + + def applyPreset(idx): + presets.setToolTip(presets.currentText()) + if not idx: + return + for p in self.test.presets[idx].parameters: + if p in self.parameters: + self.parameters[p].blockSignals(True) + self.parameters[p].setValue(self.test.presets[idx].parameters[p]) + self.parameters[p].blockSignals(False) + + resetPreset() + presets.currentTextChanged.connect(applyPreset) + + +class TestForm(QWidget): + + def __init__(self, tests, *args, **kwargs): + super().__init__(*args, **kwargs) + self.tests = tests + self.exportDir = getcwd() + self.sigSubmit = ACEstatSignal("testform_submit") + self.initUI() + + def initUI(self): + if self.layout(): + QWidget().setLayout(self.layout()) + ui = QWidget() + ui.setObjectName("TestStaging") + vLayout = QVBoxLayout() + + hLayout = QHBoxLayout() + hLayout.addWidget(QLabel("Technique:"), 0) + + testSelect = GroupComboBox() + + testSelect.setObjectName("testSelect") + testSelect.setSizeAdjustPolicy(QComboBox.AdjustToMinimumContentsLength) + + hLayout.addWidget(testSelect, 1) + # btnTestHelp = QPushButton("?") + # btnTestHelp.setObjectName("btnTestHelp") + # btnTestHelp.setFixedSize(32, 32) + # tsLayout.addWidget(btnTestHelp, 0) + vLayout.addLayout(hLayout) + + hLayout = QHBoxLayout() + hLayout.setContentsMargins(0, 0, 0, 0) + hLayout.addWidget(QLabel("Run Delay:")) + preDelay = QSpinBox() + preDelay.setRange(0, 3600) # No delay - 1 hour + hLayout.addWidget(preDelay) + hLayout.addWidget(QLabel("s")) + hLayout.addStretch() + vLayout.addLayout(hLayout) + + self.__testPanels = QStackedWidget() + self.__testPanels.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Maximum) + tests = {} + + for t in self.tests.values(): + testSelect.Group(t.technique).addChild(t.name, t.description) + p = TestPanel(t) + p.setSizePolicy(QSizePolicy.Ignored, QSizePolicy.Ignored) + tests[t.name] = self.__testPanels.addWidget(p) + + def setTest(): + testSelect.setToolTip(testSelect.currentText()) + self.__testPanels.currentWidget().setSizePolicy(QSizePolicy.Ignored, + QSizePolicy.Ignored) + self.__testPanels.setCurrentIndex(tests[testSelect.currentText()]) + self.__testPanels.currentWidget().setSizePolicy(QSizePolicy.Expanding, + QSizePolicy.Expanding) + + vLayout.addWidget(self.__testPanels) + + testSelect.currentIndexChanged.connect(setTest) + testSelect.setCurrentIndex(1) + + # Repeat + repeatGroup = QCheckBox("Repeat") + vLayout.addWidget(repeatGroup) + + rLayout = QVBoxLayout() + rLayout.setContentsMargins(15, 0, 0, 15) + + hLayout = QHBoxLayout() + hLayout.setContentsMargins(0, 0, 0, 0) + rLimited = QRadioButton() + hLayout.addWidget(rLimited) + repeatNum = QSpinBox() + repeatNum.setRange(2, 100) + rLimited.toggled.connect(repeatNum.setEnabled) + rLimited.setChecked(True) + hLayout.addWidget(repeatNum) + hLayout.addWidget(QLabel("times")) + hLayout.addStretch() + rLayout.addLayout(hLayout) + + hLayout = QHBoxLayout() + hLayout.setContentsMargins(0, 0, 0, 0) + rInfinite = QRadioButton() + hLayout.addWidget(rInfinite) + hLayout.addWidget(QLabel("Until cancelled")) + hLayout.addStretch() + rLayout.addLayout(hLayout) + + hLayout = QHBoxLayout() + hLayout.setContentsMargins(0, 0, 0, 0) + hLayout.addWidget(QLabel("Delay Between:")) + inDelay = QSpinBox() + inDelay.setRange(0, 3600) # No delay - 1 hour + hLayout.addWidget(inDelay) + hLayout.addWidget(QLabel("s")) + hLayout.addStretch() + rLayout.addLayout(hLayout) + + repeatPanel = QWidget() + repeatPanel.setLayout(rLayout) + repeatPanel.setVisible(False) + vLayout.addWidget(repeatPanel) + + repeatGroup.toggled.connect(repeatPanel.setVisible) + + # Export + exportGroup = QCheckBox("Autosave") + vLayout.addWidget(exportGroup) + + rLayout = QVBoxLayout() + rLayout.setContentsMargins(15, 0, 0, 15) + outpath = QLabel() + outpath.installEventFilter(self) + outpath.setSizePolicy(QSizePolicy.Ignored, QSizePolicy.Fixed) + + def setOutDir(): + path = QFileDialog.getExistingDirectory( + self, "Choose Directory") + if path: + self.exportDir = path + ElideLabel(outpath, path) + + rLayout.addWidget(outpath) + + hLayout = QHBoxLayout() + hLayout.setContentsMargins(0, 0, 0, 0) + exportBtn = QPushButton("Choose Location") + exportBtn.setStyleSheet("padding:2px 15px;") + exportBtn.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed) + exportBtn.clicked.connect(setOutDir) + hLayout.addWidget(exportBtn) + rLayout.addLayout(hLayout) + + exportPanel = QWidget() + exportPanel.setLayout(rLayout) + exportPanel.setVisible(False) + vLayout.addWidget(exportPanel) + + exportGroup.toggled.connect(exportPanel.setVisible) + + def runTest(): + params = self.CurrentParameters + export = False + run_forever = False + iterations = 1 + if repeatGroup.isChecked(): + if rInfinite.isChecked(): + run_forever = True + else: + iterations = repeatNum.value() + if exportGroup.isChecked(): + export = self.exportDir + self.sigSubmit.emit( + params[0], + params[1], + start_delay=preDelay.value(), + inner_delay=inDelay.value(), + run_forever=run_forever, + iterations=iterations, + export=export + ) + + hl = QHBoxLayout() + testSubmitBtn = QPushButton("Add to Queue") + testSubmitBtn.setStyleSheet("padding:2px 15px;") + testSubmitBtn.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed) + testSubmitBtn.clicked.connect(runTest) + hl.addWidget(testSubmitBtn) + vLayout.addLayout(hl) + vLayout.addStretch() + + ui.setLayout(vLayout) + + testScroll = QScrollArea() + testScroll.setFrameStyle(QFrame.NoFrame | QFrame.Plain) + testScroll.setWidget(ui) + testScroll.setWidgetResizable(True) + + mainLayout = QVBoxLayout() + mainLayout.setContentsMargins(0, 0, 0, 0) + mainLayout.addWidget(testScroll) + self.setLayout(mainLayout) + + @property + def CurrentParameters(self): + params = {} + test = self.__testPanels.currentWidget() + for p in test.parameters: + params[p] = test.parameters.get(p).getValue() + return test.test.id, params + + def eventFilter(self, obj, event): + if (event.type() == QEvent.Resize): + if isinstance(obj, QLabel): + ElideLabel(obj, self.exportDir) + return super().eventFilter(obj, event) diff --git a/src/Widgets/__init__.py b/src/Widgets/__init__.py new file mode 100644 index 0000000..071aa4a --- /dev/null +++ b/src/Widgets/__init__.py @@ -0,0 +1,10 @@ +from .CollapsibleBox import CollapsibleBox +from .Formatting import ElideLabel +from .GroupComboBox import GroupComboBox +from . import Images +from .PlotCanvas import PlotCanvas +from .FuncComboBox import FuncComboBox +from .TestForm import TestForm +from .QueuePanel import QueuePanel +from .ResultPanel import ResultPanel +from .ResultTable import ResultTable