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.