This commit is contained in:
2026-11-04 20:35:46 +01:00
parent 5f4de5e60c
commit 86abc097f4
348 changed files with 10300 additions and 0 deletions
@@ -0,0 +1,9 @@
{
"files": {
"README.md": "af0b5fd57df6929d6d1f629518541b08",
"blender_theme.py": "1a0cd158ac744d4887a832631d1c58b3",
"screenshot.png": "35fe418801156063765900cb85780f4e",
"template.toml": "2268722156753442c3797250df7f4222"
},
"version": 1
}
@@ -0,0 +1,41 @@
# Blender
Blender's theme system isn't a CSS file, it's roughly 570 individual color properties on
`bpy.context.preferences.themes[0]`, saved into a binary `userpref.blend`, not a plain-text
config. So this template doesn't just render a file, `input_path` is a Python script (with
`{{colors.role.default.hex}}` placeholders substituted the normal way), and `post_hook` runs it
through Blender itself in headless mode, which applies the colors via the real `bpy` API and
saves preferences directly. No GUI window opens, `--background` mode is fully invisible.
## What gets themed, and what deliberately doesn't
Only structural chrome: panel backgrounds, headers, tab strips, widget fills/outlines/text,
editor borders, list backgrounds, metadata overlay boxes. Around 345 of the ~570 properties.
The rest (about 225) are left at Blender's own defaults on purpose: mesh-editing selection
states (vertex/edge/face colors), node category colors, sequencer strip-type colors, syntax
highlighting, keyframe types, axis/gizmo colors. These are workflow conventions users rely on
for actual 3D work, not decoration, recoloring them to an arbitrary palette would hurt
usability rather than improve consistency. Same reasoning already applied to terminal ANSI
colors and Blender's own axis-color convention (X=red, Y=green, Z=blue) elsewhere.
## Native install
Runs `blender --background --python ...` directly. No setup beyond having `blender` on `PATH`.
## Flatpak install (`org.blender.Blender`)
Blender's Flatpak has `filesystems=host` (full host filesystem access), broader than most
sandboxed apps here. `$XDG_CONFIG_HOME` inside the sandbox is still remapped to
`~/.var/app/org.blender.Blender/config` regardless, same as every other Flatpak app, but that
doesn't matter for this template: the rendered script lives at a plain host path
(`~/.cache/noctalia/blender_theme_rendered.py`), and Blender's `host` grant means it can read
that path directly by its real location, no `flatpak override` or `.var/app`-local copy needed
at all.
## Testing
Tested against Blender 5.2.0 LTS (Flatpak). Verified via a full audit: captured Blender's true
factory-default theme values (`--factory-startup`) and diffed against the post-apply state
property by property, confirming exactly which of the ~570 were intentionally changed versus
intentionally left alone, not just spot-checked visually.
@@ -0,0 +1,192 @@
"""Applies Noctalia's palette to Blender's UI chrome, run headless via `blender --background --python`.
Only structural chrome is themed: panel backgrounds, headers, widget fills/outlines/text.
Workflow-meaningful colors (mesh selection states, node categories, sequencer strip types,
syntax highlighting) are deliberately left at Blender's own defaults, since users rely on those
as functional conventions, not decoration.
"""
import bpy
COLORS = {
"surface": "{{colors.surface.default.hex}}",
"on_surface": "{{colors.on_surface.default.hex}}",
"surface_variant": "{{colors.surface_variant.default.hex}}",
"on_surface_variant": "{{colors.on_surface_variant.default.hex}}",
"surface_container": "{{colors.surface_container.default.hex}}",
"surface_container_low": "{{colors.surface_container_low.default.hex}}",
"surface_container_high": "{{colors.surface_container_high.default.hex}}",
"outline": "{{colors.outline.default.hex}}",
"outline_variant": "{{colors.outline_variant.default.hex}}",
"primary": "{{colors.primary.default.hex}}",
"on_primary": "{{colors.on_primary.default.hex}}",
"primary_container": "{{colors.primary_container.default.hex}}",
"on_primary_container": "{{colors.on_primary_container.default.hex}}",
"secondary": "{{colors.secondary.default.hex}}",
"secondary_container": "{{colors.secondary_container.default.hex}}",
"on_secondary_container": "{{colors.on_secondary_container.default.hex}}",
"tertiary": "{{colors.tertiary.default.hex}}",
"tertiary_container": "{{colors.tertiary_container.default.hex}}",
"on_tertiary_container": "{{colors.on_tertiary_container.default.hex}}",
"error": "{{colors.error.default.hex}}",
"on_error": "{{colors.on_error.default.hex}}",
"error_container": "{{colors.error_container.default.hex}}",
}
def hex_to_rgb(hex_str):
hex_str = hex_str.lstrip("#")
return tuple(int(hex_str[i : i + 2], 16) / 255.0 for i in (0, 2, 4))
RGB = {name: hex_to_rgb(value) for name, value in COLORS.items()}
def classify(path):
if path.endswith(".space.back"):
return "surface"
if path.endswith(".space.header"):
return "surface_container"
if path.endswith(".space.header_text"):
return "on_surface"
if path.endswith(".space.header_text_hi"):
return "primary"
if path.endswith(".space.text"):
return "on_surface"
if path.endswith(".space.text_hi"):
return "primary"
if path.endswith(".space.title"):
return "on_surface_variant"
if path.endswith(".space.tab_active"):
return "surface_container_high"
if path.endswith(".space.tab_back"):
return "surface_container_low"
if path.endswith(".space.tab_outline"):
return "outline"
if path.endswith(".space.gradients.gradient"):
return "surface"
if path.endswith(".space.gradients.high_gradient"):
return "surface_container"
if path.endswith("row_alternate"):
return "surface_container_low"
if ".wcol_" in path:
suffix = path.rsplit(".", 1)[-1]
wcol_map = {
"inner": "surface_container",
"inner_sel": "primary_container",
"inner_key": "tertiary_container",
"inner_key_sel": "tertiary_container",
"inner_anim": "secondary_container",
"inner_anim_sel": "secondary_container",
"inner_driven": "tertiary",
"inner_driven_sel": "tertiary",
"inner_changed": "primary",
"inner_changed_sel": "primary",
"inner_overridden": "error_container",
"inner_overridden_sel": "error_container",
"outline": "outline",
"outline_sel": "primary",
"item": "on_surface_variant",
"text": "on_surface",
"text_sel": "on_primary",
"error": "error",
"warning": "tertiary",
"success": "secondary",
"info": "primary",
}
if suffix in wcol_map:
return wcol_map[suffix]
if path.startswith("regions."):
if path.endswith(".back") or path.endswith(".header_back"):
return "surface_container"
if path.endswith(".tab_back"):
return "surface_container_low"
if path.endswith(".text"):
return "on_surface"
if path.endswith(".text_selected"):
return "primary"
direct = {
"text_editor.line_numbers": "on_surface_variant",
"text_editor.line_numbers_background": "surface_container_low",
"text_editor.cursor": "primary",
"text_editor.selected_text": "primary_container",
"image_editor.metadatabg": "surface_container",
"image_editor.metadatatext": "on_surface",
"sequence_editor.metadatabg": "surface_container",
"sequence_editor.metadatatext": "on_surface",
"sequence_editor.preview_back": "surface",
"clip_editor.metadatabg": "surface_container",
"clip_editor.metadatatext": "on_surface",
"node_editor.node_outline": "outline",
"user_interface.widget_text_cursor": "primary",
"user_interface.editor_border": "outline",
"user_interface.editor_outline": "outline_variant",
"user_interface.editor_outline_active": "primary",
"user_interface.panel_header": "surface_container",
"user_interface.panel_back": "surface_container_low",
"user_interface.panel_outline": "outline",
"user_interface.panel_title": "on_surface",
"user_interface.panel_text": "on_surface",
"user_interface.panel_active": "primary",
"outliner.selected_highlight": "primary_container",
"outliner.active": "primary",
"outliner.selected_object": "on_primary_container",
"outliner.active_object": "on_primary",
"outliner.match": "tertiary_container",
"info.info_error_text": "error",
"info.info_warning_text": "tertiary",
"info.info_info_text": "on_surface",
"info.info_property_text": "secondary",
"info.info_operator_text": "primary",
"info.info_debug_text": "outline",
"info.info_selected": "primary_container",
"info.info_selected_text": "on_primary_container",
"console.line_output": "on_surface",
"console.line_input": "primary",
"console.line_info": "secondary",
"console.line_error": "error",
"console.select": "primary_container",
"console.cursor": "primary",
"file_browser.selected_file": "primary_container",
"preferences.match": "tertiary_container",
"properties.match": "tertiary_container",
}
if path in direct:
return direct[path]
return None
def walk_and_apply(obj, path="", depth=0):
if depth > 4:
return 0
applied = 0
for prop in obj.bl_rna.properties:
if prop.identifier == "rna_type":
continue
try:
val = getattr(obj, prop.identifier)
except Exception:
continue
full = f"{path}.{prop.identifier}" if path else prop.identifier
if prop.type == "POINTER" and val is not None:
applied += walk_and_apply(val, full, depth + 1)
elif prop.type == "FLOAT" and getattr(prop, "array_length", 0) in (3, 4):
role = classify(full)
if role is None:
continue
rgb = RGB[role]
current = list(val)
current[0], current[1], current[2] = rgb
setattr(obj, prop.identifier, current)
applied += 1
return applied
theme = bpy.context.preferences.themes[0]
count = walk_and_apply(theme)
print(f"noctalia-theme: applied {count} properties")
bpy.ops.wm.save_userpref()
Binary file not shown.

After

Width:  |  Height:  |  Size: 396 KiB

@@ -0,0 +1,15 @@
[catalog.blender]
name = "Blender"
category = "editor"
[templates.blender]
input_path = "blender_theme.py"
output_path = "~/.cache/noctalia/blender_theme_rendered.py"
post_hook = "blender --background --python ~/.cache/noctalia/blender_theme_rendered.py"
requires_path = "~/.config/blender"
[templates.blender-flatpak]
input_path = "blender_theme.py"
output_path = "~/.cache/noctalia/blender_theme_rendered.py"
post_hook = "flatpak run org.blender.Blender --background --python ~/.cache/noctalia/blender_theme_rendered.py"
requires_path = "~/.var/app/org.blender.Blender"