Add TOML-backed Orca 50 settings registry
This commit is contained in:
@@ -0,0 +1,462 @@
|
||||
# Cthulhu
|
||||
#
|
||||
# Copyright 2026 Igalia, S.L.
|
||||
# Author: Joanmarie Diggs <jdiggs@igalia.com>
|
||||
#
|
||||
# This library is free software; you can redistribute it and/or
|
||||
# modify it under the terms of the GNU Lesser General Public
|
||||
# License as published by the Free Software Foundation; either
|
||||
# version 2.1 of the License, or (at your option) any later version.
|
||||
#
|
||||
# This library is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
||||
# Lesser General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Lesser General Public
|
||||
# License along with this library; if not, write to the
|
||||
# Free Software Foundation, Inc., Franklin Street, Fifth Floor,
|
||||
# Boston MA 02110-1301 USA.
|
||||
|
||||
"""Shared migration logic for converting between JSON settings and GSettings.
|
||||
|
||||
This module has no imports of debug, settings_manager, or anything that pulls
|
||||
in Atspi, so it can be used by both the cthulhu runtime and the standalone
|
||||
gsettings_import_export tool.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from gi.repository import Gio, GLib
|
||||
|
||||
VOICE_TYPES: list[str] = ["default", "uppercase", "hyperlink", "system"]
|
||||
|
||||
# TODO - JD: Delete this in v52 (remove -i/--import-dir support).
|
||||
VOICE_MIGRATION_MAP: dict[str, tuple[str, str, Any]] = {
|
||||
"established": ("established", "b", False),
|
||||
"rate": ("rate", "i", 50),
|
||||
"average-pitch": ("pitch", "d", 5.0),
|
||||
"gain": ("volume", "d", 10.0),
|
||||
}
|
||||
|
||||
# TODO - JD: Delete this in v52 (remove -i/--import-dir support).
|
||||
VOICE_FAMILY_FIELDS: dict[str, str] = {
|
||||
"name": "family-name",
|
||||
"lang": "family-lang",
|
||||
"dialect": "family-dialect",
|
||||
"gender": "family-gender",
|
||||
"variant": "family-variant",
|
||||
}
|
||||
|
||||
# TODO - JD: Delete this in v52 (remove -i/--import-dir support).
|
||||
LEGACY_KEY_ALIASES: dict[str, str] = {
|
||||
"progressBarVerbosity": "progressBarSpeechVerbosity",
|
||||
"progressBarUpdateInterval": "progressBarSpeechInterval",
|
||||
}
|
||||
|
||||
# TODO - JD: Delete this in v52 (remove -i/--import-dir support).
|
||||
REVERSE_LEGACY_KEY_ALIASES: dict[str, str] = {v: k for k, v in LEGACY_KEY_ALIASES.items()}
|
||||
|
||||
# TODO - JD: Delete this in v52 (remove -i/--import-dir support).
|
||||
KEYBINDINGS_METADATA_KEYS: frozenset[str] = frozenset({"keyboardLayout", "cthulhuModifierKeys"})
|
||||
|
||||
|
||||
# TODO - JD: Delete this in v52 (remove -i/--import-dir support).
|
||||
@dataclass
|
||||
class SettingsMapping:
|
||||
"""Describes a mapping between a preferences key and a GSettings key."""
|
||||
|
||||
migration_key: str
|
||||
gs_key: str
|
||||
gtype: str # "b", "s", "i", "d", "as"
|
||||
default: Any
|
||||
enum_map: dict[int, str] | None = None
|
||||
string_enum: bool = False
|
||||
|
||||
|
||||
def sanitize_gsettings_path(name: str) -> str:
|
||||
"""Sanitize a name for use in a GSettings path."""
|
||||
|
||||
sanitized = name.lower()
|
||||
sanitized = re.sub(r"[^a-z0-9-]", "-", sanitized)
|
||||
sanitized = re.sub(r"-+", "-", sanitized)
|
||||
return sanitized.strip("-")
|
||||
|
||||
|
||||
# TODO - JD: Delete this in v52 (remove -i/--import-dir support).
|
||||
def resolve_enum_nick(value: Any, enum_map: dict[int, str]) -> str | None:
|
||||
"""Resolve a JSON enum value (string nick, int, or bool) to a GSettings enum nick."""
|
||||
|
||||
if isinstance(value, str):
|
||||
return value if value in enum_map.values() else None
|
||||
return enum_map.get(value)
|
||||
|
||||
|
||||
# TODO - JD: Delete this in v52 (remove -i/--import-dir support).
|
||||
_NAVIGATION_ENABLED_KEYS = (
|
||||
"caretNavigationEnabled",
|
||||
"structuralNavigationEnabled",
|
||||
"tableNavigationEnabled",
|
||||
)
|
||||
|
||||
|
||||
# TODO - JD: Delete this in v52 (remove -i/--import-dir support).
|
||||
def force_navigation_enabled(json_dict: dict) -> None:
|
||||
"""Force navigation-enabled keys to True during migration."""
|
||||
|
||||
# In the old system these defaulted to False because per-script logic controlled
|
||||
# activation. In the new system they represent the user's preference and default
|
||||
# to True. Migrating the old False would disable navigation for all scripts.
|
||||
for key in _NAVIGATION_ENABLED_KEYS:
|
||||
if key in json_dict:
|
||||
json_dict[key] = True
|
||||
|
||||
|
||||
# TODO - JD: Delete this in v52 (remove -i/--import-dir support).
|
||||
def fix_bool_enum_values(json_dict: dict) -> None:
|
||||
"""Fix enum settings stored as booleans instead of integers in JSON."""
|
||||
|
||||
# Some enum-typed settings are booleans in user JSON files instead of the
|
||||
# expected integers. Python's bool-is-int means False==0 and True==1,
|
||||
# which silently resolve to the wrong enum nick during migration.
|
||||
bool_enum_fixes: dict[str, dict[bool, int]] = {
|
||||
"findResultsVerbosity": {True: 2, False: 2}, # FIND_SPEAK_ALL
|
||||
}
|
||||
for key, mapping in bool_enum_fixes.items():
|
||||
if key in json_dict and isinstance(json_dict[key], bool):
|
||||
json_dict[key] = mapping[json_dict[key]]
|
||||
|
||||
|
||||
# TODO - JD: Delete this in v52 (remove -i/--import-dir support).
|
||||
def apply_legacy_aliases(json_dict: dict) -> None:
|
||||
"""Copy legacy key names to their modern equivalents if modern key is absent."""
|
||||
|
||||
for legacy_key, modern_key in LEGACY_KEY_ALIASES.items():
|
||||
if legacy_key in json_dict and modern_key not in json_dict:
|
||||
json_dict[modern_key] = json_dict[legacy_key]
|
||||
|
||||
|
||||
# TODO - JD: Delete this in v52 (remove -i/--import-dir support).
|
||||
def add_legacy_aliases(result: dict) -> None:
|
||||
"""Add legacy key names to exported dict for backwards compatibility with stable."""
|
||||
|
||||
for modern_key, legacy_key in REVERSE_LEGACY_KEY_ALIASES.items():
|
||||
if modern_key in result:
|
||||
result[legacy_key] = result[modern_key]
|
||||
|
||||
|
||||
# TODO - JD: Delete this in v52 (remove -i/--import-dir support).
|
||||
def hoist_keybindings_metadata(profile_prefs: dict) -> None:
|
||||
"""Move keyboardLayout and cthulhuModifierKeys from keybindings to profile level."""
|
||||
|
||||
keybindings = profile_prefs.get("keybindings", {})
|
||||
for key in KEYBINDINGS_METADATA_KEYS:
|
||||
if key in keybindings and key not in profile_prefs:
|
||||
profile_prefs[key] = keybindings[key]
|
||||
|
||||
|
||||
# TODO - JD: Delete this in v52 (remove -i/--import-dir support).
|
||||
def sink_keybindings_metadata(profile_data: dict, keybindings: dict) -> None:
|
||||
"""Move keyboardLayout and cthulhuModifierKeys from profile level into keybindings."""
|
||||
|
||||
for key in KEYBINDINGS_METADATA_KEYS:
|
||||
if key in profile_data:
|
||||
keybindings[key] = profile_data.pop(key)
|
||||
|
||||
|
||||
# TODO - JD: Delete this in v52 (remove -i/--import-dir support).
|
||||
DESKTOP_MODIFIER_KEYS_DEFAULT: list[str] = ["Insert", "KP_Insert"]
|
||||
LAPTOP_MODIFIER_KEYS_DEFAULT: list[str] = ["Caps_Lock", "Shift_Lock"]
|
||||
|
||||
|
||||
# TODO - JD: Delete this in v52 (remove -i/--import-dir support).
|
||||
def populate_per_layout_modifier_keys(
|
||||
gs: Gio.Settings,
|
||||
profile_prefs: dict,
|
||||
skip_defaults: bool,
|
||||
) -> bool:
|
||||
"""Populates keyboard-layout and modifier keys from keybinding metadata during migration."""
|
||||
|
||||
layout = profile_prefs.get("keyboardLayout", 1)
|
||||
is_desktop = layout == 1
|
||||
wrote_any = False
|
||||
|
||||
layout_nick = "desktop" if is_desktop else "laptop"
|
||||
if not skip_defaults or layout_nick != "desktop":
|
||||
gs.set_string("keyboard-layout", layout_nick)
|
||||
wrote_any = True
|
||||
|
||||
modifier_keys = profile_prefs.get("cthulhuModifierKeys")
|
||||
if modifier_keys is None:
|
||||
return wrote_any
|
||||
|
||||
if is_desktop:
|
||||
key = "desktop-modifier-keys"
|
||||
default = DESKTOP_MODIFIER_KEYS_DEFAULT
|
||||
else:
|
||||
key = "laptop-modifier-keys"
|
||||
default = LAPTOP_MODIFIER_KEYS_DEFAULT
|
||||
|
||||
if skip_defaults and modifier_keys == default:
|
||||
return wrote_any
|
||||
|
||||
gs.set_strv(key, modifier_keys)
|
||||
return True
|
||||
|
||||
|
||||
# TODO - JD: Delete this in v52 (remove -i/--import-dir support).
|
||||
def _write_one_mapping(
|
||||
gs: Gio.Settings,
|
||||
m: SettingsMapping,
|
||||
value: Any,
|
||||
skip_defaults: bool,
|
||||
) -> bool:
|
||||
"""Write a single mapping value to GSettings. Returns True if written."""
|
||||
|
||||
if m.enum_map is not None:
|
||||
nick = resolve_enum_nick(value, m.enum_map)
|
||||
if nick is None:
|
||||
return False
|
||||
if skip_defaults and m.default in (nick, value):
|
||||
return False
|
||||
gs.set_string(m.gs_key, nick)
|
||||
return True
|
||||
|
||||
if skip_defaults and value == m.default:
|
||||
return False
|
||||
if m.gtype == "b":
|
||||
gs.set_boolean(m.gs_key, value)
|
||||
elif m.gtype == "s":
|
||||
gs.set_string(m.gs_key, value)
|
||||
elif m.gtype == "i":
|
||||
gs.set_int(m.gs_key, int(value))
|
||||
elif m.gtype == "d":
|
||||
gs.set_double(m.gs_key, float(value))
|
||||
elif m.gtype == "as":
|
||||
gs.set_strv(m.gs_key, value)
|
||||
else:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
# TODO - JD: Delete this in v52 (remove -i/--import-dir support).
|
||||
def json_to_gsettings(
|
||||
json_dict: dict,
|
||||
gs: Gio.Settings,
|
||||
mappings: list[SettingsMapping],
|
||||
skip_defaults: bool = True,
|
||||
) -> bool:
|
||||
"""Writes JSON settings to a Gio.Settings object. Returns True if any value was written."""
|
||||
|
||||
wrote_any = False
|
||||
for m in mappings:
|
||||
if m.migration_key not in json_dict:
|
||||
continue
|
||||
if _write_one_mapping(gs, m, json_dict[m.migration_key], skip_defaults):
|
||||
wrote_any = True
|
||||
return wrote_any
|
||||
|
||||
|
||||
# TODO - JD: Delete this in v52 (remove -i/--import-dir support).
|
||||
def gsettings_to_json(gs: Gio.Settings, mappings: list[SettingsMapping]) -> dict:
|
||||
"""Reads explicitly-set GSettings values into a JSON-compatible dict."""
|
||||
|
||||
result: dict[str, Any] = {}
|
||||
for m in mappings:
|
||||
user_value = gs.get_user_value(m.gs_key)
|
||||
if user_value is None:
|
||||
continue
|
||||
if m.enum_map is not None:
|
||||
gs_str = user_value.get_string()
|
||||
if m.string_enum:
|
||||
result[m.migration_key] = gs_str
|
||||
else:
|
||||
reverse_map = {v: k for k, v in m.enum_map.items()}
|
||||
json_value = reverse_map.get(gs_str)
|
||||
if json_value is not None:
|
||||
result[m.migration_key] = json_value
|
||||
elif m.gtype == "b":
|
||||
result[m.migration_key] = user_value.get_boolean()
|
||||
elif m.gtype == "s":
|
||||
result[m.migration_key] = user_value.get_string()
|
||||
elif m.gtype == "i":
|
||||
result[m.migration_key] = user_value.get_int32()
|
||||
elif m.gtype == "d":
|
||||
result[m.migration_key] = user_value.get_double()
|
||||
elif m.gtype == "as":
|
||||
result[m.migration_key] = list(user_value.unpack())
|
||||
return result
|
||||
|
||||
|
||||
# TODO - JD: Delete this in v52 (remove -i/--import-dir support).
|
||||
def import_voice(gs: Gio.Settings, voice_data: dict, skip_defaults: bool = True) -> bool:
|
||||
"""Import ACSS voice data to a Gio.Settings object. Returns True if any value was written."""
|
||||
|
||||
migrated = False
|
||||
for acss_key, (gs_key, gs_type, default) in VOICE_MIGRATION_MAP.items():
|
||||
if acss_key not in voice_data:
|
||||
continue
|
||||
value = voice_data[acss_key]
|
||||
if skip_defaults:
|
||||
if gs_type == "b" and bool(value) == default:
|
||||
continue
|
||||
if gs_type == "i" and int(value) == default:
|
||||
continue
|
||||
if gs_type == "d" and float(value) == default:
|
||||
continue
|
||||
if gs_type == "b":
|
||||
gs.set_boolean(gs_key, bool(value))
|
||||
elif gs_type == "i":
|
||||
gs.set_int(gs_key, int(value))
|
||||
elif gs_type == "d":
|
||||
gs.set_double(gs_key, float(value))
|
||||
migrated = True
|
||||
|
||||
family = voice_data.get("family", {})
|
||||
if isinstance(family, dict):
|
||||
for json_field, gs_key in VOICE_FAMILY_FIELDS.items():
|
||||
val = family.get(json_field)
|
||||
if val is not None and str(val):
|
||||
gs.set_string(gs_key, str(val))
|
||||
migrated = True
|
||||
|
||||
return migrated
|
||||
|
||||
|
||||
# TODO - JD: Delete this in v52 (remove -i/--import-dir support).
|
||||
def export_voice(gs: Gio.Settings) -> dict:
|
||||
"""Export voice settings from a Gio.Settings to ACSS format."""
|
||||
|
||||
voice_data: dict[str, Any] = {}
|
||||
for acss_key, (gs_key, gs_type, _default) in VOICE_MIGRATION_MAP.items():
|
||||
user_value = gs.get_user_value(gs_key)
|
||||
if user_value is None:
|
||||
continue
|
||||
if gs_type == "b":
|
||||
voice_data[acss_key] = user_value.get_boolean()
|
||||
elif gs_type == "i":
|
||||
voice_data[acss_key] = user_value.get_int32()
|
||||
elif gs_type == "d":
|
||||
voice_data[acss_key] = user_value.get_double()
|
||||
|
||||
family_dict: dict[str, str] = {}
|
||||
for json_field, gs_key in VOICE_FAMILY_FIELDS.items():
|
||||
user_value = gs.get_user_value(gs_key)
|
||||
if user_value is not None:
|
||||
val = user_value.get_string()
|
||||
if val:
|
||||
family_dict[json_field] = val
|
||||
if family_dict:
|
||||
voice_data["family"] = family_dict
|
||||
|
||||
return voice_data
|
||||
|
||||
|
||||
# TODO - JD: Delete this in v52 (remove -i/--import-dir support).
|
||||
def import_synthesizer(gs: Gio.Settings, profile_prefs: dict) -> bool:
|
||||
"""Import speechServerInfo to GSettings. Returns True if written."""
|
||||
|
||||
speech_server_info = profile_prefs.get("speechServerInfo")
|
||||
if speech_server_info is None or len(speech_server_info) < 2:
|
||||
return False
|
||||
server_name = speech_server_info[0]
|
||||
synthesizer = speech_server_info[1]
|
||||
if server_name is None and synthesizer is None:
|
||||
return False
|
||||
gs.set_string("speech-server", server_name or "")
|
||||
gs.set_string("synthesizer", synthesizer or "")
|
||||
return True
|
||||
|
||||
|
||||
# TODO - JD: Delete this in v52 (remove -i/--import-dir support).
|
||||
def export_synthesizer(gs: Gio.Settings) -> tuple[str, str] | None:
|
||||
"""Export synthesizer from GSettings. Returns (display_name, module_id) or None."""
|
||||
|
||||
synth_value = gs.get_user_value("synthesizer")
|
||||
if synth_value is None:
|
||||
return None
|
||||
synth = synth_value.get_string()
|
||||
server_value = gs.get_user_value("speech-server")
|
||||
server_name = server_value.get_string() if server_value is not None else ""
|
||||
if synth:
|
||||
return (server_name, synth)
|
||||
return ("", "")
|
||||
|
||||
|
||||
# TODO - JD: Delete this in v52 (remove -i/--import-dir support).
|
||||
def import_pronunciations(gs: Gio.Settings, pronunciations_dict: dict) -> bool:
|
||||
"""Import pronunciation dictionary to GSettings. Returns True if written.
|
||||
|
||||
JSON format: {word: [word, replacement]} or {word: replacement}
|
||||
GSettings format: a{ss} {word: replacement}
|
||||
"""
|
||||
|
||||
converted: dict[str, str] = {}
|
||||
for key, value in pronunciations_dict.items():
|
||||
if isinstance(value, list) and len(value) >= 2:
|
||||
converted[key] = value[1]
|
||||
elif isinstance(value, list) and len(value) == 1:
|
||||
converted[key] = value[0]
|
||||
elif isinstance(value, str):
|
||||
converted[key] = value
|
||||
if not converted:
|
||||
return False
|
||||
|
||||
gs.set_value("entries", GLib.Variant("a{ss}", converted))
|
||||
return True
|
||||
|
||||
|
||||
# TODO - JD: Delete this in v52 (remove -i/--import-dir support).
|
||||
def export_pronunciations(gs: Gio.Settings) -> dict:
|
||||
"""Export pronunciation dictionary from GSettings to JSON format.
|
||||
|
||||
GSettings format: a{ss} {word: replacement}
|
||||
JSON format: {word: [word, replacement]}
|
||||
"""
|
||||
|
||||
user_value = gs.get_user_value("entries")
|
||||
if user_value is None:
|
||||
return {}
|
||||
entries = user_value.unpack()
|
||||
result: dict[str, list[str]] = {}
|
||||
for word, replacement in entries.items():
|
||||
result[word] = [word, replacement]
|
||||
return result
|
||||
|
||||
|
||||
# TODO - JD: Delete this in v52 (remove -i/--import-dir support).
|
||||
def import_keybindings(gs: Gio.Settings, keybindings_dict: dict) -> bool:
|
||||
"""Import keybinding overrides to GSettings. Returns True if written.
|
||||
|
||||
JSON format: {command_name: [[keysym, mask, mods, clicks], ...]}
|
||||
GSettings format: a{saas} (same structure, all strings)
|
||||
"""
|
||||
|
||||
converted: dict[str, list[list[str]]] = {}
|
||||
for key, value in keybindings_dict.items():
|
||||
if key in KEYBINDINGS_METADATA_KEYS:
|
||||
continue
|
||||
if isinstance(value, list):
|
||||
bindings: list[list[str]] = [
|
||||
[str(v) for v in binding] for binding in value if isinstance(binding, list)
|
||||
]
|
||||
converted[key] = bindings
|
||||
if not converted:
|
||||
return False
|
||||
|
||||
gs.set_value("entries", GLib.Variant("a{saas}", converted))
|
||||
return True
|
||||
|
||||
|
||||
# TODO - JD: Delete this in v52 (remove -i/--import-dir support).
|
||||
def export_keybindings(gs: Gio.Settings) -> dict:
|
||||
"""Export keybinding overrides from GSettings to JSON format."""
|
||||
|
||||
user_value = gs.get_user_value("entries")
|
||||
if user_value is None:
|
||||
return {}
|
||||
return user_value.unpack()
|
||||
@@ -0,0 +1,546 @@
|
||||
# Cthulhu
|
||||
#
|
||||
# Copyright 2026 Igalia, S.L.
|
||||
#
|
||||
# This library is free software; you can redistribute it and/or
|
||||
# modify it under the terms of the GNU Lesser General Public
|
||||
# License as published by the Free Software Foundation; either
|
||||
# version 2.1 of the License, or (at your option) any later version.
|
||||
|
||||
"""TOML-backed settings registry with the Orca 50 GSettings API shape."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, overload
|
||||
|
||||
from tomlkit import dumps, document, parse
|
||||
|
||||
from . import debug, gsettings_migrator
|
||||
|
||||
|
||||
CTHULHU_LEGACY_SCHEMA_ALIASES: dict[str, tuple[str, str]] = {
|
||||
"aiAssistantEnabled": ("ai-assistant", "enabled"),
|
||||
"aiProvider": ("ai-assistant", "provider"),
|
||||
"aiApiKeyFile": ("ai-assistant", "api-key-file"),
|
||||
"aiOllamaModel": ("ai-assistant", "ollama-model"),
|
||||
"aiOllamaEndpoint": ("ai-assistant", "ollama-endpoint"),
|
||||
"aiConfirmationRequired": ("ai-assistant", "confirmation-required"),
|
||||
"aiActionTimeout": ("ai-assistant", "action-timeout"),
|
||||
"aiScreenshotQuality": ("ai-assistant", "screenshot-quality"),
|
||||
"aiMaxContextLength": ("ai-assistant", "max-context-length"),
|
||||
"ocrLanguageCode": ("ocr", "language-code"),
|
||||
"ocrScaleFactor": ("ocr", "scale-factor"),
|
||||
"ocrGrayscaleImg": ("ocr", "grayscale-image"),
|
||||
"ocrInvertImg": ("ocr", "invert-image"),
|
||||
"ocrBlackWhiteImg": ("ocr", "black-white-image"),
|
||||
"ocrBlackWhiteImgValue": ("ocr", "black-white-threshold"),
|
||||
"ocrColorCalculation": ("ocr", "color-calculation"),
|
||||
"ocrColorCalculationMax": ("ocr", "color-calculation-max"),
|
||||
"ocrCopyToClipboard": ("ocr", "copy-to-clipboard"),
|
||||
"activePlugins": ("plugins", "active-plugins"),
|
||||
"pluginSources": ("plugins", "plugin-sources"),
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class SettingDescriptor:
|
||||
"""Describes a setting for registry metadata and runtime saving."""
|
||||
|
||||
gsettings_key: str
|
||||
schema: str
|
||||
gtype: str
|
||||
default: Any
|
||||
getter: Callable[[], Any] | None = None
|
||||
voice_type: str | None = None
|
||||
genum: str | None = None
|
||||
migration_key: str | None = None
|
||||
|
||||
|
||||
SettingsMapping = gsettings_migrator.SettingsMapping
|
||||
|
||||
_NOT_SET = object()
|
||||
|
||||
|
||||
def _is_table(value: object) -> bool:
|
||||
return hasattr(value, "items") and hasattr(value, "__setitem__")
|
||||
|
||||
|
||||
def _plain(value: Any) -> Any:
|
||||
if _is_table(value):
|
||||
return {key: _plain(item) for key, item in value.items()}
|
||||
if isinstance(value, list):
|
||||
return [_plain(item) for item in value]
|
||||
return value
|
||||
|
||||
|
||||
class GSettingsRegistry:
|
||||
"""Central registry facade backed by Cthulhu TOML settings files."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._app_name: str | None = None
|
||||
self._profile: str = "default"
|
||||
self._prefs_dir: Path | None = None
|
||||
self._descriptors: dict[tuple[str, str], SettingDescriptor] = {}
|
||||
self._mappings: dict[str, list[SettingsMapping]] = {}
|
||||
self._enums: dict[str, dict[str, int]] = {}
|
||||
self._schemas: dict[str, str] = {}
|
||||
self._runtime_values: dict[tuple[str, str, str | None], Any] = {}
|
||||
self._ignore_runtime: bool = False
|
||||
|
||||
def set_ignore_runtime(self, ignore: bool) -> None:
|
||||
"""Sets whether layered_lookup should skip runtime overrides."""
|
||||
|
||||
self._ignore_runtime = ignore
|
||||
|
||||
@staticmethod
|
||||
def sanitize_gsettings_path(name: str) -> str:
|
||||
"""Sanitize a name for schema/path compatibility."""
|
||||
|
||||
return gsettings_migrator.sanitize_gsettings_path(name)
|
||||
|
||||
def _settings_path(self, app_name: str = "") -> Path | None:
|
||||
if self._prefs_dir is None:
|
||||
return None
|
||||
if app_name:
|
||||
return self._prefs_dir / "app-settings" / f"{app_name}.toml"
|
||||
return self._prefs_dir / "user-settings.toml"
|
||||
|
||||
@staticmethod
|
||||
def _read_document(path: Path | None):
|
||||
if path is None or not path.exists():
|
||||
return document()
|
||||
try:
|
||||
return parse(path.read_text(encoding="utf-8"))
|
||||
except Exception as error:
|
||||
msg = f"GSETTINGS REGISTRY: Failed to read {path}: {error}"
|
||||
debug.print_message(debug.LEVEL_WARNING, msg, True)
|
||||
return document()
|
||||
|
||||
@staticmethod
|
||||
def _write_document(path: Path | None, prefs_doc) -> None:
|
||||
if path is None:
|
||||
return
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(dumps(prefs_doc), encoding="utf-8")
|
||||
|
||||
@staticmethod
|
||||
def _table(parent, key: str):
|
||||
value = parent.get(key)
|
||||
if not _is_table(value):
|
||||
parent[key] = {}
|
||||
value = parent[key]
|
||||
return value
|
||||
|
||||
def _ensure_profile(self, prefs_doc, profile: str, display_name: str | None = None):
|
||||
prefs_doc["format-version"] = 2
|
||||
profiles = self._table(prefs_doc, "profiles")
|
||||
profile_table = self._table(profiles, profile)
|
||||
metadata = self._table(profile_table, "metadata")
|
||||
metadata.setdefault("display-name", display_name or profile.replace("-", " ").title())
|
||||
metadata.setdefault("internal-name", profile)
|
||||
return profile_table
|
||||
|
||||
def _schema_table(self, prefs_doc, schema: str, profile: str):
|
||||
profile_table = self._ensure_profile(prefs_doc, profile)
|
||||
return self._table(profile_table, schema)
|
||||
|
||||
def _lookup_in_document(
|
||||
self,
|
||||
prefs_doc,
|
||||
schema: str,
|
||||
key: str,
|
||||
profile: str,
|
||||
voice_type: str | None = None,
|
||||
) -> tuple[bool, Any]:
|
||||
profiles = prefs_doc.get("profiles", {})
|
||||
if not _is_table(profiles):
|
||||
return False, None
|
||||
profile_table = profiles.get(profile, {})
|
||||
if not _is_table(profile_table):
|
||||
return False, None
|
||||
schema_table = profile_table.get(schema, {})
|
||||
if not _is_table(schema_table):
|
||||
return False, None
|
||||
if voice_type:
|
||||
schema_table = schema_table.get(voice_type, {})
|
||||
if not _is_table(schema_table):
|
||||
return False, None
|
||||
if key not in schema_table:
|
||||
return False, None
|
||||
return True, _plain(schema_table[key])
|
||||
|
||||
@overload
|
||||
def layered_lookup(
|
||||
self,
|
||||
schema: str,
|
||||
key: str,
|
||||
gtype: str,
|
||||
genum: str | None = None,
|
||||
voice_type: str | None = None,
|
||||
app_name: str | None = None,
|
||||
*,
|
||||
default: Any,
|
||||
) -> Any: ...
|
||||
|
||||
@overload
|
||||
def layered_lookup(
|
||||
self,
|
||||
schema: str,
|
||||
key: str,
|
||||
gtype: str,
|
||||
genum: str | None = None,
|
||||
voice_type: str | None = None,
|
||||
app_name: str | None = None,
|
||||
) -> Any | None: ...
|
||||
|
||||
def layered_lookup(
|
||||
self,
|
||||
schema: str,
|
||||
key: str,
|
||||
gtype: str,
|
||||
genum: str | None = None,
|
||||
voice_type: str | None = None,
|
||||
app_name: str | None = None,
|
||||
default: Any = _NOT_SET,
|
||||
) -> Any | None:
|
||||
"""Returns a setting value via app, profile, default-profile, or default layers."""
|
||||
|
||||
del gtype, genum
|
||||
if not self._ignore_runtime:
|
||||
found, value = self.get_runtime_value(schema, key, voice_type)
|
||||
if found:
|
||||
return value
|
||||
|
||||
profile = self.get_active_profile()
|
||||
effective_app = self.get_active_app() if app_name is None else app_name
|
||||
if effective_app:
|
||||
prefs_doc = self._read_document(self._settings_path(effective_app))
|
||||
found, value = self._lookup_in_document(prefs_doc, schema, key, profile, voice_type)
|
||||
if found:
|
||||
return value
|
||||
|
||||
prefs_doc = self._read_document(self._settings_path())
|
||||
found, value = self._lookup_in_document(prefs_doc, schema, key, profile, voice_type)
|
||||
if found:
|
||||
return value
|
||||
if profile != "default":
|
||||
found, value = self._lookup_in_document(
|
||||
prefs_doc,
|
||||
schema,
|
||||
key,
|
||||
"default",
|
||||
voice_type,
|
||||
)
|
||||
if found:
|
||||
return value
|
||||
if default is _NOT_SET:
|
||||
return None
|
||||
return default
|
||||
|
||||
def set_runtime_value(
|
||||
self,
|
||||
schema: str,
|
||||
key: str,
|
||||
value: Any,
|
||||
voice_type: str | None = None,
|
||||
) -> None:
|
||||
"""Stores a runtime value override."""
|
||||
|
||||
self._runtime_values[(schema, key, voice_type)] = value
|
||||
|
||||
def get_runtime_value(
|
||||
self,
|
||||
schema: str,
|
||||
key: str,
|
||||
voice_type: str | None = None,
|
||||
) -> tuple[bool, Any]:
|
||||
"""Returns (found, value) for a runtime override."""
|
||||
|
||||
runtime_key = (schema, key, voice_type)
|
||||
if runtime_key in self._runtime_values:
|
||||
return True, self._runtime_values[runtime_key]
|
||||
return False, None
|
||||
|
||||
def remove_runtime_value(self, schema: str, key: str, voice_type: str | None = None) -> None:
|
||||
"""Removes a single runtime value override."""
|
||||
|
||||
self._runtime_values.pop((schema, key, voice_type), None)
|
||||
|
||||
def clear_runtime_values(self) -> None:
|
||||
"""Clears all runtime value overrides."""
|
||||
|
||||
self._runtime_values.clear()
|
||||
|
||||
def get_pronunciations(self, profile: str = "", app_name: str = "") -> dict:
|
||||
"""Returns pronunciation entries for a profile/app layer."""
|
||||
|
||||
return self._get_entries("pronunciations", profile, app_name)
|
||||
|
||||
def get_keybindings(self, profile: str = "", app_name: str = "") -> dict:
|
||||
"""Returns keybinding entries for a profile/app layer."""
|
||||
|
||||
return self._get_entries("keybindings", profile, app_name)
|
||||
|
||||
def _get_entries(self, schema: str, profile: str = "", app_name: str = "") -> dict:
|
||||
if not profile:
|
||||
profile = self._profile
|
||||
prefs_doc = self._read_document(self._settings_path(app_name))
|
||||
found, value = self._lookup_in_document(prefs_doc, schema, "entries", profile)
|
||||
if found and isinstance(value, dict):
|
||||
return value
|
||||
return {}
|
||||
|
||||
def set_active_app(self, app_name: str | None) -> None:
|
||||
"""Sets the active app name for TOML lookups."""
|
||||
|
||||
self._app_name = app_name or None
|
||||
|
||||
def set_active_profile(self, profile: str) -> None:
|
||||
"""Sets the active profile for TOML lookups."""
|
||||
|
||||
self._profile = profile or "default"
|
||||
|
||||
def get_active_app(self) -> str | None:
|
||||
"""Returns the active app name for TOML lookups."""
|
||||
|
||||
return self._app_name
|
||||
|
||||
def get_active_profile(self) -> str:
|
||||
"""Returns the active profile for TOML lookups."""
|
||||
|
||||
return self._profile
|
||||
|
||||
def gsetting(
|
||||
self,
|
||||
key: str,
|
||||
schema: str,
|
||||
default: Any,
|
||||
summary: str,
|
||||
gtype: str = "",
|
||||
genum: str | None = None,
|
||||
voice_type: str | None = None,
|
||||
migration_key: str | None = None,
|
||||
) -> Callable[[Callable], Callable]:
|
||||
"""Decorator marking a method's associated settings key."""
|
||||
|
||||
del summary
|
||||
|
||||
def decorator(func: Callable) -> Callable:
|
||||
self._descriptors[(schema, key)] = SettingDescriptor(
|
||||
gsettings_key=key,
|
||||
schema=schema,
|
||||
gtype=gtype,
|
||||
default=default,
|
||||
getter=None,
|
||||
voice_type=voice_type,
|
||||
genum=genum,
|
||||
migration_key=migration_key,
|
||||
)
|
||||
func.gsetting_key = key # type: ignore[attr-defined]
|
||||
return func
|
||||
|
||||
return decorator
|
||||
|
||||
def gsettings_schema(self, schema_id: str, name: str) -> Callable[[type], type]:
|
||||
"""Class decorator declaring this class contributes to a settings schema."""
|
||||
|
||||
self._schemas[name] = schema_id
|
||||
|
||||
def decorator(cls: type) -> type:
|
||||
return cls
|
||||
|
||||
return decorator
|
||||
|
||||
def gsettings_enum(self, enum_id: str, values: dict[str, int]) -> Callable[[type], type]:
|
||||
"""Decorator marking an enum class for schema metadata."""
|
||||
|
||||
self._enums[enum_id] = values
|
||||
|
||||
def decorator(cls: type) -> type:
|
||||
return cls
|
||||
|
||||
return decorator
|
||||
|
||||
def get_enum_values(self, enum_id: str) -> dict[str, int] | None:
|
||||
"""Returns the nick-to-int mapping for a registered enum."""
|
||||
|
||||
return self._enums.get(enum_id)
|
||||
|
||||
def register_settings_mappings(self, schema_name: str, mappings: list[SettingsMapping]) -> None:
|
||||
"""Registers legacy-to-schema mappings for a schema."""
|
||||
|
||||
self._mappings[schema_name] = mappings
|
||||
|
||||
def _build_mappings_from_descriptors(self, schema_name: str) -> list[SettingsMapping]:
|
||||
mappings: list[SettingsMapping] = []
|
||||
for (schema, _key), desc in self._descriptors.items():
|
||||
if schema != schema_name or desc.migration_key is None:
|
||||
continue
|
||||
enum_map: dict[int, str] | None = None
|
||||
if desc.genum and desc.genum in self._enums:
|
||||
enum_map = {v: k for k, v in self._enums[desc.genum].items()}
|
||||
mappings.append(
|
||||
SettingsMapping(
|
||||
desc.migration_key,
|
||||
desc.gsettings_key,
|
||||
desc.gtype,
|
||||
desc.default,
|
||||
enum_map,
|
||||
),
|
||||
)
|
||||
return mappings
|
||||
|
||||
def _get_settings_mappings(self, schema_name: str) -> list[SettingsMapping]:
|
||||
if schema_name in self._mappings:
|
||||
return self._mappings[schema_name]
|
||||
return self._build_mappings_from_descriptors(schema_name)
|
||||
|
||||
def get_schema_names(self) -> list[str]:
|
||||
"""Returns the registered schema names."""
|
||||
|
||||
return list(self._schemas.keys())
|
||||
|
||||
def get_settings(
|
||||
self,
|
||||
schema_name: str,
|
||||
profile: str,
|
||||
sub_path: str = "",
|
||||
app_name: str = "",
|
||||
) -> None:
|
||||
"""Compatibility stub for callers that probe for Gio.Settings."""
|
||||
|
||||
del schema_name, profile, sub_path, app_name
|
||||
return None
|
||||
|
||||
def save_schema(
|
||||
self,
|
||||
schema_name: str,
|
||||
settings: dict,
|
||||
profile: str,
|
||||
app_name: str = "",
|
||||
skip_defaults: bool = False,
|
||||
) -> None:
|
||||
"""Writes one schema table to Cthulhu TOML settings."""
|
||||
|
||||
profile = profile or self.get_active_profile()
|
||||
prefs_doc = self._read_document(self._settings_path(app_name))
|
||||
schema_table = self._schema_table(prefs_doc, schema_name, profile)
|
||||
for key, value in settings.items():
|
||||
descriptor = self._descriptors.get((schema_name, key))
|
||||
if skip_defaults and descriptor is not None and value == descriptor.default:
|
||||
schema_table.pop(key, None)
|
||||
continue
|
||||
schema_table[key] = value
|
||||
self._write_document(self._settings_path(app_name), prefs_doc)
|
||||
|
||||
def save_schema_to_gsettings(
|
||||
self,
|
||||
schema_name: str,
|
||||
prefs_dict: dict,
|
||||
profile: str,
|
||||
app_name: str = "",
|
||||
skip_defaults: bool = False,
|
||||
) -> None:
|
||||
"""Compatibility alias that writes mapped settings to TOML."""
|
||||
|
||||
settings: dict[str, Any] = {}
|
||||
for mapping in self._get_settings_mappings(schema_name):
|
||||
if mapping.migration_key in prefs_dict:
|
||||
settings[mapping.gs_key] = prefs_dict[mapping.migration_key]
|
||||
self.save_schema(schema_name, settings, profile, app_name, skip_defaults)
|
||||
|
||||
def _migrate_legacy_modifier_keys(self, source: dict, target: dict) -> None:
|
||||
layout = source.get("keyboardLayout", 1)
|
||||
modifier_keys = source.get("cthulhuModifierKeys")
|
||||
if layout == 2:
|
||||
target["keyboard-layout"] = "laptop"
|
||||
if modifier_keys is not None:
|
||||
target["laptop-modifier-keys"] = list(modifier_keys)
|
||||
else:
|
||||
target["keyboard-layout"] = "desktop"
|
||||
if modifier_keys is not None:
|
||||
target["desktop-modifier-keys"] = list(modifier_keys)
|
||||
|
||||
def migrate_all(self, prefs_dir: str) -> bool:
|
||||
"""Migrates Cthulhu TOML settings to the Orca 50 schema/key shape."""
|
||||
|
||||
if not prefs_dir:
|
||||
return False
|
||||
|
||||
self._prefs_dir = Path(prefs_dir)
|
||||
settings_path = self._settings_path()
|
||||
prefs_doc = self._read_document(settings_path)
|
||||
if prefs_doc.get("format-version") == 2:
|
||||
return False
|
||||
|
||||
migrated_doc = document()
|
||||
migrated_doc["format-version"] = 2
|
||||
|
||||
general = _plain(prefs_doc.get("general", {}))
|
||||
if not isinstance(general, dict):
|
||||
general = {}
|
||||
|
||||
profiles = _plain(prefs_doc.get("profiles", {}))
|
||||
if not isinstance(profiles, dict) or not profiles:
|
||||
profiles = {"default": {"profile": ["Default", "default"]}}
|
||||
|
||||
for profile_name, profile_data in profiles.items():
|
||||
if not isinstance(profile_data, dict):
|
||||
profile_data = {}
|
||||
profile_tuple = profile_data.get("profile")
|
||||
if isinstance(profile_tuple, list) and len(profile_tuple) >= 2:
|
||||
display_name = str(profile_tuple[0])
|
||||
internal_name = str(profile_tuple[1])
|
||||
else:
|
||||
internal_name = str(profile_name)
|
||||
display_name = internal_name.replace("-", " ").title()
|
||||
|
||||
profile_table = self._ensure_profile(migrated_doc, internal_name, display_name)
|
||||
source = dict(general)
|
||||
for key, value in profile_data.items():
|
||||
if key not in {"profile", "keybindings", "pronunciations"}:
|
||||
source[key] = value
|
||||
|
||||
legacy_keybindings = profile_data.get("keybindings", {})
|
||||
if isinstance(legacy_keybindings, dict):
|
||||
for key in gsettings_migrator.KEYBINDINGS_METADATA_KEYS:
|
||||
if key in legacy_keybindings and key not in source:
|
||||
source[key] = legacy_keybindings[key]
|
||||
|
||||
keybindings = self._table(profile_table, "keybindings")
|
||||
self._migrate_legacy_modifier_keys(source, keybindings)
|
||||
if isinstance(legacy_keybindings, dict):
|
||||
entries = {
|
||||
key: value
|
||||
for key, value in legacy_keybindings.items()
|
||||
if key not in gsettings_migrator.KEYBINDINGS_METADATA_KEYS
|
||||
}
|
||||
if entries:
|
||||
keybindings["entries"] = entries
|
||||
|
||||
legacy_pronunciations = profile_data.get("pronunciations", {})
|
||||
if isinstance(legacy_pronunciations, dict) and legacy_pronunciations:
|
||||
pronunciations = self._table(profile_table, "pronunciations")
|
||||
pronunciations["entries"] = legacy_pronunciations
|
||||
|
||||
for legacy_key, (schema_name, schema_key) in CTHULHU_LEGACY_SCHEMA_ALIASES.items():
|
||||
if legacy_key not in source:
|
||||
continue
|
||||
schema_table = self._table(profile_table, schema_name)
|
||||
schema_table[schema_key] = source[legacy_key]
|
||||
|
||||
self._write_document(settings_path, migrated_doc)
|
||||
return True
|
||||
|
||||
|
||||
_registry: GSettingsRegistry = GSettingsRegistry()
|
||||
|
||||
|
||||
def get_registry() -> GSettingsRegistry:
|
||||
"""Returns the GSettingsRegistry singleton."""
|
||||
|
||||
return _registry
|
||||
@@ -52,6 +52,8 @@ cthulhu_python_sources = files([
|
||||
'formatting.py',
|
||||
'focus_manager.py',
|
||||
'generator.py',
|
||||
'gsettings_migrator.py',
|
||||
'gsettings_registry.py',
|
||||
'gstreamer_support.py',
|
||||
'guilabels.py',
|
||||
'highlighter.py',
|
||||
|
||||
@@ -163,6 +163,12 @@ class SettingsManager(object):
|
||||
if self.profile:
|
||||
self.setProfile(self.profile)
|
||||
|
||||
from . import gsettings_registry
|
||||
|
||||
registry = gsettings_registry.get_registry()
|
||||
registry.set_active_profile(self.profile or "default")
|
||||
registry.migrate_all(self._prefsDir or "")
|
||||
|
||||
def _loadBackend(self) -> bool:
|
||||
"""Load specific backend for manage user settings"""
|
||||
|
||||
|
||||
Reference in New Issue
Block a user