utils.py PySideAbdhUI — Resource Handling, Theme Management & Color Utilities

utils.py is the central utility module for the PySideAbdhUI framework, providing three distinct but interconnected capabilities: resource path resolution, theme management, and contrast-aware color generation. Together, these utilities ensure that the framework can locate its packaged assets (SVG icons, QSS templates, JSON color-role definitions) regardless of installation location, apply user-selectable themes at runtime by replacing placeholder tokens in a QSS template, and generate random colors that meet WCAG accessibility contrast requirements against any background.

The module is designed around a resource-loading pipeline that uses Python's importlib.resources to resolve packaged resources, a ThemeManager class that reads/writes a JSON-based theme configuration and applies themes by performing placeholder substitution on a QSS template file, and a standalone function random_contrasting_hex() that computes perceptual luminance and contrast ratios using the WCAG 2.0 algorithm to guarantee readable foreground colors.

Dependencies

ModuleComponents UsedPurpose
random random.uniform Generating random HSL values for the contrast-aware color utility.
re re.compile Regular expression matching for widget property editing in QSS templates.
json json.load, json.dump Reading and writing the JSON-based theme configuration file.
importlib.resources importlib.resources.path Loading packaged resources (icons, templates, JSON) from the package's data directories using Python's standard resource API.
pathlib Path Type annotation for resource path return values.
PySide6.QtWidgets QApplication Applying stylesheets to the running application instance in ThemeManager.apply_theme().
PySide6.QtGui QColor Color representation, HSL construction, and component access for contrast calculations.

Architecture

The module follows a layered architecture where low-level resource resolution functions feed into higher-level theme management. The following diagram shows the data flow:

Data Flow Architecture: ┌─────────────────────────────────────────────────────────────┐ │ Resource Layer │ │ │ │ get_resource_path(package, resource, ext) │ │ │ │ │ │ │ │ ▼ ▼ ▼ ▼ │ │ get_icon() get_styles_template() get_color_roles() │ │ (SVG icons) (QSS template) (JSON color roles) │ └───────┬───────────────┬──────────────────┬──────────────────┘ │ │ │ ▼ ▼ ▼ ┌─────────────────────────────────────────────────────────────┐ │ ThemeManager Class │ │ │ │ load() ← color-roles.json ──→ save() │ │ │ │ │ │ ▼ │ │ │ get_current_theme() │ │ │ get_color(category, name) │ │ │ get_all_themes() │ │ │ │ │ │ │ ▼ │ │ │ switch_theme() ────────────────┘ │ │ │ │ │ ▼ │ │ apply_theme(app, name) │ │ ├── reads qss-template.qss │ │ ├── replaces --placeholder-- tokens with color values │ │ └── app.setStyleSheet(qss) │ │ │ │ add_property_to_widget(widget, property, value) │ │ ├── reads qss-template.qss │ │ ├── regex-matches widget block │ │ ├── inserts or updates CSS property │ │ └── writes qss-template.qss │ └─────────────────────────────────────────────────────────────┘ ┌─────────────────────────────────────────────────────────────┐ │ Standalone Color Utility │ │ │ │ random_contrasting_hex(background, theme, min_contrast) │ │ ├── Computes WCAG luminance of background │ │ ├── Generates random HSL colors │ │ ├── Tests contrast ratio >= min_contrast │ │ └── Returns hex string of passing color │ └─────────────────────────────────────────────────────────────┘

Resource Resolution Functions

The resource resolution layer provides a unified API for locating packaged resources (icons, stylesheets, configuration files) using Python's importlib.resources module. This approach ensures that resources can be found regardless of whether the package is installed as a regular directory, a zip-imported egg, or a wheel. All functions in this layer delegate to get_resource_path(), which is the core resolver.

get_resource_path(package: str, resource: str, ext: str = 'svg') -> Path Function

Retrieves the full filesystem path to a specified resource located within a given Python package. This is the foundational function upon which all other resource accessors depend. It uses importlib.resources.path() as a context manager to resolve the resource, which handles the complexity of different package installation formats transparently.

The function automatically appends the file extension to the resource name. If ext is provided (defaulting to 'svg'), the resource is looked up as f'{resource}.{ext}'. If ext is falsy (empty string or None), the extension is derived from the last segment of the package path — for example, a package path of 'PySideAbdhUI.resources.styles' would yield ext = 'styles', which is likely incorrect. This fallback behavior appears to be a design artifact rather than a useful feature.

