from enum import StrEnum
from typing import Dict
[docs]
class CardHighlightMode(StrEnum):
"""
Enum for different card highlight modes.
Determines how Dash.Card objects are styled relative to the background
color.
"""
#: Background color + 20% white
LIGHT = "light"
#: Background color + 10% white
SUBTLE_LIGHT = "subtle-light"
#: Background color + 10% black
SUBTLE_DARK = "subtle-dark"
#: Background color + 20% black
DARK = "dark"
[docs]
class ColorConfig:
"""
Configuration for color settings in the GUI.
This class manages all color-related configurations for the application interface,
including background colors, theme colors, text colors, status indicators, and
button styling. It provides methods for color manipulation and supports both
unified and separate button color modes.
Args:
background_color: Hexadecimal color code for the main background.
Defaults to "#000000" (black).
theme_color_primary: Primary theme color used for main UI elements.
Defaults to "#343a40" (dark gray).
theme_color_secondary: Secondary theme color used for accents and highlights.
Defaults to "#009688" (teal).
theme_color_tertiary: Tertiary theme color for additional UI elements.
Defaults to "#000000" (black).
text_color: Default text color. Defaults to "#FFFFFF" (white).
text_color_highlight: Color for highlighted text elements.
Defaults to "#000000" (black).
text_color_selected: Color for selected text elements.
Defaults to "#FFFFFF" (white).
menu_hover: Optional custom color for menu hover states. If None, a default
hover color is calculated based on the primary theme color.
status_colors: Optional dictionary mapping status names to color codes.
Supported keys include:
- "processing": Color for items being processed
- "queued": Color for queued items
- "completed": Color for completed items
- "failed": Color for failed items
- "created": Color for newly created items
If not provided or keys are missing, defaults are calculated using
linear combinations of theme colors.
button_color_mode: Determines how button colors are applied. Options:
- ButtonColorMode.UNIFIED: All buttons share the same base color
- ButtonColorMode.SEPARATE: Each button type has its own distinct color
Defaults to ButtonColorMode.SEPARATE.
button_text: Color for text on buttons. Defaults to "#FFFFFF" (white).
button_colors: Optional dictionary for customizing button colors. The available
keys depend on the button_color_mode:
**For SEPARATE mode**, individual button types can be customized:
- "derive", "derive_hover": Derive button and its hover state
- "delete", "delete_hover": Delete button and its hover state
- "save", "save_hover": Save button and its hover state
- "import", "import_hover": Import button and its hover state
- "upload", "upload_hover": Upload button and its hover state
- "download", "download_hover": Download button and its hover state
- Modal buttons: "derive_cancel", "delete_cancel", etc. with "_hover" variants
- "new_scenario", "new_scenario_hover": New scenario button
- "compare": Compare toggle button
- "standard": Standard toggle button
**For UNIFIED mode**, use these keys:
- "unified_color": Base color for all buttons
- "unified_hover": Hover color for all buttons
**Scenario page action buttons** (work in both modes):
- "scenario_process", "scenario_process_hover": Process button (created state)
- "scenario_refresh", "scenario_refresh_hover": Refresh button (completed state)
- "scenario_cancel", "scenario_cancel_hover": Cancel/Delete button
If not provided or keys are missing, defaults are calculated based on
theme colors with different ratios for visual distinction in SEPARATE mode.
Note:
Button color modes affect how colors are retrieved and applied:
- **SEPARATE mode**: Each button type gets a unique color by default, calculated
as a linear combination between secondary and primary theme colors at different
ratios (0.0 for derive, 0.2 for delete, 0.4 for save, etc.). This creates
visual distinction between different actions.
- **UNIFIED mode**: All buttons use the secondary theme color by default,
providing a consistent look across all button types.
Custom colors in the button_colors dictionary always take precedence over
calculated defaults.
Example:
>>> # Create configuration with separate button colors
>>> config = ColorConfig(
... background_color="#FFFFFF",
... theme_color_primary="#3366CA",
... theme_color_secondary="#009688",
... button_color_mode=ButtonColorMode.SEPARATE,
... button_colors={
... "derive": "#FF5733",
... "delete": "#C70039"
... }
... )
>>> # Create configuration with unified button colors
>>> config_unified = ColorConfig(
... button_color_mode=ButtonColorMode.UNIFIED,
... button_colors={
... "unified_color": "#4CAF50",
... "unified_hover": "#45A049"
... }
... )
"""
def __init__(
self,
background_color: str = "#000000",
theme_color_primary: str = "#343a40",
theme_color_secondary: str = "#009688",
theme_color_tertiary: str = "#000000",
text_color: str = "#FFFFFF",
text_color_highlight: str = "#000000",
text_color_selected: str = "#FFFFFF",
menu_hover: str | None = None,
status_colors: dict[str, str] | None = None,
button_color_mode: ButtonColorMode = ButtonColorMode.SEPARATE,
button_text: str = "#FFFFFF",
button_colors: dict[str, str] | None = None,
):
self._background_color = background_color
self._theme_color_primary = theme_color_primary
self._theme_color_secondary = theme_color_secondary
self._theme_color_tertiary = theme_color_tertiary
self.text_color = text_color
self.text_color_highlight = text_color_highlight
self.text_color_selected = text_color_selected
self.menu_hover = menu_hover
self.status_colors = status_colors or {}
self._button_text = button_text
self.button_color_mode = button_color_mode
self.dm_colors = button_colors or {}
@staticmethod
def _hex_to_rgba(hex_str: str) -> tuple[int, ...]:
"""Convert hex color to RGBA tuple. If no alpha is provided, defaults to 255."""
h = hex_str.lstrip("#")
if len(h) == 6:
return tuple(int(h[i : i + 2], 16) for i in (0, 2, 4)) + (255,)
elif len(h) == 8:
return tuple(int(h[i : i + 2], 16) for i in (0, 2, 4, 6))
else:
raise ValueError("Invalid hex color format")
@staticmethod
def _rgba_to_hex(rgba: tuple[int, int, int, int]) -> str:
"""Convert RGBA tuple to hex string with an alpha channel."""
return "#%02x%02x%02x%02x" % rgba
[docs]
@staticmethod
def reduce_color_opacity(color: str, opacity: float) -> str:
"""
Reduces the opacity of a color by setting its alpha channel.
Args:
color: The hexadecimal color value (e.g., "#RRGGBB" or "#RRGGBBAA").
opacity: The desired opacity level, must be between 0 and 1 inclusive, where
0 is fully transparent and 1 is fully opaque.
Raises:
AssertionError
If the opacity parameter is not within the range [0, 1].
ValueError
If the color format is invalid.
Returns:
Hexadecimal representation with alpha channel (e.g., "#RRGGBBAA").
"""
assert 0 <= opacity <= 1, "opacity must be between 0 and 1"
r, g, b, _ = ColorConfig._hex_to_rgba(color)
alpha = int(opacity * 255)
return ColorConfig._rgba_to_hex((r, g, b, alpha))
[docs]
@staticmethod
def linear_combination_hex(a_hex: str, b_hex: str, t: float) -> str:
"""
Performs a linear combination of two hexadecimal color values based on a given ratio.
This static method calculates a blended color between two hex colors
using a provided ratio `t`. The calculation is performed by linearly
interpolating the red, green, and blue components separately.
Parameters:
a_hex: str
First hexadecimal color value in string format (e.g., "#RRGGBB").
b_hex: str
Second hexadecimal color value in string format (e.g., "#RRGGBB").
t: float
Blend ratio must be a value between 0 and 1 inclusive, where 0 corresponds
to the first color, and 1 corresponds to the second color.
Raises:
AssertionError
If the `t` parameter is not within the range [0, 1].
Returns:
Hexadecimal representation of the blended color (e.g., "#RRGGBB").
"""
assert 0 <= t <= 1, "t must be between 0 and 1"
ar, ag, ab, ao = ColorConfig._hex_to_rgba(a_hex)
br, bg, bb, bo = ColorConfig._hex_to_rgba(b_hex)
rr = int(ar + (br - ar) * t)
rg = int(ag + (bg - ag) * t)
rb = int(ab + (bb - ab) * t)
ro = int(ao + (bo - ao) * t)
return ColorConfig._rgba_to_hex((rr, rg, rb, ro))
def _get_card_surface_shading(
self, card_highlight_mode: str = CardHighlightMode.SUBTLE_DARK
):
"""Get the surface shading color for cards based on the specified highlight mode.
Args:
card_highlight_mode: The highlight mode for card shading.
Defaults to CardHighlightMode.SUBTLE_DARK.
Returns:
Hexadecimal representation of the shading color for card surfaces.
"""
match card_highlight_mode:
case CardHighlightMode.SUBTLE_LIGHT:
return self.linear_combination_hex(
self._background_color, "#FFFFFF", 0.1
)
case CardHighlightMode.LIGHT:
return self.linear_combination_hex(
self._background_color, "#FFFFFF", 0.2
)
case CardHighlightMode.SUBTLE_DARK:
return self.linear_combination_hex(
self._background_color, "#000000", 0.1
)
case CardHighlightMode.DARK:
return self.linear_combination_hex(
self._background_color, "#000000", 0.2
)
raise ValueError(f"Invalid card highlight mode: {card_highlight_mode}")
def _is_light_color(self, color: str) -> bool:
"""
Determines if a given hex color is light based on its RGB values.
A color is considered light if the sum of its RGB components is greater
than 384. This function takes a hex color code and checks its lightness.
Parameters:
color: str
A string representing a hex color code, such as "#FFFFFF" or "FFFFFF".
Returns:
bool
True if the color is light, False otherwise.
"""
return (
self._hex_to_rgba(color)[0]
+ self._hex_to_rgba(color)[1]
+ self._hex_to_rgba(color)[2]
> 384
)
def _default_hover_highlight(self, color: str) -> str:
"""
Generates a hover highlight color based on the input color's luminance.
If the input color is perceived as light, it blends the color with white;
otherwise, it blends the color with black. The blending factor used is 0.2.
Args:
color (str): A hexadecimal color string.
Returns:
str: A hexadecimal color string representing the hover highlight color.
"""
if self._is_light_color(color):
return self.linear_combination_hex(color, "#FFFFFF", 0.2)
else:
return self.linear_combination_hex(color, "#000000", 0.2)
@property
def menu_hover_color(self):
default = self._default_hover_highlight(self._theme_color_primary)
return self.menu_hover or default
@property
def status_processing(self):
default = self.linear_combination_hex(
self._theme_color_secondary, self._theme_color_primary, 0
)
return self.status_colors.get("processing", default)
@property
def status_queued(self):
default = self.linear_combination_hex(
self._theme_color_secondary, self._theme_color_primary, 0.25
)
return self.status_colors.get("queued", default)
@property
def status_completed(self):
default = self.linear_combination_hex(
self._theme_color_secondary, self._theme_color_primary, 0.5
)
return self.status_colors.get("completed", default)
@property
def status_failed(self):
default = self.linear_combination_hex(
self._theme_color_secondary, self._theme_color_primary, 0.75
)
return self.status_colors.get("failed", default)
@property
def status_created(self):
default = self.linear_combination_hex(
self._theme_color_secondary, self._theme_color_primary, 1
)
return self.status_colors.get("created", default)
def _get_button_color_with_default(self, tag, ratio):
"""
Determines and returns the appropriate color for a button based on the current button color
mode and provided parameters.
Parameters:
tag: str
The identifier for the button for which the color is being determined.
ratio: float
The weight used in calculating the combination of colors when the button color
mode is set to SEPARATE.
Raises:
ValueError:
If the button color mode is set to an invalid value.
Returns:
str: The hex color code of the determined button color.
"""
if self.button_color_mode == ButtonColorMode.SEPARATE:
default = self.linear_combination_hex(
self._theme_color_secondary, self._theme_color_primary, ratio
)
return self.dm_colors.get(tag, default)
elif self.button_color_mode == ButtonColorMode.UNIFIED:
default = self._theme_color_secondary
return self.dm_colors.get("unified_color", default)
else:
raise ValueError(f"Invalid button color mode: {self.button_color_mode}")
def _get_button_hover_color_with_default(self, color, tag):
"""
Retrieves the hover color for a button, with a default fallback mechanism.
This method determines the hover color for a button based on the current
button color mode. The hover color is derived from `tag` or a default
highlight fallback is returned when a specific hover color is not defined.
The method supports two button color modes: "SEPARATE" and "UNIFIED".
Parameters:
color: The base color used to calculate the default hover color.
tag: A string identifier used to determine the specific hover color.
Returns:
The hover color as defined in the button's design mode colors or the
default hover highlight color if the specific hover color is not found.
Raises:
ValueError: If the button color mode is not "SEPARATE" or "UNIFIED".
"""
if self.button_color_mode == ButtonColorMode.SEPARATE:
tag_st_mode = tag + "_hover"
elif self.button_color_mode == ButtonColorMode.UNIFIED:
tag_st_mode = "unified_hover"
else:
raise ValueError(f"Invalid button color mode: {self.button_color_mode}")
default = self._default_hover_highlight(color)
return self.dm_colors.get(tag_st_mode, default)
@property
def dm_derive(self):
return self._get_button_color_with_default("derive", 0)
@property
def dm_derive_hover(self):
return self._get_button_hover_color_with_default(self.dm_derive, "derive")
@property
def dm_delete(self):
return self._get_button_color_with_default("delete", 0.20)
@property
def dm_delete_hover(self):
return self._get_button_hover_color_with_default(self.dm_delete, "delete")
@property
def dm_save(self):
return self._get_button_color_with_default("save", 0.40)
@property
def dm_save_hover(self):
return self._get_button_hover_color_with_default(self.dm_save, "save")
@property
def dm_import(self):
return self._get_button_color_with_default("import", 0.60)
@property
def dm_import_hover(self):
return self._get_button_hover_color_with_default(self.dm_import, "import")
@property
def dm_upload(self):
return self._get_button_color_with_default("upload", 0.80)
@property
def dm_upload_hover(self):
return self._get_button_hover_color_with_default(self.dm_upload, "upload")
@property
def dm_download(self):
return self._get_button_color_with_default("download", 1)
@property
def dm_download_hover(self):
return self._get_button_hover_color_with_default(self.dm_download, "download")
# Modal colors for derive
@property
def derive_confirm(self):
return self.dm_derive
@property
def derive_confirm_hover(self):
return self.dm_derive_hover
@property
def derive_cancel(self):
return self._get_button_color_with_default("derive_cancel", 0.20)
@property
def derive_cancel_hover(self):
return self._get_button_hover_color_with_default(
self.derive_cancel, "derive_cancel"
)
# Modal colors for delete
@property
def delete_confirm(self):
return self.dm_delete
@property
def delete_confirm_hover(self):
return self.dm_delete_hover
@property
def delete_cancel(self):
return self._get_button_color_with_default("delete_cancel", 0.20)
@property
def delete_cancel_hover(self):
return self._get_button_hover_color_with_default(
self.delete_cancel, "delete_cancel"
)
# Modal colors for save
@property
def save_confirm(self):
return self.dm_save
@property
def save_confirm_hover(self):
return self.dm_save_hover
@property
def save_cancel(self):
return self._get_button_color_with_default("save_cancel", 0.20)
@property
def save_cancel_hover(self):
return self._get_button_hover_color_with_default(
self.save_cancel, "save_cancel"
)
# Modal colors for import
@property
def import_confirm(self):
return self.dm_import
@property
def import_confirm_hover(self):
return self.dm_import_hover
@property
def import_cancel(self):
return self._get_button_color_with_default("import_cancel", 0.20)
@property
def import_cancel_hover(self):
return self._get_button_hover_color_with_default(
self.import_cancel, "import_cancel"
)
# Modal colors for upload
@property
def upload_confirm(self):
return self.dm_upload
@property
def upload_confirm_hover(self):
return self.dm_upload_hover
@property
def upload_cancel(self):
return self._get_button_color_with_default("upload_cancel", 0.20)
@property
def upload_cancel_hover(self):
return self._get_button_hover_color_with_default(
self.upload_cancel, "upload_cancel"
)
# Modal colors for download
@property
def download_confirm(self):
return self.dm_download
@property
def download_confirm_hover(self):
return self.dm_download_hover
@property
def download_cancel(self):
return self._get_button_color_with_default("download_cancel", 0.20)
@property
def download_cancel_hover(self):
return self._get_button_hover_color_with_default(
self.download_cancel, "download_cancel"
)
# Scenario actions
@property
def new_scenario(self):
return self._get_button_color_with_default("new_scenario", 0.40)
@property
def new_scenario_hover(self):
return self._get_button_hover_color_with_default(
self.new_scenario, "new_scenario"
)
@property
def new_scenario_confirm(self):
return self.new_scenario
@property
def new_scenario_confirm_hover(self):
return self.new_scenario_hover
@property
def new_scenario_cancel(self):
return self._get_button_color_with_default("new_scenario_cancel", 0.20)
@property
def new_scenario_cancel_hover(self):
return self._get_button_hover_color_with_default(
self.new_scenario_cancel, "new_scenario_cancel"
)
# Scenario card action buttons
@property
def scenario_process(self):
return self._get_button_color_with_default("scenario_process", 0.0)
@property
def scenario_process_hover(self):
return self._get_button_hover_color_with_default(
self.scenario_process, "scenario_process"
)
@property
def scenario_refresh(self):
return self._get_button_color_with_default("scenario_refresh", 0.60)
@property
def scenario_refresh_hover(self):
return self._get_button_hover_color_with_default(
self.scenario_refresh, "scenario_refresh"
)
@property
def scenario_cancel(self):
return self._get_button_color_with_default("scenario_cancel", 0.20)
@property
def scenario_cancel_hover(self):
return self._get_button_hover_color_with_default(
self.scenario_cancel, "scenario_cancel"
)
@property
def scenario_delete_disabled(self):
return self.linear_combination_hex(self.scenario_cancel, "#FFFFFF", 0.3)
# Compare toggle
@property
def compare_toggle(self):
return self._get_button_color_with_default("compare", 1)
@staticmethod
def _get_handle_url(color):
color_no_hex = color.lstrip("#")
return (
(
f"url(\"data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' "
f"viewBox='-4 -4 8 8'%3e%3ccircle r='3' fill='%23{color_no_hex}'/%3e%3c/svg%3e\")"
),
)
@property
def compare_active_handle_url(self):
return self._get_handle_url(self.text_color_selected)
@property
def compare_inactive_handle_url(self):
return self._get_handle_url(self.text_color)
@property
def compare_focussed_handle_url(self):
shadow_color = self.reduce_color_opacity(self._theme_color_primary, 0.3)
return self._get_handle_url(shadow_color)
@property
def toggle_background_color(self):
return self._get_card_surface_shading()
@property
def toggle_shadow_color(self):
return self.reduce_color_opacity(self.toggle_active_color, 0.3)
@property
def toggle_active_color(self):
return self._get_button_color_with_default("standard", 1)
@property
def toggle_handle_selected(self):
return self._get_handle_url(self.text_color_selected)
@property
def toggle_handle_focussed(self):
return self._get_handle_url(self.toggle_shadow_color)
@property
def toggle_handle_inactive(self):
return self._get_handle_url(self.text_color)
@staticmethod
def dm_bootstrap_defaults() -> Dict[str, str]:
return {
"derive": "primary",
"delete": "danger",
"upload": "secondary",
"save": "success",
}
def get_theme_colors(
self, card_highlight_mode: str = CardHighlightMode.SUBTLE_LIGHT
):
main_colors = {
"--background-color": self._background_color,
"--theme-primary": self._theme_color_primary,
"--theme-secondary": self._theme_color_secondary,
"--theme-tertiary": self._theme_color_tertiary,
"--text-color": self.text_color,
"--text-selected": self.text_color_selected,
"--text-highlight": self.text_color_highlight,
"--card-surface": self._get_card_surface_shading(card_highlight_mode),
"--button-text": self._button_text,
}
data_management_colors = {
"--status-processing": self.status_processing,
"--status-queued": self.status_queued,
"--status-completed": self.status_completed,
"--status-failed": self.status_failed,
"--status-created": self.status_created,
"--derive-color": self.dm_derive,
"--derive-color-hover": self.dm_derive_hover,
"--delete-color": self.dm_delete,
"--delete-color-hover": self.dm_delete_hover,
"--save-color": self.dm_save,
"--save-color-hover": self.dm_save_hover,
"--import-color": self.dm_import,
"--import-color-hover": self.dm_import_hover,
"--upload-color": self.dm_upload,
"--upload-color-hover": self.dm_upload_hover,
"--download-color": self.dm_download,
"--download-color-hover": self.dm_download_hover,
}
data_modal_colors = {
"--derive-modal-confirm-color": self.derive_confirm,
"--derive-modal-confirm-color-hover": self.derive_confirm_hover,
"--derive-modal-cancel-color": self.derive_cancel,
"--derive-modal-cancel-color-hover": self.derive_cancel_hover,
"--delete-modal-confirm-color": self.delete_confirm,
"--delete-modal-confirm-color-hover": self.delete_confirm_hover,
"--delete-modal-cancel-color": self.delete_cancel,
"--delete-modal-cancel-color-hover": self.delete_cancel_hover,
"--save-modal-confirm-color": self.save_confirm,
"--save-modal-confirm-color-hover": self.save_confirm_hover,
"--save-modal-cancel-color": self.save_cancel,
"--save-modal-cancel-color-hover": self.save_cancel_hover,
"--import-modal-confirm-color": self.import_confirm,
"--import-modal-confirm-color-hover": self.import_confirm_hover,
"--import-modal-cancel-color": self.import_cancel,
"--import-modal-cancel-color-hover": self.import_cancel_hover,
"--upload-modal-confirm-color": self.upload_confirm,
"--upload-modal-confirm-color-hover": self.upload_confirm_hover,
"--upload-modal-cancel-color": self.upload_cancel,
"--upload-modal-cancel-color-hover": self.upload_cancel_hover,
"--download-modal-confirm-color": self.download_confirm,
"--download-modal-confirm-color-hover": self.download_confirm_hover,
"--download-modal-cancel-color": self.download_cancel,
"--download-modal-cancel-color-hover": self.download_cancel_hover,
}
scenarios_colors = {
"--new-scenario-color": self.new_scenario,
"--new-scenario-color-hover": self.new_scenario_hover,
"--new-scenario-modal-confirm-color": self.new_scenario_confirm,
"--new-scenario-modal-confirm-color-hover": self.new_scenario_confirm_hover,
"--new-scenario-modal-cancel-color": self.new_scenario_cancel,
"--new-scenario-modal-cancel-color-hover": self.new_scenario_cancel_hover,
"--scenario-process-color": self.scenario_process,
"--scenario-process-color-hover": self.scenario_process_hover,
"--scenario-refresh-color": self.scenario_refresh,
"--scenario-refresh-color-hover": self.scenario_refresh_hover,
"--scenario-cancel-color": self.scenario_cancel,
"--scenario-cancel-color-hover": self.scenario_cancel_hover,
"--scenario-delete-disabled-color": self.scenario_delete_disabled,
}
compare_colors = {
"--compare-toggle-color": self.compare_toggle,
"--compare-active-handle-url": self.compare_active_handle_url,
"--compare-inactive-handle-url": self.compare_inactive_handle_url,
"--compare-focussed-handle-url": self.compare_focussed_handle_url,
}
toggle_colors = {
"--toggle-background-color": self.toggle_background_color,
"--toggle-shadow-color": self.toggle_shadow_color,
"--toggle-active-color": self.toggle_active_color,
"--toggle-handle-selected": self.toggle_handle_selected,
"--toggle-handle-focussed": self.toggle_handle_focussed,
"--toggle-handle-inactive": self.toggle_handle_inactive,
}
all_colors = {
**main_colors,
**data_management_colors,
**data_modal_colors,
**scenarios_colors,
**compare_colors,
**toggle_colors,
}
return all_colors