pylearn-reader 1.0.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. pylearn_reader-1.0.0/LICENSE +21 -0
  2. pylearn_reader-1.0.0/PKG-INFO +17 -0
  3. pylearn_reader-1.0.0/README.md +210 -0
  4. pylearn_reader-1.0.0/pyproject.toml +65 -0
  5. pylearn_reader-1.0.0/setup.cfg +4 -0
  6. pylearn_reader-1.0.0/src/pylearn/__init__.py +2 -0
  7. pylearn_reader-1.0.0/src/pylearn/__main__.py +6 -0
  8. pylearn_reader-1.0.0/src/pylearn/app.py +37 -0
  9. pylearn_reader-1.0.0/src/pylearn/core/__init__.py +2 -0
  10. pylearn_reader-1.0.0/src/pylearn/core/config.py +306 -0
  11. pylearn_reader-1.0.0/src/pylearn/core/constants.py +75 -0
  12. pylearn_reader-1.0.0/src/pylearn/core/database.py +499 -0
  13. pylearn_reader-1.0.0/src/pylearn/core/models.py +285 -0
  14. pylearn_reader-1.0.0/src/pylearn/executor/__init__.py +2 -0
  15. pylearn_reader-1.0.0/src/pylearn/executor/output_handler.py +77 -0
  16. pylearn_reader-1.0.0/src/pylearn/executor/sandbox.py +329 -0
  17. pylearn_reader-1.0.0/src/pylearn/executor/session.py +265 -0
  18. pylearn_reader-1.0.0/src/pylearn/main.py +167 -0
  19. pylearn_reader-1.0.0/src/pylearn/parser/__init__.py +2 -0
  20. pylearn_reader-1.0.0/src/pylearn/parser/book_profiles.py +176 -0
  21. pylearn_reader-1.0.0/src/pylearn/parser/cache_manager.py +103 -0
  22. pylearn_reader-1.0.0/src/pylearn/parser/code_extractor.py +92 -0
  23. pylearn_reader-1.0.0/src/pylearn/parser/content_classifier.py +184 -0
  24. pylearn_reader-1.0.0/src/pylearn/parser/exercise_extractor.py +212 -0
  25. pylearn_reader-1.0.0/src/pylearn/parser/font_analyzer.py +320 -0
  26. pylearn_reader-1.0.0/src/pylearn/parser/pdf_parser.py +263 -0
  27. pylearn_reader-1.0.0/src/pylearn/parser/structure_detector.py +210 -0
  28. pylearn_reader-1.0.0/src/pylearn/renderer/__init__.py +2 -0
  29. pylearn_reader-1.0.0/src/pylearn/renderer/code_highlighter.py +45 -0
  30. pylearn_reader-1.0.0/src/pylearn/renderer/html_renderer.py +218 -0
  31. pylearn_reader-1.0.0/src/pylearn/renderer/theme.py +73 -0
  32. pylearn_reader-1.0.0/src/pylearn/ui/__init__.py +2 -0
  33. pylearn_reader-1.0.0/src/pylearn/ui/book_controller.py +229 -0
  34. pylearn_reader-1.0.0/src/pylearn/ui/bookmark_dialog.py +108 -0
  35. pylearn_reader-1.0.0/src/pylearn/ui/console_panel.py +58 -0
  36. pylearn_reader-1.0.0/src/pylearn/ui/editor_panel.py +211 -0
  37. pylearn_reader-1.0.0/src/pylearn/ui/exercise_panel.py +144 -0
  38. pylearn_reader-1.0.0/src/pylearn/ui/external_editor.py +143 -0
  39. pylearn_reader-1.0.0/src/pylearn/ui/library_panel.py +127 -0
  40. pylearn_reader-1.0.0/src/pylearn/ui/main_window.py +886 -0
  41. pylearn_reader-1.0.0/src/pylearn/ui/notes_dialog.py +142 -0
  42. pylearn_reader-1.0.0/src/pylearn/ui/progress_dialog.py +82 -0
  43. pylearn_reader-1.0.0/src/pylearn/ui/reader_panel.py +335 -0
  44. pylearn_reader-1.0.0/src/pylearn/ui/search_dialog.py +173 -0
  45. pylearn_reader-1.0.0/src/pylearn/ui/styles.py +157 -0
  46. pylearn_reader-1.0.0/src/pylearn/ui/theme_registry.py +116 -0
  47. pylearn_reader-1.0.0/src/pylearn/ui/toc_panel.py +164 -0
  48. pylearn_reader-1.0.0/src/pylearn/ui/toolbar.py +103 -0
  49. pylearn_reader-1.0.0/src/pylearn/utils/__init__.py +2 -0
  50. pylearn_reader-1.0.0/src/pylearn/utils/error_handler.py +140 -0
  51. pylearn_reader-1.0.0/src/pylearn/utils/text_utils.py +122 -0
  52. pylearn_reader-1.0.0/src/pylearn_reader.egg-info/PKG-INFO +17 -0
  53. pylearn_reader-1.0.0/src/pylearn_reader.egg-info/SOURCES.txt +55 -0
  54. pylearn_reader-1.0.0/src/pylearn_reader.egg-info/dependency_links.txt +1 -0
  55. pylearn_reader-1.0.0/src/pylearn_reader.egg-info/entry_points.txt +2 -0
  56. pylearn_reader-1.0.0/src/pylearn_reader.egg-info/requires.txt +9 -0
  57. pylearn_reader-1.0.0/src/pylearn_reader.egg-info/top_level.txt +2 -0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Nathan Tritle
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,17 @@
1
+ Metadata-Version: 2.4
2
+ Name: pylearn-reader
3
+ Version: 1.0.0
4
+ Summary: Interactive Python learning desktop app with PDF book reader and code editor
5
+ Author: Nate Tritle
6
+ License-Expression: MIT
7
+ Requires-Python: >=3.12
8
+ License-File: LICENSE
9
+ Requires-Dist: PyQt6>=6.6.0
10
+ Requires-Dist: PyQt6-QScintilla>=2.14.0
11
+ Requires-Dist: PyMuPDF>=1.23.0
12
+ Requires-Dist: Pygments>=2.17.0
13
+ Provides-Extra: dev
14
+ Requires-Dist: pytest>=7.4.0; extra == "dev"
15
+ Requires-Dist: pytest-qt>=4.2.0; extra == "dev"
16
+ Requires-Dist: mypy>=1.8.0; extra == "dev"
17
+ Dynamic: license-file
@@ -0,0 +1,210 @@
1
+ # PyLearn
2
+
3
+ [![CI](https://github.com/fritz99-lang/pylearn/actions/workflows/ci.yml/badge.svg?branch=master)](https://github.com/fritz99-lang/pylearn/actions/workflows/ci.yml)
4
+ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
5
+ [![Python 3.12+](https://img.shields.io/badge/Python-3.12%2B-green.svg)](https://www.python.org/)
6
+ [![mypy](https://img.shields.io/badge/type--checked-mypy-blue.svg)](https://mypy-lang.org/)
7
+
8
+ An interactive desktop app for learning programming from PDF books. Split-pane interface with a book reader on the left and a code editor + console on the right — read the book, write code, and run it all in one place.
9
+
10
+ ## Screenshots
11
+
12
+ **Light mode** — reading with Book menu open:
13
+
14
+ ![Light mode](docs/screenshots/light-mode.png)
15
+
16
+ **Dark mode** — reading with code execution:
17
+
18
+ ![Dark mode](docs/screenshots/dark-mode.png)
19
+
20
+ **Sepia mode** — reading view:
21
+
22
+ ![Sepia mode](docs/screenshots/sepia-mode.png)
23
+
24
+ **Sepia mode** — running code (Zen of Python):
25
+
26
+ ![Code execution](docs/screenshots/sepia-code-execution.png)
27
+
28
+ ## Features
29
+
30
+ - **PDF Book Reader** — Parses PDF books into structured, styled HTML with headings, body text, and syntax-highlighted code blocks
31
+ - **Code Editor** — QScintilla-powered editor with syntax highlighting, line numbers, auto-indent, and configurable font/tab settings
32
+ - **Code Execution** — Run Python code directly from the editor with output displayed in an integrated console (30s timeout, sandboxed subprocess)
33
+ - **Table of Contents** — Auto-generated chapter navigation from PDF structure
34
+ - **Progress Tracking** — SQLite database tracks chapter completion status, bookmarks, and notes per book
35
+ - **Bookmarks & Notes** — Save bookmarks and attach notes to any page
36
+ - **Multiple Book Profiles** — Supports Python, C++, and HTML/CSS books with per-book font classification profiles
37
+ - **Themes** — Light, dark, and sepia themes for the reader panel
38
+ - **External Editor** — Launch code in Notepad++ or your preferred external editor
39
+ - **Parsed Content Caching** — PDF parsing results cached as JSON for fast subsequent loads
40
+
41
+ ## Requirements
42
+
43
+ - Python 3.12+
44
+ - Windows, macOS, or Linux
45
+
46
+ ## Installation
47
+
48
+ ```bash
49
+ # Clone the repository
50
+ git clone https://github.com/fritz99-lang/pylearn.git
51
+ cd pylearn
52
+
53
+ # Create a virtual environment
54
+ python -m venv .venv
55
+ source .venv/bin/activate # Linux/macOS
56
+ .venv\Scripts\activate # Windows
57
+
58
+ # Install the app
59
+ pip install -e .
60
+
61
+ # Or with dev tools (pytest, mypy)
62
+ pip install -e ".[dev]"
63
+ ```
64
+
65
+ You can also install directly from GitHub without cloning:
66
+
67
+ ```bash
68
+ pip install git+https://github.com/fritz99-lang/pylearn.git
69
+ ```
70
+
71
+ ### Platform Notes
72
+
73
+ | Platform | Notes |
74
+ |----------|-------|
75
+ | **Windows** | Works out of the box with Python 3.12+ |
76
+ | **Linux** | Install system deps first: `sudo apt-get install libegl1 libxkbcommon0` |
77
+ | **macOS** | May need Xcode command line tools: `xcode-select --install` |
78
+
79
+ ## Setup
80
+
81
+ 1. **Copy the example config files:**
82
+
83
+ ```bash
84
+ cp config/app_config.json.example config/app_config.json
85
+ cp config/books.json.example config/books.json
86
+ cp config/editor_config.json.example config/editor_config.json
87
+ ```
88
+
89
+ 2. **Register your books** by editing `config/books.json`:
90
+
91
+ ```json
92
+ {
93
+ "books": [
94
+ {
95
+ "book_id": "learning_python",
96
+ "title": "Learning Python",
97
+ "pdf_path": "/path/to/your/book.pdf",
98
+ "profile_name": "learning_python"
99
+ }
100
+ ]
101
+ }
102
+ ```
103
+
104
+ Available `profile_name` values: `learning_python`, `cpp_generic`, or leave empty for auto-detection.
105
+
106
+ 3. **Launch the app:**
107
+
108
+ ```bash
109
+ python -m pylearn
110
+ ```
111
+
112
+ ## Usage
113
+
114
+ | Area | What it does |
115
+ |------|-------------|
116
+ | **Left panel** | Book reader — navigate chapters via the table of contents sidebar |
117
+ | **Right panel (top)** | Code editor — write or paste code from the book |
118
+ | **Right panel (bottom)** | Console — see output from running your code |
119
+ | **Toolbar** | Theme switching, bookmarks, notes, progress tracking |
120
+
121
+ ## Keyboard Shortcuts
122
+
123
+ | Category | Shortcut | Action |
124
+ |----------|----------|--------|
125
+ | **Navigation** | `Alt+Left` / `Alt+Right` | Previous / next chapter |
126
+ | | `Ctrl+M` | Mark chapter complete |
127
+ | | `Ctrl+T` | Toggle TOC panel |
128
+ | **Search** | `Ctrl+F` | Find in current chapter |
129
+ | | `Ctrl+Shift+F` | Search all books |
130
+ | **Code** | `F5` | Run code |
131
+ | | `Shift+F5` | Stop execution |
132
+ | | `Ctrl+S` | Save code to file |
133
+ | | `Ctrl+O` | Load code from file |
134
+ | | `Ctrl+E` | Open in external editor |
135
+ | **View** | `Ctrl+=` / `Ctrl+-` | Increase / decrease font size |
136
+ | | `Ctrl+1` / `2` / `3` | Focus TOC / reader / editor |
137
+ | **Notes** | `Ctrl+B` | Add bookmark |
138
+ | | `Ctrl+N` | Add note |
139
+ | **Help** | `Ctrl+/` | Show shortcuts dialog |
140
+
141
+ ## Configuration
142
+
143
+ All config files live in `config/` and are JSON:
144
+
145
+ - **`app_config.json`** — Window size, theme, splitter positions, last opened book
146
+ - **`books.json`** — Registered books with PDF paths and profile names
147
+ - **`editor_config.json`** — Editor font size, tab width, line numbers, execution timeout
148
+
149
+ ## Development
150
+
151
+ ```bash
152
+ # Run all tests (702 tests)
153
+ pytest tests/ -v
154
+
155
+ # Skip slow tests
156
+ pytest tests/ -v -m "not slow"
157
+
158
+ # Type checking
159
+ mypy src/pylearn/
160
+
161
+ # Pre-parse books to cache
162
+ python scripts/parse_books.py
163
+
164
+ # Analyze PDF font metadata (useful for creating new book profiles)
165
+ python scripts/analyze_pdf_fonts.py path/to/book.pdf
166
+ ```
167
+
168
+ ## Project Structure
169
+
170
+ ```
171
+ src/pylearn/
172
+ parser/ PDF parsing, font analysis, content classification, caching
173
+ renderer/ HTML rendering, syntax highlighting, themes
174
+ executor/ Subprocess-based code execution with sandboxing
175
+ ui/ PyQt6 widgets (main window, reader, editor, console, dialogs)
176
+ core/ Config, database, models, constants
177
+ utils/ Text utilities, error handling
178
+ config/ User-specific JSON config (not committed; see *.json.example)
179
+ data/ SQLite database + parsed PDF cache (not committed)
180
+ tests/ 702 tests (500+ unit + 150+ integration)
181
+ scripts/ Utility scripts for PDF analysis and book parsing
182
+ ```
183
+
184
+ ## Troubleshooting
185
+
186
+ **App won't start**
187
+ - Make sure PyQt6 is installed: `pip install PyQt6 PyQt6-QScintilla`
188
+ - Verify Python 3.12+: `python --version`
189
+ - On Linux, install system deps: `sudo apt-get install libegl1 libxkbcommon0`
190
+
191
+ **PDF not found**
192
+ - Check the `pdf_path` in `config/books.json` — use absolute paths
193
+ - Verify the file exists at the specified path
194
+
195
+ **Book not parsing correctly**
196
+ - Delete the cached JSON in `data/` and re-launch to force a re-parse
197
+ - Check if a matching `profile_name` exists in `book_profiles.py`
198
+ - Run `python scripts/analyze_pdf_fonts.py path/to/book.pdf` to inspect font metadata
199
+
200
+ **Code execution timeout**
201
+ - Default timeout is 30 seconds
202
+ - Increase it in `config/editor_config.json` by setting `"execution_timeout"` to a higher value
203
+
204
+ ## Acknowledgments
205
+
206
+ Built in partnership with [Claude Code](https://claude.ai/claude-code) (Anthropic) — architecture, implementation, testing, and code review.
207
+
208
+ ## License
209
+
210
+ [MIT](LICENSE) - Copyright (c) 2026 Nathan Tritle
@@ -0,0 +1,65 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68.0", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "pylearn-reader"
7
+ version = "1.0.0"
8
+ description = "Interactive Python learning desktop app with PDF book reader and code editor"
9
+ license = "MIT"
10
+ authors = [{name = "Nate Tritle"}]
11
+ requires-python = ">=3.12"
12
+ dependencies = [
13
+ "PyQt6>=6.6.0",
14
+ "PyQt6-QScintilla>=2.14.0",
15
+ "PyMuPDF>=1.23.0",
16
+ "Pygments>=2.17.0",
17
+ ]
18
+
19
+ [project.optional-dependencies]
20
+ dev = [
21
+ "pytest>=7.4.0",
22
+ "pytest-qt>=4.2.0",
23
+ "mypy>=1.8.0",
24
+ ]
25
+
26
+ [project.scripts]
27
+ pylearn = "pylearn.main:main"
28
+
29
+ [tool.setuptools.packages.find]
30
+ where = ["src"]
31
+
32
+ [tool.pytest.ini_options]
33
+ testpaths = ["tests"]
34
+ pythonpath = ["src"]
35
+ markers = ["slow: marks tests as slow (deselect with '-m \"not slow\"')"]
36
+
37
+ [tool.mypy]
38
+ python_version = "3.12"
39
+ warn_return_any = true
40
+ warn_unused_configs = true
41
+ disallow_untyped_defs = true
42
+ disallow_incomplete_defs = true
43
+ check_untyped_defs = true
44
+ no_implicit_optional = true
45
+ warn_redundant_casts = true
46
+ warn_unused_ignores = true
47
+ strict_equality = true
48
+
49
+ [[tool.mypy.overrides]]
50
+ module = "pylearn.ui.*"
51
+ disallow_untyped_defs = false
52
+ disallow_incomplete_defs = false
53
+ warn_return_any = false
54
+ disable_error_code = ["union-attr", "arg-type", "override", "name-defined", "assignment", "valid-type", "misc", "operator"]
55
+
56
+ [[tool.mypy.overrides]]
57
+ module = [
58
+ "fitz",
59
+ "fitz.*",
60
+ "PyQt6.Qsci",
61
+ "PyQt6.Qsci.*",
62
+ "pygments",
63
+ "pygments.*",
64
+ ]
65
+ ignore_missing_imports = true
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,2 @@
1
+ # Copyright (c) 2026 Nate Tritle. Licensed under the MIT License.
2
+ """PyLearn - Interactive Python Learning Desktop App."""
@@ -0,0 +1,6 @@
1
+ # Copyright (c) 2026 Nate Tritle. Licensed under the MIT License.
2
+ """Allow running as: python -m pylearn"""
3
+
4
+ from pylearn.main import main
5
+
6
+ main()
@@ -0,0 +1,37 @@
1
+ # Copyright (c) 2026 Nate Tritle. Licensed under the MIT License.
2
+ """QApplication setup and configuration."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import sys
7
+
8
+ from PyQt6.QtWidgets import QApplication
9
+ from PyQt6.QtGui import QFont
10
+
11
+ from pylearn.core.constants import APP_NAME
12
+ from pylearn.ui.main_window import MainWindow
13
+ from pylearn.utils.error_handler import setup_logging, install_global_exception_handler
14
+
15
+
16
+ def create_app(debug: bool = False) -> tuple[QApplication, MainWindow]:
17
+ """Create and configure the application."""
18
+ setup_logging(debug=debug)
19
+ install_global_exception_handler()
20
+
21
+ app = QApplication(sys.argv)
22
+ app.setApplicationName(APP_NAME)
23
+ app.setStyle("Fusion")
24
+
25
+ # Default font
26
+ font = QFont("Segoe UI", 10)
27
+ app.setFont(font)
28
+
29
+ window = MainWindow()
30
+ return app, window
31
+
32
+
33
+ def run_app(debug: bool = False) -> int:
34
+ """Create and run the application."""
35
+ app, window = create_app(debug=debug)
36
+ window.show()
37
+ return app.exec()
@@ -0,0 +1,2 @@
1
+ # Copyright (c) 2026 Nate Tritle. Licensed under the MIT License.
2
+ """PyLearn core business logic."""
@@ -0,0 +1,306 @@
1
+ # Copyright (c) 2026 Nate Tritle. Licensed under the MIT License.
2
+ """JSON configuration loading and saving."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import json
7
+ import logging
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ from pylearn.core.constants import (
12
+ APP_CONFIG_PATH,
13
+ BOOKS_CONFIG_PATH,
14
+ EDITOR_CONFIG_PATH,
15
+ DEFAULT_WINDOW_WIDTH,
16
+ DEFAULT_WINDOW_HEIGHT,
17
+ DEFAULT_FONT_SIZE,
18
+ DEFAULT_EDITOR_FONT_SIZE,
19
+ DEFAULT_TAB_WIDTH,
20
+ DEFAULT_EXECUTION_TIMEOUT,
21
+ DEFAULT_THEME,
22
+ READER_SPLITTER_RATIO,
23
+ EDITOR_CONSOLE_RATIO,
24
+ TOC_WIDTH,
25
+ )
26
+
27
+
28
+ logger = logging.getLogger("pylearn.config")
29
+
30
+
31
+ def _safe_int(value: Any, default: int) -> int:
32
+ """Convert value to int, returning default on failure."""
33
+ try:
34
+ return int(value)
35
+ except (ValueError, TypeError):
36
+ return default
37
+
38
+
39
+ def _load_json(path: Path) -> dict[str, Any]:
40
+ if path.exists():
41
+ try:
42
+ data = json.loads(path.read_text(encoding="utf-8"))
43
+ if isinstance(data, dict):
44
+ return data
45
+ except (json.JSONDecodeError, UnicodeDecodeError, OSError) as e:
46
+ logger.error(f"Corrupt config file {path}: {e} — using defaults")
47
+ return {}
48
+
49
+
50
+ def _save_json(path: Path, data: dict) -> None:
51
+ path.parent.mkdir(parents=True, exist_ok=True)
52
+ tmp = path.with_suffix(".tmp")
53
+ try:
54
+ tmp.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8")
55
+ tmp.replace(path)
56
+ except OSError:
57
+ try:
58
+ path.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8")
59
+ except OSError:
60
+ pass
61
+ finally:
62
+ tmp.unlink(missing_ok=True)
63
+
64
+
65
+ class AppConfig:
66
+ """Application-level configuration."""
67
+
68
+ def __init__(self) -> None:
69
+ self._data: dict[str, Any] = {}
70
+ self.load()
71
+
72
+ def load(self) -> None:
73
+ self._data = _load_json(APP_CONFIG_PATH)
74
+
75
+ def save(self) -> None:
76
+ _save_json(APP_CONFIG_PATH, self._data)
77
+
78
+ @property
79
+ def window_width(self) -> int:
80
+ return _safe_int(self._data.get("window_width", DEFAULT_WINDOW_WIDTH), DEFAULT_WINDOW_WIDTH)
81
+
82
+ @window_width.setter
83
+ def window_width(self, value: int) -> None:
84
+ self._data["window_width"] = value
85
+
86
+ @property
87
+ def window_height(self) -> int:
88
+ return _safe_int(self._data.get("window_height", DEFAULT_WINDOW_HEIGHT), DEFAULT_WINDOW_HEIGHT)
89
+
90
+ @window_height.setter
91
+ def window_height(self, value: int) -> None:
92
+ self._data["window_height"] = value
93
+
94
+ @property
95
+ def window_x(self) -> int | None:
96
+ return self._data.get("window_x")
97
+
98
+ @window_x.setter
99
+ def window_x(self, value: int) -> None:
100
+ self._data["window_x"] = value
101
+
102
+ @property
103
+ def window_y(self) -> int | None:
104
+ return self._data.get("window_y")
105
+
106
+ @window_y.setter
107
+ def window_y(self, value: int) -> None:
108
+ self._data["window_y"] = value
109
+
110
+ @property
111
+ def window_maximized(self) -> bool:
112
+ return bool(self._data.get("window_maximized", False))
113
+
114
+ @window_maximized.setter
115
+ def window_maximized(self, value: bool) -> None:
116
+ self._data["window_maximized"] = value
117
+
118
+ @property
119
+ def theme(self) -> str:
120
+ return str(self._data.get("theme", DEFAULT_THEME))
121
+
122
+ @theme.setter
123
+ def theme(self, value: str) -> None:
124
+ self._data["theme"] = value
125
+
126
+ @property
127
+ def reader_font_size(self) -> int:
128
+ val = _safe_int(self._data.get("reader_font_size", DEFAULT_FONT_SIZE), DEFAULT_FONT_SIZE)
129
+ return max(6, min(72, val))
130
+
131
+ @reader_font_size.setter
132
+ def reader_font_size(self, value: int) -> None:
133
+ self._data["reader_font_size"] = max(6, min(72, value))
134
+
135
+ @property
136
+ def last_book_id(self) -> str | None:
137
+ return self._data.get("last_book_id")
138
+
139
+ @last_book_id.setter
140
+ def last_book_id(self, value: str) -> None:
141
+ self._data["last_book_id"] = value
142
+
143
+ @property
144
+ def splitter_sizes(self) -> list[int]:
145
+ return list(self._data.get("splitter_sizes", READER_SPLITTER_RATIO))
146
+
147
+ @splitter_sizes.setter
148
+ def splitter_sizes(self, value: list[int]) -> None:
149
+ self._data["splitter_sizes"] = value
150
+
151
+ @property
152
+ def editor_console_sizes(self) -> list[int]:
153
+ return list(self._data.get("editor_console_sizes", EDITOR_CONSOLE_RATIO))
154
+
155
+ @editor_console_sizes.setter
156
+ def editor_console_sizes(self, value: list[int]) -> None:
157
+ self._data["editor_console_sizes"] = value
158
+
159
+ @property
160
+ def toc_width(self) -> int:
161
+ return _safe_int(self._data.get("toc_width", TOC_WIDTH), TOC_WIDTH)
162
+
163
+ @toc_width.setter
164
+ def toc_width(self, value: int) -> None:
165
+ self._data["toc_width"] = value
166
+
167
+ @property
168
+ def toc_visible(self) -> bool:
169
+ return bool(self._data.get("toc_visible", True))
170
+
171
+ @toc_visible.setter
172
+ def toc_visible(self, value: bool) -> None:
173
+ self._data["toc_visible"] = value
174
+
175
+
176
+ class BooksConfig:
177
+ """Book registry configuration."""
178
+
179
+ def __init__(self) -> None:
180
+ self._data: dict[str, Any] = {}
181
+ self.load()
182
+
183
+ def load(self) -> None:
184
+ self._data = _load_json(BOOKS_CONFIG_PATH)
185
+ # Ensure "books" is a list (guard against corrupted JSON)
186
+ if not isinstance(self._data.get("books"), list):
187
+ self._data["books"] = []
188
+ # Migrate: ensure all entries have required keys
189
+ for book in self._data.get("books", []):
190
+ book.setdefault("language", "python")
191
+ book.setdefault("profile_name", "")
192
+
193
+ def save(self) -> None:
194
+ _save_json(BOOKS_CONFIG_PATH, self._data)
195
+
196
+ @property
197
+ def books(self) -> list[dict[str, Any]]:
198
+ return list(self._data.get("books", []))
199
+
200
+ def add_book(self, book_id: str, title: str, pdf_path: str,
201
+ language: str = "python", profile_name: str = "") -> None:
202
+ books = self.books
203
+ for b in books:
204
+ if b["book_id"] == book_id:
205
+ b.update(title=title, pdf_path=pdf_path,
206
+ language=language, profile_name=profile_name)
207
+ self._data["books"] = books
208
+ return
209
+ books.append({
210
+ "book_id": book_id,
211
+ "title": title,
212
+ "pdf_path": pdf_path,
213
+ "language": language,
214
+ "profile_name": profile_name,
215
+ })
216
+ self._data["books"] = books
217
+
218
+ def get_book(self, book_id: str) -> dict | None:
219
+ for b in self.books:
220
+ if b["book_id"] == book_id:
221
+ return b
222
+ return None
223
+
224
+ def remove_book(self, book_id: str) -> None:
225
+ self._data["books"] = [b for b in self.books if b["book_id"] != book_id]
226
+
227
+
228
+ class EditorConfig:
229
+ """Code editor configuration."""
230
+
231
+ def __init__(self) -> None:
232
+ self._data: dict[str, Any] = {}
233
+ self.load()
234
+
235
+ def load(self) -> None:
236
+ self._data = _load_json(EDITOR_CONFIG_PATH)
237
+
238
+ def save(self) -> None:
239
+ _save_json(EDITOR_CONFIG_PATH, self._data)
240
+
241
+ @property
242
+ def font_size(self) -> int:
243
+ val = _safe_int(self._data.get("font_size", DEFAULT_EDITOR_FONT_SIZE), DEFAULT_EDITOR_FONT_SIZE)
244
+ return max(6, min(72, val))
245
+
246
+ @font_size.setter
247
+ def font_size(self, value: int) -> None:
248
+ self._data["font_size"] = max(6, min(72, value))
249
+
250
+ @property
251
+ def tab_width(self) -> int:
252
+ val = _safe_int(self._data.get("tab_width", DEFAULT_TAB_WIDTH), DEFAULT_TAB_WIDTH)
253
+ return max(1, min(16, val))
254
+
255
+ @tab_width.setter
256
+ def tab_width(self, value: int) -> None:
257
+ self._data["tab_width"] = max(1, min(16, value))
258
+
259
+ @property
260
+ def show_line_numbers(self) -> bool:
261
+ return bool(self._data.get("show_line_numbers", True))
262
+
263
+ @show_line_numbers.setter
264
+ def show_line_numbers(self, value: bool) -> None:
265
+ self._data["show_line_numbers"] = value
266
+
267
+ @property
268
+ def auto_indent(self) -> bool:
269
+ return bool(self._data.get("auto_indent", True))
270
+
271
+ @auto_indent.setter
272
+ def auto_indent(self, value: bool) -> None:
273
+ self._data["auto_indent"] = value
274
+
275
+ @property
276
+ def word_wrap(self) -> bool:
277
+ return bool(self._data.get("word_wrap", False))
278
+
279
+ @word_wrap.setter
280
+ def word_wrap(self, value: bool) -> None:
281
+ self._data["word_wrap"] = value
282
+
283
+ @property
284
+ def execution_timeout(self) -> int:
285
+ val = _safe_int(self._data.get("execution_timeout", DEFAULT_EXECUTION_TIMEOUT), DEFAULT_EXECUTION_TIMEOUT)
286
+ return max(5, min(300, val))
287
+
288
+ @execution_timeout.setter
289
+ def execution_timeout(self, value: int) -> None:
290
+ self._data["execution_timeout"] = max(5, min(300, value))
291
+
292
+ @property
293
+ def external_editor_path(self) -> str:
294
+ return str(self._data.get("external_editor_path", "notepad++.exe"))
295
+
296
+ @external_editor_path.setter
297
+ def external_editor_path(self, value: str) -> None:
298
+ self._data["external_editor_path"] = value
299
+
300
+ @property
301
+ def external_editor_enabled(self) -> bool:
302
+ return bool(self._data.get("external_editor_enabled", True))
303
+
304
+ @external_editor_enabled.setter
305
+ def external_editor_enabled(self, value: bool) -> None:
306
+ self._data["external_editor_enabled"] = value