Add TOML-backed Orca 50 settings registry

This commit is contained in:
2026-04-11 02:04:03 -04:00
parent c10735e093
commit 56d7d2616a
4 changed files with 1016 additions and 0 deletions
+462
View File
@@ -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()
+546
View File
@@ -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
+2
View File
@@ -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',
+6
View File
@@ -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"""