If the resource cannot be located, a RuntimeError is raised with a descriptive message that includes both the resource name and the package path, and the original exception is chained via from e.

ParameterTypeDefaultDescription
packagestrDotted package path where the resource is located. E.g., 'PySideAbdhUI.resources.icons.svg'.
resourcestrBase filename of the resource without extension. E.g., 'menu' for menu.svg.
extstr'svg'File extension to append. If falsy, derived from the package path (usually incorrect).
Returns: Path — The full filesystem path to the resource.

Known Issue: The default value of ext='svg' means that calling get_resource_path() without specifying ext will always look for an .svg file, even for non-SVG resources. The convenience functions (get_styles_template, get_color_roles) override this correctly, but direct callers must remember to pass the appropriate extension.

get_icon(name: str, package: str = 'PySideAbdhUI.resources.icons.svg', ext: str = 'svg') -> str Function

Convenience function that resolves the path to an SVG icon resource and returns it as a POSIX-style string. This is the primary function used by the framework's widgets to load icons — every QIcon(get_icon('icon-name')) call in the codebase goes through this function. The default package path points to the PySideAbdhUI.resources.icons.svg subpackage, where all SVG icons are expected to reside.

ParameterTypeDefaultDescription
namestrThe icon name without extension. E.g., 'menu' resolves to menu.svg.
packagestr'PySideAbdhUI.resources.icons.svg'Dotted package path where icons are stored.
extstr'svg'File extension of the icon resource.
Returns: str — POSIX-style path string suitable for QIcon() or QAction() constructors.
get_styles_template(package: str = 'PySideAbdhUI.resources.styles') -> str Function

Convenience function that resolves the path to the QSS template file (qss-template.qss) within the styles package. The template contains CSS rules with placeholder tokens in the format --token-name-- that are replaced with actual color values during theme application. The template serves as the single source of truth for the application's stylesheet structure, while the color values come from the theme JSON file.

ParameterTypeDefaultDescription
packagestr'PySideAbdhUI.resources.styles'Dotted package path where style resources are stored.
Returns: str — POSIX-style path string to the qss-template.qss file.
get_color_roles(package: str = 'PySideAbdhUI.resources.styles') -> str Function

Convenience function that resolves the path to the color-roles JSON file (color-roles.json) within the styles package. This JSON file defines all available themes, their color categories and roles, and the currently active theme. The file is the persistence layer for the ThemeManager — it is read on initialization and written back when themes are switched or properties are modified.

ParameterTypeDefaultDescription
packagestr'PySideAbdhUI.resources.styles'Dotted package path where style resources are stored.
Returns: str — POSIX-style path string to the color-roles.json file.

ThemeManager

ThemeManager

Standalone class (no base class)

Manages the application's theme lifecycle: loading theme definitions from a JSON file, switching between themes, applying themes by performing placeholder substitution on a QSS template, and editing CSS properties within the template. The class acts as the bridge between the structured theme data (stored in color-roles.json) and the flat QSS template (stored in qss-template.qss), converting structured color definitions into a complete, application-ready stylesheet.

The theme system uses a simple but effective placeholder replacement approach: the QSS template contains tokens in the format --token-name--, and the theme JSON provides a nested dictionary of categories, role names, and color values. During application, each placeholder is replaced with its corresponding color value. This approach avoids the complexity of a full CSS preprocessor while still providing theme-switching capability.

Constructor

ThemeManager.__init__(self) Public

Initializes the theme manager by resolving the paths to the color-roles JSON file and the QSS template, then loading the theme data. The constructor performs the following steps:

  1. Calls get_color_roles() to resolve the path to color-roles.json.
  2. Calls get_styles_template() to resolve the path to qss-template.qss.
  3. Stores both paths as self.color_roles and self.template_path.
  4. Calls self.load() to read and parse the JSON file into self.data.
AttributeTypeDescription
color_rolesstrPOSIX path to the color-roles.json file.
template_pathstrPOSIX path to the qss-template.qss file.
datadictParsed JSON data containing theme definitions and the active theme name.

Persistence (load / save)

load(self) -> dict Public

Reads and parses the color-roles.json file, returning the resulting dictionary. The file is opened with UTF-8 encoding and parsed with json.load(). After reading, the file is explicitly closed with f.close() (though the with statement would handle this automatically).

