lib.slides_config
Configuración de usuario compartida entre CLI y GUI. Fichero: ~/.config/temario/temario.yaml
1# Copyright (C) 2026 David Vaquero <pepesan@gmail.com> 2# 3# This program is free software: you can redistribute it and/or modify 4# it under the terms of the GNU General Public License as published by 5# the Free Software Foundation, either version 3 of the License, or 6# (at your option) any later version. 7# 8# This program is distributed in the hope that it will be useful, 9# but WITHOUT ANY WARRANTY; without even the implied warranty of 10# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 11# GNU General Public License for more details. 12# 13# You should have received a copy of the GNU General Public License 14# along with this program. If not, see <https://www.gnu.org/licenses/>. 15 16""" 17Configuración de usuario compartida entre CLI y GUI. 18Fichero: ~/.config/temario/temario.yaml 19""" 20 21from pathlib import Path 22import yaml 23 24CONFIG_PATH = Path.home() / '.config' / 'temario' / 'temario.yaml' 25DEFAULT_COURSES_DIR = Path.home() / 'cursos' 26DEFAULT_LOGS_DIR = Path.home() / '.local' / 'share' / 'temario' / 'logs' 27 28 29def _load() -> dict: 30 if not CONFIG_PATH.exists(): 31 return {} 32 try: 33 return yaml.safe_load(CONFIG_PATH.read_text(encoding='utf-8')) or {} 34 except Exception: 35 return {} 36 37 38def _save(data: dict) -> None: 39 CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True) 40 CONFIG_PATH.write_text( 41 yaml.dump(data, allow_unicode=True, sort_keys=False), 42 encoding='utf-8', 43 ) 44 45 46def get_courses_dir() -> Path | None: 47 """Devuelve el directorio de cursos configurado, o None si no está definido.""" 48 value = _load().get('courses_dir') 49 return Path(value) if value else None 50 51 52def save_courses_dir(path: Path) -> None: 53 """Guarda el directorio de cursos en la configuración del usuario.""" 54 data = _load() 55 data['courses_dir'] = str(path) 56 _save(data) 57 58 59def get_logs_dir() -> Path | None: 60 """Devuelve el directorio de logs. 61 - No definido → DEFAULT_LOGS_DIR (~/.local/share/temario/logs/) 62 - logs_dir: "" → None (logs desactivados) 63 - logs_dir: /ruta → esa ruta 64 """ 65 value = _load().get('logs_dir', None) 66 if value is None: 67 return DEFAULT_LOGS_DIR 68 if not str(value).strip(): 69 return None 70 return Path(str(value)).expanduser() 71 72 73def save_logs_dir(path: Path | None) -> None: 74 """Guarda el directorio de logs. Path(None) lo desactiva (guarda cadena vacía).""" 75 data = _load() 76 data['logs_dir'] = str(path) if path else '' 77 _save(data) 78 79 80def get_theme() -> str: 81 """Devuelve el tema activo. 82 Si no está configurado, detecta la preferencia del escritorio (dark/light).""" 83 saved = _load().get('theme') 84 if saved in ('dark', 'light'): 85 return saved 86 return _detect_desktop_theme() 87 88 89def _detect_desktop_theme() -> str: 90 """Detecta si el escritorio usa dark o light mode. 91 Comprueba D-Bus (GNOME/KDE), variable de entorno y config de GTK.""" 92 # 1. PySide6 / Qt colorScheme (disponible a partir de Qt 6.5) 93 try: 94 from PySide6.QtGui import QGuiApplication, Qt 95 hints = QGuiApplication.styleHints() 96 if hasattr(hints, 'colorScheme'): 97 scheme = hints.colorScheme() 98 # Qt.ColorScheme.Dark = 2, Light = 1 99 if hasattr(Qt, 'ColorScheme'): 100 if scheme == Qt.ColorScheme.Dark: 101 return 'dark' 102 if scheme == Qt.ColorScheme.Light: 103 return 'light' 104 except Exception: 105 pass 106 107 # 2. D-Bus org.freedesktop.portal (GTK/GNOME/KDE/XFCE) 108 try: 109 import subprocess 110 result = subprocess.run( 111 ['gdbus', 'call', '--session', 112 '--dest', 'org.freedesktop.portal.Desktop', 113 '--object-path', '/org/freedesktop/portal/desktop', 114 '--method', 'org.freedesktop.portal.Settings.Read', 115 'org.freedesktop.appearance', 'color-scheme'], 116 capture_output=True, text=True, timeout=2, 117 ) 118 if result.returncode == 0: 119 out = result.stdout.strip() 120 # retorna <<uint32 1>> para dark, <<uint32 2>> para light, 0 para sin pref 121 if '1' in out: 122 return 'dark' 123 if '2' in out: 124 return 'light' 125 except Exception: 126 pass 127 128 # 3. GTK settings.ini 129 try: 130 from pathlib import Path 131 gtk_ini = Path.home() / '.config' / 'gtk-3.0' / 'settings.ini' 132 if gtk_ini.exists(): 133 text = gtk_ini.read_text(errors='ignore').lower() 134 if 'gtk-application-prefer-dark-theme=true' in text or \ 135 'gtk-application-prefer-dark-theme=1' in text: 136 return 'dark' 137 except Exception: 138 pass 139 140 # 4. Fallback: dark 141 return 'dark' 142 143 144def save_theme(name: str) -> None: 145 """Guarda el tema preferido.""" 146 data = _load() 147 data['theme'] = name 148 _save(data) 149 150 151def get_theme_overrides(theme_name: str | None = None) -> dict[str, str]: 152 """Devuelve las sobreescrituras de color. 153 Si theme_name es None devuelve todo; si es 'dark'/'light' devuelve solo las de ese tema.""" 154 raw = _load().get('theme_overrides') or {} 155 if theme_name is None: 156 return dict(raw) 157 return dict(raw.get(theme_name) or {}) 158 159 160def save_theme_overrides(overrides: dict[str, str], theme_name: str) -> None: 161 """Guarda el dict completo de sobreescrituras para un tema.""" 162 data = _load() 163 data.setdefault('theme_overrides', {})[theme_name] = dict(overrides) 164 _save(data) 165 166 167def save_theme_override(key: str, value: str, theme_name: str = 'dark') -> None: 168 """Guarda una sobreescritura de color individual para un tema.""" 169 data = _load() 170 data.setdefault('theme_overrides', {}).setdefault(theme_name, {})[key] = value 171 _save(data) 172 173 174def clear_theme_overrides(theme_name: str | None = None) -> None: 175 """Elimina todas las sobreescrituras (o solo las de un tema).""" 176 data = _load() 177 if theme_name: 178 (data.get('theme_overrides') or {}).pop(theme_name, None) 179 else: 180 data.pop('theme_overrides', None) 181 _save(data) 182 183 184_AUTOSAVE_DEFAULT = 60 185 186 187# ── Sesión (último curso y presentación activos) ────────────────────────────── 188 189def get_last_session() -> tuple[str | None, str | None]: 190 """Devuelve (last_course, last_yaml) guardados, o (None, None).""" 191 session = _load().get('session') or {} 192 return session.get('last_course'), session.get('last_yaml') 193 194 195def save_last_session(course: str | None, yaml: str | None) -> None: 196 data = _load() 197 data.setdefault('session', {}) 198 if course is not None: 199 data['session']['last_course'] = course 200 if yaml is not None: 201 data['session']['last_yaml'] = yaml 202 _save(data) 203 204 205# ── Configuración de generación de slides ───────────────────────────────────── 206 207_GENERATION_DEFAULTS: dict = { 208 'code_split_lines': 20, 209 'list_split_items': 14, 210 'compare_split_lines': 20, 211 'code_font_sizes': [[12, 16], [16, 14], [21, 12], [27, 10], [9999, 9]], 212} 213 214 215def get_generation_config() -> dict: 216 """Devuelve la configuración de generación con los valores por defecto si no están definidos.""" 217 raw = _load().get('generation') or {} 218 result = dict(_GENERATION_DEFAULTS) 219 for key, default in _GENERATION_DEFAULTS.items(): 220 if key in raw: 221 try: 222 if key == 'code_font_sizes': 223 result[key] = [[int(a), int(b)] for a, b in raw[key]] 224 else: 225 result[key] = max(1, int(raw[key])) 226 except (TypeError, ValueError): 227 pass 228 return result 229 230 231def save_generation_param(key: str, value) -> None: 232 if key not in _GENERATION_DEFAULTS: 233 raise ValueError(f'Clave de generación desconocida: {key!r}') 234 data = _load() 235 data.setdefault('generation', {})[key] = value 236 _save(data) 237 238 239def get_autosave_seconds() -> int: 240 """Devuelve el intervalo de autoguardado en segundos. 0 = desactivado.""" 241 value = _load().get('autosave_seconds', _AUTOSAVE_DEFAULT) 242 try: 243 return max(0, int(value)) 244 except (TypeError, ValueError): 245 return _AUTOSAVE_DEFAULT 246 247 248def save_autosave_seconds(value: int) -> None: 249 data = _load() 250 data['autosave_seconds'] = max(0, int(value)) 251 _save(data) 252 253 254def get_language() -> str | None: 255 """Devuelve el idioma configurado ('es', 'en', …), o None si no está definido.""" 256 return _load().get('language') or None 257 258 259def save_language(lang: str) -> None: 260 """Guarda el idioma preferido en la configuración del usuario.""" 261 data = _load() 262 data['language'] = lang 263 _save(data) 264 265 266# ── Configuración de pantalla (Qt scaling) ──────────────────────────────────── 267 268_DISPLAY_DEFAULTS: dict[str, str] = { 269 'scale_factor': '1.0', 270 'highdpi_scaling': '1', 271 'auto_screen_scale': '1', 272 'scale_rounding_policy': 'PassThrough', 273} 274 275 276def get_display_settings() -> dict[str, str]: 277 """Devuelve los ajustes de pantalla con sus valores por defecto si no están configurados.""" 278 display = _load().get('display') or {} 279 return {k: str(display.get(k, v)) for k, v in _DISPLAY_DEFAULTS.items()} 280 281 282def save_display_setting(key: str, value: str) -> None: 283 """Guarda un ajuste de pantalla individual.""" 284 if key not in _DISPLAY_DEFAULTS: 285 raise ValueError(f'Clave de pantalla desconocida: {key!r}') 286 data = _load() 287 data.setdefault('display', {})[key] = value 288 _save(data)
47def get_courses_dir() -> Path | None: 48 """Devuelve el directorio de cursos configurado, o None si no está definido.""" 49 value = _load().get('courses_dir') 50 return Path(value) if value else None
Devuelve el directorio de cursos configurado, o None si no está definido.
53def save_courses_dir(path: Path) -> None: 54 """Guarda el directorio de cursos en la configuración del usuario.""" 55 data = _load() 56 data['courses_dir'] = str(path) 57 _save(data)
Guarda el directorio de cursos en la configuración del usuario.
60def get_logs_dir() -> Path | None: 61 """Devuelve el directorio de logs. 62 - No definido → DEFAULT_LOGS_DIR (~/.local/share/temario/logs/) 63 - logs_dir: "" → None (logs desactivados) 64 - logs_dir: /ruta → esa ruta 65 """ 66 value = _load().get('logs_dir', None) 67 if value is None: 68 return DEFAULT_LOGS_DIR 69 if not str(value).strip(): 70 return None 71 return Path(str(value)).expanduser()
Devuelve el directorio de logs.
- No definido → DEFAULT_LOGS_DIR (~/.local/share/temario/logs/)
- logs_dir: "" → None (logs desactivados)
- logs_dir: /ruta → esa ruta
74def save_logs_dir(path: Path | None) -> None: 75 """Guarda el directorio de logs. Path(None) lo desactiva (guarda cadena vacía).""" 76 data = _load() 77 data['logs_dir'] = str(path) if path else '' 78 _save(data)
Guarda el directorio de logs. Path(None) lo desactiva (guarda cadena vacía).
81def get_theme() -> str: 82 """Devuelve el tema activo. 83 Si no está configurado, detecta la preferencia del escritorio (dark/light).""" 84 saved = _load().get('theme') 85 if saved in ('dark', 'light'): 86 return saved 87 return _detect_desktop_theme()
Devuelve el tema activo. Si no está configurado, detecta la preferencia del escritorio (dark/light).
145def save_theme(name: str) -> None: 146 """Guarda el tema preferido.""" 147 data = _load() 148 data['theme'] = name 149 _save(data)
Guarda el tema preferido.
152def get_theme_overrides(theme_name: str | None = None) -> dict[str, str]: 153 """Devuelve las sobreescrituras de color. 154 Si theme_name es None devuelve todo; si es 'dark'/'light' devuelve solo las de ese tema.""" 155 raw = _load().get('theme_overrides') or {} 156 if theme_name is None: 157 return dict(raw) 158 return dict(raw.get(theme_name) or {})
Devuelve las sobreescrituras de color. Si theme_name es None devuelve todo; si es 'dark'/'light' devuelve solo las de ese tema.
161def save_theme_overrides(overrides: dict[str, str], theme_name: str) -> None: 162 """Guarda el dict completo de sobreescrituras para un tema.""" 163 data = _load() 164 data.setdefault('theme_overrides', {})[theme_name] = dict(overrides) 165 _save(data)
Guarda el dict completo de sobreescrituras para un tema.
168def save_theme_override(key: str, value: str, theme_name: str = 'dark') -> None: 169 """Guarda una sobreescritura de color individual para un tema.""" 170 data = _load() 171 data.setdefault('theme_overrides', {}).setdefault(theme_name, {})[key] = value 172 _save(data)
Guarda una sobreescritura de color individual para un tema.
175def clear_theme_overrides(theme_name: str | None = None) -> None: 176 """Elimina todas las sobreescrituras (o solo las de un tema).""" 177 data = _load() 178 if theme_name: 179 (data.get('theme_overrides') or {}).pop(theme_name, None) 180 else: 181 data.pop('theme_overrides', None) 182 _save(data)
Elimina todas las sobreescrituras (o solo las de un tema).
190def get_last_session() -> tuple[str | None, str | None]: 191 """Devuelve (last_course, last_yaml) guardados, o (None, None).""" 192 session = _load().get('session') or {} 193 return session.get('last_course'), session.get('last_yaml')
Devuelve (last_course, last_yaml) guardados, o (None, None).
216def get_generation_config() -> dict: 217 """Devuelve la configuración de generación con los valores por defecto si no están definidos.""" 218 raw = _load().get('generation') or {} 219 result = dict(_GENERATION_DEFAULTS) 220 for key, default in _GENERATION_DEFAULTS.items(): 221 if key in raw: 222 try: 223 if key == 'code_font_sizes': 224 result[key] = [[int(a), int(b)] for a, b in raw[key]] 225 else: 226 result[key] = max(1, int(raw[key])) 227 except (TypeError, ValueError): 228 pass 229 return result
Devuelve la configuración de generación con los valores por defecto si no están definidos.
240def get_autosave_seconds() -> int: 241 """Devuelve el intervalo de autoguardado en segundos. 0 = desactivado.""" 242 value = _load().get('autosave_seconds', _AUTOSAVE_DEFAULT) 243 try: 244 return max(0, int(value)) 245 except (TypeError, ValueError): 246 return _AUTOSAVE_DEFAULT
Devuelve el intervalo de autoguardado en segundos. 0 = desactivado.
255def get_language() -> str | None: 256 """Devuelve el idioma configurado ('es', 'en', …), o None si no está definido.""" 257 return _load().get('language') or None
Devuelve el idioma configurado ('es', 'en', …), o None si no está definido.
260def save_language(lang: str) -> None: 261 """Guarda el idioma preferido en la configuración del usuario.""" 262 data = _load() 263 data['language'] = lang 264 _save(data)
Guarda el idioma preferido en la configuración del usuario.
277def get_display_settings() -> dict[str, str]: 278 """Devuelve los ajustes de pantalla con sus valores por defecto si no están configurados.""" 279 display = _load().get('display') or {} 280 return {k: str(display.get(k, v)) for k, v in _DISPLAY_DEFAULTS.items()}
Devuelve los ajustes de pantalla con sus valores por defecto si no están configurados.
283def save_display_setting(key: str, value: str) -> None: 284 """Guarda un ajuste de pantalla individual.""" 285 if key not in _DISPLAY_DEFAULTS: 286 raise ValueError(f'Clave de pantalla desconocida: {key!r}') 287 data = _load() 288 data.setdefault('display', {})[key] = value 289 _save(data)
Guarda un ajuste de pantalla individual.