noctalia
This commit is contained in:
@@ -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"
|
||||
Reference in New Issue
Block a user