If the file cannot be opened or parsed, the with block exits and the code after it (return {"active-theme": "", "themes": {}}) is reached. However, this fallback is unreachable because the return statement inside the with block always executes first if the file is successfully read, and an exception would propagate rather than falling through.

Returns: dict — The parsed JSON data with theme definitions.

Unreachable Code: The line return {"active-theme": "", "themes": {}} is unreachable because it appears after a return inside a with block. If the file read fails, an exception will be raised rather than reaching this fallback. A proper implementation would use a try/except around the file operation.

save(self) Public

Writes the current self.data dictionary back to the color-roles.json file with 4-space indentation. The file is opened with UTF-8 encoding in write mode. Like load(), the file is explicitly closed with f.close() despite the with statement handling this automatically. This method is called by switch_theme() to persist the active theme name change.

Theme Queries

get_current_theme_name(self) -> str Public

Returns the name of the currently active theme. The name is stored under the "active-theme" key in the JSON data. If the key is missing, an empty string is returned.

Returns: str — The active theme name, or an empty string if not set.
get_current_theme(self) -> dict Public

Returns the full theme dictionary for the currently active theme. The theme dictionary is a nested structure organized by color categories and role names. For example, a theme might have categories like "surface", "text", "accent", each containing role entries with a "color" field. If the active theme name does not exist in the themes dictionary, an empty dict is returned.

Returns: dict — The active theme's color definitions, or {} if not found.
get_color(self, role_category: str, role_name: str) -> str | None Public

Retrieves a specific color value from the current theme by navigating the nested dictionary structure: theme[role_category][role_name]["color"]. This provides a convenient lookup API that abstracts away the dictionary structure. If the category, role, or color key does not exist, None is returned due to the chained .get() calls.

ParameterTypeDescription
role_categorystrThe top-level category in the theme (e.g., "surface", "text", "accent").
role_namestrThe specific role within the category (e.g., "primary", "secondary", "disabled").
Returns: str | None — The color value (typically a hex string like "#1E1E2E"), or None if not found.
get_all_themes(self) -> list Public

Returns a list of all available theme names in the configuration. This is useful for populating a theme selector UI where the user can choose from the installed themes.

Returns: list[str] — List of theme name strings.

Theme Switching

switch_theme(self, new_theme_name: str) -> bool Public

Switches the active theme to the specified theme name. The method first checks whether the requested theme exists in the "themes" dictionary. If it does, the "active-theme" key in self.data is updated and self.save() is called to persist the change to disk. If the theme name does not exist, the method returns False without making any changes.

ParameterTypeDescription
new_theme_namestrThe name of the theme to switch to. Must match a key in the "themes" dictionary.
Returns: True if the switch was successful; False if the theme name was not found.

Applying Themes

apply_theme(self, app: QApplication, theme_name: str = 'default-dark') Public

The main entry point for applying a theme to the application. This method performs a complete theme application cycle:

  1. Switch theme: Calls switch_theme(theme_name) to update the active theme in the JSON data and persist the change.
  2. Load template: Reads the QSS template file from self.template_path into a string.
  3. Replace placeholders: Iterates over the current theme's categories and roles, replacing each placeholder token --role_name-- in the QSS string with the corresponding color value. The iteration order ensures all nested categories and roles are covered.
  4. Apply stylesheet: Calls app.setStyleSheet(qss) on the QApplication instance to apply the completed stylesheet.

If the QSS template file cannot be read, an error message is printed to the console and the method returns without applying any changes. The existing stylesheet remains in effect.

