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)
CONFIG_PATH = PosixPath('/home/pepesan/.config/temario/temario.yaml')
DEFAULT_COURSES_DIR = PosixPath('/home/pepesan/cursos')
DEFAULT_LOGS_DIR = PosixPath('/home/pepesan/.local/share/temario/logs')
def get_courses_dir() -> pathlib.Path | None:
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.

def save_courses_dir(path: pathlib.Path) -> None:
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.

def get_logs_dir() -> pathlib.Path | None:
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
def save_logs_dir(path: pathlib.Path | None) -> None:
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).

def get_theme() -> str:
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).

def save_theme(name: str) -> None:
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.

def get_theme_overrides(theme_name: str | None = None) -> dict[str, str]:
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.

def save_theme_overrides(overrides: dict[str, str], theme_name: str) -> None:
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.

def save_theme_override(key: str, value: str, theme_name: str = 'dark') -> None:
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.

def clear_theme_overrides(theme_name: str | None = None) -> None:
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).

def get_last_session() -> tuple[str | None, str | None]:
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).

def save_last_session(course: str | None, yaml: str | None) -> None:
196def save_last_session(course: str | None, yaml: str | None) -> None:
197    data = _load()
198    data.setdefault('session', {})
199    if course is not None:
200        data['session']['last_course'] = course
201    if yaml is not None:
202        data['session']['last_yaml'] = yaml
203    _save(data)
def get_generation_config() -> dict:
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.

def save_generation_param(key: str, value) -> None:
232def save_generation_param(key: str, value) -> None:
233    if key not in _GENERATION_DEFAULTS:
234        raise ValueError(f'Clave de generación desconocida: {key!r}')
235    data = _load()
236    data.setdefault('generation', {})[key] = value
237    _save(data)
def get_autosave_seconds() -> int:
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.

def save_autosave_seconds(value: int) -> None:
249def save_autosave_seconds(value: int) -> None:
250    data = _load()
251    data['autosave_seconds'] = max(0, int(value))
252    _save(data)
def get_language() -> str | None:
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.

def save_language(lang: str) -> None:
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.

def get_display_settings() -> dict[str, str]:
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.

def save_display_setting(key: str, value: str) -> None:
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.