mirror of
https://github.com/Cheviiot/Vual.git
synced 2026-08-04 00:07:25 +00:00
370 lines
11 KiB
Python
370 lines
11 KiB
Python
"""Wine theme management for Proton prefixes.
|
|
|
|
Safely applies dark/light color schemes to Wine prefixes via registry.
|
|
Only modifies string and dword values — never touches binary/hex data.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
from pathlib import Path
|
|
from typing import Iterator
|
|
|
|
# Registry section pattern
|
|
_SECTION_RE = re.compile(r'^\[([^\]]+)\]', re.MULTILINE)
|
|
|
|
# Wine dark color scheme (Control Panel\Colors)
|
|
# Format: "R G B" strings
|
|
DARK_COLORS = {
|
|
"ActiveBorder": "49 54 59",
|
|
"ActiveTitle": "49 54 59",
|
|
"AppWorkSpace": "30 30 30",
|
|
"Background": "30 30 30",
|
|
"ButtonAlternateFace": "49 54 59",
|
|
"ButtonDkShadow": "20 20 20",
|
|
"ButtonFace": "49 54 59",
|
|
"ButtonHilight": "70 75 80",
|
|
"ButtonLight": "60 65 70",
|
|
"ButtonShadow": "35 38 41",
|
|
"ButtonText": "230 230 230",
|
|
"GradientActiveTitle": "49 54 59",
|
|
"GradientInactiveTitle": "40 42 45",
|
|
"GrayText": "128 128 128",
|
|
"Hilight": "61 174 233",
|
|
"HilightText": "255 255 255",
|
|
"HotTrackingColor": "61 174 233",
|
|
"InactiveBorder": "40 42 45",
|
|
"InactiveTitle": "40 42 45",
|
|
"InactiveTitleText": "128 128 128",
|
|
"InfoText": "230 230 230",
|
|
"InfoWindow": "49 54 59",
|
|
"Menu": "49 54 59",
|
|
"MenuBar": "49 54 59",
|
|
"MenuHilight": "61 174 233",
|
|
"MenuText": "230 230 230",
|
|
"Scrollbar": "49 54 59",
|
|
"TitleText": "230 230 230",
|
|
"Window": "35 38 41",
|
|
"WindowFrame": "49 54 59",
|
|
"WindowText": "230 230 230",
|
|
}
|
|
|
|
# Standard light colors (Windows defaults)
|
|
LIGHT_COLORS = {
|
|
"ActiveBorder": "180 180 180",
|
|
"ActiveTitle": "153 180 209",
|
|
"AppWorkSpace": "171 171 171",
|
|
"Background": "0 0 0",
|
|
"ButtonAlternateFace": "0 0 0",
|
|
"ButtonDkShadow": "105 105 105",
|
|
"ButtonFace": "240 240 240",
|
|
"ButtonHilight": "255 255 255",
|
|
"ButtonLight": "227 227 227",
|
|
"ButtonShadow": "160 160 160",
|
|
"ButtonText": "0 0 0",
|
|
"GradientActiveTitle": "185 209 234",
|
|
"GradientInactiveTitle": "215 228 242",
|
|
"GrayText": "109 109 109",
|
|
"Hilight": "0 120 215",
|
|
"HilightText": "255 255 255",
|
|
"HotTrackingColor": "0 102 204",
|
|
"InactiveBorder": "244 247 252",
|
|
"InactiveTitle": "191 205 219",
|
|
"InactiveTitleText": "0 0 0",
|
|
"InfoText": "0 0 0",
|
|
"InfoWindow": "255 255 225",
|
|
"Menu": "240 240 240",
|
|
"MenuBar": "240 240 240",
|
|
"MenuHilight": "0 120 215",
|
|
"MenuText": "0 0 0",
|
|
"Scrollbar": "200 200 200",
|
|
"TitleText": "0 0 0",
|
|
"Window": "255 255 255",
|
|
"WindowFrame": "100 100 100",
|
|
"WindowText": "0 0 0",
|
|
}
|
|
|
|
# Color scheme presets
|
|
COLOR_SCHEMES = {
|
|
"dark": DARK_COLORS,
|
|
"light": LIGHT_COLORS,
|
|
}
|
|
|
|
# DWM (Desktop Window Manager) settings for dark theme
|
|
# AccentColor is ABGR format as dword
|
|
DARK_DWM = {
|
|
"AccentColor": 0xff3b3b3b, # Dark gray accent
|
|
"AccentColorInactive": 0xff2d2d2d, # Darker inactive
|
|
"ColorizationAfterglow": 0xc43b3b3b,
|
|
"ColorizationColor": 0xc43b3b3b,
|
|
"ColorizationColorBalance": 0x59,
|
|
"ColorizationGlassAttribute": 0x01,
|
|
"ColorPrevalence": 0x01, # Use accent color on title bars
|
|
"EnableWindowColorization": 0x01,
|
|
}
|
|
|
|
LIGHT_DWM = {
|
|
"AccentColor": 0xffd77800, # Blue accent (Windows default)
|
|
"AccentColorInactive": 0xffdbdbdb,
|
|
"ColorizationAfterglow": 0xc44f8bcd,
|
|
"ColorizationColor": 0xc44f8bcd,
|
|
"ColorizationColorBalance": 0x59,
|
|
"ColorizationGlassAttribute": 0x01,
|
|
"ColorPrevalence": 0x00,
|
|
"EnableWindowColorization": 0x01,
|
|
}
|
|
|
|
DWM_SCHEMES = {
|
|
"dark": DARK_DWM,
|
|
"light": LIGHT_DWM,
|
|
}
|
|
|
|
# Explorer accent settings
|
|
DARK_EXPLORER = {
|
|
"AccentColorMenu": 0xff3b3b3b,
|
|
}
|
|
|
|
LIGHT_EXPLORER = {
|
|
"AccentColorMenu": 0xffd77800,
|
|
}
|
|
|
|
EXPLORER_SCHEMES = {
|
|
"dark": DARK_EXPLORER,
|
|
"light": LIGHT_EXPLORER,
|
|
}
|
|
|
|
# Vual's own Wine prefix
|
|
VUAL_PREFIX = Path.home() / ".local" / "share" / "vual" / "wine_prefix"
|
|
|
|
|
|
def get_vual_prefix() -> Path | None:
|
|
"""Return Vual's Wine prefix if it exists."""
|
|
if VUAL_PREFIX.is_dir() and (VUAL_PREFIX / "user.reg").is_file():
|
|
return VUAL_PREFIX
|
|
return None
|
|
|
|
|
|
def get_all_prefixes(steamapps: Path) -> Iterator[Path]:
|
|
"""Yield all Proton prefix paths under steamapps/compatdata."""
|
|
compatdata = steamapps / "compatdata"
|
|
if not compatdata.is_dir():
|
|
return
|
|
|
|
for entry in compatdata.iterdir():
|
|
if not entry.is_dir():
|
|
continue
|
|
# Skip non-numeric (not app IDs)
|
|
if not entry.name.isdigit():
|
|
continue
|
|
|
|
pfx = entry / "pfx"
|
|
if pfx.is_dir():
|
|
yield pfx
|
|
|
|
|
|
def _find_or_create_section(lines: list[str], section_name: str) -> int:
|
|
"""Find section index or create it. Returns index of first line after section header.
|
|
|
|
Wine registry sections can have timestamps: [Section\\Name] 1234567890
|
|
We match by prefix to handle this.
|
|
"""
|
|
target = f"[{section_name}]"
|
|
target_lower = target.lower()
|
|
|
|
for i, line in enumerate(lines):
|
|
stripped = line.strip().lower()
|
|
# Match exact or with timestamp suffix
|
|
if stripped == target_lower or stripped.startswith(target_lower + " "):
|
|
return i + 1
|
|
|
|
# Section not found — add at end
|
|
if lines and lines[-1].strip():
|
|
lines.append("")
|
|
lines.append(target)
|
|
lines.append("")
|
|
return len(lines) - 1
|
|
|
|
|
|
def _set_string_value(lines: list[str], section_idx: int, key: str, value: str) -> None:
|
|
"""Set a string value in registry section. Only modifies string values."""
|
|
# Find section end (next section or EOF)
|
|
section_end = len(lines)
|
|
for i in range(section_idx, len(lines)):
|
|
if lines[i].startswith("["):
|
|
section_end = i
|
|
break
|
|
|
|
# Look for existing key
|
|
key_pattern = f'"{key}"='
|
|
for i in range(section_idx, section_end):
|
|
if lines[i].startswith(key_pattern):
|
|
lines[i] = f'"{key}"="{value}"'
|
|
return
|
|
|
|
# Key not found — insert before section end
|
|
insert_at = section_end
|
|
# Find last non-empty line in section
|
|
for i in range(section_end - 1, section_idx - 1, -1):
|
|
if lines[i].strip():
|
|
insert_at = i + 1
|
|
break
|
|
|
|
lines.insert(insert_at, f'"{key}"="{value}"')
|
|
|
|
|
|
def _set_dword_value(lines: list[str], section_idx: int, key: str, value: int) -> None:
|
|
"""Set a dword value in registry section."""
|
|
# Find section end (next section or EOF)
|
|
section_end = len(lines)
|
|
for i in range(section_idx, len(lines)):
|
|
if lines[i].startswith("["):
|
|
section_end = i
|
|
break
|
|
|
|
# Format: "Key"=dword:00000000
|
|
dword_str = f'"{key}"=dword:{value:08x}'
|
|
|
|
# Look for existing key
|
|
key_pattern = f'"{key}"='
|
|
for i in range(section_idx, section_end):
|
|
if lines[i].startswith(key_pattern):
|
|
lines[i] = dword_str
|
|
return
|
|
|
|
# Key not found — insert before section end
|
|
insert_at = section_end
|
|
for i in range(section_end - 1, section_idx - 1, -1):
|
|
if lines[i].strip():
|
|
insert_at = i + 1
|
|
break
|
|
|
|
lines.insert(insert_at, dword_str)
|
|
|
|
|
|
def apply_colors_to_prefix(pfx: Path, colors: dict[str, str], is_dark: bool) -> bool:
|
|
"""Apply color scheme to a single prefix.
|
|
|
|
Modifies:
|
|
- [Control Panel\\Colors] — color values as "R G B" strings
|
|
- [Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize] — dark mode flags
|
|
|
|
Args:
|
|
pfx: Path to prefix directory (containing user.reg).
|
|
colors: Dict of color name -> "R G B" string values.
|
|
is_dark: True for dark theme, False for light.
|
|
|
|
Returns:
|
|
True if successful, False otherwise.
|
|
"""
|
|
user_reg = pfx / "user.reg"
|
|
if not user_reg.is_file():
|
|
return False
|
|
|
|
try:
|
|
content = user_reg.read_text(encoding="utf-8", errors="replace")
|
|
except OSError:
|
|
return False
|
|
|
|
lines = content.splitlines()
|
|
|
|
# Find or create Colors section
|
|
colors_idx = _find_or_create_section(
|
|
lines,
|
|
"Control Panel\\\\Colors" # Escaped backslash for .reg format
|
|
)
|
|
|
|
# Apply each color
|
|
for key, value in colors.items():
|
|
_set_string_value(lines, colors_idx, key, value)
|
|
|
|
# Find or create Personalize section (Windows 10+ dark mode)
|
|
personalize_idx = _find_or_create_section(
|
|
lines,
|
|
"Software\\\\Microsoft\\\\Windows\\\\CurrentVersion\\\\Themes\\\\Personalize"
|
|
)
|
|
|
|
# Set dark mode flags: 0 = dark, 1 = light
|
|
light_value = 0 if is_dark else 1
|
|
_set_dword_value(lines, personalize_idx, "AppsUseLightTheme", light_value)
|
|
_set_dword_value(lines, personalize_idx, "SystemUsesLightTheme", light_value)
|
|
|
|
# Find or create DWM section for titlebar colors
|
|
dwm_idx = _find_or_create_section(
|
|
lines,
|
|
"Software\\\\Microsoft\\\\Windows\\\\DWM"
|
|
)
|
|
|
|
# Apply DWM settings
|
|
dwm_settings = DARK_DWM if is_dark else LIGHT_DWM
|
|
for key, value in dwm_settings.items():
|
|
_set_dword_value(lines, dwm_idx, key, value)
|
|
|
|
# Find or create Explorer Accent section
|
|
explorer_idx = _find_or_create_section(
|
|
lines,
|
|
"Software\\\\Microsoft\\\\Windows\\\\CurrentVersion\\\\Explorer\\\\Accent"
|
|
)
|
|
|
|
# Apply Explorer accent settings
|
|
explorer_settings = DARK_EXPLORER if is_dark else LIGHT_EXPLORER
|
|
for key, value in explorer_settings.items():
|
|
_set_dword_value(lines, explorer_idx, key, value)
|
|
|
|
# Find ThemeManager section and disable msstyles for dark theme
|
|
# (Wine visual styles can override our color settings)
|
|
theme_mgr_idx = _find_or_create_section(
|
|
lines,
|
|
"Software\\\\Microsoft\\\\Windows\\\\CurrentVersion\\\\ThemeManager"
|
|
)
|
|
|
|
if is_dark:
|
|
# Disable visual styles to use our dark colors
|
|
_set_string_value(lines, theme_mgr_idx, "ThemeActive", "0")
|
|
else:
|
|
_set_string_value(lines, theme_mgr_idx, "ThemeActive", "1")
|
|
|
|
# Write back
|
|
try:
|
|
user_reg.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
|
return True
|
|
except OSError:
|
|
return False
|
|
|
|
|
|
def apply_theme_to_all(steamapps: Path, theme: str) -> tuple[int, int]:
|
|
"""Apply theme to all Proton prefixes and Vual's own prefix.
|
|
|
|
Args:
|
|
steamapps: Path to Steam/steamapps directory.
|
|
theme: Theme name ("dark", "light", or "system").
|
|
|
|
Returns:
|
|
Tuple of (success_count, fail_count).
|
|
"""
|
|
if theme == "system" or theme not in COLOR_SCHEMES:
|
|
return (0, 0)
|
|
|
|
colors = COLOR_SCHEMES[theme]
|
|
is_dark = (theme == "dark")
|
|
success = 0
|
|
failed = 0
|
|
|
|
# Apply to Steam prefixes
|
|
for pfx in get_all_prefixes(steamapps):
|
|
if apply_colors_to_prefix(pfx, colors, is_dark):
|
|
success += 1
|
|
else:
|
|
failed += 1
|
|
|
|
# Apply to Vual's own prefix
|
|
vual_pfx = get_vual_prefix()
|
|
if vual_pfx:
|
|
if apply_colors_to_prefix(vual_pfx, colors, is_dark):
|
|
success += 1
|
|
else:
|
|
failed += 1
|
|
|
|
return (success, failed)
|
|
|
|
|