ParameterTypeDefaultDescription
appQApplicationThe application instance to apply the stylesheet to.
theme_namestr'default-dark'The name of the theme to apply.
QSS Template Placeholder Replacement: Before (qss-template.qss): ┌────────────────────────────────────────────────────┐ │ QPushButton { │ │ background-color: --primary--; │ │ color: --on-primary--; │ │ border: 1px solid --outline--; │ │ } │ └────────────────────────────────────────────────────┘ Theme JSON data: ┌────────────────────────────────────────────────────┐ │ "surface": { │ │ "primary": { "color": "#6750A4" }, │ │ "on-primary": { "color": "#FFFFFF" }, │ │ "outline": { "color": "#79747E" } │ │ } │ └────────────────────────────────────────────────────┘ After replacement: ┌────────────────────────────────────────────────────┐ │ QPushButton { │ │ background-color: #6750A4; │ │ color: #FFFFFF; │ │ border: 1px solid #79747E; │ │ } │ └────────────────────────────────────────────────────┘

Note: The placeholder format --role_name-- uses double dashes on both sides, which is different from CSS custom property syntax (--name). This avoids conflicts with CSS custom properties that might exist in the template. The replacement is a simple string substitution (qss.replace(placeholder, color)), not a regex, so placeholders must match exactly.

Widget Property Editing

add_property_to_widget(self, widget_name: str, property_name: str, property_value: str) Public

Adds or updates a CSS property within a specific widget's style block in the QSS template file. This method provides a programmatic way to customize the stylesheet without manually editing the template file. It uses regular expressions to locate and modify the target widget block.

The method performs the following steps:

  1. Read template: Reads the entire QSS template file into a string.
  2. Match widget block: Uses the regex widget_name\s*\{[^}]*\} to find the CSS block for the specified widget. This pattern matches the widget name followed by a brace-enclosed block of properties.
  3. Check for existing property: Within the matched block, searches for property_name\s*:\s*[^;]+; to determine if the property already exists.
  4. Update or insert: If the property exists, its value is replaced. If it does not exist, the new property is appended before the closing brace with proper indentation.
  5. Write template: The modified QSS string is written back to the template file.
ParameterTypeDescription
widget_namestrThe CSS selector name (e.g., "QPushButton", "QLabel").
property_namestrThe CSS property name (e.g., "font-family", "border-radius").
property_valuestrThe CSS property value (e.g., "'Arial'", "8px").

Limitations: The regex pattern widget_name\s*\{[^}]*\} does not support nested braces, which means it will fail for widget blocks that contain nested selectors (e.g., QPushButton:hover { ... }). Additionally, if the widget block spans multiple lines with complex formatting, the regex may not match correctly. The method only writes back to the template file if a match is found — if the widget does not exist in the template, no changes are made.

Note: After modifying the template, the commented-out line #self.apply_theme(QApplication.instance(), self.get_current_theme_name()) suggests that the theme was originally re-applied automatically after property changes. This is currently disabled, meaning the user must manually call apply_theme() after using this method to see the changes.

random_contrasting_hex

random_contrasting_hex(background: QColor | str, theme: str = "auto", min_contrast: float = 4.5) -> str Function

Generates a random hex color that is guaranteed to be readable against the given background color, meeting a specified minimum WCAG contrast ratio. The function uses the WCAG 2.0 relative luminance algorithm to compute perceptual brightness and the standard contrast ratio formula to ensure accessibility. The generated color is never extremely bright (no white or near-white) and tends toward bolder, more saturated colors due to the minimum saturation threshold.

The function supports two operating modes controlled by the theme parameter:

  • "auto" (default): Automatically determines whether a light or dark foreground is needed based on the background's luminance. If the background luminance is below 0.5, a light foreground is generated; otherwise, a dark one.
  • "dark" or "light": Forces the generation of a specific foreground type regardless of the background brightness. Use "dark" to generate light foreground colors (for dark backgrounds) and "light" for dark foreground colors (for light backgrounds).
ParameterTypeDefaultDescription
backgroundQColor | strThe background color to contrast against. Accepts a QColor object or a CSS color string (e.g., "#1E1E2E").
themestr"auto"Controls foreground brightness mode: "auto", "dark" (light foreground), or any other value (dark foreground).
min_contrastfloat4.5Minimum WCAG contrast ratio. The default of 4.5 meets WCAG AA for normal text. Use 7.0 for AAA compliance.
Returns: str — A hex color string (e.g., "#C8A2FF") that meets the contrast requirement against the background.

Contrast Algorithm Details

The function implements the WCAG 2.0 contrast ratio algorithm, which is the industry standard for evaluating text readability on colored backgrounds. The algorithm consists of two nested helper functions defined inside random_contrasting_hex():

Luminance Calculation

The luminance(color) function computes the relative luminance of a color using the sRGB linearization formula defined in WCAG 2.0 specification. Each RGB channel is first normalized to the 0-1 range, then linearized using the standard transfer function: values below 0.04045 are divided by 12.92, while values above are transformed using ((c + 0.055) / 1.055) ^ 2.4. The linearized values are then combined using the weighted sum: 0.2126 * R + 0.7152 * G + 0.0722 * B, where the weights reflect the human eye's varying sensitivity to different wavelengths of light.

