diff --git a/.gitignore b/.gitignore index 4a0034b4..6417c670 100644 --- a/.gitignore +++ b/.gitignore @@ -7,4 +7,5 @@ scratch*.py *.log coverage.xml docs/_build/ -debian/files \ No newline at end of file +debian/files +*.egg-info \ No newline at end of file diff --git a/faslr/__main__.py b/faslr/__main__.py index 65980b16..c06a6e1a 100644 --- a/faslr/__main__.py +++ b/faslr/__main__.py @@ -30,6 +30,8 @@ ) from faslr.style.main import ( + MAIN_WINDOW_BACKGROUND_COLOR_DARK, + MAIN_WINDOW_BACKGROUND_COLOR_LIGHT, MAIN_WINDOW_HEIGHT, MAIN_WINDOW_WIDTH, MAIN_WINDOW_TITLE @@ -41,6 +43,11 @@ QThreadPool ) +from PyQt6.QtGui import ( + QGuiApplication, + QPalette +) + from PyQt6.QtWidgets import ( QApplication, QMainWindow, @@ -80,6 +87,8 @@ def __init__( MAIN_WINDOW_HEIGHT ) + self.set_background_color(scheme=QGuiApplication.styleHints().colorScheme()) + self.setWindowTitle(MAIN_WINDOW_TITLE) self.layout = QVBoxLayout() @@ -110,6 +119,8 @@ def __init__( self.auto_triangle = load_sample('us_industry_auto') self.xyz_triangle = load_sample('uspp_incr_case') + + QGuiApplication.styleHints().colorSchemeChanged.connect(self.set_background_color) self.auto_tab = AnalysisTab( triangle=self.auto_triangle ) @@ -118,6 +129,7 @@ def __init__( ) self.analysis_pane = QTabWidget() + self.analysis_pane.setTabsClosable(True) self.analysis_pane.setMovable(True) self.analysis_pane.addTab(self.auto_tab, "Auto") @@ -153,6 +165,19 @@ def __init__( main_window=self ) + + + def set_background_color(self, scheme: Qt.ColorScheme) -> None: + QApplication.setPalette(QApplication.style().standardPalette()) + + palette = self.palette() + if scheme == Qt.ColorScheme.Dark: + color = MAIN_WINDOW_BACKGROUND_COLOR_DARK + else: + color = MAIN_WINDOW_BACKGROUND_COLOR_LIGHT + palette.setColor(self.backgroundRole(), color) + self.setPalette(palette) + def remove_tab( self, index: int diff --git a/faslr/about.py b/faslr/about.py index bc87ab26..f24f7525 100644 --- a/faslr/about.py +++ b/faslr/about.py @@ -1,3 +1,5 @@ +from faslr.common.icon import InfoIcon + from faslr.constants import ( BUILD_VERSION, CURRENT_BRANCH, @@ -11,8 +13,8 @@ Qt ) -from PyQt6.QtSvgWidgets import ( - QSvgWidget +from PyQt6.QtGui import ( + QGuiApplication, QPalette, QColor ) from PyQt6.QtWidgets import ( @@ -40,30 +42,44 @@ def __init__( faslr_version = "FASLR v" + BUILD_VERSION - faslr_version_link = '' + \ - faslr_version + '' + faslr_version_link = "https://github.com/casact/faslr" - branch_link = '' + \ - CURRENT_BRANCH + '' + branch_link = "https://github.com/casact/faslr/tree/" + CURRENT_BRANCH - commit_link = '' + \ - CURRENT_COMMIT + '' + commit_link = "https://github.com/casact/faslr/commit/" + CURRENT_COMMIT_LONG - version_label = LinkLabel(faslr_version_link) + version_label = LinkLabel( + url=faslr_version_link, + label_text=faslr_version + ) - branch_label = LinkLabel(branch_link) - commit_label = LinkLabel(commit_link) + branch_label = LinkLabel( + url=branch_link, + label_text=CURRENT_BRANCH + ) + + commit_label = LinkLabel( + url=commit_link, + label_text=CURRENT_COMMIT + ) - gh_svg = InfoIcon(ICONS_PATH + "github.svg") + gh_svg = InfoIcon( + svg_path=ICONS_PATH + "github.svg", + width=24, + height=24 + ) - branch_svg = InfoIcon(OCTICONS_PATH + "git-branch-24.svg") + branch_svg = InfoIcon( + svg_path=OCTICONS_PATH + "git-branch-24.svg", + width=24, + height=24 + ) - commit_svg = InfoIcon(OCTICONS_PATH + "commit-24.svg") + commit_svg = InfoIcon( + OCTICONS_PATH + "commit-24.svg", + width=24, + height=24 + ) layout = QVBoxLayout() @@ -99,22 +115,18 @@ def ok(self) -> None: self.close() -class InfoIcon(QSvgWidget): - def __init__( - self, - svg_path: str - ): - super().__init__(svg_path) - - self.setFixedSize(24, 24) class LinkLabel(QLabel): def __init__( self, - url: str + url: str, + label_text: str ): - super().__init__(url) + super().__init__() + + self.url = url + self.label_text = label_text self.setTextFormat(Qt.TextFormat.RichText) @@ -123,3 +135,19 @@ def __init__( ) self.setOpenExternalLinks(True) + self.apply_theme( + scheme=QGuiApplication.styleHints().colorScheme() # noqa + ) + + QGuiApplication.styleHints().colorSchemeChanged.connect(self.apply_theme) # noqa + + + def apply_theme(self, scheme: Qt.ColorScheme): + + if scheme == Qt.ColorScheme.Dark: + color = "#FFFFFF" + else: + color = "#000000" + + self.setText(f'{self.label_text}') + diff --git a/faslr/analysis.py b/faslr/analysis.py index 9dbbb528..84513d0a 100644 --- a/faslr/analysis.py +++ b/faslr/analysis.py @@ -12,13 +12,21 @@ VALUE_TYPES_COMBO_BOX_WIDTH ) +from faslr.style.analysis import ( + qss_analysis_tab_palette, + qss_column_tab +) + from faslr.utilities.accessors import get_column from PyQt6.QtCore import ( Qt ) -from PyQt6.QtGui import QColor +from PyQt6.QtGui import ( + QColor, + QGuiApplication +) from PyQt6.QtWidgets import ( QComboBox, @@ -59,6 +67,7 @@ def __init__( self.triangle = triangle self.lob = lob + self.theme = QGuiApplication.styleHints().colorScheme() self.layout = QVBoxLayout() @@ -89,11 +98,11 @@ def __init__( # Used to solve some issues with borders not appearing when there's only 1 tab. if column_count == 1: - bottom_border_width = 1 - margin_top = "22" + self.bottom_border_width = "1" + self.margin_top = "22" else: - bottom_border_width = 0 - margin_top = "0" + self.bottom_border_width = "0" + self.margin_top = "0" # For each chainladder column, we create a horizontal tab to the left. for i in self.column_list: @@ -151,15 +160,6 @@ def __init__( triangle_model = TriangleModel(triangle_column, 'value') self.triangle_views[i].setModel(triangle_model) - # self.analysis_containers[i].setStyleSheet( - # """ - # DiagnosticWidget { - # border: 2px solid darkgrey; - # background: rgb(230, 230, 230); - # } - # """ - # ) - self.column_tab.addTab(self.analysis_containers[i], i) self.layout.addWidget( @@ -170,55 +170,11 @@ def __init__( self.setLayout(self.layout) - self.column_tab.setStyleSheet( - """ - QTabBar::tab:first {{ - margin-top: 22px; - border-bottom: {}px solid darkgrey; - }} - - - QTabBar::tab {{ - margin-top: {}px; - background: rgb(230, 230, 230); - border: 1px solid darkgrey; - border-bottom: 1px solid darkgrey; - padding: 5px; - padding-left: 10px; - height: 125px; - margin-right: 0px; - border-right: 0px; - }} - - QTabBar::tab:selected {{ - background: rgb(245, 245, 245); - - }} - - QTabWidget::pane {{ - border: 1px solid darkgrey; - }} - """.format( - bottom_border_width, - margin_top - ) - ) - self.setAutoFillBackground(True) - palette = self.palette() - - palette.setColor( - self.backgroundRole(), - QColor.fromRgb( - 240, - 240, - 240 - ) - ) - - self.setPalette(palette) + self.apply_theme(scheme=self.theme) self.value_box.currentTextChanged.connect(self.update_value_type) # noqa + QGuiApplication.styleHints().colorSchemeChanged.connect(self.apply_theme) def resizeEvent(self, event): @@ -262,6 +218,25 @@ def update_value_type(self): else: self.analysis_containers[tab_name].setCurrentIndex(1) + def apply_theme(self, scheme: Qt.ColorScheme): + + self.column_tab.setStyleSheet( + qss_column_tab( + scheme=scheme, + bottom_border_width=self.bottom_border_width, + margin_top=self.margin_top + ) + ) + + palette = self.palette() + + qss_analysis_tab_palette( + scheme=scheme, + palette=palette, + role=self.backgroundRole() + ) + + self.setPalette(palette) class MackValuationModel(FAbstractTableModel): def __init__( diff --git a/faslr/common/icon.py b/faslr/common/icon.py new file mode 100644 index 00000000..d99e70c2 --- /dev/null +++ b/faslr/common/icon.py @@ -0,0 +1,65 @@ +from __future__ import annotations + +import os + +from PyQt6.QtCore import ( + QObject, + Qt +) + +from PyQt6.QtGui import ( + QGuiApplication, + QIcon +) + +from PyQt6.QtSvgWidgets import ( + QSvgWidget +) + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from typing import Union + from PyQt6.QtGui import QAction + from PyQt6.QtWidgets import QAbstractButton + +class InfoIcon(QSvgWidget): + def __init__( + self, + svg_path: str, + width: int = None, + height: int = None + ): + super().__init__(svg_path) + root, ext = os.path.splitext(svg_path) + self.light_path = svg_path + self.dark_path = root + '_white' + ext + self.apply_theme(QGuiApplication.styleHints().colorScheme()) # noqa + QGuiApplication.styleHints().colorSchemeChanged.connect(self.apply_theme) # noqa + + if width and height: + self.setFixedSize(width, height) + + def apply_theme(self, scheme:Qt.ColorScheme) -> None: + path = self.dark_path if scheme == Qt.ColorScheme.Dark else self.light_path + self.load(path) + + +class MenuIcon(QObject): + def __init__( + self, + icon_path: str, + widget: Union[QAction, QAbstractButton] + ): + super().__init__() + + root, ext = os.path.splitext(icon_path) + self.light_path = icon_path + self.dark_path = root + '_white' + ext + self._widget = widget + self.apply_theme(QGuiApplication.styleHints().colorScheme()) # noqa + QGuiApplication.styleHints().colorSchemeChanged.connect(self.apply_theme) # noqa + + def apply_theme(self, scheme: Qt.ColorScheme) -> None: + path = self.dark_path if scheme == Qt.ColorScheme.Dark else self.light_path + self._widget.setIcon(QIcon(path)) diff --git a/faslr/common/menu.py b/faslr/common/menu.py new file mode 100644 index 00000000..33414114 --- /dev/null +++ b/faslr/common/menu.py @@ -0,0 +1,28 @@ +import os + +from PyQt6.QtCore import Qt + +from PyQt6.QtGui import ( + QAction, + QGuiApplication, + QIcon +) + + +class MenuAction(QAction): + def __init__( + self, + icon_path: str, + text: str, + parent=None + ): + super().__init__(text, parent) + root, ext = os.path.splitext(icon_path) + self.light_path = icon_path + self.dark_path = root + '_white' + ext + self.apply_theme(QGuiApplication.styleHints().colorScheme()) # noqa + QGuiApplication.styleHints().colorSchemeChanged.connect(self.apply_theme) # noqa + + def apply_theme(self, scheme: Qt.ColorScheme) -> None: + path = self.dark_path if scheme == Qt.ColorScheme.Dark else self.light_path + self.setIcon(QIcon(path)) diff --git a/faslr/common/table.py b/faslr/common/table.py index ae6277a7..8c61b292 100644 --- a/faslr/common/table.py +++ b/faslr/common/table.py @@ -1,5 +1,9 @@ from __future__ import annotations +from faslr.style.table import ( + corner_button_qss +) + from typing import TYPE_CHECKING if TYPE_CHECKING: @@ -10,6 +14,8 @@ Qt ) +from PyQt6.QtGui import QGuiApplication + from PyQt6.QtWidgets import ( QAbstractButton, QLabel, @@ -34,13 +40,12 @@ def make_corner_button( btn.setLayout(btn_layout) opt = QStyleOptionHeader() - parent.setStyleSheet( - """ - QTableCornerButton::section { - border: 1px outset darkgrey; - } - """ - ) + def apply_style(scheme: Qt.ColorScheme): + + btn.setStyleSheet(corner_button_qss(scheme=scheme)) + + apply_style(QGuiApplication.styleHints().colorScheme()) + QGuiApplication.styleHints().colorSchemeChanged.connect(apply_style) # noqa s = QSize(btn.style().sizeFromContents( QStyle.ContentsType.CT_HeaderSection, opt, QSize(), btn). diff --git a/faslr/connection.py b/faslr/connection.py index c56cb0d0..8a7f4f75 100644 --- a/faslr/connection.py +++ b/faslr/connection.py @@ -6,7 +6,6 @@ import sqlalchemy as sa from faslr.constants import ( - CONFIG_PATH, DB_NOT_FOUND_TEXT, DEFAULT_DIALOG_PATH, QT_FILEPATH_OPTION @@ -274,8 +273,7 @@ def populate_project_tree( for lob, lob_uuid in lobs: lob_item = ProjectItem( lob, - segment_level='lob', - text_color=QColor(0, 77, 122) + segment_level='lob' ) lob_row = [lob_item, QStandardItem(lob_uuid)] diff --git a/faslr/constants/settings.py b/faslr/constants/settings.py index bc7405f2..a25ccbca 100644 --- a/faslr/constants/settings.py +++ b/faslr/constants/settings.py @@ -1,5 +1,6 @@ SETTINGS_LIST = [ "Startup", "User", - "Plots" + "Plots", + "Display" ] diff --git a/faslr/factor.py b/faslr/factor.py index 3bc5e9ca..e2063659 100644 --- a/faslr/factor.py +++ b/faslr/factor.py @@ -16,6 +16,10 @@ TEMP_LDF_LIST ) +from faslr.style.factor import ( + FACTOR_VIEW_QSS +) + from faslr.utilities import df_set_false from pandas import DataFrame @@ -57,7 +61,7 @@ from faslr.style.triangle import ( BLANK_TEXT, EXCL_FACTOR_COLOR, - LOWER_DIAG_COLOR, + LOWER_DIAG_COLOR_LIGHT, MAIN_TRIANGLE_COLOR, RATIO_STYLE, VALUE_STYLE @@ -84,7 +88,7 @@ def __init__( self.heatmap_checked = False self.heatmap_frame = self.triangle.to_frame(origin_as_datetime=False).astype(str) - self.heatmap_frame.loc[:] = LOWER_DIAG_COLOR.name() + self.heatmap_frame.loc[:] = LOWER_DIAG_COLOR_LIGHT.name() self.ldf_types = TEMP_LDF_LIST self.num_ldf_types = self.ldf_types[self.ldf_types["Selected"]].shape[0] @@ -189,7 +193,7 @@ def data( # Case when the index is on the lower diagonal if (index.column() >= self.n_triangle_rows - index.row()) and \ (index.row() < self.triangle_spacer_row): - return LOWER_DIAG_COLOR + return LOWER_DIAG_COLOR_LIGHT # Case when the index is on the triangle elif index.row() < self.triangle_spacer_row: if self.heatmap_checked: @@ -202,12 +206,12 @@ def data( else: return MAIN_TRIANGLE_COLOR elif (index.row() == self.selected_spacer_row) | (index.column() > self.n_triangle_columns - 1): - return LOWER_DIAG_COLOR + return LOWER_DIAG_COLOR_LIGHT else: if index.row() < self.triangle_spacer_row - 1: return MAIN_TRIANGLE_COLOR else: - return LOWER_DIAG_COLOR + return LOWER_DIAG_COLOR_LIGHT # Strike out the link ratios if double-clicked, but not the averaged factors at the bottom if (role == Qt.ItemDataRole.FontRole) and \ @@ -482,13 +486,7 @@ def __init__(self): # Set the styling for the table corner so that it matches the rest of the headers. self.setStyleSheet( - """ - QTableCornerButton::section{ - border-width: 1px; - border-style: solid; - border-color:none darkgrey darkgrey none; - } - """ + FACTOR_VIEW_QSS ) s = QSize(btn.style().sizeFromContents( diff --git a/faslr/menu.py b/faslr/menu.py index 556e131d..b8de8d48 100644 --- a/faslr/menu.py +++ b/faslr/menu.py @@ -19,6 +19,8 @@ OCTICONS_PATH ) +from faslr.common.menu import MenuAction + import faslr.core as core from faslr.engine import EngineDialog @@ -53,7 +55,7 @@ def __init__( self.parent = parent - self.connection_action = QAction(QIcon(ICONS_PATH + "db.svg"), "&Connection", self) + self.connection_action = MenuAction(ICONS_PATH + "db.svg", "&Connection", self) self.connection_action.setShortcut(QKeySequence("Ctrl+Shift+c")) self.connection_action.setStatusTip("Edit database connection.") # noinspection PyUnresolvedReferences @@ -85,20 +87,24 @@ def __init__( # noinspection PyUnresolvedReferences self.about_action.triggered.connect(self.display_about) - self.documentation_action = QAction(QIcon(ICONS_PATH + "open-in-browser.svg"), "&Documentation", self) + self.documentation_action = MenuAction(ICONS_PATH + "open-in-browser.svg", "&Documentation", self) self.documentation_action.setStatusTip("Go to the documentation website.") self.documentation_action.setShortcut("F1") self.documentation_action.triggered.connect(open_documentation) # noqa - self.github_action = QAction(QIcon(ICONS_PATH + "github.svg"), "&GitHub Repo", self) + self.github_action = MenuAction( + icon_path=ICONS_PATH + "github.svg", + text="&GitHub Repo", + parent=self + ) self.github_action.setStatusTip("Go to the GitHub Repo.") self.github_action.triggered.connect(open_github) # noqa - self.discussions_action = QAction(QIcon(OCTICONS_PATH + "comment-discussion-24.svg"), "&Discussion Board") + self.discussions_action = MenuAction(OCTICONS_PATH + "comment-discussion-24.svg", "&Discussion Board") self.discussions_action.setStatusTip("Go to the discussion board.") self.discussions_action.triggered.connect(open_discussions) # noqa - self.issues_action = QAction(QIcon(ICONS_PATH + "kanban-board.svg"), "&Open an Issue") + self.issues_action = MenuAction(ICONS_PATH + "kanban-board.svg", "&Open an Issue") self.issues_action.setStatusTip("Open an issue on GitHub.") self.issues_action.triggered.connect(open_issue) # noqa diff --git a/faslr/project.py b/faslr/project.py index 6ba3feba..00de0fc8 100644 --- a/faslr/project.py +++ b/faslr/project.py @@ -138,12 +138,7 @@ def make_project( lob = ProjectItem( text=lob_text, - segment_level="lob", - text_color=QColor( - 155, - 0, - 0 - ) + segment_level="lob" ) # Check if the country is already in the database diff --git a/faslr/project_item.py b/faslr/project_item.py index cdf6ef44..ae911637 100644 --- a/faslr/project_item.py +++ b/faslr/project_item.py @@ -1,8 +1,15 @@ -from faslr.style.project import DEFAULT_PROJECT_FONT +from faslr.style.project import ( + DEFAULT_PROJECT_FONT, + PROJECT_ITEM_TEXT_DARK, + PROJECT_ITEM_TEXT_LIGHT +) + +from PyQt6.QtCore import Qt from PyQt6.QtGui import ( QColor, QFont, + QGuiApplication, QStandardItem ) @@ -32,7 +39,7 @@ def __init__( segment_level: str, font_size: int = 12, set_bold: bool = False, - text_color: QColor = QColor(0, 0, 0) + text_color: QColor = None ): super().__init__() @@ -42,7 +49,24 @@ def __init__( ) project_font.setBold(set_bold) + # Set the text color based on theme. + if not text_color: + theme = QGuiApplication.styleHints().colorScheme() # noqa + self.text_color = PROJECT_ITEM_TEXT_DARK if theme == Qt.ColorScheme.Dark else PROJECT_ITEM_TEXT_LIGHT + else: + self.text_color = text_color + self.segment_level: str = segment_level - self.setForeground(text_color) + self.text_color = self.text_color + self.setForeground(self.text_color) self.setFont(project_font) self.setText(text) + + QGuiApplication.styleHints().colorSchemeChanged.connect(self.toggle_light_dark_text) # noqa + + def toggle_light_dark_text(self, theme: Qt.ColorScheme) -> None: + color = QColor(255, 255, 255) if theme == Qt.ColorScheme.Dark else QColor(0, 0, 0) + self.setForeground(color) + if self.model(): + idx = self.index() + self.model().dataChanged.emit(idx, idx) # noqa diff --git a/faslr/settings.py b/faslr/settings.py index 6202c7ad..402ee110 100644 --- a/faslr/settings.py +++ b/faslr/settings.py @@ -23,15 +23,18 @@ ) from PyQt6.QtGui import ( - QCloseEvent + QCloseEvent, + QGuiApplication ) from PyQt6.QtWidgets import ( QButtonGroup, + QComboBox, QDialog, QDialogButtonBox, QFileDialog, QGroupBox, + QHBoxLayout, QLabel, QListView, QPushButton, @@ -72,6 +75,8 @@ def data( if role == Qt.ItemDataRole.DisplayRole: return self.setting_items[index.row()] + else: + return None def rowCount( self, @@ -133,17 +138,20 @@ def __init__( self.startup_unconnected_container = QWidget() self.user_container = QWidget() self.plot_container = QWidget() + self.display_container = QWidget() self.startup_unconnected_layout() self.startup_connected_layout() self.user_layout() self.plot_layout() + self.display_layout() for widget in [ self.startup_connected_container, self.startup_unconnected_container, self.user_container, - self.plot_container + self.plot_container, + self.display_container ]: self.configuration_layout.addWidget(widget) @@ -184,6 +192,8 @@ def update_config_layout( self.configuration_layout.setCurrentIndex(2) elif index.data() == "Plots": self.configuration_layout.setCurrentIndex(3) + elif index.data() == "Display": + self.configuration_layout.setCurrentIndex(4) def startup_unconnected_layout(self) -> None: """ @@ -253,6 +263,31 @@ def plot_layout(self) -> None: self.plot_container.setLayout(layout) + def display_layout(self) -> None: + + theme_toggle = { + "System": Qt.ColorScheme.Unknown, + "Light": Qt.ColorScheme.Light, + "Dark": Qt.ColorScheme.Dark + } + theme_layout = QHBoxLayout() + theme_label = QLabel("Theme: ") + self.theme_combobox = QComboBox() + self.theme_combobox.addItems(["System", "Light", "Dark"]) + self.theme_combobox.setCurrentText(self.config['DISPLAY']['theme']) + + theme_layout.setAlignment(Qt.AlignmentFlag.AlignTop) + + theme_layout.addWidget(theme_label) + theme_layout.addWidget(self.theme_combobox) + theme_layout.addStretch() + + self.theme_combobox.currentTextChanged.connect( + lambda text: QGuiApplication.styleHints().setColorScheme(theme_toggle[text]) # noqa + ) + + self.display_container.setLayout(theme_layout) + def reset_connection(self) -> None: """ @@ -310,7 +345,9 @@ def accept(self) -> None: :return: None """ logging.info("Settings accepted.") - + self.config['DISPLAY']['theme'] = self.theme_combobox.currentText() + with open(self.config_path, 'w') as configfile: + self.config.write(configfile) self.close() def closeEvent( diff --git a/faslr/style/about.py b/faslr/style/about.py new file mode 100644 index 00000000..21ab0fc4 --- /dev/null +++ b/faslr/style/about.py @@ -0,0 +1,3 @@ +ABOUT_DIALOG_LINK_COLOR_DARK = '#FFFFFF' + +ABOUT_DIALOG_LINK_COLOR_LIGHT = '#000000' \ No newline at end of file diff --git a/faslr/style/analysis.py b/faslr/style/analysis.py new file mode 100644 index 00000000..0dca17c7 --- /dev/null +++ b/faslr/style/analysis.py @@ -0,0 +1,184 @@ +from PyQt6.QtCore import Qt +from PyQt6.QtGui import ( + QColor, + QPalette +) + +def qss_column_tab( + scheme: Qt.ColorScheme, + bottom_border_width: str, + margin_top: str, + + ) -> str: + """ + Styles the column tab of the AnalysisPane. ColumnTab is the tab containing column information, with column + referring to a Triangle column in Chainladder. + + Parameters + ---------- + scheme: Qt.ColorScheme + The current color scheme (Light, Dark, Unknown). + bottom_border_width: str + The bottom border width of the tab, in pixels. + margin_top: str + Top margin adjustment for when there's only one column tab open. + + Returns + ------- + The QSS string used to style the ColumnTab. + + """ + + light_unselected = "rgb(230, 230, 230)" + dark_unselected = "rgb(50, 50, 50)" + + light_selected = "rgb(245, 245, 245)" + dark_selected = "rgb(55, 55, 55)" + + if scheme == Qt.ColorScheme.Dark: + tab_unselected_background = dark_unselected + tab_selected_background = dark_selected + # scrollbar_horizontal_background = dark_selected + qtabbar_border = 'darkgrey' + # scrollbar_horizontal_groove_background = "rgb(42, 42, 42)" + # scrollbar_handle_background = scrollbar_horizontal_background + # Else, default to light, including headless environments like in GitHub Actions. + else: + tab_unselected_background = light_unselected + tab_selected_background = light_selected + # scrollbar_horizontal_background = "rgb(217, 217, 217)" + qtabbar_border = light_selected + # scrollbar_horizontal_groove_background = light_selected + # scrollbar_handle_background = scrollbar_horizontal_background + + + # Adjust issue with border not showing up when there's only 1 tab. + qtabbar_tab_first = """ + QTabBar::tab:first {{ + margin-top: 22px; + border-bottom: {}px solid darkgrey; + }} + """.format(bottom_border_width) + + # Some stylings on the tab, particularly adjusting the background color when tab is not in focus. + qttabbar_tab = """ + QTabBar::tab {{ + margin-top: {}px; + background: {}; + border: 1px solid darkgrey; + border-bottom: 1px solid darkgrey; + padding: 5px; + padding-left: 10px; + height: 125px; + margin-right: 0px; + border-right: 0px; + }} + """.format( + margin_top, + tab_unselected_background + ) + + # Tab background color when it is selected. + qttabbar_selected = """ + QTabBar::tab:selected {{ + background: {}; + }} + """.format(tab_selected_background) + + # Actually used to suppress the border around the table to keep it from being too prominent. + qtabwidget_pane = """ + QTabWidget::pane {{ + border: 1px solid {}; + }} + """.format(qtabbar_border) + + # Scrollbar corrections when there were issues with color retention toggling between light/dark mode. + # Fixed when Palette is redrawn via __main__.py, might look later if issue ever resurfaces. + + # qscrollbar_horizontal = """ + # QScrollBar:horizontal {{ + # background: {}; + # height: 14px; + # }} + # """.format(scrollbar_horizontal_groove_background) + # + # qscrollbar_handle_horizontal = """ + # QScrollBar::handle:horizontal {{ + # background: {}; + # border: none; + # border-radius: 7px; + # margin: 2px 0px; + # min-width: 10px; + # }} + # """.format(scrollbar_handle_background) + # + # qscrollbar_sub_line_horizontal = """ + # QScrollBar::sub-line:horizontal { + # width: 0px; + # } + # """ + # + # qscrollbar = """ + # QScrollBar::add-line:horizontal { + # width: 0px; + # } + #""" + + qss_str = "\n".join([ + qtabbar_tab_first, + qttabbar_tab, + qttabbar_selected, + qtabwidget_pane, + # qscrollbar_horizontal, + # qscrollbar_handle_horizontal, + # qscrollbar_sub_line_horizontal, + # qscrollbar + ]) + + # Further refinement - remove solid darkgrey borders if in dark mode. + if scheme == Qt.ColorScheme.Dark: + qss_str = qss_str.replace(' solid darkgrey', '') + + return qss_str + + +def qss_analysis_tab_palette( + scheme: Qt.ColorScheme, + palette: QPalette, + role: QPalette.ColorRole +) -> None: + """ + Adjusts the color surrounding the TriangleView but within the top-level tabs of the AnalysisTab. + + Parameters + ---------- + scheme: Qt.ColorScheme + The color scheme, light or dark mode. + palette: QPalette + The palette of the AnalysisTab. + role: QPalette.ColorRole + The backgroundRole of the AnalysisTab. + + Returns + ------- + None + + """ + if scheme == Qt.ColorScheme.Dark: + palette.setColor( + role, + QColor.fromRgb( + 42, + 42, + 42 + ) + ) + else: + palette.setColor( + role, + QColor.fromRgb( + 245, + 245, + 245 + ) + ) diff --git a/faslr/style/factor.py b/faslr/style/factor.py new file mode 100644 index 00000000..13f6048d --- /dev/null +++ b/faslr/style/factor.py @@ -0,0 +1,7 @@ +FACTOR_VIEW_QSS = """ + QTableCornerButton::section{ + border-width: 1px; + border-style: solid; + border-color:none darkgrey darkgrey none; + } +""" \ No newline at end of file diff --git a/faslr/style/icons/db_white.svg b/faslr/style/icons/db_white.svg new file mode 100644 index 00000000..d9a4bf01 --- /dev/null +++ b/faslr/style/icons/db_white.svg @@ -0,0 +1 @@ + diff --git a/faslr/style/icons/github_white.svg b/faslr/style/icons/github_white.svg new file mode 100644 index 00000000..0e1d5c9a --- /dev/null +++ b/faslr/style/icons/github_white.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/faslr/style/icons/kanban-board_white.svg b/faslr/style/icons/kanban-board_white.svg new file mode 100644 index 00000000..385f5558 --- /dev/null +++ b/faslr/style/icons/kanban-board_white.svg @@ -0,0 +1 @@ + diff --git a/faslr/style/icons/octicons/LICENSE b/faslr/style/icons/octicons/LICENSE new file mode 100644 index 00000000..d3b88f85 --- /dev/null +++ b/faslr/style/icons/octicons/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 GitHub Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/faslr/style/icons/octicons/comment-discussion-24_white.svg b/faslr/style/icons/octicons/comment-discussion-24_white.svg new file mode 100644 index 00000000..f7cd50e1 --- /dev/null +++ b/faslr/style/icons/octicons/comment-discussion-24_white.svg @@ -0,0 +1 @@ + diff --git a/faslr/style/icons/octicons/commit-24_white.svg b/faslr/style/icons/octicons/commit-24_white.svg new file mode 100644 index 00000000..3e1249f6 --- /dev/null +++ b/faslr/style/icons/octicons/commit-24_white.svg @@ -0,0 +1 @@ + diff --git a/faslr/style/icons/octicons/git-branch-24_white.svg b/faslr/style/icons/octicons/git-branch-24_white.svg new file mode 100644 index 00000000..b8103ee3 --- /dev/null +++ b/faslr/style/icons/octicons/git-branch-24_white.svg @@ -0,0 +1 @@ + diff --git a/faslr/style/icons/open-in-browser_white.svg b/faslr/style/icons/open-in-browser_white.svg new file mode 100644 index 00000000..023e0889 --- /dev/null +++ b/faslr/style/icons/open-in-browser_white.svg @@ -0,0 +1 @@ + diff --git a/faslr/style/main.py b/faslr/style/main.py index 1ae07551..db4db24b 100644 --- a/faslr/style/main.py +++ b/faslr/style/main.py @@ -1,4 +1,9 @@ +from PyQt6.QtGui import QColor + MAIN_WINDOW_WIDTH = 2500 MAIN_WINDOW_HEIGHT = 900 MAIN_WINDOW_TITLE = "FASLR - Free Actuarial System for Loss Reserving" + +MAIN_WINDOW_BACKGROUND_COLOR_LIGHT = QColor.fromRgb(245, 245, 245) +MAIN_WINDOW_BACKGROUND_COLOR_DARK = QColor.fromRgb(42, 42, 42) diff --git a/faslr/style/project.py b/faslr/style/project.py index c8f458ff..da029ba1 100644 --- a/faslr/style/project.py +++ b/faslr/style/project.py @@ -1 +1,7 @@ +from PyQt6.QtGui import QColor + DEFAULT_PROJECT_FONT = 'Ubuntu Regular' + +PROJECT_ITEM_TEXT_LIGHT = QColor(0, 0, 0) + +PROJECT_ITEM_TEXT_DARK = QColor(255, 255, 255) \ No newline at end of file diff --git a/faslr/style/table.py b/faslr/style/table.py new file mode 100644 index 00000000..666107c2 --- /dev/null +++ b/faslr/style/table.py @@ -0,0 +1,18 @@ +from PyQt6.QtCore import Qt + +CORNER_BUTTON_BORDER_COLOR_DARK = "rgb(60, 60, 60)" + +CORNER_BUTTON_BORDER_COLOR_LIGHT = "darkgrey" + +def corner_button_qss( + scheme: Qt.ColorScheme +) -> str: + + if scheme == Qt.ColorScheme.Dark: + color = CORNER_BUTTON_BORDER_COLOR_DARK + else: + color = CORNER_BUTTON_BORDER_COLOR_LIGHT + + qss_str = f"border: 1px outset {color};" + + return qss_str \ No newline at end of file diff --git a/faslr/style/triangle.py b/faslr/style/triangle.py index 6f8f6d1e..7544058b 100644 --- a/faslr/style/triangle.py +++ b/faslr/style/triangle.py @@ -4,7 +4,9 @@ EXCL_FACTOR_COLOR = QColor(255, 230, 230) -LOWER_DIAG_COLOR = QColor(238, 237, 238) +LOWER_DIAG_COLOR_LIGHT = QColor(238, 237, 238) + +LOWER_DIAG_COLOR_DARK = QColor(75, 75, 75) MAIN_TRIANGLE_COLOR = QColor(255, 255, 255) diff --git a/faslr/templates/config_template.ini b/faslr/templates/config_template.ini index eec8e018..5a21f5ab 100644 --- a/faslr/templates/config_template.ini +++ b/faslr/templates/config_template.ini @@ -3,3 +3,6 @@ startup_db = None [PLOTTING_STYLE] plotting_style = Regular + +[DISPLAY] +theme = System \ No newline at end of file diff --git a/faslr/tests/test_factor.py b/faslr/tests/test_factor.py index ec005ef3..c1ad6cdb 100644 --- a/faslr/tests/test_factor.py +++ b/faslr/tests/test_factor.py @@ -5,7 +5,7 @@ from faslr.factor import AddLDFDialog from faslr.methods.development import DevelopmentTab, LDFAverageBox from faslr.style.triangle import ( - LOWER_DIAG_COLOR, + LOWER_DIAG_COLOR_LIGHT, EXCL_FACTOR_COLOR, MAIN_TRIANGLE_COLOR ) @@ -84,7 +84,7 @@ def test_factor_data(development_tab): ) assert lower_diag_blank_value == '' - assert lower_diag_blank_back == LOWER_DIAG_COLOR + assert lower_diag_blank_back == LOWER_DIAG_COLOR_LIGHT idx = development_tab.factor_model.index(0, 0) diff --git a/faslr/tests/test_triangle_model.py b/faslr/tests/test_triangle_model.py index a9e36141..e02e3a05 100644 --- a/faslr/tests/test_triangle_model.py +++ b/faslr/tests/test_triangle_model.py @@ -6,7 +6,8 @@ ) from faslr.style.triangle import ( - LOWER_DIAG_COLOR + LOWER_DIAG_COLOR_DARK, + LOWER_DIAG_COLOR_LIGHT ) from faslr.triangle_model import ( @@ -24,6 +25,10 @@ QTimer ) +from PyQt6.QtGui import ( + QGuiApplication +) + from PyQt6.QtWidgets import ( QApplication ) @@ -155,7 +160,19 @@ def test_triangle_view( ) # Check for correct color displayed in the lower diagonal of the triangle. - assert lower_diag_color_test == LOWER_DIAG_COLOR + + # Extract the theme. + theme = QGuiApplication.styleHints().colorScheme() + + diag_color_dict = { + Qt.ColorScheme.Dark: LOWER_DIAG_COLOR_DARK, + Qt.ColorScheme.Light: LOWER_DIAG_COLOR_LIGHT + } + + # Default to light if theme is unknown. + lower_diag_color_expectation = diag_color_dict.get(theme, LOWER_DIAG_COLOR_LIGHT) + + assert lower_diag_color_test == lower_diag_color_expectation # rect = QRect(0, 0) triangle_view.selectRow(1) diff --git a/faslr/triangle_model.py b/faslr/triangle_model.py index 7f6e77cf..285ae713 100644 --- a/faslr/triangle_model.py +++ b/faslr/triangle_model.py @@ -9,7 +9,8 @@ from faslr.style.triangle import ( BLANK_TEXT, - LOWER_DIAG_COLOR, + LOWER_DIAG_COLOR_DARK, + LOWER_DIAG_COLOR_LIGHT, RATIO_STYLE, VALUE_STYLE ) @@ -25,6 +26,7 @@ from PyQt6.QtGui import ( QAction, + QGuiApplication, QKeySequence ) @@ -59,6 +61,12 @@ def __init__( self.excl_frame = self._data.copy() self.excl_frame = df_set_false(df=self.excl_frame) + theme = QGuiApplication.styleHints().colorScheme() + + self.lower_diag_color = LOWER_DIAG_COLOR_DARK if theme == Qt.ColorScheme.Dark else LOWER_DIAG_COLOR_LIGHT + + QGuiApplication.styleHints().colorSchemeChanged.connect(self.on_color_scheme_changed) + def data( self, index, @@ -102,17 +110,7 @@ def data( if role == Qt.ItemDataRole.BackgroundRole and (index.column() >= self.n_rows - index.row()): - return LOWER_DIAG_COLOR - - # if (role == Qt.ItemDataRole.FontRole) and (self.value_type == "ratio"): - # - # font = QFont() - # exclude = self.excl_frame.iloc[[index.row()], [index.column()]].squeeze() - # if exclude: - # font.setStrikeOut(True) - # else: - # font.setStrikeOut(False) - # return font + return self.lower_diag_color def headerData( self, @@ -129,6 +127,12 @@ def headerData( if qt_orientation == Qt.Orientation.Vertical: return str(self._data.index[p_int]) + def on_color_scheme_changed(self, scheme): + self.lower_diag_color = ( + LOWER_DIAG_COLOR_DARK if scheme == Qt.ColorScheme.Dark else LOWER_DIAG_COLOR_LIGHT + ) + self.layoutChanged.emit() # noqa + class TriangleView(FTableView): def __init__(self): @@ -150,19 +154,6 @@ def __init__(self): self.corner_button = make_corner_button(parent=self) - # Set the styling for the table corner so that it matches the rest of the headers. - # self.setStyleSheet( - # """ - # QTableCornerButton::section{ - # border-right: 1px; - # border-bottom: 1px; - # border-style: solid; - # border-color:none darkgrey darkgrey none; - # margin-right: 0px; - # } - # """ - # ) - self.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) self.customContextMenuRequested.connect(self.contextMenuEvent) # noqa diff --git a/faslr/utilities/style_parser.py b/faslr/utilities/style_parser.py index b6741b47..843e04ac 100644 --- a/faslr/utilities/style_parser.py +++ b/faslr/utilities/style_parser.py @@ -2,7 +2,7 @@ from matplotlib.colors import Colormap from pandas import DataFrame -from faslr.style.triangle import LOWER_DIAG_COLOR +from faslr.style.triangle import LOWER_DIAG_COLOR_LIGHT def extract_style( @@ -48,7 +48,7 @@ def parse_styler( # Initial colors will be the FASLR lower diagonal color for the entire triangle. # Then we override the upper triangle with the heatmap colors. - color_triangle.loc[:] = LOWER_DIAG_COLOR.name() + color_triangle.loc[:] = LOWER_DIAG_COLOR_LIGHT.name() # Parse css to get the background colors. Create a list of cell-color mappings. Each element maps # all the cells that correspond to a certain color.