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.
- pylearn_reader-1.0.0/LICENSE +21 -0
- pylearn_reader-1.0.0/PKG-INFO +17 -0
- pylearn_reader-1.0.0/README.md +210 -0
- pylearn_reader-1.0.0/pyproject.toml +65 -0
- pylearn_reader-1.0.0/setup.cfg +4 -0
- pylearn_reader-1.0.0/src/pylearn/__init__.py +2 -0
- pylearn_reader-1.0.0/src/pylearn/__main__.py +6 -0
- pylearn_reader-1.0.0/src/pylearn/app.py +37 -0
- pylearn_reader-1.0.0/src/pylearn/core/__init__.py +2 -0
- pylearn_reader-1.0.0/src/pylearn/core/config.py +306 -0
- pylearn_reader-1.0.0/src/pylearn/core/constants.py +75 -0
- pylearn_reader-1.0.0/src/pylearn/core/database.py +499 -0
- pylearn_reader-1.0.0/src/pylearn/core/models.py +285 -0
- pylearn_reader-1.0.0/src/pylearn/executor/__init__.py +2 -0
- pylearn_reader-1.0.0/src/pylearn/executor/output_handler.py +77 -0
- pylearn_reader-1.0.0/src/pylearn/executor/sandbox.py +329 -0
- pylearn_reader-1.0.0/src/pylearn/executor/session.py +265 -0
- pylearn_reader-1.0.0/src/pylearn/main.py +167 -0
- pylearn_reader-1.0.0/src/pylearn/parser/__init__.py +2 -0
- pylearn_reader-1.0.0/src/pylearn/parser/book_profiles.py +176 -0
- pylearn_reader-1.0.0/src/pylearn/parser/cache_manager.py +103 -0
- pylearn_reader-1.0.0/src/pylearn/parser/code_extractor.py +92 -0
- pylearn_reader-1.0.0/src/pylearn/parser/content_classifier.py +184 -0
- pylearn_reader-1.0.0/src/pylearn/parser/exercise_extractor.py +212 -0
- pylearn_reader-1.0.0/src/pylearn/parser/font_analyzer.py +320 -0
- pylearn_reader-1.0.0/src/pylearn/parser/pdf_parser.py +263 -0
- pylearn_reader-1.0.0/src/pylearn/parser/structure_detector.py +210 -0
- pylearn_reader-1.0.0/src/pylearn/renderer/__init__.py +2 -0
- pylearn_reader-1.0.0/src/pylearn/renderer/code_highlighter.py +45 -0
- pylearn_reader-1.0.0/src/pylearn/renderer/html_renderer.py +218 -0
- pylearn_reader-1.0.0/src/pylearn/renderer/theme.py +73 -0
- pylearn_reader-1.0.0/src/pylearn/ui/__init__.py +2 -0
- pylearn_reader-1.0.0/src/pylearn/ui/book_controller.py +229 -0
- pylearn_reader-1.0.0/src/pylearn/ui/bookmark_dialog.py +108 -0
- pylearn_reader-1.0.0/src/pylearn/ui/console_panel.py +58 -0
- pylearn_reader-1.0.0/src/pylearn/ui/editor_panel.py +211 -0
- pylearn_reader-1.0.0/src/pylearn/ui/exercise_panel.py +144 -0
- pylearn_reader-1.0.0/src/pylearn/ui/external_editor.py +143 -0
- pylearn_reader-1.0.0/src/pylearn/ui/library_panel.py +127 -0
- pylearn_reader-1.0.0/src/pylearn/ui/main_window.py +886 -0
- pylearn_reader-1.0.0/src/pylearn/ui/notes_dialog.py +142 -0
- pylearn_reader-1.0.0/src/pylearn/ui/progress_dialog.py +82 -0
- pylearn_reader-1.0.0/src/pylearn/ui/reader_panel.py +335 -0
- pylearn_reader-1.0.0/src/pylearn/ui/search_dialog.py +173 -0
- pylearn_reader-1.0.0/src/pylearn/ui/styles.py +157 -0
- pylearn_reader-1.0.0/src/pylearn/ui/theme_registry.py +116 -0
- pylearn_reader-1.0.0/src/pylearn/ui/toc_panel.py +164 -0
- pylearn_reader-1.0.0/src/pylearn/ui/toolbar.py +103 -0
- pylearn_reader-1.0.0/src/pylearn/utils/__init__.py +2 -0
- pylearn_reader-1.0.0/src/pylearn/utils/error_handler.py +140 -0
- pylearn_reader-1.0.0/src/pylearn/utils/text_utils.py +122 -0
- pylearn_reader-1.0.0/src/pylearn_reader.egg-info/PKG-INFO +17 -0
- pylearn_reader-1.0.0/src/pylearn_reader.egg-info/SOURCES.txt +55 -0
- pylearn_reader-1.0.0/src/pylearn_reader.egg-info/dependency_links.txt +1 -0
- pylearn_reader-1.0.0/src/pylearn_reader.egg-info/entry_points.txt +2 -0
- pylearn_reader-1.0.0/src/pylearn_reader.egg-info/requires.txt +9 -0
- 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
|
+
[](https://github.com/fritz99-lang/pylearn/actions/workflows/ci.yml)
|
|
4
|
+
[](LICENSE)
|
|
5
|
+
[](https://www.python.org/)
|
|
6
|
+
[](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
|
+

|
|
15
|
+
|
|
16
|
+
**Dark mode** — reading with code execution:
|
|
17
|
+
|
|
18
|
+

|
|
19
|
+
|
|
20
|
+
**Sepia mode** — reading view:
|
|
21
|
+
|
|
22
|
+

|
|
23
|
+
|
|
24
|
+
**Sepia mode** — running code (Zen of Python):
|
|
25
|
+
|
|
26
|
+

|
|
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,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,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
|