WCAG 2.0 Luminance & Contrast Formulas: Relative Luminance (L): ┌─────────────────────────────────────────────────────────────┐ │ For each sRGB channel (R, G, B) in [0, 255]: │ │ │ │ c_norm = c / 255.0 │ │ │ │ if c_norm <= 0.04045: │ │ c_lin = c_norm / 12.92 │ │ else: │ │ c_lin = ((c_norm + 0.055) / 1.055) ^ 2.4 │ │ │ │ L = 0.2126 * R_lin + 0.7152 * G_lin + 0.0722 * B_lin │ └─────────────────────────────────────────────────────────────┘ Contrast Ratio (CR): ┌─────────────────────────────────────────────────────────────┐ │ CR = (L_lighter + 0.05) / (L_darker + 0.05) │ │ │ │ Range: 1:1 (no contrast) to 21:1 (max contrast) │ │ WCAG AA normal text: CR >= 4.5 │ │ WCAG AA large text: CR >= 3.0 │ │ WCAG AAA normal text: CR >= 7.0 │ └─────────────────────────────────────────────────────────────┘ Color Generation Strategy: ┌─────────────────────────────────────────────────────────────┐ │ Light foreground (dark background): │ │ H = random(0, 360) │ │ S = random(0.4, 1.0) ← bold saturation │ │ L = random(0.4, 0.7) ← never too bright │ │ │ │ Dark foreground (light background): │ │ H = random(0, 360) │ │ S = random(0.4, 1.0) ← bold saturation │ │ L = random(0.1, 0.35) ← deep darks │ │ │ │ Rejection filters: │ │ • Skip #FFFFFF (pure white) │ │ • Skip colors with lightness > 85% │ │ • Skip colors below min_contrast threshold │ │ │ │ Fallback after 1000 failed attempts: │ │ Light → "#C8C8C8" (dark gray, readable on black) │ │ Dark → "#1A1A1A" (very dark gray, near black) │ └─────────────────────────────────────────────────────────────┘

The color generation loop runs up to 1000 attempts, generating random HSL colors and testing each against the contrast threshold. The HSL parameters are constrained to produce visually appealing colors: saturation is always at least 40% to avoid washed-out pastels, and lightness is bounded to prevent extremely bright or dark results. Pure white (#ffffff) and colors with a lightness value above 85% are explicitly rejected regardless of contrast ratio, ensuring the output is never glaringly bright.

If no suitable color is found after 1000 attempts (which is extremely rare given the constrained parameter ranges), a safe fallback color is returned: "#C8C8C8" for light foregrounds (a medium gray readable on dark backgrounds) or "#1A1A1A" for dark foregrounds (a near-black readable on light backgrounds). These fallbacks deliberately avoid pure white and pure black, maintaining the function's guarantee of "never extremely bright" output.

Tip: When calling this function, the default min_contrast=4.5 meets WCAG AA requirements for normal-sized text. For large text (18pt+ or 14pt+ bold), min_contrast=3.0 is sufficient. For the highest accessibility standard (WCAG AAA), use min_contrast=7.0.

Full Method / Function Index

TypeNameBrief Description
Functions get_resource_pathResolve filesystem path to a packaged resource via importlib
get_iconResolve SVG icon path as POSIX string
get_styles_templateResolve QSS template path as POSIX string
get_color_rolesResolve color-roles JSON path as POSIX string
ThemeManager __init__Resolve resource paths and load theme data
loadRead and parse color-roles.json
saveWrite theme data back to color-roles.json
get_current_theme_nameGet the active theme name
get_current_themeGet the full active theme dictionary
get_colorLook up a specific color by category and role
get_all_themesList all available theme names
switch_themeChange active theme and persist to disk
apply_themeApply theme to QApplication via placeholder replacement
add_property_to_widgetAdd/update CSS property in QSS template via regex
Color Utility random_contrasting_hexGenerate WCAG-compliant contrasting random color

Resource Package Structure: The utility functions expect the following package structure within PySideAbdhUI: resources/icons/svg/ (SVG icon files), resources/styles/ (qss-template.qss and color-roles.json). If the package is installed without these resources, all resource resolution functions will raise RuntimeError.